feat: AirBridge relay - connect Mac and Android when not on the same network (macOS Integration)#214
feat: AirBridge relay - connect Mac and Android when not on the same network (macOS Integration)#214tornado-bunk wants to merge 18 commits intosameerasw:mainfrom
Conversation
- AirBridge relay client for Mac-Android communication over internet - HMAC Challenge-Response authentication for LAN security - Anti-replay protection with bounded nonce cache - Keychain integration for encrypted keys and relay credentials - Graceful LAN-to-relay transport fallback - ADB restricted to direct LAN connections only - New onboarding step and settings UI for relay configuration - QR code generation includes optional relay parameters - Enhanced KeychainStorage API with binary data support - Port binding retry logic with exponential backoff - Disabled PII reporting for privacy compliance New Files: - AirBridgeClient.swift and AirBridgeModels.swift - AirBridgeSetupView.swift and AirBridgeSettingsView.swift Modified: 14 core files (WebSocketServer, AppState, Keychain, Crypto, etc.)
…ocal network connections
- Added support for ping and pong message types in MessageType enum. - Updated WebSocketServer to handle ping/pong messages for keepalive. - Refactored server start logic to simplify connection handling. - Ensured AirBridge auto-connects upon enabling in settings. - Improved credential management and default relay URL setup in AirBridge settings.
…BridgeClient refactor: Update import statements to internal for Combine in multiple files
…oss multiple files and improve code clarity - Adjusted Combine import visibility from internal to standard in various files. - Refined comments for clarity and consistency in Keychain handling and connection management. - Updated default relay URL in settings for better user guidance.
…dling - Introduced connection generation tracking to manage reconnections more effectively. - Updated connect, disconnect, and reconnect logic to ensure proper state handling. - Refactored sendRegistration and startReceiving methods to include expected connection generation checks. - Added support for handling ping and pong messages in WebSocketServer for improved keepalive functionality. - Improved error handling and state management during message processing.
…es in AirBridgeClient and QuickShareManager
…er online or not in relay mode
…and improve connection handling
…etServer - Added support for transport offer, answer, check, and nomination message types. - Enhanced transport generation tracking to manage LAN negotiation effectively. - Implemented debounce logic for LAN state transitions to prevent rapid oscillation. - Updated AirBridgeClient to send transport offers when transitioning to relay mode.
…and help text based on connection state
- Added `requestConnectionFromCurrentTransport` method in ADBConnector to manage ADB connection logic for local LAN and relay-only sessions. - Updated ConnectionStatusPill and SettingsFeaturesView to utilize the new ADB connection method, streamlining connection requests and improving user feedback.
…ient - Added support for HMAC challenge-response authentication during WebSocket connection. - Introduced `AirBridgeChallengeMessage` and updated `AirBridgeRegisterMessage` to include HMAC signature and initialization key. - Enhanced connection state management to handle challenge reception and registration flow. - Updated UI to reflect the new challenge state in connection status.
…ridge - Changed Keychain service identifier to "com.sameerasw.airsync.trial" for trial version support. - Updated AirBridgeSetupView title to "AirBridge Relay (Beta)" to indicate beta status. - Modified default relay URL placeholder in AirBridgeSettingsView for clearer user guidance.
…s label - Removed unnecessary debug print statements across multiple files to clean up the codebase. - Updated the AirBridgeSettingsView label to indicate the feature is in beta, improving clarity for users.
There was a problem hiding this comment.
Pull request overview
Adds AirBridge relay support to the macOS AirSync app so Mac ↔ Android can communicate when they aren’t on the same LAN, with UI for onboarding/settings and transport-aware feature gating.
Changes:
- Introduces an AirBridge WebSocket relay client plus onboarding and settings UI (pairing ID/secret, connection status, save/reconnect).
- Extends
WebSocketServerto route messages via relay, publish LAN session events, and negotiate LAN ↔ relay transport. - Updates app behavior/UX for transport awareness (QR includes relay credentials; ADB/Quick Share gated; safer server restarts; Keychain preload to reduce prompts).
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| airsync-mac/airsync_macApp.swift | Preloads Keychain items at app init to reduce multiple prompts. |
| airsync-mac/Screens/Settings/SettingsView.swift | Uses requestRestart() on adapter changes; adds AirBridge settings section. |
| airsync-mac/Screens/Settings/SettingsFeaturesView.swift | Routes ADB connect via transport-aware connector; updates enable/disable logic. |
| airsync-mac/Screens/Settings/AirBridgeSettingsView.swift | New AirBridge settings UI (toggle, status, credentials, save/reconnect). |
| airsync-mac/Screens/ScannerView/ScannerView.swift | Regenerates QR on AirBridge toggle; QR now embeds relay URL/pairing/secret. |
| airsync-mac/Screens/OnboardingView/OnboardingView.swift | Adds AirBridge setup step to onboarding flow. |
| airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift | New onboarding UI with connectivity test + credential entry. |
| airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift | Disables Quick Share button when on relay transport. |
| airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift | Updates transport indicator and popover fields for relay vs LAN. |
| airsync-mac/Screens/HomeScreen/AppContentView.swift | Refresh button now uses requestRestart() instead of stop/start. |
| airsync-mac/Model/Message.swift | Adds relay keepalive + transport negotiation message types. |
| airsync-mac/Core/WebSocket/WebSocketServer.swift | Adds LAN session event publishing, transport generation state, relay RX routing, and requestRestart(). |
| airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift | Avoids hard restart on stale sessions during relay-active switching; uses requestRestart(). |
| airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift | Falls back to relay for outbound when LAN session is down; adds transport negotiation senders; blocks ADB port refresh without LAN. |
| airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift | Uses requestRestart() on IP change for safer recycling. |
| airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift | Adds relay-only handler routing + transport negotiation handlers; hardens ADB auto-connect to LAN-only. |
| airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift | Adds bulk preload + in-memory cache; adds delete API; tweaks accessibility settings. |
| airsync-mac/Core/Util/CLI/ADBConnector.swift | Adds transport-aware ADB connection entry point (LAN vs relay-only wired). |
| airsync-mac/Core/QuickShare/QuickShareManager.swift | Blocks Quick Share sending when only relay transport is available. |
| airsync-mac/Core/Discovery/UDPDiscoveryManager.swift | Adds staged wake recovery bursts and a delayed server restart. |
| airsync-mac/Core/AppleScriptSupport.swift | Gates AppleScript ADB command to LAN-only sessions. |
| airsync-mac/Components/Buttons/SaveAndRestartButton.swift | Uses requestRestart() instead of stop/start. |
| airsync-mac/Core/AppState.swift | Persists AirBridge enablement, subscribes to LAN session events, introduces peer transport hint + effective transport computation, auto-connects relay on launch. |
| airsync-mac/Core/AirBridge/AirBridgeModels.swift | Adds AirBridge protocol message models + connection state enum. |
| airsync-mac/Core/AirBridge/AirBridgeClient.swift | New AirBridge relay client (challenge-response auth, reconnect, ping/pong, wake handling, URL normalization). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Fallback: If decryption fails, check if it's valid plaintext JSON. | ||
| // This handles cases where keys are out of sync or the client sends plaintext via the secure relay tunnel. | ||
| if text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { | ||
| decryptedText = text | ||
| } else { |
There was a problem hiding this comment.
Relay RX path falls back to accepting plaintext JSON whenever decryption fails. That effectively disables the app-level encryption guarantee for all message types whenever keys are out of sync, and allows an authenticated relay peer to inject arbitrary plaintext control messages. If this fallback is needed for compatibility, consider restricting it to a tight allowlist (e.g., only ping/pong) or gating it behind an explicit debug/compat flag, otherwise drop the message and force re-keying.
| if case .relayActive = AirBridgeClient.shared.connectionState { | ||
| return AirBridgeClient.shared.isPeerConnected ? "AirBridge Relay (peer online)" : "AirBridge Relay (peer offline)" | ||
| } | ||
| return "AirBridge Relay" |
There was a problem hiding this comment.
connectionIconHelp returns "AirBridge Relay" for the non-local, non-.relayActive case. That will show a misleading tooltip when the app is simply disconnected, or when connected over another non-LAN transport (e.g. the existing Tailscale/VPN support referenced elsewhere in the app). Consider distinguishing between (a) relay active, (b) other remote transports, and (c) no connection, so the UI matches the actual state.
| return "AirBridge Relay" | |
| // Non-local, non-relay transport: distinguish between active remote connection and no connection | |
| if AirBridgeClient.shared.isPeerConnected { | |
| return "Remote connection (non-local transport)" | |
| } else { | |
| return "Not connected" | |
| } |
| 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" | ||
| ) |
There was a problem hiding this comment.
Popover transport text maps any non-isEffectivelyLocalTransport connection to "AirBridge Relay". If the device is connected over a VPN/Tailscale session (100.*) or similar direct-but-not-LAN path, this label becomes inaccurate. Consider deriving this label from the actual active transport (LAN vs relay vs VPN) rather than a binary local/relay flag.
| 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 { |
There was a problem hiding this comment.
In relay mode this method returns early without updating any UI state (e.g., transferState) or informing the caller why sending was blocked. Since Quick Share can be triggered from multiple entry points (menu bar / drag & drop / Services), this can leave the transfer sheet in a confusing state. Consider surfacing an error state/message and/or closing the sheet when relay transport blocks the operation.
| !AppState.shared.isEffectivelyLocalTransport { | |
| !AppState.shared.isEffectivelyLocalTransport { | |
| print("[quickshare] Blocking Quick Share send: relay-only connection, no local transport available") | |
| transferState = .idle | |
| AppState.shared.showingQuickShareTransfer = false |
| if let sendError = sendError { | ||
| settle(.failure(sendError)) | ||
| } else { | ||
| settle(.success(())) |
There was a problem hiding this comment.
testConnectivity reports success immediately after sending the register message, without waiting for any server response (e.g. relay_started or an error). This can show a false-positive “connectivity OK” even when the server rejects the credentials right after the send. Consider receiving at least one follow-up frame (or close code) and only succeeding when the server confirms registration.
| settle(.success(())) | |
| // Wait for at least one follow-up frame from the server | |
| task.receive { result in | |
| switch result { | |
| case .success(_): | |
| // Any successful follow-up message indicates that the | |
| // server did not immediately reject the registration. | |
| settle(.success(())) | |
| case .failure(let error): | |
| // A receive failure or early close indicates connectivity failure. | |
| settle(.failure(error)) | |
| } | |
| } |
| var h = url | ||
| // Strip scheme if present | ||
| if h.hasPrefix("wss://") { h = String(h.dropFirst(6)) } | ||
| else if h.hasPrefix("ws://") { h = String(h.dropFirst(5)) } | ||
| return h.components(separatedBy: ":").first?.components(separatedBy: "/").first ?? "" |
There was a problem hiding this comment.
normalizeRelayURL() extracts the host by splitting on ":" and "/", which will break for IPv6 literals (e.g. wss://[::1]:8080/ws) and could mis-classify the host as public/private. Consider using URLComponents (or URL(string:)?.host) to parse the host safely, including IPv6 bracket syntax and explicit ports.
| var h = url | |
| // Strip scheme if present | |
| if h.hasPrefix("wss://") { h = String(h.dropFirst(6)) } | |
| else if h.hasPrefix("ws://") { h = String(h.dropFirst(5)) } | |
| return h.components(separatedBy: ":").first?.components(separatedBy: "/").first ?? "" | |
| // 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 ?? "" |
| // MARK: - Keychain Config Blob (consolidated storage) | ||
|
|
||
| /// All AirBridge credentials stored as a single Keychain entry to minimise password prompts | ||
| struct AirBridgeConfigBlob: Codable { | ||
| let url: String | ||
| let pid: String | ||
| let sec: String | ||
| } | ||
|
|
||
| // MARK: - Connection State | ||
|
|
There was a problem hiding this comment.
AirBridgeConfigBlob is introduced as a consolidated Keychain storage model, but it isn’t referenced anywhere in the implementation (credentials are split across UserDefaults + a separate Keychain key). If the consolidated blob approach is no longer planned, consider removing this unused type to avoid confusion; otherwise, wire it up so storage matches the model/documentation.
| // MARK: - Keychain Config Blob (consolidated storage) | |
| /// All AirBridge credentials stored as a single Keychain entry to minimise password prompts | |
| struct AirBridgeConfigBlob: Codable { | |
| let url: String | |
| let pid: String | |
| let sec: String | |
| } | |
| // MARK: - Connection State | |
| // MARK: - Connection State |
| let workItem = DispatchWorkItem { [weak self] in | ||
| guard let self = self else { return } | ||
| self.stop() | ||
| self.start(port: restartPort) | ||
| } |
There was a problem hiding this comment.
requestRestart() schedules a new stop/start work item but doesn't keep a reference to cancel/merge previously scheduled restarts. If multiple triggers fire close together (IP change + wake recovery + settings), this can queue several restarts and cause flapping/races. Consider storing a pendingRestartWorkItem (or using a debounce) and cancelling the previous one before scheduling the next.
Summary
This PR introduces AirBridge, a self-hosted relay server integration that enables AirSync to work even when Mac and Android are not on the same local network (you can find the server here: AirBridge). The relay acts as an encrypted WebSocket bridge: when a direct LAN connection isn't available, messages are tunneled through the relay server.
Motivation
AirSync is already amazing for local use, but I wanted to extend its versatility to support remote workflows (hotspots, office networks, etc.) without relying on third-party cloud providers.
What's Changed
New files:
AirBridgeClient.swift— WebSocket client that connects to the relay server, handles HMAC-SHA256 challenge-response authentication, manages connection lifecycle (reconnect with exponential backoff, ping/pong keepalive, system wake detection)AirBridgeModels.swift— Protocol models (AirBridgeAction, connection states, register/challenge messages, Keychain config blob)AirBridgeSetupView.swift— Onboarding step for configuring AirBridge (relay URL, pairing ID, secret) with a built-in connectivity testAirBridgeSettingsView.swift— Settings panel for managing AirBridge configuration post-onboarding, with live connection status and save & reconnectModified files:
WebSocketServer.swiftWebSocketServer+Handlers.swiftmacWake,peerTransport,transportOffer/Answer/Check/Nominate), relay-aware ping/pongWebSocketServer+Outgoing.swiftWebSocketServer+Networking.swiftrequestRestart()instead of raw stop/start on network IP changes for safer server recyclingWebSocketServer+Ping.swiftAppState.swiftairBridgeEnabledpersisted setting,PeerTransportHintenum,isEffectivelyLocalTransportcomputed property, auto-connect on launch, LAN session event subscriptionKeychainStorage.swiftSecItemCopyMatchingwithkSecMatchLimitAllto avoid multiple macOS password prompts at launchMessage.swiftMessageTypecases:ping,pong,peerTransport,transportOffer,transportAnswer,transportCheck,transportCheckAck,transportNominateConnectionStatusPill.swiftOnboardingView.swiftScannerView.swiftSettingsView.swift/SettingsFeaturesView.swiftADBConnector.swiftAppleScriptSupport.swiftQuickShareManager.swiftUDPDiscoveryManager.swiftSaveAndRestartButton.swiftrequestRestart()instead of raw stop/start for safer server recyclingAppContentView.swiftrequestRestart()instead of raw stop/startScreenView.swiftairsync_macApp.swiftpreload()call at app init to trigger a single macOS password promptKnown Limitations
How to Test
Note
This feature is marked as Beta. The relay itself is stable and functional — the "beta" designation reflects that the automatic switching between LAN and relay transport can occasionally be imperfect (e.g. after a Mac sleep/wake cycle or a network change). This will be refined in future updates.
This implementation was developed using an AI-assisted workflow. While AI helped accelerate the cross-platform integration and the Go relay logic, the entire architecture was manually designed and every part of the code has been personally reviewed.
Screenshots: