Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class AppState: ObservableObject {
let validatedAdapter = AppState.validateAndGetNetworkAdapter(savedName: savedAdapterName)
self.selectedNetworkAdapterName = validatedAdapter

let adapterIP = WebSocketServer.shared.getLocalIPAddress(adapterName: validatedAdapter) ?? "N/A"
let adapterIP = WebSocketServer.shared.getPrimaryConnectionIP(adapterName: validatedAdapter) ?? "N/A"
let deviceName = UserDefaults.standard.string(forKey: "deviceName") ?? (Host.current().localizedName ?? "My Mac")
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.0.0"
let portNumStr = UserDefaults.standard.string(forKey: "devicePort") ?? String(Defaults.serverPort)
Expand Down Expand Up @@ -185,14 +185,85 @@ class AppState: ObservableObject {
@Published var recentApps: [AndroidApp] = []

var isConnectedOverLocalNetwork: Bool {
guard let ip = device?.ipAddress else { return true }
// Tailscale IPs usually start with 100.
return !ip.hasPrefix("100.")
transportConnectionType == .local
}

var connectionTransportLabel: String {
switch transportConnectionType {
case .local:
return "LAN"
case .extended:
return "Internet"
case .unknown:
return "Unknown"
}
}

var connectionTransportHelp: String {
switch transportConnectionType {
case .local:
return "Connected directly over the local network"
case .extended:
return "Connected over an extended path such as Tailscale or another routed network"
case .unknown:
return "Connection transport is not available"
}
}

var connectionTransportIcon: String {
switch transportConnectionType {
case .local:
return "wifi"
case .extended:
return "globe"
case .unknown:
return "questionmark.circle"
}
}

// Audio player for ringtone
private var ringtonePlayer: AVAudioPlayer?

private enum ConnectionTransportType {
case local
case extended
case unknown
}

private var transportConnectionType: ConnectionTransportType {
guard let ip = connectionTransportIP else { return .unknown }
return AppState.classifyConnectionIP(ip)
}

private var connectionTransportIP: String? {
if let active = activeMacIp?.trimmingCharacters(in: .whitespaces), !active.isEmpty {
return active
}
if let deviceIP = device?.ipAddress.trimmingCharacters(in: .whitespaces), !deviceIP.isEmpty {
return deviceIP
}
return nil
}

private static func classifyConnectionIP(_ ip: String) -> ConnectionTransportType {
if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") || ip.hasPrefix("127.") || ip.hasPrefix("169.254.") {
return .local
}

if ip.hasPrefix("172.") {
let parts = ip.split(separator: ".")
if parts.count > 1, let secondOctet = Int(parts[1]), (16...31).contains(secondOctet) {
return .local
}
}

if ip.hasPrefix("100.") {
return .extended
}

return .extended
}

@Published var selectedNetworkAdapterName: String? { // e.g., "en0"
didSet {
UserDefaults.standard.set(selectedNetworkAdapterName, forKey: "selectedNetworkAdapterName")
Expand Down
30 changes: 19 additions & 11 deletions airsync-mac/Core/Discovery/UDPDiscoveryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,17 @@ class UDPDiscoveryManager: ObservableObject {
}

func broadcastPresence() {
let adapters = WebSocketServer.shared.getAvailableNetworkAdapters()
guard !adapters.isEmpty else { return }
let candidateIPs = WebSocketServer.shared.getConnectionCandidateIPs(
adapterName: AppState.shared.selectedNetworkAdapterName
)
guard !candidateIPs.isEmpty else { return }

// knownPeerIPs: List of IPs we have connected to before (e.g. Android on Tailscale)
let knownPeerIPs = Set(QuickConnectManager.shared.lastConnectedDevices.values.map { $0.ipAddress })

let allIPs = adapters.map { $0.address }
let knownPeerIPs = Set(
QuickConnectManager.shared.lastConnectedDevices.values
.map { $0.ipAddress }
.filter { !$0.hasPrefix("100.") }
)

let info = AppState.shared.myDevice
let port = info?.port ?? Int(Defaults.serverPort)
Expand All @@ -134,22 +138,22 @@ class UDPDiscoveryManager: ObservableObject {
"deviceType": "mac",
"id": uuid,
"name": name,
"ips": allIPs, // Send ALL IPs
"ips": candidateIPs,
"port": port
]

if let data = try? JSONSerialization.data(withJSONObject: payload),
let jsonString = String(data: data, encoding: .utf8) {
for adapter in adapters {
sendBroadcast(message: jsonString, sourceIP: adapter.address)
for sourceIP in candidateIPs {
sendBroadcast(message: jsonString, sourceIP: sourceIP)
}
}

if let data = try? JSONSerialization.data(withJSONObject: payload),
let jsonString = String(data: data, encoding: .utf8) {

for peerIP in knownPeerIPs {
if allIPs.contains(peerIP) { continue }
if candidateIPs.contains(peerIP) { continue }
sendUnicast(message: jsonString, targetIP: peerIP)
}
}
Expand Down Expand Up @@ -336,8 +340,12 @@ class UDPDiscoveryManager: ObservableObject {

/// IP validation
private func isValidCandidateIP(_ ip: String) -> Bool {
// 1. Allow Tailscale (100.x.x.x)
if ip.hasPrefix("100.") { return true }
let localCandidates = WebSocketServer.shared.getConnectionCandidateIPs(
adapterName: AppState.shared.selectedNetworkAdapterName
)
let hasLocalLAN = localCandidates.contains { !$0.hasPrefix("100.") }

if ip.hasPrefix("100.") { return !hasLocalLAN }

// 2. Allow Standard Local LAN (192.168.x.x)
if ip.hasPrefix("192.168.") { return true }
Expand Down
31 changes: 14 additions & 17 deletions airsync-mac/Core/QuickConnect/QuickConnectManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,12 @@ class QuickConnectManager: ObservableObject {

/// Attempts to wake up and reconnect to a specific discovered device
func connect(to discoveredDevice: DiscoveredDevice) {
// Pick best IP: prefer local (non-100.x) over VPN
let bestIP = discoveredDevice.ips.first(where: { !$0.hasPrefix("100.") }) ?? discoveredDevice.ips.first ?? ""
let bestIP = discoveredDevice.ips.sorted {
let lhsIsTailscale = $0.hasPrefix("100.")
let rhsIsTailscale = $1.hasPrefix("100.")
if lhsIsTailscale != rhsIsTailscale { return !lhsIsTailscale }
return $0 < $1
}.first ?? ""

// Convert DiscoveredDevice to Device model
let device = Device(
Expand Down Expand Up @@ -105,7 +109,7 @@ class QuickConnectManager: ObservableObject {
// MARK: - Private Implementation

private func getCurrentMacIP() -> String? {
return WebSocketServer.shared.getLocalIPAddress(
return WebSocketServer.shared.getPrimaryConnectionIP(
adapterName: AppState.shared.selectedNetworkAdapterName
)
}
Expand Down Expand Up @@ -162,29 +166,22 @@ class QuickConnectManager: ObservableObject {
/// Selects the best local IP to present to the target device
/// Prioritizes IPs that match the target's subnet/prefix (e.g. Tailscale 100.x)
private func getBestLocalIP(for targetIP: String) -> String? {
let adapters = WebSocketServer.shared.getAvailableNetworkAdapters()
let allIPs = adapters.map { $0.address }
let allIPs = WebSocketServer.shared.getConnectionCandidateIPs(
adapterName: AppState.shared.selectedNetworkAdapterName,
includeExpandedNetworking: false
)

// 1. If user manually selected an adapter, MUST use that
if let selected = AppState.shared.selectedNetworkAdapterName {
if let match = adapters.first(where: { $0.name == selected }) {
if let match = WebSocketServer.shared.getAvailableNetworkAdapters().first(where: { $0.name == selected }) {
return match.address
}
}

// 2. If valid target IP, try to match prefix
if !targetIP.isEmpty {
// Check for Tailscale (100.x)
if targetIP.hasPrefix("100.") {
if let tailscaleIP = allIPs.first(where: { $0.hasPrefix("100.") }) {
return tailscaleIP
}
}

// Check for other common prefixes (subnet match)
let parts = targetIP.split(separator: ".")
if let firstOctet = parts.first {
let prefix = "\(firstOctet)."
if parts.count >= 3 {
let prefix = "\(parts[0]).\(parts[1]).\(parts[2])."
if let match = allIPs.first(where: { $0.hasPrefix(prefix) }) {
return match
}
Expand Down
97 changes: 88 additions & 9 deletions airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,99 @@ extension WebSocketServer {

// MARK: - Local IP handling

private func isTailscaleIP(_ ip: String) -> Bool {
ip.hasPrefix("100.")
}

private func isPrivateLANIP(_ ip: String) -> Bool {
if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") { return true }
if ip.hasPrefix("172.") {
let parts = ip.split(separator: ".")
if parts.count > 1, let second = Int(parts[1]), (16...31).contains(second) {
return true
}
}
return false
}

private func rankConnectionIPs(_ ips: [String], includeExpandedNetworking: Bool) -> [String] {
let sanitized = ips
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let filtered = sanitized.filter { includeExpandedNetworking || !isTailscaleIP($0) }
let base = filtered.isEmpty ? sanitized : filtered

let ranked = uniqueStrings(base)
return ranked.sorted {
let lhsRank = connectionPriority(for: $0, includeExpandedNetworking: includeExpandedNetworking)
let rhsRank = connectionPriority(for: $1, includeExpandedNetworking: includeExpandedNetworking)
if lhsRank != rhsRank { return lhsRank < rhsRank }
return $0 < $1
}
}

private func connectionPriority(for ip: String, includeExpandedNetworking: Bool) -> Int {
if isPrivateLANIP(ip) && !isTailscaleIP(ip) { return 0 }
if includeExpandedNetworking && isTailscaleIP(ip) { return 1 }
if isTailscaleIP(ip) { return 2 }
return 3
}

func getConnectionCandidateIPs(
adapterName: String?,
includeExpandedNetworking: Bool = false
) -> [String] {
let adapters = getAvailableNetworkAdapters()
let rankedAll = rankConnectionIPs(adapters.map { $0.address }, includeExpandedNetworking: includeExpandedNetworking)

guard let adapterName else {
return rankedAll
}

guard let exact = adapters.first(where: { $0.name == adapterName }) else {
return rankedAll
}

let manualFirst = [exact.address] + rankedAll.filter { $0 != exact.address }
return rankConnectionIPs(
manualFirst,
includeExpandedNetworking: includeExpandedNetworking || isTailscaleIP(exact.address)
)
}

func getPrimaryConnectionIP(
adapterName: String?,
includeExpandedNetworking: Bool = false
) -> String? {
getConnectionCandidateIPs(
adapterName: adapterName,
includeExpandedNetworking: includeExpandedNetworking
).first
}

/// Retrieves the local IP address based on configuration.
/// Supports binding to a specific adapter or auto-discovery of all available non-loopback interfaces.
func getLocalIPAddress(adapterName: String?) -> String? {
let adapters = getAvailableNetworkAdapters()

if let adapterName = adapterName {
if let exact = adapters.first(where: { $0.name == adapterName }) {
let candidates = getConnectionCandidateIPs(adapterName: adapterName)
if let exact = candidates.first {
self.lock.lock()
let lastLogged = lastLoggedSelectedAdapter
self.lock.unlock()

if lastLogged?.name != exact.name || lastLogged?.address != exact.address {
print("[websocket] Selected adapter match: \(exact.name) -> \(exact.address)")
if lastLogged?.name != adapterName || lastLogged?.address != exact {
print("[websocket] Selected adapter match: \(adapterName) -> \(exact)")
self.lock.lock()
lastLoggedSelectedAdapter = (exact.name, exact.address)
lastLoggedSelectedAdapter = (adapterName, exact)
self.lock.unlock()
}
return exact.address
return exact
}
}

// Auto mode
if adapterName == nil {
let allAddresses = adapters.map { $0.address }
let allAddresses = getConnectionCandidateIPs(adapterName: nil)
if !allAddresses.isEmpty {
let joined = allAddresses.joined(separator: ",")

Expand Down Expand Up @@ -87,7 +156,7 @@ extension WebSocketServer {
}
freeifaddrs(ifaddr)
}
return adapters
return uniqueAdapters(adapters)
}

func ipIsPrivatePreferred(_ ip: String) -> Bool {
Expand All @@ -102,6 +171,16 @@ extension WebSocketServer {
return false
}

private func uniqueStrings(_ values: [String]) -> [String] {
var seen = Set<String>()
return values.filter { seen.insert($0).inserted }
}

private func uniqueAdapters(_ adapters: [(name: String, address: String)]) -> [(name: String, address: String)] {
var seen = Set<String>()
return adapters.filter { seen.insert("\($0.name)|\($0.address)").inserted }
}

// MARK: - Network Monitoring

func startNetworkMonitoring() {
Expand Down
Loading