Skip to content

feat: AirBridge relay - connect Mac and Android when not on the same network (macOS Integration)#214

Open
tornado-bunk wants to merge 18 commits intosameerasw:mainfrom
tornado-bunk:feat/airbridge-server
Open

feat: AirBridge relay - connect Mac and Android when not on the same network (macOS Integration)#214
tornado-bunk wants to merge 18 commits intosameerasw:mainfrom
tornado-bunk:feat/airbridge-server

Conversation

@tornado-bunk
Copy link
Copy Markdown

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 test
  • AirBridgeSettingsView.swift — Settings panel for managing AirBridge configuration post-onboarding, with live connection status and save & reconnect

Modified files:

File Changes
WebSocketServer.swift Relay message routing, transport negotiation state machine (generation-based), LAN session publish/subscribe, peer transport status exchange
WebSocketServer+Handlers.swift New message types (macWake, peerTransport, transportOffer/Answer/Check/Nominate), relay-aware ping/pong
WebSocketServer+Outgoing.swift Relay fallback for outgoing messages when LAN is down
WebSocketServer+Networking.swift Uses requestRestart() instead of raw stop/start on network IP changes for safer server recycling
WebSocketServer+Ping.swift Encrypted ping payloads to reduce plaintext noise on the relay
AppState.swift airBridgeEnabled persisted setting, PeerTransportHint enum, isEffectivelyLocalTransport computed property, auto-connect on launch, LAN session event subscription
KeychainStorage.swift AirBridge secret stored securely in Keychain; added bulk preload via SecItemCopyMatching with kSecMatchLimitAll to avoid multiple macOS password prompts at launch
Message.swift New MessageType cases: ping, pong, peerTransport, transportOffer, transportAnswer, transportCheck, transportCheckAck, transportNominate
ConnectionStatusPill.swift Dynamic icon color (green for LAN, blue for relay, orange for peer offline), help text that reflects the active transport
OnboardingView.swift New AirBridge setup step in the onboarding flow
ScannerView.swift Updated to handle AirBridge-generated pairing credentials
SettingsView.swift / SettingsFeaturesView.swift AirBridge settings section
ADBConnector.swift ADB connection handling based on transport type (disabled over relay)
AppleScriptSupport.swift ADB AppleScript command gated to LAN-only — returns error message when in relay mode
QuickShareManager.swift Quick Share disabled whe connected via relay
UDPDiscoveryManager.swift Enhanced system wake recovery: staged broadcast bursts at T+3s/6s/10s and WebSocket server restart at T+2s
SaveAndRestartButton.swift Uses requestRestart() instead of raw stop/start for safer server recycling
AppContentView.swift Refresh button uses requestRestart() instead of raw stop/start
ScreenView.swift Quick Share button disabled when connected via relay
airsync_macApp.swift Keychain preload() call at app init to trigger a single macOS password prompt

Known Limitations

  • LAN ↔ relay switching after Mac sleep/wake or network changes may not always be seamless — in rare cases the UI might briefly show the wrong transport or require a manual reconnect
  • Quick Share and ADB are disabled when connected via relay (they require a direct LAN connection)
  • Requires a self-hosted AirBridge relay server

How to Test

  1. Deploy an AirBridge relay server
  2. On Mac: enable AirBridge in Settings or during onboarding, enter the relay URL, and use the generated pairing ID + secret
  3. On Android: enter the same relay URL, pairing ID, and secret (via QR or manually)
  4. Disconnect from the same Wi-Fi → verify connection falls back to relay
  5. Reconnect to the same Wi-Fi → verify connection upgrades back to LAN

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:

Onboarding Settings
onboarding settings

- 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.)
- 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.
…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.
- 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.
Copilot AI review requested due to automatic review settings March 30, 2026 18:30
@tornado-bunk tornado-bunk changed the title AirBridge relay - connect Mac and Android when not on the same network (macOS Integration) feat: AirBridge relay - connect Mac and Android when not on the same network (macOS Integration) Mar 30, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 WebSocketServer to 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.

Comment on lines +419 to +423
// 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 {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
if case .relayActive = AirBridgeClient.shared.connectionState {
return AirBridgeClient.shared.isPeerConnected ? "AirBridge Relay (peer online)" : "AirBridge Relay (peer offline)"
}
return "AirBridge Relay"
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"
}

Copilot uses AI. Check for mistakes.
Comment on lines 140 to 144
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"
)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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 {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
!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

Copilot uses AI. Check for mistakes.
if let sendError = sendError {
settle(.failure(sendError))
} else {
settle(.success(()))
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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))
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +612 to +616
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 ?? ""
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 ?? ""

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +66
// 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

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +167
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.stop()
self.start(port: restartPort)
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants