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..65f40a44 --- /dev/null +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -0,0 +1,686 @@ +// +// AirBridgeClient.swift +// airsync-mac +// +// 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 +import Combine +import CryptoKit +import AppKit + +class AirBridgeClient: ObservableObject { + static let shared = AirBridgeClient() + + // MARK: - Published State + + @Published var connectionState: AirBridgeConnectionState = .disconnected + @Published var isPeerConnected: Bool = false + + // Ping mechanism + private var pingTimer: DispatchSourceTimer? + private var lastPongReceived: Date = .distantPast + private let pingInterval: TimeInterval = 8.0 + private let peerTimeout: TimeInterval = 20.0 + + // MARK: - Configuration + // + // 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" + + // In-memory cache for the secret + private var _cachedSecret: String? + private var _secretLoaded = false + + /// Loads the secret from Keychain once + private func loadSecretIfNeeded() { + guard !_secretLoaded else { return } + _secretLoaded = true + + // Current key + if let s = KeychainStorage.string(for: Self.keychainKeySecret) { + _cachedSecret = s + } + } + + 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 + 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 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( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.handleWakeFromSleep() + } + } + + // MARK: - Public Interface + + /// Connects to the relay server. Does nothing if already connected or URL is empty. + func connect() { + queue.async { [weak self] in + guard let self = self else { return } + guard !self.receiveLoopActive || self.webSocketTask == nil else { return } + 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.connectionGeneration += 1 + self.pendingReconnectWorkItem?.cancel() + self.pendingReconnectWorkItem = nil + 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, 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 for HMAC). + /// - 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 + } + + 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)) + } + + // Wait for challenge from server (step 1) + task.receive { [weak self] result in + guard self != nil else { + settle(.failure(ConnectivityError.timeout)) + 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 + } + + // 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)) + } + } + } + + // 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. + 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: - 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() { + guard !relayServerURL.isEmpty else { + DispatchQueue.main.async { self.connectionState = .disconnected } + return + } + + // Ensure credentials exist before connecting + 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") + DispatchQueue.main.async { self.connectionState = .failed(error: "Invalid URL") } + return + } + + isManuallyDisconnected = false + pendingReconnectWorkItem?.cancel() + pendingReconnectWorkItem = nil + pendingNonce = nil + connectionGeneration += 1 + let generation = connectionGeneration + 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 — the first message should be the challenge + receiveLoopActive = true + startReceiving(expectedGeneration: generation) + } + + /// 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 = .challengeReceived } + + let (sig, kInit) = Self.computeHMAC(secretRaw: secret, nonce: nonce, pairingId: pairingId, role: "mac") + + 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, + sig: sig, + kInit: kInit, + localIp: localIP, + port: port + ) + + 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 { + guard expectedGeneration == self.connectionGeneration else { return } + if let error = error { + print("[airbridge] Registration send failed: \(error.localizedDescription)") + self.scheduleReconnect(sourceGeneration: expectedGeneration) + } else { + DispatchQueue.main.async { + self.connectionState = .waitingForPeer + } + self.reconnectAttempt = 0 + } + } + } + } + } catch { + print("[airbridge] Failed to encode registration: \(error)") + } + } + + // MARK: - Receive Loop + + private func startReceiving(expectedGeneration: Int) { + guard receiveLoopActive, let task = webSocketTask else { return } + + task.receive { [weak self] result in + 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, expectedGeneration: expectedGeneration) + // Continue receiving + self.startReceiving(expectedGeneration: expectedGeneration) + case .failure(let error): + print("[airbridge] Receive error: \(error.localizedDescription)") + self.receiveLoopActive = false + self.scheduleReconnect(sourceGeneration: expectedGeneration) + } + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message, expectedGeneration: Int) { + switch message { + case .string(let text): + handleTextMessage(text, expectedGeneration: expectedGeneration) + case .data(let data): + handleBinaryMessage(data) + @unknown default: + print("[airbridge] Unknown message type received") + } + } + + 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) { + 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 + self?.pendingReconnectWorkItem?.cancel() + self?.pendingReconnectWorkItem = nil + self?.reconnectAttempt = 0 + } + DispatchQueue.main.async { + 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") + WebSocketServer.shared.sendTransportOffer(reason: "relay_started") + } + 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. + WebSocketServer.shared.handleRelayedMessage(text) + } + + private func handleBinaryMessage(_ data: Data) { + // Binary data from the relay is currently unused in the AirSync protocol + _ = data + } + + private func startPingLoop() { + queue.async { [weak self] in + guard let self = self else { return } + 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 } + + let timeSinceLastPong = Date().timeIntervalSince(self.lastPongReceived) + 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 + } + } + } + + let pingJson = "{\"type\":\"ping\"}" + // 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() + } + } + + func processPong() { + queue.async { [weak self] in + guard let self = self else { return } + self.lastPongReceived = Date() + DispatchQueue.main.async { + self.isPeerConnected = true + } + } + } + + /// 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) { + guard !isManuallyDisconnected else { return } + guard sourceGeneration == connectionGeneration else { + return + } + + tearDown(reason: "Preparing reconnect") + + let delay = min(pow(2.0, Double(reconnectAttempt)), maxReconnectDelay) + reconnectAttempt += 1 + + DispatchQueue.main.async { + self.connectionState = .connecting + } + + 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) { + receiveLoopActive = false + pendingNonce = nil + webSocketTask?.cancel(with: .goingAway, reason: reason.data(using: .utf8)) + webSocketTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil + + // Clean up ping timer + queue.async { [weak self] in + guard let self = self else { return } + self.pingTimer?.cancel() + self.pingTimer = nil + DispatchQueue.main.async { + self.isPeerConnected = false + } + } + } + + // 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) + + // 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") + 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) + 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 static let licenseDetailsKey = "licenseDetails" @Published var isOS26: Bool = true init() { + // Load all Keychain items up front before any subsystem tries to read individual keys and triggers multiple prompts. + KeychainStorage.preload() + self.isPlus = false let adbPortValue = UserDefaults.standard.integer(forKey: "adbPort") @@ -76,6 +86,8 @@ class AppState: ObservableObject { self.isCrashReportingEnabled = UserDefaults.standard.object(forKey: "isCrashReportingEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isCrashReportingEnabled") + self.airBridgeEnabled = UserDefaults.standard.bool(forKey: "airBridgeEnabled") + let savedAdapterName = UserDefaults.standard.string(forKey: "selectedNetworkAdapterName") let validatedAdapter = AppState.validateAndGetNetworkAdapter(savedName: savedAdapterName) self.selectedNetworkAdapterName = validatedAdapter @@ -100,6 +112,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") @@ -115,6 +138,11 @@ class AppState: ObservableObject { // Ensure dock icon visibility is applied on launch updateDockIconVisibility() + + // Auto-connect to AirBridge relay if previously enabled + if airBridgeEnabled { + AirBridgeClient.shared.connect() + } } @Published var minAndroidVersion = Bundle.main.infoDictionary?["AndroidVersion"] as? String ?? "2.0.0" @@ -184,10 +212,32 @@ 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.") + // 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 @@ -363,6 +413,19 @@ class AppState: ObservableObject { } } + @Published var airBridgeEnabled: Bool { + didSet { + UserDefaults.standard.set(airBridgeEnabled, forKey: "airBridgeEnabled") + // Connection is managed explicitly: + // 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() + } + } + } + @Published var isOnboardingActive: Bool = false { didSet { NotificationCenter.default.post( @@ -671,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 WebSocketServer.shared.hasActiveLocalSession() { + 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/AppleScriptSupport.swift b/airsync-mac/Core/AppleScriptSupport.swift index b355262b..666c56e8 100644 --- a/airsync-mac/Core/AppleScriptSupport.swift +++ b/airsync-mac/Core/AppleScriptSupport.swift @@ -490,6 +490,11 @@ class AirSyncConnectADBCommand: NSScriptCommand { return "Connected" } + // ADB is supported only on direct LAN sessions, not relay mode. + guard WebSocketServer.shared.hasActiveLocalSession() else { + return "ADB works only on local LAN connections. Relay mode is not supported for ADB." + } + // Start ADB connection (like the Connect ADB button in settings) DispatchQueue.main.async { ADBConnector.connectToADB(ip: device.ipAddress) diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index 431f3a45..ec3ef8fd 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -96,8 +96,32 @@ class UDPDiscoveryManager: ObservableObject { } @objc private func handleSystemWake() { - print("[Discovery] System wake detected") + print("[Discovery] System wake detected.") + + // 1. Immediate burst broadcastBurst() + + // 2. Schedule a series of recovery actions to catch the network as it comes up + + // T+2s: Force WebSocket Server to re-evaluate network binding + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + 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 + self?.broadcastBurst() + } + + // T+6s: Burst 2 (Retry) + DispatchQueue.global().asyncAfter(deadline: .now() + 6.0) { [weak self] in + self?.broadcastBurst() + } + + // T+10s: Burst 3 (Final retry) + DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { [weak self] in + self?.broadcastBurst() + } } // MARK: - Broadcasting diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 22619c1e..b07bbe0d 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -149,6 +149,11 @@ 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, + !AppState.shared.isEffectivelyLocalTransport { + return + } transferState = .connecting(deviceID) transferProgress = 0 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/Core/Util/SecureStorage/KeychainStorage.swift b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift index 8131fbc9..4d9f2c2d 100644 --- a/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift +++ b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift @@ -1,30 +1,121 @@ 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" + /// 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 { + 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() + + } + + // 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 +131,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 9512178f..1a906611 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -64,12 +64,77 @@ extension WebSocketServer { handleRemoteControl(message) case .browseData: handleBrowseData(message) - case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl: + 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 } } + /// 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 .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 .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 + case .macInfo: + // outbound from Mac -> Android in normal flow, ignore inbound + break + case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .callControl, .notificationAction, .ping, .pong, .fileTransferInit, .fileChunk, .fileChunkAck, .fileTransferComplete, .transferVerified, .fileTransferCancel: + break + } + } + // MARK: - Private Handlers /// Processes initial device handshake. @@ -142,6 +207,12 @@ 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 + return + } if AppState.shared.wiredAdbEnabled { ADBConnector.getWiredDeviceSerial(completion: { serial in if let serial = serial { @@ -585,6 +656,137 @@ extension WebSocketServer { } } + private func handlePeerTransportUpdate(_ message: Message) { + guard let dict = message.data.value as? [String: Any] else { return } + let transport = dict["transport"] as? String + AppState.shared.updatePeerTransportHint(transport) + } + + 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) + guard isTransportMessageFresh(dict) else { + return + } + let candidateEval = evaluateTransportCandidates(dict) + guard candidateEval.isValid else { + 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) + guard isTransportMessageFresh(dict) else { + return + } + let candidateEval = evaluateTransportCandidates(dict) + guard candidateEval.isValid else { + 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) + guard isTransportGenerationActive(generation), hasActiveLocalSession() else { + 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 path = dict["path"] as? String ?? "relay" + guard isTransportGenerationActive(generation) else { + return + } + if path == "lan" { + guard hasActiveLocalSession(), isTransportGenerationValidated(generation) else { + 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+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 8a5e091c..b543eec2 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -20,18 +20,22 @@ 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 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 { + session.writeText(outgoing) + } else if AirBridgeClient.shared.connectionState == .relayActive { + AirBridgeClient.shared.sendText(outgoing) } } @@ -57,12 +61,94 @@ 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 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 + ]) + } + + func sendTransportAnswer(generation: Int64, reason: String) { + guard isTransportGenerationActive(generation) else { + 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 + ]) + } + + func sendTransportCheckAck(generation: Int64, token: String) { + guard isTransportGenerationActive(generation) else { + return + } + sendMessage(type: "transportCheckAck", data: [ + "source": "mac", + "generation": generation, + "token": token, + "ts": Int(Date().timeIntervalSince1970 * 1000) + ]) + } + + func sendTransportNominate(path: String, generation: Int64, reason: String) { + guard isTransportGenerationActive(generation) else { + return + } + if path == "lan" && !isTransportGenerationValidated(generation) { + return + } + sendMessage(type: "transportNominate", data: [ + "source": "mac", + "generation": generation, + "path": path, + "ts": Int(Date().timeIntervalSince1970 * 1000), + "reason": reason + ]) + } + func sendQuickShareTrigger() { // print("[websocket] Quick Share trigger requested") sendMessage(type: "startQuickShare", data: [:]) } func sendRefreshAdbPortsRequest() { + guard hasActiveLocalSession() else { + 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..05d0f314 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -55,15 +55,42 @@ 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 { + 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() + } + 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 c9903895..098ebf7c 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -47,6 +47,18 @@ 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. + internal let lanSessionEvents = PassthroughSubject() // true = started, false = ended + internal var lastPublishedLanState: Bool? init() { loadOrGenerateSymmetricKey() @@ -143,17 +155,157 @@ class WebSocketServer: ObservableObject { servers.removeAll() } + 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 } + self.stop() + self.start(port: restartPort) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + func stop() { lock.lock() stopAllServers() activeSessions.removeAll() primarySessionID = nil + pendingLanDownWorkItem?.cancel() + pendingLanDownWorkItem = nil stopPing() lock.unlock() + publishLanTransportState(isActive: false, reason: "server_stop") 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 }) + } + + /// 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() { + 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 _reason: String) { + lock.lock() + let previous = lastPublishedLanState + if previous == isActive { + lock.unlock() + return + } + lastPublishedLanState = isActive + lock.unlock() + + DispatchQueue.main.async { + self.lanSessionEvents.send(isActive) + AppState.shared.updatePeerTransportHint(isActive ? "wifi" : "relay") + } + 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 _reason: String) { + guard generation > 0 else { return } + lock.lock() + activeTransportGeneration = generation + activeTransportGenerationStartedAt = Date() + validatedTransportGeneration = 0 + lock.unlock() + } + + 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 + } + return false + } + + internal func markTransportGenerationValidated(_ generation: Int64, reason _reason: String) { + guard isTransportGenerationActive(generation) else { return } + lock.lock() + validatedTransportGeneration = generation + lock.unlock() + } + + 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) { @@ -203,17 +355,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.publishLanTransportState(isActive: true, reason: "connected_primary_session") + } }, disconnected: { [weak self] session in guard let self = self else { return } @@ -230,18 +390,241 @@ class WebSocketServer: ObservableObject { } if wasPrimary { + self.publishLanTransportState(isActive: false, reason: "disconnected_primary_session") DispatchQueue.main.async { 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 { + // 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("{") { + decryptedText = text + } else { + 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. + decryptedText = text + } + + guard let data = decryptedText.data(using: .utf8) else { + print("[transport] RX via RELAY dropped: UTF-8 conversion failed") + 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) + + // Handle Pong for AirBridge keepalive + if message.type == .pong { + AirBridgeClient.shared.processPong() + return + } + + DispatchQueue.main.async { + self.handleRelayedMessageInternal(message) + } + } catch { + print("[airbridge] Failed to decode relayed message: \(error)") + } + } + + + + /// 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 + var evictedPrimaryAsStale = false + 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 + evictedPrimaryAsStale = true + } + session = nil + sessionCount = activeSessions.count + } + } + lock.unlock() + + if evictedPrimaryAsStale { + publishLanTransportState(isActive: false, reason: "stale_primary_evicted_during_relay_rx") + } + + if sessionCount == 0 { + MacRemoteManager.shared.stopVolumeMonitoring() + stopPing() + } + + if let session = session { + handleMessage(message, session: session) + } else { + // No local session — dispatch directly to AppState for non-session-critical messages + 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" + + // 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, + "isPlusSubscription": isPlusSubscription, // Essential for Android check + "version": appVersion, + "model": modelId, + "type": categoryType, + "categoryType": categoryType, + "exactDeviceName": exactDeviceName, + "savedAppPackages": savedAppPackages + ] + ] + + 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) + } + } + } + + /// 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": [ + "ips": ipList, + "port": port, + "adapter": adapter as Any + ] + ] + + 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) + } + + // Also emit a transport offer round so Android can immediately try LAN upgrade. + sendTransportOffer(reason: "mac_wake") + } + // MARK: - Crypto Helpers func loadOrGenerateSymmetricKey() { @@ -273,7 +656,7 @@ class WebSocketServer: ObservableObject { symmetricKey = SymmetricKey(data: data) } } - + func wakeUpLastConnectedDevice() { QuickConnectManager.shared.wakeUpLastConnectedDevice() } diff --git a/airsync-mac/Model/Message.swift b/airsync-mac/Model/Message.swift index 4d0c4619..cec74d3c 100644 --- a/airsync-mac/Model/Message.swift +++ b/airsync-mac/Model/Message.swift @@ -40,6 +40,17 @@ enum MessageType: String, Codable { // file browser case browseLs case browseData + // relay keepalive + case ping + case pong + // peer transport hints + case peerTransport + // relay-assisted LAN negotiation + case transportOffer + case transportAnswer + case transportCheck + case transportCheckAck + case transportNominate } struct Message: Codable { diff --git a/airsync-mac/Screens/HomeScreen/AppContentView.swift b/airsync-mac/Screens/HomeScreen/AppContentView.swift index 90dbd6f2..a75cf80e 100644 --- a/airsync-mac/Screens/HomeScreen/AppContentView.swift +++ b/airsync-mac/Screens/HomeScreen/AppContentView.swift @@ -32,8 +32,10 @@ struct AppContentView: View { .help("Feedback and How to?") Button("Refresh", systemImage: "repeat") { - WebSocketServer.shared.stop() - WebSocketServer.shared.start() + WebSocketServer.shared.requestRestart( + reason: "Manual refresh button", + delay: 0.2 + ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { appState.shouldRefreshQR = true } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 32bc5357..e4bccedb 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -18,9 +18,10 @@ struct ConnectionStatusPill: View { }) { HStack(spacing: 8) { // Network Connection Icon - Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") + Image(systemName: appState.isEffectivelyLocalTransport ? "wifi" : "globe") + .foregroundStyle(connectionIconColor) .contentTransition(.symbolEffect(.replace)) - .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") + .help(connectionIconHelp) if appState.isPlus { if appState.adbConnecting { @@ -65,7 +66,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) @@ -96,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 { @@ -117,12 +138,20 @@ struct ConnectionPillPopover: View { ) ConnectionInfoText( - label: "IP Address", - icon: "wifi", - text: currentIPAddress, - activeIp: appState.activeMacIp + label: "Transport", + icon: appState.isEffectivelyLocalTransport ? "wifi" : "globe", + text: appState.isEffectivelyLocalTransport ? "Local WiFi" : "AirBridge Relay" ) + if appState.isEffectivelyLocalTransport { + ConnectionInfoText( + label: "IP Address", + icon: "network", + text: currentIPAddress, + activeIp: appState.activeMacIp + ) + } + if appState.isPlus && appState.adbConnected { ConnectionInfoText( label: "ADB Connection", @@ -161,10 +190,7 @@ struct ConnectionPillPopover: View { primary: false, action: { if !appState.adbConnecting { - 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/HomeScreen/PhoneView/ScreenView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift index c455c670..1f5ca517 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.isEffectivelyLocalTransport && + AirBridgeClient.shared.connectionState == .relayActive + ) + .help( + (!appState.isEffectivelyLocalTransport && + AirBridgeClient.shared.connectionState == .relayActive) + ? "Quick Share is unavailable over relay connection" + : "Send files with Quick Share" + ) .transition(.identity) .keyboardShortcut( "f", diff --git a/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift new file mode 100644 index 00000000..c4d335c6 --- /dev/null +++ b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift @@ -0,0 +1,189 @@ +// +// AirBridgeSetupView.swift +// AirSync +// +// Created by tornado-bunk and an AI Assistant. +// + +import SwiftUI + +struct AirBridgeSetupView: View { + let onNext: () -> 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 (Beta)") + .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("airbridge.yourdomain.com", 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() + AirBridgeClient.shared.connect() + 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..261a8b5f --- /dev/null +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -0,0 +1,219 @@ +// +// AirBridgeSettingsView.swift +// airsync-mac +// +// Created by tornado-bunk and an 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 (Beta)", 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) + 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 { + Button("Retry") { + AirBridgeClient.shared.connect() + } + .buttonStyle(.borderless) + .font(.system(size: 11)) + } + } + + Divider() + + // Relay Server URL + HStack { + Label("Relay Server", systemImage: "server.rack") + Spacer() + TextField("airbridge.yourdomain.com", 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 { + // Ensure default URL if missing + if airBridge.relayServerURL.isEmpty { + airBridge.relayServerURL = "wss://airbridge.yourdomain.com/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() + } + } + } + + // 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 .challengeReceived: 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 a1f920c6..e2bb8650 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -59,15 +59,18 @@ struct SettingsFeaturesView: View { systemImage: appState.adbConnecting ? "hourglass" : "play.circle", action: { if !appState.adbConnecting { - 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 + appState.device == nil || + appState.adbConnecting || + !AppState.shared.isPlus || + ( + !WebSocketServer.shared.hasActiveLocalSession() && + !(AirBridgeClient.shared.connectionState == .relayActive && appState.wiredAdbEnabled) + ) ) } diff --git a/airsync-mac/Screens/Settings/SettingsView.swift b/airsync-mac/Screens/Settings/SettingsView.swift index a7a8ce9b..bbbc5e85 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 66832d88..e4d5ccd8 100644 --- a/airsync-mac/airsync_macApp.swift +++ b/airsync-mac/airsync_macApp.swift @@ -25,6 +25,8 @@ 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 the individual prompts. + KeychainStorage.preload() let center = UNUserNotificationCenter.current() center.delegate = notificationDelegate