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
73 changes: 69 additions & 4 deletions MacApp/MacConnectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
38 changes: 37 additions & 1 deletion Shared/RemoteCommand.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(MultipeerConnectivity)
import MultipeerConnectivity
#endif

// MARK: - Commands sent from iPhone to Mac
enum RemoteCommand: String, Codable {
Expand All @@ -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 {
Expand All @@ -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
}
}
}
Expand All @@ -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
103 changes: 96 additions & 7 deletions iPhoneApp/iPhoneConnectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)")
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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)..."
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading