diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index 5fdc4096..fecd3ae7 100644 --- a/AirSync.xcodeproj/project.pbxproj +++ b/AirSync.xcodeproj/project.pbxproj @@ -283,7 +283,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 28; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -301,7 +301,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -459,7 +459,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 28; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -477,7 +477,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -507,7 +507,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 28; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -525,7 +525,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.2.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/airsync-mac/Components/Buttons/GlassButtonView.swift b/airsync-mac/Components/Buttons/GlassButtonView.swift index f0bb4665..b8d5f25d 100644 --- a/airsync-mac/Components/Buttons/GlassButtonView.swift +++ b/airsync-mac/Components/Buttons/GlassButtonView.swift @@ -74,7 +74,7 @@ struct GlassButtonView: View { } } -private extension View { +extension View { @ViewBuilder func ifLet(_ value: T?, transform: (Self, T) -> Content) -> some View { if let value = value { 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/Components/Containers/GlassBoxView.swift b/airsync-mac/Components/Containers/GlassBoxView.swift index a56a87a1..80a27538 100644 --- a/airsync-mac/Components/Containers/GlassBoxView.swift +++ b/airsync-mac/Components/Containers/GlassBoxView.swift @@ -62,3 +62,22 @@ extension View { } } +extension View { + public func segmentStyle(cornerRadius: CGFloat = 20) -> some View { + self.applyGlassViewIfAvailable(cornerRadius: cornerRadius) + .contentShape(Rectangle()) + .shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5) + } + + public func staggeredEntrance(index: Int, isVisible: Bool) -> some View { + self.zIndex(Double(10 - index)) + .offset(y: isVisible ? 0 : -12) + .opacity(isVisible ? 1 : 0) + .animation( + .interpolatingSpring(stiffness: 120, damping: 14) + .delay(Double(index) * 0.05 + 0.05), + value: isVisible + ) + } +} + diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift new file mode 100644 index 00000000..d922fb12 --- /dev/null +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -0,0 +1,705 @@ +// +// 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 } + print("[airbridge] Mac woke from sleep. Tearing down stale relay connection.") + + // If the user hasn't explicitly disabled AirBridge, trigger a fresh reconnect. + if !self.isManuallyDisconnected { + self.connectionGeneration += 1 + self.pendingReconnectWorkItem?.cancel() + self.pendingReconnectWorkItem = nil + self.tearDown(reason: "System wake") + + DispatchQueue.main.async { + self.connectionState = .connecting + } + + // Add a delay to allow the Wi-Fi adapter to authenticate with the new network + self.queue.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.connectInternal() + } + } + } + } + + // 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 = { + // Use Foundation URL parsing to handle IPv6, ports, and paths correctly. + let parsingURLString: String + if url.hasPrefix("ws://") || url.hasPrefix("wss://") { + parsingURLString = url + } else { + // Prepend a dummy scheme for parsing purposes only. + parsingURLString = "ws://\(url)" + } + return URL(string: parsingURLString)?.host ?? "" + }() + + 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") @@ -77,6 +87,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 @@ -101,6 +113,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") @@ -117,6 +140,10 @@ 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() + } // Reset mirroring state on launch to prevent auto-opening if it was open during last session self.isNativeMirroring = false } @@ -189,10 +216,32 @@ class AppState: ObservableObject { @Published var recentApps: [AndroidApp] = [] @Published var isNativeMirroring: Bool = false - 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 @@ -378,6 +427,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( @@ -690,6 +752,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 a2add757..2d184c8e 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -108,8 +108,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/MenuBar/MenubarPanel.swift b/airsync-mac/Core/MenuBar/MenubarPanel.swift new file mode 100644 index 00000000..189593e8 --- /dev/null +++ b/airsync-mac/Core/MenuBar/MenubarPanel.swift @@ -0,0 +1,37 @@ +// +// MenubarPanel.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-07. +// + +import AppKit +import SwiftUI + +class MenubarPanel: NSPanel { + init(contentRect: NSRect, rootView: some View) { + super.init( + contentRect: contentRect, + styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + self.isFloatingPanel = true + self.level = .statusBar + self.collectionBehavior = [.canJoinAllSpaces, .ignoresCycle] + self.backgroundColor = .clear + self.isOpaque = false + self.hasShadow = false + + let hostingView = NSHostingView(rootView: rootView) + hostingView.frame = self.contentView?.bounds ?? .zero + hostingView.autoresizingMask = [.width, .height] + self.contentView = hostingView + self.becomesKeyOnlyIfNeeded = false + } + + override var canBecomeKey: Bool { + return true + } +} diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index 7bd75f44..f9443ed1 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -13,7 +13,7 @@ class MenuBarManager: NSObject { static let shared = MenuBarManager() private var statusItem: NSStatusItem? - private var popover: NSPopover? + private var menubarPanel: MenubarPanel? private var eventMonitor: Any? private var cancellables = Set() private var appState = AppState.shared @@ -54,11 +54,7 @@ class MenuBarManager: NSObject { } private func setupPopover() { - let popover = NSPopover() - popover.contentSize = NSSize(width: 320, height: 480) - popover.behavior = .transient - popover.contentViewController = NSHostingController(rootView: MenubarView().environmentObject(appState)) - self.popover = popover + // Initialized on first show to ensure proper sizing } private func setupBindings() { @@ -143,7 +139,7 @@ class MenuBarManager: NSObject { } func togglePopover() { - if popover?.isShown == true { + if menubarPanel?.isVisible == true { hidePopover() } else { showPopover() @@ -151,34 +147,50 @@ class MenuBarManager: NSObject { } func showPopover() { - guard let button = statusItem?.button, let popover = popover else { return } - if !popover.isShown { - NSApp.activate(ignoringOtherApps: true) - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - - if let popoverWindow = popover.contentViewController?.view.window { - popoverWindow.makeKeyAndOrderFront(nil) - popoverWindow.orderFrontRegardless() + guard let button = statusItem?.button else { return } + + if menubarPanel == nil { + let contentView = MenubarView().environmentObject(appState) + menubarPanel = MenubarPanel(contentRect: NSRect(x: 0, y: 0, width: 320, height: 1), rootView: contentView) + } + + guard let panel = menubarPanel else { return } + + if !panel.isVisible { + // Update content size + if let hostingView = panel.contentView { + let size = hostingView.fittingSize + panel.setContentSize(size) } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak popover] in - guard let popoverWindow = popover?.contentViewController?.view.window else { return } - NSApp.activate(ignoringOtherApps: true) - popoverWindow.makeKeyAndOrderFront(nil) - popoverWindow.orderFrontRegardless() + + // Position panel + let buttonFrame = button.window?.frame ?? .zero + let panelFrame = panel.frame + + let x = buttonFrame.origin.x + (buttonFrame.width / 2) - (panelFrame.width / 2) + let y = buttonFrame.origin.y - panelFrame.height - 5 + + panel.setFrameOrigin(NSPoint(x: x, y: y)) + + DispatchQueue.main.async { + panel.makeKeyAndOrderFront(nil) } appState.isMenubarWindowOpen = true // Monitor clicks outside to close eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in - self?.hidePopover() + if let eventLocation = NSEvent.mouseLocation as NSPoint?, + let panelFrame = self?.menubarPanel?.frame, + !NSMouseInRect(eventLocation, panelFrame, false) { + self?.hidePopover() + } } } } func hidePopover() { - popover?.performClose(nil) + menubarPanel?.orderOut(nil) appState.isMenubarWindowOpen = false if let monitor = eventMonitor { NSEvent.removeMonitor(monitor) diff --git a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift index 32595be4..c5ff217c 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -143,9 +143,28 @@ class QuickConnectManager: ObservableObject { private func sendWakeUpRequest(to device: Device) async { // Get current connection info to send in wake-up request - guard let currentIP = getBestLocalIP(for: device.ipAddress), - let currentPort = getCurrentMacPort() else { - print("[quick-connect] Cannot wake up device - no current connection info available") + var currentIP = getBestLocalIP(for: device.ipAddress) + var currentPort = getCurrentMacPort() + + if currentIP == nil || currentPort == nil { + print("[quick-connect] Network info not ready, waiting for WebSocket server...") + for i in 1...10 { + try? await Task.sleep(nanoseconds: 200_000_000) // 200ms + currentIP = getBestLocalIP(for: device.ipAddress) + currentPort = getCurrentMacPort() + + if currentIP != nil && currentPort != nil { + print("[quick-connect] Network info obtained after \(i * 200)ms") + break + } + } + } + + guard let finalIP = currentIP, let finalPort = currentPort else { + print("[quick-connect] Cannot wake up device - no current connection info available after waiting") + DispatchQueue.main.async { + self.connectingDeviceID = nil + } return } @@ -156,8 +175,8 @@ class QuickConnectManager: ObservableObject { { "type": "wakeUpRequest", "data": { - "macIP": "\(currentIP)", - "macPort": \(currentPort), + "macIP": "\(finalIP)", + "macPort": \(finalPort), "macName": "\(macName)", "isPlus": \(AppState.shared.isPlus) } @@ -224,19 +243,38 @@ class QuickConnectManager: ObservableObject { request.httpBody = message.data(using: .utf8) request.timeoutInterval = 5.0 + var success = false + do { let (_, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { print("[quick-connect] Wake-up request successful - device should reconnect soon") + success = true + } else if httpResponse.statusCode == 502 { + print("[quick-connect] Wake-up request failed with 502 (Bad Gateway). Retrying once...") + + // Small delay before retry + try? await Task.sleep(nanoseconds: 500_000_000) + + if let (_, secondResponse) = try? await URLSession.shared.data(for: request), + let secondHttpResponse = secondResponse as? HTTPURLResponse, + secondHttpResponse.statusCode == 200 { + print("[quick-connect] Wake-up retry successful") + success = true + } else { + print("[quick-connect] Wake-up retry failed") + } } else { print("[quick-connect] Wake-up request failed with status: \(httpResponse.statusCode)") } } } catch { print("[quick-connect] Failed to send wake-up request: \(error)") - + } + + if !success { // Fallback: Try UDP broadcast await sendUDPWakeUpRequest(to: device, message: message) } diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 22619c1e..20f5705b 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -149,6 +149,14 @@ 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 { + print("[quickshare] Blocking Quick Share send: relay-only connection, no local transport available") + transferState = .idle + AppState.shared.showingQuickShareTransfer = false + return + } transferState = .connecting(deviceID) transferProgress = 0 diff --git a/airsync-mac/Core/SentryInitializer.swift b/airsync-mac/Core/SentryInitializer.swift index 3a7650d9..99667283 100644 --- a/airsync-mac/Core/SentryInitializer.swift +++ b/airsync-mac/Core/SentryInitializer.swift @@ -25,6 +25,12 @@ struct SentryInitializer { options.sendDefaultPii = true options.beforeSend = { event in + // Ignore transient wake-up failures (often 502/timeout while device is waking up) + if let request = event.request, let url = request.url, url.contains("/wakeup") { + print("[SentryInitializer] Filtering out transient wake-up error for: \(url)") + return nil + } + if let exceptions = event.exceptions, let firstException = exceptions.first, firstException.type == "App Hanging" { diff --git a/airsync-mac/Core/Util/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 2d04f6eb..a669f121 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 { @@ -591,6 +662,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 7011528d..57482540 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -58,6 +58,30 @@ 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 + } + let isPrimary = (sessionId == primary) if isPrimary { // Primary session has gone silent — full reconnect cycle diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index eff27e75..4fc977db 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -48,6 +48,19 @@ 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? + internal var pendingRestartWorkItem: 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() @@ -144,17 +157,162 @@ 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 + pendingRestartWorkItem?.cancel() + lock.unlock() + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.stop() + self.start(port: restartPort) + } + + lock.lock() + pendingRestartWorkItem = workItem + lock.unlock() + + 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) { @@ -204,17 +362,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 } @@ -231,6 +397,7 @@ class WebSocketServer: ObservableObject { } if wasPrimary { + self.publishLanTransportState(isActive: false, reason: "disconnected_primary_session") DispatchQueue.main.async { AppState.shared.disconnectDevice() ADBConnector.disconnectADB() @@ -243,6 +410,219 @@ class WebSocketServer: ObservableObject { ) } + // 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] SECURITY: RX via RELAY dropped — decryption failed.") + return + } + } else { + print("[transport] SECURITY: RX via RELAY dropped — no symmetric key available.") + return + } + + 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() { @@ -274,7 +654,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/NotificationView/NotificationCardView.swift b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift index ef068b87..cf26a237 100644 --- a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift +++ b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift @@ -13,13 +13,15 @@ struct NotificationCardView: View { let deleteNotification: () -> Void let hideNotification: () -> Void + @State private var isHovering = false + var body: some View { - ZStack { + ZStack(alignment: .topTrailing) { HStack(alignment: .top) { appIconView() .aspectRatio(contentMode: .fit) - .frame(width: 25, height: 25) - .padding(5) + .frame(width: 30, height: 30) + .padding(2) VStack(alignment: .leading) { Text(notification.app + " - " + notification.title) @@ -52,7 +54,39 @@ struct NotificationCardView: View { .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) + + // Hover Actions Pill + if isHovering { + HStack(spacing: 4) { + Button { + hideNotification() + } label: { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .bold)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .help("Hide") + .glassBoxIfAvailable(radius: 24) + + Button { + deleteNotification() + } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .bold)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .help("Dismiss") + .glassBoxIfAvailable(radius: 32) + + } + .padding(6) + .transition(.move(edge: .top).combined(with: .opacity)) + } } + .contentShape(Rectangle()) + .onHover { isHovering = $0 } .swipeActions(edge: .leading) { Button(role: .cancel) { hideNotification() diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 32bc5357..b1ba44ac 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 "Connecting via 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/DeviceStatusView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift index 2a7520ca..0e4cbac1 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift @@ -14,6 +14,7 @@ struct DeviceStatusView: View { @State private var isDragging = false @State private var showingPlusPopover = false var showMediaToggle: Bool = true + var useGlass: Bool = true var body: some View { diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift index b95c05b1..1e1fe475 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/MenubarView/MenuBarNotificationsListView.swift b/airsync-mac/Screens/MenubarView/MenuBarNotificationsListView.swift index ab297838..3a082b92 100644 --- a/airsync-mac/Screens/MenubarView/MenuBarNotificationsListView.swift +++ b/airsync-mac/Screens/MenubarView/MenuBarNotificationsListView.swift @@ -9,29 +9,57 @@ import SwiftUI struct MenuBarNotificationsListView: View { @ObservedObject private var appState = AppState.shared - private let maxItems = 10 - + private let displayLimit = 4 + var body: some View { - Group { - if !appState.notifications.isEmpty { - List { - ForEach(appState.notifications.prefix(maxItems)) { notif in - NotificationCardView( - notification: notif, - deleteNotification: { appState.removeNotification(notif) }, - hideNotification: { appState.hideNotification(notif) } - ) - .listRowInsets(EdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10)) - .listRowBackground(Color.clear) - .applyGlassViewIfAvailable() - .animation(nil, value: appState.notifications.count) + VStack(spacing: 6) { + ForEach(appState.notifications.prefix(displayLimit)) { notif in + NotificationCardView( + notification: notif, + deleteNotification: { appState.removeNotification(notif) }, + hideNotification: { appState.hideNotification(notif) } + ) + .padding(6) + .segmentStyle() + } + + if appState.notifications.count > 0 { + HStack(spacing: 6) { + if appState.notifications.count > displayLimit { + Button { + AppDelegate.shared?.showAndActivateMainWindow() + MenuBarManager.shared.hidePopover() + } label: { + HStack(spacing: 6) { + Text("View more in app") + .font(.system(size: 11, weight: .medium)) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .bold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .segmentStyle(cornerRadius: 20) + } + .buttonStyle(.plain) + } + + Button { + appState.clearNotifications() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .frame(width: 28, height: 28) + + if appState.notifications.count <= displayLimit { + Text("Clear All") + .font(.system(size: 11, weight: .medium)) + .padding(.trailing, 8) + } } + .buttonStyle(.plain) + .segmentStyle(cornerRadius: 14) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .frame(maxWidth: .infinity) - .frame(minHeight: 100, maxHeight: 750) - .transaction { txn in txn.animation = nil } + .padding(.top, 4) } } } diff --git a/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift b/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift new file mode 100644 index 00000000..4d559269 --- /dev/null +++ b/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift @@ -0,0 +1,50 @@ +// +// MenubarDeviceDiscoveryView.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-07. +// + +import SwiftUI + +struct MenubarDeviceDiscoveryView: View { + @ObservedObject private var udpDiscovery = UDPDiscoveryManager.shared + @ObservedObject private var quickConnectManager = QuickConnectManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let devices = udpDiscovery.discoveredDevices + if !devices.isEmpty { + Text("Nearby Devices") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.horizontal, 4) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + let lastConnected = quickConnectManager.getLastConnectedDevice() + ForEach(devices) { device in + DeviceCard( + device: device, + isLastConnected: lastConnected?.name == device.name && (lastConnected != nil && device.ips.contains(lastConnected!.ipAddress)), + isCompact: true, + connectAction: { + quickConnectManager.connect(to: device) + }, + namespace: nil + ) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 4) + } + } + } + } +} + +#Preview { + MenubarDeviceDiscoveryView() + .frame(width: 320) + .background(Color.black.opacity(0.8)) +} diff --git a/airsync-mac/Screens/MenubarView/MenubarSegments.swift b/airsync-mac/Screens/MenubarView/MenubarSegments.swift new file mode 100644 index 00000000..23310beb --- /dev/null +++ b/airsync-mac/Screens/MenubarView/MenubarSegments.swift @@ -0,0 +1,222 @@ +// +// MenubarSegments.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-07. +// + +import SwiftUI + +struct TopSegmentView: View { + @ObservedObject var appState = AppState.shared + let toolButtonSize: CGFloat + let openAndFocusMainWindow: () -> Void + + var body: some View { + VStack(spacing: 12) { + HStack { + Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: toolButtonSize, height: toolButtonSize) + + Menu { + #if DEBUG + Button("Crash", systemImage: "bolt.trianglebadge.exclamationmark") { + fatalError("Sentry Test Crash") + } + #endif + + Button("Quit", systemImage: "power") { + NSApplication.shared.terminate(nil) + } + } label: { + Text("AirSync") + .font(.title2) + } + .menuStyle(.borderlessButton) + .focusable(false) + + Spacer() + + ConnectionStatusPill() + .focusable(false) + + GlassButtonView( + label: "Open App", + systemImage: "arrow.up.forward.app", + iconOnly: true, + circleSize: toolButtonSize + ) { + openAndFocusMainWindow() + } + } + + if appState.device != nil { + HStack(spacing: 4) { + GlassButtonView( + label: "Send Clipboard", + systemImage: "clipboard", + iconOnly: true, + circleSize: toolButtonSize, + action: { + sendClipboard() + } + ) + + GlassButtonView( + label: "QuickShare", + systemImage: "square.and.arrow.up", + iconOnly: true, + circleSize: toolButtonSize, + action: { + openQuickShare() + } + ) + + if appState.adbConnected { + GlassButtonView( + label: "Mirror", + systemImage: "apps.iphone", + iconOnly: true, + circleSize: toolButtonSize, + action: { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone" + ) + } + ) + .contextMenu { + Button("Android Mirror") { + appState.isNativeMirroring = true + } + + Button("Desktop Mode") { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone", + desktop: true + ) + } + } + } + + + GlassButtonView( + label: "DND", + systemImage: appState.silenceAllNotifications ? "bell.slash.fill" : "bell.badge", + iconOnly: true, + circleSize: toolButtonSize + ) { + appState.silenceAllNotifications.toggle() + } + } + + if appState.adbConnected && !appState.recentApps.isEmpty { + RecentAppsGridView() + } + } + } + .padding(12) + .segmentStyle() + } + + private func sendClipboard() { + let pasteboard = NSPasteboard.general + if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], let firstUrl = urls.first { + DispatchQueue.global(qos: .userInitiated).async { + WebSocketServer.shared.sendFile(url: firstUrl, isClipboard: true) + } + } else if let image = NSImage(pasteboard: pasteboard) { + let tempDir = FileManager.default.temporaryDirectory + let tempUrl = tempDir.appendingPathComponent("clipboard_image_\(Int(Date().timeIntervalSince1970)).png") + if let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) { + do { + try pngData.write(to: tempUrl) + DispatchQueue.global(qos: .userInitiated).async { + WebSocketServer.shared.sendFile(url: tempUrl, isClipboard: true) + } + } catch { + print("[MenubarView] Failed to save clipboard image: \(error)") + } + } + } + } + + private func openQuickShare() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + if panel.runModal() == .OK { + let targetName = appState.device?.name + QuickShareManager.shared.startDiscovery(autoTargetName: targetName) + QuickShareManager.shared.transferURLs = panel.urls + appState.showingQuickShareTransfer = true + } + } +} + +struct MediaSegmentView: View { + @ObservedObject var appState = AppState.shared + + var body: some View { + if let status = appState.status { + DeviceStatusView(showMediaToggle: true) + .background { + let artwork = status.music.albumArt + if !appState.isMusicCardHidden, + !artwork.isEmpty, + let data = Data(base64Encoded: artwork), + let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } + } + .clipShape(RoundedRectangle(cornerRadius: 20)) + .transition(.scale.combined(with: .opacity)) + .animation(.interpolatingSpring(stiffness: 200, damping: 30), value: appState.isMusicCardHidden) + } + } +} + + + +struct DiscoverySegmentView: View { + @ObservedObject var appState = AppState.shared + @StateObject private var udpDiscovery = UDPDiscoveryManager.shared + + var body: some View { + if appState.device == nil && !udpDiscovery.discoveredDevices.isEmpty { + MenubarDeviceDiscoveryView() + .padding(10) + .segmentStyle() + } + } +} + +struct NotificationsSegmentView: View { + @ObservedObject var appState = AppState.shared + + var body: some View { + if appState.device != nil && !appState.notifications.isEmpty { + VStack(spacing: 6) { + HStack { + Text("Notifications") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + + MenuBarNotificationsListView() + } + } + } +} diff --git a/airsync-mac/Screens/MenubarView/MenubarView.swift b/airsync-mac/Screens/MenubarView/MenubarView.swift index 08911eea..f0763d06 100644 --- a/airsync-mac/Screens/MenubarView/MenubarView.swift +++ b/airsync-mac/Screens/MenubarView/MenubarView.swift @@ -44,208 +44,36 @@ struct MenubarView: View { appState.device?.name ?? "Ready" } - private let minWidthTabs: CGFloat = 280 - private let toolButtonSize: CGFloat = 38 + private let minWidthTabs: CGFloat = 360 + private let toolButtonSize: CGFloat = 42 - var body: some View { - VStack { - VStack(spacing: 12){ - // Header - Text("AirSync - \(getDeviceName())") - .font(.headline) - - HStack(spacing: 10){ - GlassButtonView( - label: "Open App", - systemImage: "arrow.up.forward.app", - iconOnly: true, - circleSize: toolButtonSize - ) { - openAndFocusMainWindow() - } - - if (appState.device != nil){ - GlassButtonView( - label: "Sync Clipboard", - systemImage: "doc.on.clipboard", - iconOnly: true, - circleSize: toolButtonSize, - action: { - let pasteboard = NSPasteboard.general - if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], let firstUrl = urls.first { - DispatchQueue.global(qos: .userInitiated).async { - WebSocketServer.shared.sendFile(url: firstUrl, isClipboard: true) - } - } else if let image = NSImage(pasteboard: pasteboard) { - // Handle copied image data - let tempDir = FileManager.default.temporaryDirectory - let tempUrl = tempDir.appendingPathComponent("clipboard_image_\(Int(Date().timeIntervalSince1970)).png") - if let tiffData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) { - do { - try pngData.write(to: tempUrl) - DispatchQueue.global(qos: .userInitiated).async { - WebSocketServer.shared.sendFile(url: tempUrl, isClipboard: true) - } - } catch { - print("[MenubarView] Failed to save clipboard image: \(error)") - } - } - } - } - ) - .transition(.identity) - .keyboardShortcut( - "v", - modifiers: [.command, .shift] - ) - - GlassButtonView( - label: "Send", - systemImage: "paperplane.fill", - iconOnly: true, - circleSize: toolButtonSize, - action: { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = true - panel.canChooseFiles = true - panel.canChooseDirectories = false - - if panel.runModal() == .OK { - let targetName = appState.device?.name - QuickShareManager.shared.startDiscovery(autoTargetName: targetName) - QuickShareManager.shared.transferURLs = panel.urls - appState.showingQuickShareTransfer = true - } - } - ) - .transition(.identity) - .keyboardShortcut( - "f", - modifiers: .command - ) - } - - - if appState.adbConnected{ - GlassButtonView( - label: "Mirror", - systemImage: "apps.iphone", - iconOnly: true, - circleSize: toolButtonSize, - action: { - ADBConnector - .startScrcpy( - ip: appState.device?.ipAddress ?? "", - port: appState.adbPort, - deviceName: appState.device?.name ?? "My Phone" - ) - } - ) - .transition(.identity) - .keyboardShortcut( - "p", - modifiers: .command - ) - .contextMenu { - Button("Android Mirror") { - appState.isNativeMirroring = true - } - - Button("Desktop Mode") { - ADBConnector.startScrcpy( - ip: appState.device?.ipAddress ?? "", - port: appState.adbPort, - deviceName: appState.device?.name ?? "My Phone", - desktop: true - ) - } - } - .keyboardShortcut( - "p", - modifiers: [.command, .shift] - ) - } - - GlassButtonView( - label: appState.silenceAllNotifications ? "Disable DND" : "Enable DND", - systemImage: appState.silenceAllNotifications ? "bell.slash.fill" : "bell.badge", - iconOnly: true, - circleSize: toolButtonSize - ) { - appState.silenceAllNotifications.toggle() - } - .help(appState.silenceAllNotifications ? "Do Not Disturb is ON" : "Turn on Do Not Disturb") - - GlassButtonView( - label: "Quit", - systemImage: "power", - iconOnly: true, - circleSize: toolButtonSize - ) { - NSApplication.shared.terminate(nil) - } - - #if DEBUG - GlassButtonView( - label: "Crash", - systemImage: "bolt.trianglebadge.exclamationmark", - iconOnly: true, - circleSize: toolButtonSize - ) { - fatalError("Sentry Test Crash") - } - #endif - } - .padding(8) - - if appState.adbConnected && !appState.recentApps.isEmpty { - RecentAppsGridView() - .transition(.opacity.combined(with: .scale(scale: 0.95))) - } - - if (appState.status != nil){ - DeviceStatusView(showMediaToggle: false) - .transition(.opacity.combined(with: .scale)) - } + @State private var isAppearing = false - if let music = appState.status?.music, - let title = appState.status?.music.title.trimmingCharacters(in: .whitespacesAndNewlines), - !title.isEmpty { - - MediaPlayerView(music: music) - .transition(.opacity.combined(with: .scale)) - } - - - if !appState.notifications.isEmpty { - GlassButtonView( - label: "Clear All", - systemImage: "wind", - action: { - appState.clearNotifications() - } - ) - .help("Clear all notifications") - } - } - .padding(10) - - if appState.device != nil { - MenuBarNotificationsListView() - .frame(maxWidth: .infinity) - } - - } - .frame(minWidth: minWidthTabs) - .frame(maxWidth: .infinity) - .dropTarget(appState: appState, autoTargetName: appState.device?.name) - .onAppear { - appState.isMenubarWindowOpen = true + var body: some View { + VStack(spacing: 6) { + + TopSegmentView( + toolButtonSize: toolButtonSize, + openAndFocusMainWindow: openAndFocusMainWindow + ) + .staggeredEntrance(index: 0, isVisible: appState.isMenubarWindowOpen) + + DiscoverySegmentView() + .staggeredEntrance(index: 1, isVisible: appState.isMenubarWindowOpen) + + MediaSegmentView() + .staggeredEntrance(index: 2, isVisible: appState.isMenubarWindowOpen) + + NotificationsSegmentView() + .staggeredEntrance(index: 3, isVisible: appState.isMenubarWindowOpen) } - .onDisappear { - appState.isMenubarWindowOpen = false + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 24) + .frame(width: minWidthTabs + 48) + .environment(\.controlActiveState, .active) + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { _ in + // Optional: close if it loses focus } } } 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/DeviceCard.swift b/airsync-mac/Screens/ScannerView/DeviceCard.swift index 0f973dbd..1d2be24a 100644 --- a/airsync-mac/Screens/ScannerView/DeviceCard.swift +++ b/airsync-mac/Screens/ScannerView/DeviceCard.swift @@ -5,7 +5,7 @@ struct DeviceCard: View { let isLastConnected: Bool let isCompact: Bool let connectAction: () -> Void - let namespace: Namespace.ID + let namespace: Namespace.ID? @State private var wallpaperImage: NSImage? @ObservedObject private var quickConnectManager = QuickConnectManager.shared @@ -20,21 +20,28 @@ struct DeviceCard: View { // Compact Mode Button(action: connectAction) { HStack(spacing: 8) { - if isLoading { - ProgressView() - .controlSize(.small) - .frame(width: 16, height: 16) - } else { + ZStack { + if isLoading { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + } + Image(systemName: "iphone") .font(.system(size: 16)) - .matchedGeometryEffect(id: "icon-\(device.id)", in: namespace) + .ifLet(namespace) { view, ns in + view.matchedGeometryEffect(id: "icon-\(device.id)", in: ns) + } + .opacity(isLoading ? 0 : 1) } VStack(alignment: .leading, spacing: 0) { Text(device.name) .font(.system(size: 12, weight: .semibold)) .lineLimit(1) - .matchedGeometryEffect(id: "name-\(device.id)", in: namespace) + .ifLet(namespace) { view, ns in + view.matchedGeometryEffect(id: "name-\(device.id)", in: ns) + } } if !device.isActive { @@ -59,28 +66,16 @@ struct DeviceCard: View { Image(systemName: "clock.arrow.circlepath") .font(.caption2) .foregroundColor(.accentColor) - .matchedGeometryEffect(id: "status-\(device.id)", in: namespace) + .ifLet(namespace) { view, ns in + view.matchedGeometryEffect(id: "status-\(device.id)", in: ns) + } } } .padding(.horizontal, 12) .padding(.vertical, 8) .glassBoxIfAvailable(radius: 20) - .tint(isLastConnected && device.isActive ? Color.accentColor.opacity(0.5) : Color.clear) .opacity(device.isActive ? 1.0 : 0.7) .grayscale(device.isActive ? 0 : 0.4) - .background( - GeometryReader { geometry in - if let nsImage = wallpaperImage { - Image(nsImage: nsImage) - .resizable() - .scaledToFill() - .blur(radius: device.isActive ? 0 : 3) - .frame(width: geometry.size.width, height: geometry.size.height) - .clipped() - } - } - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - ) } .buttonStyle(.plain) } else { @@ -90,13 +85,17 @@ struct DeviceCard: View { .font(.system(size: 50)) .foregroundColor(.secondary) .padding(.top, 16) - .matchedGeometryEffect(id: "icon-\(device.id)", in: namespace) + .ifLet(namespace) { view, ns in + view.matchedGeometryEffect(id: "icon-\(device.id)", in: ns) + } VStack(spacing: 4) { Text(device.name) .font(.system(size: 18, weight: .bold)) .multilineTextAlignment(.center) - .matchedGeometryEffect(id: "name-\(device.id)", in: namespace) + .ifLet(namespace) { view, ns in + view.matchedGeometryEffect(id: "name-\(device.id)", in: ns) + } HStack(spacing: 8) { if device.ips.contains(where: { !$0.hasPrefix("100.") }) { @@ -129,7 +128,9 @@ struct DeviceCard: View { .padding(.horizontal, 8) .padding(.vertical, 2) .background(Color.accentColor.opacity(0.2), in: .capsule) - .matchedGeometryEffect(id: "status-\(device.id)", in: namespace) + .ifLet(namespace) { view, ns in + view.matchedGeometryEffect(id: "status-\(device.id)", in: ns) + } } Spacer() diff --git a/airsync-mac/Screens/ScannerView/ScannerView.swift b/airsync-mac/Screens/ScannerView/ScannerView.swift index 84f2e7e0..44e3ccb3 100644 --- a/airsync-mac/Screens/ScannerView/ScannerView.swift +++ b/airsync-mac/Screens/ScannerView/ScannerView.swift @@ -12,8 +12,8 @@ import CryptoKit struct ScannerView: View { @ObservedObject var appState = AppState.shared - @StateObject private var quickConnectManager = QuickConnectManager.shared - @StateObject private var udpDiscovery = UDPDiscoveryManager.shared + @ObservedObject private var quickConnectManager = QuickConnectManager.shared + @ObservedObject private var udpDiscovery = UDPDiscoveryManager.shared @State private var qrImage: CGImage? @State private var showQR = true @State private var copyStatus: String? @@ -132,6 +132,9 @@ struct ScannerView: View { } } .transition(.move(edge: .top).combined(with: .opacity)) + .onTapGesture { + generateQRAsync() + } } else { Spacer() } @@ -191,8 +194,8 @@ struct ScannerView: View { .scrollClipDisabled() .animation(.spring(response: 0.5, dampingFraction: 0.8), value: udpDiscovery.discoveredDevices) .frame(maxWidth: .infinity) - .frame(height: showQR ? 100 : nil) - .frame(maxHeight: showQR ? 100 : 400) + .frame(height: showQR ? 80 : 260) + .frame(maxHeight: 400) } .padding(.top, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -206,9 +209,7 @@ struct ScannerView: View { .onDisappear { // UDP Discovery is now managed globally in App/AppDelegate } - .onTapGesture { - generateQRAsync() - } + .onChange(of: appState.shouldRefreshQR) { _, newValue in if newValue { generateQRAsync() @@ -229,6 +230,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 +277,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 +309,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 56cf31f4..c1894f9a 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 56b14da9..3afe9f7b 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 diff --git a/appcast.xml b/appcast.xml index c05cb00a..4d9f0d22 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,12 +3,12 @@ AirSync - 3.1.0 - Sat, 11 Apr 2026 02:32:57 +0530 - 26 - 3.1.0 + 3.2.0 + Sat, 09 May 2026 14:14:48 +0530 + 28 + 3.2.0 14.5 - +