From 55a11e5b3cd90d664709f607cad34e244b4ed3b5 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 13 Jul 2026 12:50:14 +0000 Subject: [PATCH 1/5] fix: send wake request to tunnel on system wake --- .../Coder-DesktopHelper/Manager.swift | 10 +- .../SystemWakeObserver.swift | 63 +++++++++ Coder-Desktop/Coder-DesktopHelper/main.swift | 9 ++ Coder-Desktop/VPNLib/Speaker+Wake.swift | 32 +++++ Coder-Desktop/VPNLib/Speaker.swift | 22 ++- Coder-Desktop/VPNLib/vpn.pb.swift | 129 ++++++++++++++++++ Coder-Desktop/VPNLib/vpn.proto | 10 ++ Coder-Desktop/VPNLibTests/ProtoTests.swift | 10 ++ Coder-Desktop/VPNLibTests/SpeakerTests.swift | 92 +++++++++++++ 9 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift create mode 100644 Coder-Desktop/VPNLib/Speaker+Wake.swift diff --git a/Coder-Desktop/Coder-DesktopHelper/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift index df79d8a1..098712af 100644 --- a/Coder-Desktop/Coder-DesktopHelper/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -155,7 +155,7 @@ actor Manager { Task { try? await appXPCServerDelegate.onPeerUpdate(update: msg.peerUpdate) } case let .log(logMsg): writeVpnLog(logMsg) - case .networkSettings, .start, .stop: + case .networkSettings, .start, .stop, .wake: logger.critical("received unexpected message: `\(String(describing: msgType))`") } } @@ -182,7 +182,7 @@ actor Manager { } }) } - case .log, .peerUpdate, .start, .stop: + case .log, .peerUpdate, .start, .stop, .wake: logger.critical("received unexpected rpc: `\(String(describing: msgType))`") } } @@ -222,6 +222,12 @@ actor Manager { logger.info("startVPN done") } + // Notifies the tunnel that the system has woken from sleep. Best-effort: + // failures are logged by the speaker, not thrown. + func wake() async { + await speaker.wake() + } + func stopVPN() async throws(ManagerError) { logger.info("sending stop rpc") let resp: Vpn_TunnelMessage diff --git a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift new file mode 100644 index 00000000..ec591502 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift @@ -0,0 +1,63 @@ +import Foundation +import IOKit +import IOKit.pwr_mgt +import os + +/// Observes system power events and invokes `onWake` once the system has +/// fully woken from sleep. +/// +/// `kIOMessageSystemHasPoweredOn` is only delivered on a full wake, never on +/// a dark wake, matching the semantics of `NSWorkspace.didWakeNotification` +/// while remaining deliverable to a daemon without a GUI session. +final class SystemWakeObserver { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "system-wake-observer") + private var notifyPort: IONotificationPortRef? + private var notifier: io_object_t = 0 + private var rootPort: io_connect_t = 0 + private let onWake: @Sendable () -> Void + + init?(onWake: @escaping @Sendable () -> Void) { + self.onWake = onWake + let refcon = Unmanaged.passUnretained(self).toOpaque() + rootPort = IORegisterForSystemPower(refcon, ¬ifyPort, { refcon, _, messageType, messageArgument in + guard let refcon else { return } + let observer = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + observer.handle(messageType: messageType, messageArgument: messageArgument) + }, ¬ifier) + guard rootPort != 0, let notifyPort else { + logger.error("failed to register for system power notifications") + return nil + } + CFRunLoopAddSource( + CFRunLoopGetMain(), + IONotificationPortGetRunLoopSource(notifyPort).takeUnretainedValue(), + .defaultMode + ) + } + + private func handle(messageType: natural_t, messageArgument: UnsafeMutableRawPointer?) { + switch messageType { + case UInt32(kIOMessageSystemHasPoweredOn): + logger.info("system woke from sleep") + onWake() + case UInt32(kIOMessageCanSystemSleep), UInt32(kIOMessageSystemWillSleep): + // The system delays sleep until these are acknowledged. + IOAllowPowerChange(rootPort, Int(bitPattern: messageArgument)) + default: + break + } + } + + deinit { + if let notifyPort { + CFRunLoopRemoveSource( + CFRunLoopGetMain(), + IONotificationPortGetRunLoopSource(notifyPort).takeUnretainedValue(), + .defaultMode + ) + IODeregisterForSystemPower(¬ifier) + IOServiceClose(rootPort) + IONotificationPortDestroy(notifyPort) + } + } +} diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift index da777746..e22624c8 100644 --- a/Coder-Desktop/Coder-DesktopHelper/main.swift +++ b/Coder-Desktop/Coder-DesktopHelper/main.swift @@ -15,4 +15,13 @@ let appXPCServer = NSXPCListener(machServiceName: helperAppMachServiceName) appXPCServer.delegate = appXPCServerDelegate appXPCServer.resume() +// Nudges the tunnel to rediscover network paths on system wake, as a short +// same-network sleep often produces no link-change event the tunnel could +// react to on its own. +let wakeObserver = SystemWakeObserver { + Task { @MainActor in + await globalManager?.wake() + } +} + RunLoop.main.run() diff --git a/Coder-Desktop/VPNLib/Speaker+Wake.swift b/Coder-Desktop/VPNLib/Speaker+Wake.swift new file mode 100644 index 00000000..07f3b9b9 --- /dev/null +++ b/Coder-Desktop/VPNLib/Speaker+Wake.swift @@ -0,0 +1,32 @@ +import Foundation +import os + +public extension Speaker where SendMsg == Vpn_ManagerMessage, RecvMsg == Vpn_TunnelMessage { + /// Notifies the tunnel that the system has woken from sleep, so it can + /// immediately rediscover network paths instead of waiting for a periodic + /// re-STUN. No-op when the negotiated protocol version predates + /// WakeRequest (1.3). Best-effort: failures are logged, not thrown. + func wake() async { + let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "proto") + guard let negotiatedVersion, negotiatedVersion >= ProtoVersion(1, 3) else { + logger.debug("skipping wake rpc: negotiated protocol version doesn't support it") + return + } + logger.info("sending wake rpc") + let resp: Vpn_TunnelMessage + do { + resp = try await unaryRPC( + .with { msg in + msg.wake = .init() + }) + } catch { + logger.error("wake rpc failed: \(error.localizedDescription, privacy: .public)") + return + } + guard case let .wake(wakeResp) = resp.msg else { + logger.error("unexpected wake rpc response: \(String(describing: resp.msg), privacy: .public)") + return + } + logger.debug("wake rpc response success: \(wakeResp.success)") + } +} diff --git a/Coder-Desktop/VPNLib/Speaker.swift b/Coder-Desktop/VPNLib/Speaker.swift index 74597b1c..b4f53c1f 100644 --- a/Coder-Desktop/VPNLib/Speaker.swift +++ b/Coder-Desktop/VPNLib/Speaker.swift @@ -22,17 +22,21 @@ enum ProtoRole: String { } /// A version of the VPN protocol that can be negotiated. -public struct ProtoVersion: CustomStringConvertible, Equatable, Codable, Sendable { +public struct ProtoVersion: CustomStringConvertible, Equatable, Comparable, Codable, Sendable { let major: Int let minor: Int public var description: String { "\(major).\(minor)" } - init(_ major: Int, _ minor: Int) { + public init(_ major: Int, _ minor: Int) { self.major = major self.minor = minor } + public static func < (lhs: ProtoVersion, rhs: ProtoVersion) -> Bool { + (lhs.major, lhs.minor) < (rhs.major, rhs.minor) + } + init(parse str: String) throws(HandshakeError) { let parts = str.split(separator: ".").map { Int($0) } if parts.count != 2 { @@ -60,6 +64,9 @@ public actor Speaker private let secretary = RPCSecretary() let role: ProtoRole + /// The protocol version negotiated during the handshake, or nil if the + /// handshake has not yet completed. + public private(set) var negotiatedVersion: ProtoVersion? /// Creates an instance that communicates over the provided file handles. public init(writeFD: FileHandle, readFD: FileHandle) { @@ -91,11 +98,12 @@ public actor Speaker(decoder: inout D) throws { @@ -1033,6 +1076,19 @@ extension Vpn_ManagerMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImple self.msg = .stop(v) } }() + case 6: try { + var v: Vpn_WakeRequest? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .wake(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .wake(v) + } + }() default: break } } @@ -1063,6 +1119,10 @@ extension Vpn_ManagerMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImple guard case .stop(let v)? = self.msg else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 5) }() + case .wake?: try { + guard case .wake(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -1085,6 +1145,7 @@ extension Vpn_TunnelMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem 4: .standard(proto: "network_settings"), 5: .same(proto: "start"), 6: .same(proto: "stop"), + 7: .same(proto: "wake"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -1159,6 +1220,19 @@ extension Vpn_TunnelMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem self.msg = .stop(v) } }() + case 7: try { + var v: Vpn_WakeResponse? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .wake(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .wake(v) + } + }() default: break } } @@ -1193,6 +1267,10 @@ extension Vpn_TunnelMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem guard case .stop(let v)? = self.msg else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 6) }() + case .wake?: try { + guard case .wake(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -2429,3 +2507,54 @@ extension Vpn_Status.Lifecycle: SwiftProtobuf._ProtoNameProviding { 4: .same(proto: "STOPPED"), ] } + +extension Vpn_WakeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WakeRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_WakeRequest, rhs: Vpn_WakeRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Vpn_WakeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WakeResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "success"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBoolField(value: &self.success) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.success != false { + try visitor.visitSingularBoolField(value: self.success, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_WakeResponse, rhs: Vpn_WakeResponse) -> Bool { + if lhs.success != rhs.success {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Coder-Desktop/VPNLib/vpn.proto b/Coder-Desktop/VPNLib/vpn.proto index 59ea1933..c180a883 100644 --- a/Coder-Desktop/VPNLib/vpn.proto +++ b/Coder-Desktop/VPNLib/vpn.proto @@ -30,6 +30,7 @@ message ManagerMessage { NetworkSettingsResponse network_settings = 3; StartRequest start = 4; StopRequest stop = 5; + WakeRequest wake = 6; } } @@ -42,6 +43,7 @@ message TunnelMessage { NetworkSettingsRequest network_settings = 4; StartResponse start = 5; StopResponse stop = 6; + WakeResponse wake = 7; } } @@ -267,3 +269,11 @@ message Status { // be populated. PeerUpdate peer_update = 3; } + +// WakeRequest is sent by the manager after the system wakes from sleep. The +// tunnel uses this as a hint to rediscover network paths. +message WakeRequest {} + +message WakeResponse { + bool success = 1; +} diff --git a/Coder-Desktop/VPNLibTests/ProtoTests.swift b/Coder-Desktop/VPNLibTests/ProtoTests.swift index 6a837940..42b5f0aa 100644 --- a/Coder-Desktop/VPNLibTests/ProtoTests.swift +++ b/Coder-Desktop/VPNLibTests/ProtoTests.swift @@ -55,6 +55,16 @@ struct SenderReceiverTests { } } +@Suite(.timeLimit(.minutes(1))) +struct ProtoVersionTests { + @Test func comparable() { + #expect(ProtoVersion(1, 1) < ProtoVersion(1, 3)) + #expect(ProtoVersion(1, 3) >= ProtoVersion(1, 3)) + #expect(ProtoVersion(2, 0) > ProtoVersion(1, 9)) + #expect(!(ProtoVersion(1, 2) >= ProtoVersion(1, 3))) + } +} + @Suite(.timeLimit(.minutes(1))) struct HandshakerTests { let pipeMT = Pipe() diff --git a/Coder-Desktop/VPNLibTests/SpeakerTests.swift b/Coder-Desktop/VPNLibTests/SpeakerTests.swift index dd837d70..8d6ec3ba 100644 --- a/Coder-Desktop/VPNLibTests/SpeakerTests.swift +++ b/Coder-Desktop/VPNLibTests/SpeakerTests.swift @@ -37,7 +37,9 @@ struct SpeakerTests: Sendable { @Test func handshake() async throws { async let v = handshaker.handshake() try await uut.handshake() + // The peer only supports 1.1, so that's what we negotiate down to. #expect(try await v == ProtoVersion(1, 1)) + #expect(await uut.negotiatedVersion == ProtoVersion(1, 1)) } @Test func handleSingleMessage() async throws { @@ -118,3 +120,93 @@ struct SpeakerTests: Sendable { try await readDone.value } } + +@Suite(.timeLimit(.minutes(1))) +struct ManagerSpeakerTests: Sendable { + let pipeMT = Pipe() + let pipeTM = Pipe() + let queue: DispatchQueue = .global(qos: .utility) + let uut: Speaker + let sender: Sender + let dispatch: DispatchIO + let receiver: Receiver + let handshaker: Handshaker + + init() { + uut = Speaker( + writeFD: pipeMT.fileHandleForWriting, + readFD: pipeTM.fileHandleForReading + ) + dispatch = DispatchIO( + type: .stream, + fileDescriptor: pipeMT.fileHandleForReading.fileDescriptor, + queue: queue, + cleanupHandler: { error in print("cleanupHandler: \(error)") } + ) + sender = Sender(writeFD: pipeTM.fileHandleForWriting) + receiver = Receiver(dispatch: dispatch, queue: queue) + handshaker = Handshaker( + writeFD: pipeTM.fileHandleForWriting, + dispatch: dispatch, queue: queue, + role: .tunnel, + versions: [ProtoVersion(1, 3)] + ) + } + + @Test func handshake() async throws { + async let v = handshaker.handshake() + try await uut.handshake() + #expect(try await v == ProtoVersion(1, 3)) + #expect(await uut.negotiatedVersion == ProtoVersion(1, 3)) + } + + @Test func wake() async throws { + async let v = handshaker.handshake() + try await uut.handshake() + _ = try await v + // Speaker must be reading from the receiver for `unaryRPC` to return + let readDone = Task { + for try await _ in uut {} + } + async let tunnelDone = Task { + var count = 0 + for try await req in try await receiver.messages() { + #expect(req.msg == .wake(Vpn_WakeRequest())) + try #require(req.rpc.msgID != 0) + var reply = Vpn_TunnelMessage() + reply.wake = Vpn_WakeResponse() + reply.wake.success = true + reply.rpc.responseTo = req.rpc.msgID + try await sender.send(reply) + count += 1 + } + #expect(count == 1) + } + await uut.wake() + await uut.closeWrite() + _ = await tunnelDone + try await sender.close() + try await readDone.value + } + + @Test func wakeUnsupportedByPeer() async throws { + // A peer that predates WakeRequest (1.3) must not be sent one. + let oldHandshaker = Handshaker( + writeFD: pipeTM.fileHandleForWriting, + dispatch: dispatch, queue: queue, + role: .tunnel, + versions: [ProtoVersion(1, 1)] + ) + async let v = oldHandshaker.handshake() + try await uut.handshake() + #expect(try await v == ProtoVersion(1, 1)) + await uut.wake() + await uut.closeWrite() + var count = 0 + for try await _ in try await receiver.messages() { + count += 1 + } + #expect(count == 0) + try await sender.close() + } +} From 7729755a20303b27ad47cae3763fd8269cc99322 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 13 Jul 2026 12:56:00 +0000 Subject: [PATCH 2/5] fix: remove duplicate IOKit import --- Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift index ec591502..353c549a 100644 --- a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift +++ b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift @@ -1,6 +1,5 @@ import Foundation import IOKit -import IOKit.pwr_mgt import os /// Observes system power events and invokes `onWake` once the system has From f7d7028835e7a73b908506808f7173899ac7defc Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 13 Jul 2026 13:04:01 +0000 Subject: [PATCH 3/5] fix: import IOKit power management submodule --- Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift index 353c549a..e8e794d9 100644 --- a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift +++ b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift @@ -1,5 +1,5 @@ import Foundation -import IOKit +import IOKit.pwr_mgt import os /// Observes system power events and invokes `onWake` once the system has From 90b3c5074a22dff564ae0a3c18f2648a504ff606 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 13 Jul 2026 13:12:55 +0000 Subject: [PATCH 4/5] fix: define IOKit power message constants manually --- .../Coder-DesktopHelper/SystemWakeObserver.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift index e8e794d9..b5746d3f 100644 --- a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift +++ b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift @@ -2,6 +2,12 @@ import Foundation import IOKit.pwr_mgt import os +// IOKit message macros (iokit_common_msg) are not importable into Swift. +// Values from . +private let kIOMessageCanSystemSleep: UInt32 = 0xE000_0270 +private let kIOMessageSystemWillSleep: UInt32 = 0xE000_0280 +private let kIOMessageSystemHasPoweredOn: UInt32 = 0xE000_0300 + /// Observes system power events and invokes `onWake` once the system has /// fully woken from sleep. /// @@ -36,10 +42,10 @@ final class SystemWakeObserver { private func handle(messageType: natural_t, messageArgument: UnsafeMutableRawPointer?) { switch messageType { - case UInt32(kIOMessageSystemHasPoweredOn): + case kIOMessageSystemHasPoweredOn: logger.info("system woke from sleep") onWake() - case UInt32(kIOMessageCanSystemSleep), UInt32(kIOMessageSystemWillSleep): + case kIOMessageCanSystemSleep, kIOMessageSystemWillSleep: // The system delays sleep until these are acknowledged. IOAllowPowerChange(rootPort, Int(bitPattern: messageArgument)) default: From be39adb66f8b83bf58813e0e221a4af446c946ac Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 14 Jul 2026 21:34:31 +0000 Subject: [PATCH 5/5] refactor: simplify wake observer and trim comments --- .../Coder-DesktopHelper/Manager.swift | 10 ++-- .../SystemWakeObserver.swift | 50 +++++++------------ Coder-Desktop/Coder-DesktopHelper/main.swift | 4 +- Coder-Desktop/VPNLib/Speaker+Wake.swift | 37 ++++---------- Coder-Desktop/VPNLib/Speaker.swift | 9 ++-- Coder-Desktop/VPNLibTests/SpeakerTests.swift | 19 ++----- 6 files changed, 41 insertions(+), 88 deletions(-) diff --git a/Coder-Desktop/Coder-DesktopHelper/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift index 098712af..a83b3d51 100644 --- a/Coder-Desktop/Coder-DesktopHelper/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -222,10 +222,14 @@ actor Manager { logger.info("startVPN done") } - // Notifies the tunnel that the system has woken from sleep. Best-effort: - // failures are logged by the speaker, not thrown. + // Notifies the tunnel that the system has woken from sleep. func wake() async { - await speaker.wake() + logger.info("sending wake rpc") + do { + try await speaker.wake() + } catch { + logger.error("wake rpc failed: \(error.localizedDescription, privacy: .public)") + } } func stopVPN() async throws(ManagerError) { diff --git a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift index b5746d3f..fa2746ae 100644 --- a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift +++ b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift @@ -2,36 +2,35 @@ import Foundation import IOKit.pwr_mgt import os -// IOKit message macros (iokit_common_msg) are not importable into Swift. -// Values from . +// From ; the iokit_common_msg macros aren't imported into Swift. private let kIOMessageCanSystemSleep: UInt32 = 0xE000_0270 private let kIOMessageSystemWillSleep: UInt32 = 0xE000_0280 private let kIOMessageSystemHasPoweredOn: UInt32 = 0xE000_0300 -/// Observes system power events and invokes `onWake` once the system has -/// fully woken from sleep. -/// -/// `kIOMessageSystemHasPoweredOn` is only delivered on a full wake, never on -/// a dark wake, matching the semantics of `NSWorkspace.didWakeNotification` -/// while remaining deliverable to a daemon without a GUI session. +/// Calls `onWake` when the system fully wakes from sleep (never on dark wake). +/// Unlike `NSWorkspace.didWakeNotification`, IOKit power events are delivered +/// to daemons running outside a GUI login session. final class SystemWakeObserver { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "system-wake-observer") - private var notifyPort: IONotificationPortRef? - private var notifier: io_object_t = 0 private var rootPort: io_connect_t = 0 private let onWake: @Sendable () -> Void - init?(onWake: @escaping @Sendable () -> Void) { + init(onWake: @escaping @Sendable () -> Void) { self.onWake = onWake - let refcon = Unmanaged.passUnretained(self).toOpaque() - rootPort = IORegisterForSystemPower(refcon, ¬ifyPort, { refcon, _, messageType, messageArgument in - guard let refcon else { return } - let observer = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - observer.handle(messageType: messageType, messageArgument: messageArgument) - }, ¬ifier) + var notifyPort: IONotificationPortRef? + var notifier: io_object_t = 0 + rootPort = IORegisterForSystemPower( + Unmanaged.passUnretained(self).toOpaque(), + ¬ifyPort, + { refcon, _, messageType, messageArgument in + let observer = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() + observer.handle(messageType: messageType, messageArgument: messageArgument) + }, + ¬ifier + ) guard rootPort != 0, let notifyPort else { logger.error("failed to register for system power notifications") - return nil + return } CFRunLoopAddSource( CFRunLoopGetMain(), @@ -46,23 +45,10 @@ final class SystemWakeObserver { logger.info("system woke from sleep") onWake() case kIOMessageCanSystemSleep, kIOMessageSystemWillSleep: - // The system delays sleep until these are acknowledged. + // Sleep is delayed until acknowledged. IOAllowPowerChange(rootPort, Int(bitPattern: messageArgument)) default: break } } - - deinit { - if let notifyPort { - CFRunLoopRemoveSource( - CFRunLoopGetMain(), - IONotificationPortGetRunLoopSource(notifyPort).takeUnretainedValue(), - .defaultMode - ) - IODeregisterForSystemPower(¬ifier) - IOServiceClose(rootPort) - IONotificationPortDestroy(notifyPort) - } - } } diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift index e22624c8..aacdaf4b 100644 --- a/Coder-Desktop/Coder-DesktopHelper/main.swift +++ b/Coder-Desktop/Coder-DesktopHelper/main.swift @@ -15,9 +15,7 @@ let appXPCServer = NSXPCListener(machServiceName: helperAppMachServiceName) appXPCServer.delegate = appXPCServerDelegate appXPCServer.resume() -// Nudges the tunnel to rediscover network paths on system wake, as a short -// same-network sleep often produces no link-change event the tunnel could -// react to on its own. +// Nudge the tunnel to rediscover network paths on system wake. let wakeObserver = SystemWakeObserver { Task { @MainActor in await globalManager?.wake() diff --git a/Coder-Desktop/VPNLib/Speaker+Wake.swift b/Coder-Desktop/VPNLib/Speaker+Wake.swift index 07f3b9b9..19f28d99 100644 --- a/Coder-Desktop/VPNLib/Speaker+Wake.swift +++ b/Coder-Desktop/VPNLib/Speaker+Wake.swift @@ -1,32 +1,13 @@ -import Foundation -import os +import SwiftProtobuf public extension Speaker where SendMsg == Vpn_ManagerMessage, RecvMsg == Vpn_TunnelMessage { - /// Notifies the tunnel that the system has woken from sleep, so it can - /// immediately rediscover network paths instead of waiting for a periodic - /// re-STUN. No-op when the negotiated protocol version predates - /// WakeRequest (1.3). Best-effort: failures are logged, not thrown. - func wake() async { - let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "proto") - guard let negotiatedVersion, negotiatedVersion >= ProtoVersion(1, 3) else { - logger.debug("skipping wake rpc: negotiated protocol version doesn't support it") - return - } - logger.info("sending wake rpc") - let resp: Vpn_TunnelMessage - do { - resp = try await unaryRPC( - .with { msg in - msg.wake = .init() - }) - } catch { - logger.error("wake rpc failed: \(error.localizedDescription, privacy: .public)") - return - } - guard case let .wake(wakeResp) = resp.msg else { - logger.error("unexpected wake rpc response: \(String(describing: resp.msg), privacy: .public)") - return - } - logger.debug("wake rpc response success: \(wakeResp.success)") + /// Asks the tunnel to rediscover network paths after system wake. + /// No-op if the negotiated protocol version predates WakeRequest (1.3). + func wake() async throws { + guard let negotiatedVersion, negotiatedVersion >= ProtoVersion(1, 3) else { return } + _ = try await unaryRPC( + .with { msg in + msg.wake = .init() + }) } } diff --git a/Coder-Desktop/VPNLib/Speaker.swift b/Coder-Desktop/VPNLib/Speaker.swift index b4f53c1f..e2f60f55 100644 --- a/Coder-Desktop/VPNLib/Speaker.swift +++ b/Coder-Desktop/VPNLib/Speaker.swift @@ -64,8 +64,7 @@ public actor Speaker private let secretary = RPCSecretary() let role: ProtoRole - /// The protocol version negotiated during the handshake, or nil if the - /// handshake has not yet completed. + /// The version negotiated during the handshake, or nil before it completes. public private(set) var negotiatedVersion: ProtoVersion? /// Creates an instance that communicates over the provided file handles. @@ -99,10 +98,8 @@ public actor Speaker