Skip to content
Merged
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
23 changes: 18 additions & 5 deletions airsync-mac/Core/Discovery/UDPDiscoveryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct DiscoveredDevice: Identifiable, Equatable, Hashable {
}

var isActive: Bool {
return Date().timeIntervalSince(lastSeen) < 14
return Date().timeIntervalSince(lastSeen) < 20
}

static func == (lhs: DiscoveredDevice, rhs: DiscoveredDevice) -> Bool {
Expand All @@ -39,6 +39,8 @@ class UDPDiscoveryManager: ObservableObject {
private let broadcastPort: NWEndpoint.Port = 8889
private var cancellables = Set<AnyCancellable>()
private var isListening = false
private var lastBroadcastTime: Date = .distantPast
private var networkChangePendingWork: DispatchWorkItem?

private init() {
// Init logic only
Expand Down Expand Up @@ -81,10 +83,20 @@ class UDPDiscoveryManager: ObservableObject {
networkMonitor = NWPathMonitor()
networkMonitor?.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
if path.status == .satisfied {
print("[Discovery] Network change detected: \(path.status)")
guard path.status == .satisfied else { return }

// Debounce: cancel any pending burst and schedule a new one 2 s out.
// This prevents a flood of UDP sends during a rapid network transition.
self.networkChangePendingWork?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
// Skip if we already sent a burst very recently (e.g. from wake handler)
guard Date().timeIntervalSince(self.lastBroadcastTime) >= 2.0 else { return }
print("[Discovery] Network change detected – broadcasting presence")
self.broadcastBurst()
}
self.networkChangePendingWork = work
queue.asyncAfter(deadline: .now() + 2.0, execute: work)
}
networkMonitor?.start(queue: queue)
}
Expand All @@ -105,6 +117,7 @@ class UDPDiscoveryManager: ObservableObject {
/// Sends a rapid burst of broadcasts to ensure delivery (Active Burst support)
func broadcastBurst() {
print("[Discovery] Triggering broadcast burst")
lastBroadcastTime = Date()

// Send 3 packets with slight delay
for i in 0..<3 {
Expand Down Expand Up @@ -377,8 +390,8 @@ class UDPDiscoveryManager: ObservableObject {
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.6)) {
let initialCount = self.discoveredDevices.count
self.discoveredDevices = self.discoveredDevices.filter {
now.timeIntervalSince($0.lastSeen) <= 20
self.discoveredDevices = self.discoveredDevices.filter {
now.timeIntervalSince($0.lastSeen) <= 35
}

let newCount = self.discoveredDevices.count
Expand Down
33 changes: 23 additions & 10 deletions airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ extension WebSocketServer {

/// Performs a session health check.
/// Identifies and forcibly disconnects stale sessions that have exceeded the activity timeout.
/// Only the *primary* session going stale triggers a full server restart. Non-primary zombie
/// sessions are force-closed silently to avoid cascading restarts.
func performPing() {
self.lock.lock()
let sessions = activeSessions
let timeout = self.activityTimeout
let key = self.symmetricKey
let primary = self.primarySessionID
self.lock.unlock()

if sessions.isEmpty { return }
Expand All @@ -55,17 +58,27 @@ extension WebSocketServer {
let isStale = now.timeIntervalSince(lastDate) > timeout

if isStale {
print("[websocket] Session \(sessionId) is stale. Performing hard reset and discovery restart.")
DispatchQueue.main.async {
// Disconnect and restart
AppState.shared.disconnectDevice()
ADBConnector.disconnectADB()
AppState.shared.adbConnected = false

self.stop()
self.start(port: self.localPort ?? Defaults.serverPort)
let isPrimary = (sessionId == primary)
if isPrimary {
// Primary session has gone silent — full reconnect cycle
print("[websocket] Primary session \(sessionId) is stale (>\(Int(timeout))s). Restarting server.")
DispatchQueue.main.async {
AppState.shared.disconnectDevice()
ADBConnector.disconnectADB()
AppState.shared.adbConnected = false
self.restartServer()
}
return // Let the restart handle everything; stop iterating
} else {
// Non-primary zombie — just evict it without touching app state
print("[websocket] Non-primary session \(sessionId) is stale. Force-closing silently.")
self.lock.lock()
self.activeSessions.removeAll { $0 === session }
self.lastActivity.removeValue(forKey: sessionId)
self.lock.unlock()
session.writeBinary([]) // Force-close
continue
}
return
}

let pingJson = "{\"type\":\"ping\",\"data\":{}}"
Expand Down
41 changes: 38 additions & 3 deletions airsync-mac/Core/WebSocket/WebSocketServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class WebSocketServer: ObservableObject {
internal var pingTimer: Timer?
internal let pingInterval: TimeInterval = 5.0
internal var lastActivity: [ObjectIdentifier: Date] = [:]
internal let activityTimeout: TimeInterval = 11.0
internal let activityTimeout: TimeInterval = 35.0

@Published var symmetricKey: SymmetricKey?
@Published var localPort: UInt16?
Expand All @@ -30,6 +30,7 @@ class WebSocketServer: ObservableObject {
@Published var deviceStatus: DeviceStatus?

internal var lastKnownIP: String?
internal var isRestarting: Bool = false
internal var networkMonitorTimer: Timer?
internal let networkCheckInterval: TimeInterval = 10.0
internal let lock = NSRecursiveLock()
Expand Down Expand Up @@ -234,8 +235,8 @@ class WebSocketServer: ObservableObject {
AppState.shared.disconnectDevice()
ADBConnector.disconnectADB()
AppState.shared.adbConnected = false
self.stop()
self.start(port: self.localPort ?? Defaults.serverPort)
// Guard against cascading restarts from multiple disconnected callbacks
self.restartServer()
}
}
}
Expand Down Expand Up @@ -277,4 +278,38 @@ class WebSocketServer: ObservableObject {
func wakeUpLastConnectedDevice() {
QuickConnectManager.shared.wakeUpLastConnectedDevice()
}

// MARK: - Restart Helper

/// Single entry-point for all server restart logic.
/// Guarded by `isRestarting` to prevent cascading calls from multiple
/// simultaneous `disconnected` callbacks or stale-ping handlers.
/// Waits 1.5 s before restarting so any remaining callbacks finish first,
/// then re-broadcasts presence so Android can rediscover the Mac.
func restartServer() {
self.lock.lock()
guard !isRestarting else {
self.lock.unlock()
print("[websocket] Restart already in progress – skipping duplicate request")
return
}
isRestarting = true
let port = self.localPort ?? Defaults.serverPort
self.lock.unlock()

print("[websocket] Scheduling server restart in 1.5 s…")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.stop()
self.start(port: port)

// Re-announce presence immediately after restart so Android can find us
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
UDPDiscoveryManager.shared.broadcastBurst()
self.lock.lock()
self.isRestarting = false
self.lock.unlock()
print("[websocket] Server restart complete. Presence re-broadcast sent.")
}
}
}
}