diff --git a/Magic Switch/Manager/OutgoingConnection.swift b/Magic Switch/Manager/OutgoingConnection.swift index ecbb9d6..5d53333 100644 --- a/Magic Switch/Manager/OutgoingConnection.swift +++ b/Magic Switch/Manager/OutgoingConnection.swift @@ -10,6 +10,12 @@ enum OutgoingFailure: Error { case connectionFailed(String) case connectTimeout case handshakeFailed(SecureChannelError) + /// The authenticated peer received the request but reported that it could + /// not complete the operation. + case remoteOperationFailed + /// The authenticated peer replied with data that is not valid for the + /// current command. + case invalidResponse case bodyFailed /// Self-throttled because of recent repeated failures to this host. /// Prevents a runaway retry loop from racking up failures the peer's @@ -36,6 +42,10 @@ enum OutgoingFailure: Error { return "Couldn't establish a secure connection (possible tampering)." case .handshakeFailed: return "Couldn't establish a secure connection." + case .remoteOperationFailed: + return "The other Mac reported that it couldn't complete the operation." + case .invalidResponse: + return "The other Mac returned an invalid response." case .bodyFailed: return "The connection dropped mid-message." case .tooManyRecentFailures: @@ -163,7 +173,10 @@ final class OutgoingConnection { /// receives a `Result` whose failure case carries a categorised reason /// (see `OutgoingFailure`) so the caller can render a useful notification. func run( - body: @escaping (SecureChannel, @escaping (Bool) -> Void) -> Void, + body: + @escaping ( + SecureChannel, @escaping (Result) -> Void + ) -> Void, completion: @escaping (Result) -> Void ) { selfRef = self @@ -222,9 +235,8 @@ final class OutgoingConnection { provedByHandshake: provedFingerprint) } self.startBodyTimer(completion: completion) - body(channel) { ok in - self.finish( - ok ? .success(()) : .failure(.bodyFailed), completion: completion) + body(channel) { result in + self.finish(result, completion: completion) } case .failure(let err): print("OutgoingConnection handshake failed: \(err)") @@ -270,13 +282,15 @@ final class OutgoingConnection { bodyTimer = nil channel?.cancel() connection.cancel() - // Feed the outbound rate limiter so a series of failures throttles - // future attempts, and a success clears the counter immediately. Skip + // Feed the outbound rate limiter so a series of transport/protocol + // failures throttles future attempts, and a healthy authenticated reply + // clears the counter immediately. OP_FAILED is an operation failure, not + // a network failure: the peer received and answered the request. Skip // `tooManyRecentFailures` — that's the limiter's own refusal and would // double-count. Background reachability probes opt out entirely. if countsTowardRateLimit { switch result { - case .success: + case .success, .failure(.remoteOperationFailed): rateLimiter.recordSuccess(host: host) case .failure(.tooManyRecentFailures): break diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 3e0b80f..e2ba732 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -2106,7 +2106,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// failure; an adoption — no prior claim — takes it only once the peer is /// provably absent: unreachable at the connect layer for /// `adoptionRequiredAbsentStreak` consecutive probes. A peer that answers - /// at all — an explicit "not holding" (`.bodyFailed`) included — outranks + /// at all — an explicit "not holding" (`.remoteOperationFailed`) included — outranks /// us, so stand down and leave the move to its reclaim or to the user. /// Pair attempts are capped: a free peripheral pairs on the first try, so /// repeated failures mean it's busy with a peer we can't reach. diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index df20a46..68c48de 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -907,6 +907,25 @@ enum DeviceCommand: String, Codable { case introduce = "INTRODUCE" } +/// Decodes the standard OP_SUCCESS/OP_FAILED acknowledgement used by normal +/// commands. A valid failure acknowledgement proves the authenticated channel +/// stayed healthy; malformed or command-shaped replies are protocol errors. +private func decodeOperationResponse(_ data: Data) -> Result { + guard let response = String(data: data, encoding: .utf8), + let command = DeviceCommand(rawValue: response) + else { + return .failure(.invalidResponse) + } + switch command { + case .operationSuccess: + return .success(()) + case .operationFailed: + return .failure(.remoteOperationFailed) + default: + return .failure(.invalidResponse) + } +} + /// Identity carried by `INTRODUCE` frames: `|`, port first /// so the name may contain `|`. struct IntroducedIdentity { @@ -995,21 +1014,16 @@ extension NetworkDeviceStore { channel.send(Data(command.rawValue.utf8)) { sendErr in if let sendErr = sendErr { print("Failed to send command: \(sendErr)") - done(false) + done(.failure(.bodyFailed)) return } channel.receive { result in switch result { case .failure(let err): print("Failed to receive response: \(err)") - done(false) + done(.failure(.bodyFailed)) case .success(let data): - let response = String(data: data, encoding: .utf8) ?? "" - if let resp = DeviceCommand(rawValue: response) { - done(resp == .operationSuccess) - } else { - done(false) - } + done(decodeOperationResponse(data)) } } } @@ -1036,14 +1050,14 @@ extension NetworkDeviceStore { channel.send(Data(DeviceCommand.notification.rawValue.utf8)) { err in if let err = err { print("Notification command send failed: \(err)") - done(false) + done(.failure(.bodyFailed)) return } let payload = "\(title)|\(message)" channel.send(Data(payload.utf8)) { err2 in if let err2 = err2 { print("Notification payload send failed: \(err2)") - done(false) + done(.failure(.bodyFailed)) return } // Wait for the receiver's OP_SUCCESS/OP_FAILED before tearing @@ -1055,10 +1069,9 @@ extension NetworkDeviceStore { switch result { case .failure(let err): print("Notification ack receive failed: \(err)") - done(false) + done(.failure(.bodyFailed)) case .success(let data): - let response = String(data: data, encoding: .utf8) ?? "" - done(DeviceCommand(rawValue: response) == .operationSuccess) + done(decodeOperationResponse(data)) } } } @@ -1100,13 +1113,13 @@ extension NetworkDeviceStore { channel.send(Data(DeviceCommand.syncPeripherals.rawValue.utf8)) { err in if let err = err { print("syncPeripherals command send failed: \(err)") - done(false) + done(.failure(.bodyFailed)) return } channel.send(Data(jsonString.utf8)) { err2 in if let err2 = err2 { print("syncPeripherals payload send failed: \(err2)") - done(false) + done(.failure(.bodyFailed)) return } // Same rationale as the notification path: wait for the @@ -1116,10 +1129,9 @@ extension NetworkDeviceStore { switch result { case .failure(let err): print("syncPeripherals ack receive failed: \(err)") - done(false) + done(.failure(.bodyFailed)) case .success(let data): - let response = String(data: data, encoding: .utf8) ?? "" - done(DeviceCommand(rawValue: response) == .operationSuccess) + done(decodeOperationResponse(data)) } } } @@ -1221,32 +1233,32 @@ extension NetworkDeviceStore { channel.send(Data(DeviceCommand.introduce.rawValue.utf8)) { err in if let err = err { print("INTRODUCE command send failed: \(err)") - done(false) + done(.failure(.bodyFailed)) return } channel.send(Data(local.encoded.utf8)) { err2 in if let err2 = err2 { print("INTRODUCE payload send failed: \(err2)") - done(false) + done(.failure(.bodyFailed)) return } channel.receive { result in switch result { case .failure(let err): print("INTRODUCE reply receive failed: \(err)") - done(false) + done(.failure(.bodyFailed)) case .success(let data): let response = String(data: data, encoding: .utf8) ?? "" if let identity = IntroducedIdentity(payload: response), let proved = outgoing.provedFingerprint { reply = .peer(identity, provedFingerprint: proved) - done(true) + done(.success(())) } else if DeviceCommand(rawValue: response) == .operationFailed { reply = .legacy - done(true) + done(.success(())) } else { - done(false) + done(.failure(.invalidResponse)) } } } @@ -1355,23 +1367,22 @@ extension NetworkDeviceStore { channel.send(Data(command.rawValue.utf8)) { err in if let err = err { print("\(command.rawValue) command send failed: \(err)") - done(false) + done(.failure(.bodyFailed)) return } channel.send(Data(payload.utf8)) { err2 in if let err2 = err2 { print("\(command.rawValue) payload send failed: \(err2)") - done(false) + done(.failure(.bodyFailed)) return } channel.receive { result in switch result { case .failure(let err): print("\(command.rawValue) ack receive failed: \(err)") - done(false) + done(.failure(.bodyFailed)) case .success(let data): - let response = String(data: data, encoding: .utf8) ?? "" - done(DeviceCommand(rawValue: response) == .operationSuccess) + done(decodeOperationResponse(data)) } } }