diff --git a/MacApp/MacConnectionManager.swift b/MacApp/MacConnectionManager.swift index afe4c3b..8a04338 100644 --- a/MacApp/MacConnectionManager.swift +++ b/MacApp/MacConnectionManager.swift @@ -36,7 +36,16 @@ class MacConnectionManager: NSObject, ObservableObject { private let myPeerID: MCPeerID private var session: MCSession? private var advertiser: MCNearbyServiceAdvertiser? - + + // MARK: - Connection Health Watchdog + // MultipeerConnectivity can keep reporting `.connected` long after the + // underlying path is dead ("half-open"). We track the time of the last + // inbound packet (any command, including keepalives) and tear the session + // down if it goes stale, forcing a clean reconnect. + private var watchdogTimer: Timer? + private var lastReceivedTime = Date() + private let staleTimeout: TimeInterval = 15.0 // ~5 missed 3s keepalives + // MARK: - Callback for keystroke var onCommandReceived: ((RemoteCommand) -> Void)? @@ -75,7 +84,7 @@ class MacConnectionManager: NSObject, ObservableObject { // MARK: - Initialization override init() { - self.myPeerID = MCPeerID(displayName: Host.current().localizedName ?? "Mac") + self.myPeerID = RemoteServiceConfig.persistentPeerID(displayName: Host.current().localizedName ?? "Mac") super.init() debugLog("Initializing with peer ID: \(myPeerID.displayName)", level: .info) setupSession() @@ -120,10 +129,49 @@ class MacConnectionManager: NSObject, ObservableObject { } func disconnect() { + stopWatchdog() session?.disconnect() connectedDevices.removeAll() statusMessage = "Disconnected" } + + // MARK: - Connection Health Watchdog + private func startWatchdog() { + stopWatchdog() + lastReceivedTime = Date() + watchdogTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + self?.checkConnectionHealth() + } + } + + private func stopWatchdog() { + watchdogTimer?.invalidate() + watchdogTimer = nil + } + + private func checkConnectionHealth() { + guard !connectedDevices.isEmpty else { return } + let elapsed = Date().timeIntervalSince(lastReceivedTime) + if elapsed > staleTimeout { + debugLog("Watchdog: no data for \(Int(elapsed))s, tearing down stale session", level: .warning) + // Disconnecting fires `.notConnected`, which recreates the session + // and restarts advertising for a clean reconnect. + session?.disconnect() + } + } + + /// Recreates the session so the next invitation lands on a fresh, clean + /// session rather than one left in a half-dead state by a previous drop. + private func recreateSession() { + session?.delegate = nil + setupSession() + } + + private func sendKeepaliveAck() { + guard let session = session, !session.connectedPeers.isEmpty else { return } + guard let data = RemoteCommand.keepaliveAck.rawValue.data(using: .utf8) else { return } + try? session.send(data, toPeers: session.connectedPeers, with: .reliable) + } } // MARK: - MCSessionDelegate @@ -140,6 +188,7 @@ extension MacConnectionManager: MCSessionDelegate { } self.statusMessage = "Connected to \(peerID.displayName)" self.lastError = nil + self.startWatchdog() case .connecting: self.debugLog("SESSION STATE: Connecting to \(peerID.displayName)", level: .network) @@ -151,6 +200,10 @@ extension MacConnectionManager: MCSessionDelegate { self.sessionState = "Not connected" self.connectedDevices.removeAll { $0 == peerID } if self.connectedDevices.isEmpty { + self.stopWatchdog() + // Recreate the session so the next invitation lands on a + // clean session instead of this half-dead one. + self.recreateSession() self.statusMessage = "Waiting for iPhone to reconnect..." if self.advertiser == nil { self.debugLog("Re-starting advertiser for reconnection", level: .network) @@ -173,9 +226,21 @@ extension MacConnectionManager: MCSessionDelegate { print("⚠️ Failed to decode command") return } - + + // Any inbound packet proves the link is alive — feed the watchdog. + DispatchQueue.main.async { + self.lastReceivedTime = Date() + } + + // Keepalives are link-health probes, not user actions: reply and stop. + if command == .keepalive { + sendKeepaliveAck() + return + } + if command == .keepaliveAck { return } + print("📥 Received command: \(command.rawValue) from \(peerID.displayName)") - + DispatchQueue.main.async { self.lastCommand = command self.onCommandReceived?(command) diff --git a/Shared/RemoteCommand.swift b/Shared/RemoteCommand.swift index 1c80b73..c613cc9 100644 --- a/Shared/RemoteCommand.swift +++ b/Shared/RemoteCommand.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(MultipeerConnectivity) +import MultipeerConnectivity +#endif // MARK: - Commands sent from iPhone to Mac enum RemoteCommand: String, Codable { @@ -7,7 +10,8 @@ enum RemoteCommand: String, Codable { case startPresentation = "start" case endPresentation = "end" case blackScreen = "black" - case keepalive = "keepalive" // Used to maintain connection on hotspot + case keepalive = "keepalive" // iPhone → Mac liveness probe (also keeps hotspot NAT warm) + case keepaliveAck = "keepalive_ack" // Mac → iPhone reply, lets the phone detect a half-open link var keyCode: UInt16? { switch self { @@ -17,6 +21,7 @@ enum RemoteCommand: String, Codable { case .endPresentation: return 53 // Escape case .blackScreen: return 11 // 'B' key (PowerPoint black screen) case .keepalive: return nil // No keystroke for keepalive + case .keepaliveAck: return nil // No keystroke for keepalive ack } } } @@ -26,3 +31,34 @@ struct RemoteServiceConfig { static let serviceType = "clickerremote" // Must be 1-15 chars, lowercase, no spaces static let displayName = "Deck" } + +// MARK: - Persistent Peer Identity +// MultipeerConnectivity is unavailable on watchOS, so this helper is compiled +// only for the Mac and iPhone targets that actually own an MCSession. +// +// Apple recommends reusing a single MCPeerID instance rather than recreating one +// on every launch: a fresh MCPeerID with the same display name is treated as a +// *different* peer, which produces phantom/duplicate peers and confuses +// reconnection-by-name. Persisting it across launches keeps identity stable. +#if canImport(MultipeerConnectivity) +extension RemoteServiceConfig { + private static let peerIDDataKey = "DeckCachedPeerIDData" + private static let peerIDNameKey = "DeckCachedPeerIDName" + + static func persistentPeerID(displayName: String) -> MCPeerID { + let defaults = UserDefaults.standard + if defaults.string(forKey: peerIDNameKey) == displayName, + let data = defaults.data(forKey: peerIDDataKey), + let peerID = try? NSKeyedUnarchiver.unarchivedObject(ofClass: MCPeerID.self, from: data) { + return peerID + } + + let peerID = MCPeerID(displayName: displayName) + if let data = try? NSKeyedArchiver.archivedData(withRootObject: peerID, requiringSecureCoding: true) { + defaults.set(data, forKey: peerIDDataKey) + defaults.set(displayName, forKey: peerIDNameKey) + } + return peerID + } +} +#endif diff --git a/iPhoneApp/iPhoneConnectionManager.swift b/iPhoneApp/iPhoneConnectionManager.swift index c410d70..4eb324a 100644 --- a/iPhoneApp/iPhoneConnectionManager.swift +++ b/iPhoneApp/iPhoneConnectionManager.swift @@ -44,7 +44,25 @@ class iPhoneConnectionManager: NSObject, ObservableObject { private var reconnectTimer: Timer? private var keepaliveTimer: Timer? private let keepaliveInterval: TimeInterval = 3.0 // Send keepalive every 3 seconds - private let reconnectDelay: TimeInterval = 1.0 // Wait 1 second before reconnecting + private let reconnectDelay: TimeInterval = 1.0 // Base delay before reconnecting + + // Exponential backoff with jitter, so a flapping Mac doesn't cause a tight + // invitation storm. Reset to 0 on every successful connection. + private var reconnectAttempts = 0 + private let maxReconnectDelay: TimeInterval = 8.0 + + // Guards against firing overlapping invitations (the reconnect timer and + // `foundPeer` can both try to connect to the same Mac at once, which makes + // MultipeerConnectivity bounce the connection). + private var isInviting = false + private var inviteGuardTimer: Timer? + + // MARK: - Connection Health Watchdog + // Detects a half-open link where MCSession still says `.connected` but the + // Mac's keepalive ACKs have stopped arriving, and forces a clean reconnect. + private var watchdogTimer: Timer? + private var lastReceivedTime = Date() + private let staleTimeout: TimeInterval = 15.0 // ~5 missed 3s keepalives // MARK: - Haptic Feedback private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium) @@ -93,7 +111,7 @@ class iPhoneConnectionManager: NSObject, ObservableObject { // MARK: - Initialization override init() { - self.myPeerID = MCPeerID(displayName: UIDevice.current.name) + self.myPeerID = RemoteServiceConfig.persistentPeerID(displayName: UIDevice.current.name) super.init() debugLog("Initializing with peer ID: \(myPeerID.displayName)", level: .info) setupSession() @@ -127,6 +145,8 @@ class iPhoneConnectionManager: NSObject, ObservableObject { private func setupSession() { debugLog("Setting up session with encryption: optional", level: .network) + // Detach any prior session so a discarded one can't deliver late callbacks. + session?.delegate = nil session = MCSession( peer: myPeerID, securityIdentity: nil, @@ -175,12 +195,43 @@ class iPhoneConnectionManager: NSObject, ObservableObject { keepaliveTimer = nil } + // MARK: - Connection Health Watchdog + private func startWatchdog() { + stopWatchdog() + lastReceivedTime = Date() + watchdogTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + self?.checkConnectionHealth() + } + } + + private func stopWatchdog() { + watchdogTimer?.invalidate() + watchdogTimer = nil + } + + private func checkConnectionHealth() { + guard isConnected else { return } + let elapsed = Date().timeIntervalSince(lastReceivedTime) + if elapsed > staleTimeout { + debugLog("Watchdog: no reply for \(Int(elapsed))s, forcing reconnect", level: .warning) + // Disconnecting fires `.notConnected`, which schedules a reconnect. + session?.disconnect() + } + } + private func sendKeepalive() { guard let session = session, !session.connectedPeers.isEmpty else { return } guard let data = RemoteCommand.keepalive.rawValue.data(using: .utf8) else { return } do { try session.send(data, toPeers: session.connectedPeers, with: .reliable) + // A successful reliable send proves our side of the link is up, so it + // counts as liveness too. This decouples the watchdog from the Mac's + // keepalive ACK: a healthy connection is never torn down just because + // an ACK was slow or the Mac build doesn't reply. A genuinely dead + // link still recovers — the send eventually throws or MCSession flips + // to `.notConnected`, and the Mac's own receive watchdog reconnects. + lastReceivedTime = Date() print("💓 Sent keepalive") } catch { print("⚠️ Keepalive failed: \(error.localizedDescription)") @@ -190,7 +241,12 @@ class iPhoneConnectionManager: NSObject, ObservableObject { // MARK: - Auto Reconnect private func scheduleReconnect() { reconnectTimer?.invalidate() - reconnectTimer = Timer.scheduledTimer(withTimeInterval: reconnectDelay, repeats: false) { [weak self] _ in + // Exponential backoff (1, 2, 4, 8…s capped) plus a little jitter. + let backoff = min(reconnectDelay * pow(2.0, Double(reconnectAttempts)), maxReconnectDelay) + let delay = backoff + Double.random(in: 0...0.5) + reconnectAttempts += 1 + debugLog("Scheduling reconnect in \(String(format: "%.1f", delay))s (attempt \(reconnectAttempts))", level: .info) + reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in self?.attemptReconnect() } } @@ -229,9 +285,24 @@ class iPhoneConnectionManager: NSObject, ObservableObject { lastError = "Session not initialized" return } + guard !isConnected else { return } + // Skip if an invitation is already in flight — avoids overlapping invites + // from the reconnect timer and browser(foundPeer:) racing each other. + guard !isInviting else { + debugLog("Skipping duplicate invite to \(peer.displayName) (already inviting)", level: .info) + return + } - debugLog("Inviting peer: \(peer.displayName) (timeout: 30s)", level: .network) - browser.invitePeer(peer, to: session, withContext: nil, timeout: 30) + isInviting = true + inviteGuardTimer?.invalidate() + // Backstop just past the invite timeout, in case the invitation fails + // silently without a `.notConnected` callback to clear the flag. + inviteGuardTimer = Timer.scheduledTimer(withTimeInterval: 16.0, repeats: false) { [weak self] _ in + self?.isInviting = false + } + + debugLog("Inviting peer: \(peer.displayName) (timeout: 15s)", level: .network) + browser.invitePeer(peer, to: session, withContext: nil, timeout: 15) statusMessage = "Connecting to \(peer.displayName)..." sessionState = "Inviting \(peer.displayName)..." } @@ -241,7 +312,12 @@ class iPhoneConnectionManager: NSObject, ObservableObject { lastConnectedMacName = nil reconnectTimer?.invalidate() reconnectTimer = nil + reconnectAttempts = 0 + inviteGuardTimer?.invalidate() + inviteGuardTimer = nil + isInviting = false stopKeepalive() + stopWatchdog() session?.disconnect() connectedMac = nil isConnected = false @@ -293,7 +369,12 @@ extension iPhoneConnectionManager: MCSessionDelegate { self.statusMessage = "Connected to \(peerID.displayName)" self.reconnectTimer?.invalidate() self.reconnectTimer = nil + self.reconnectAttempts = 0 + self.isInviting = false + self.inviteGuardTimer?.invalidate() + self.inviteGuardTimer = nil self.startKeepalive() + self.startWatchdog() self.updateWatchWithConnectionStatus() self.isSearching = false self.lastError = nil @@ -307,10 +388,14 @@ extension iPhoneConnectionManager: MCSessionDelegate { case .notConnected: self.debugLog("SESSION STATE: Not connected (was: \(peerID.displayName))", level: .warning) self.sessionState = "Not connected" + self.isInviting = false + self.inviteGuardTimer?.invalidate() + self.inviteGuardTimer = nil if self.connectedMac == peerID || self.lastConnectedMacName == peerID.displayName { self.connectedMac = nil self.isConnected = false self.stopKeepalive() + self.stopWatchdog() self.updateWatchWithConnectionStatus() LiveActivityManager.shared.endActivity() if self.lastConnectedMacName != nil { @@ -329,8 +414,12 @@ extension iPhoneConnectionManager: MCSessionDelegate { } func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - // Mac could send data back (e.g., slide number, notes) - // Not used in basic version + // Any inbound packet (notably the Mac's keepalive ACK) proves the link + // is still alive — feed the watchdog so it doesn't tear down a healthy + // but quiet connection. + DispatchQueue.main.async { + self.lastReceivedTime = Date() + } } // Required delegate methods