From 1530ac2e4e474436bfe2170ce3e46d6cf1034bd3 Mon Sep 17 00:00:00 2001 From: Derek Zen Date: Wed, 19 Nov 2025 15:04:30 +0800 Subject: [PATCH 1/2] fix(macos): decode plaintext frames and track device status --- .../HypoApp/Services/LanWebSocketServer.swift | 31 +++++++++---- .../Sources/HypoApp/Services/SyncEngine.swift | 46 +++++++++++-------- .../HypoApp/Services/TransportManager.swift | 19 +++++++- 3 files changed, 67 insertions(+), 29 deletions(-) diff --git a/macos/Sources/HypoApp/Services/LanWebSocketServer.swift b/macos/Sources/HypoApp/Services/LanWebSocketServer.swift index a7dd3d2..a7cdb2d 100644 --- a/macos/Sources/HypoApp/Services/LanWebSocketServer.swift +++ b/macos/Sources/HypoApp/Services/LanWebSocketServer.swift @@ -20,6 +20,11 @@ public protocol LanWebSocketServerDelegate: AnyObject { func server(_ server: LanWebSocketServer, didReceiveClipboardData data: Data, from connection: UUID) func server(_ server: LanWebSocketServer, didAcceptConnection id: UUID) func server(_ server: LanWebSocketServer, didCloseConnection id: UUID) + func server(_ server: LanWebSocketServer, didIdentifyDevice deviceId: String, for connection: UUID) +} + +public extension LanWebSocketServerDelegate { + func server(_ server: LanWebSocketServer, didIdentifyDevice deviceId: String, for connection: UUID) {} } @MainActor @@ -79,12 +84,12 @@ public final class LanWebSocketServer { connectionMetadata[connectionId] } - public func updateConnectionMetadata(connectionId: UUID, deviceId: String) { - if var existing = connectionMetadata[connectionId] { - connectionMetadata[connectionId] = ConnectionMetadata(deviceId: deviceId, connectedAt: existing.connectedAt) - } else { - connectionMetadata[connectionId] = ConnectionMetadata(deviceId: deviceId, connectedAt: Date()) - } + @discardableResult + public func updateConnectionMetadata(connectionId: UUID, deviceId: String) -> ConnectionMetadata { + let connectedAt = connectionMetadata[connectionId]?.connectedAt ?? Date() + let metadata = ConnectionMetadata(deviceId: deviceId, connectedAt: connectedAt) + connectionMetadata[connectionId] = metadata + return metadata } #if canImport(os) @@ -598,6 +603,12 @@ public final class LanWebSocketServer { logger.info("✅ CLIPBOARD MESSAGE RECEIVED: forwarding to delegate, \(data.count) bytes") #endif print("✅ [LanWebSocketServer] CLIPBOARD MESSAGE RECEIVED: \(data.count) bytes, forwarding to delegate") + let previousDeviceId = connectionMetadata[connectionId]?.deviceId + let deviceId = envelope.payload.deviceId + updateConnectionMetadata(connectionId: connectionId, deviceId: deviceId) + if previousDeviceId != deviceId { + delegate?.server(self, didIdentifyDevice: deviceId, for: connectionId) + } delegate?.server(self, didReceiveClipboardData: data, from: connectionId) return case .control: @@ -850,11 +861,11 @@ public final class LanWebSocketServer { // This method is kept for backward compatibility but may not be called private func closeConnection(_ id: UUID) { - connections[id]?.connection.cancel() - connections.removeValue(forKey: id) - connectionMetadata.removeValue(forKey: id) + let context = connections.removeValue(forKey: id) + context?.connection.cancel() delegate?.server(self, didCloseConnection: id) - + connectionMetadata.removeValue(forKey: id) + #if canImport(os) logger.info("Connection closed: \(id.uuidString)") #endif diff --git a/macos/Sources/HypoApp/Services/SyncEngine.swift b/macos/Sources/HypoApp/Services/SyncEngine.swift index a4a1682..d2a1d1d 100644 --- a/macos/Sources/HypoApp/Services/SyncEngine.swift +++ b/macos/Sources/HypoApp/Services/SyncEngine.swift @@ -105,30 +105,40 @@ public struct SyncEnvelope: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) algorithm = try container.decode(String.self, forKey: .algorithm) - + // Decode nonce from base64 string (Android uses standard base64 without padding) let nonceString = try container.decode(String.self, forKey: .nonce) - let nonceRemainder = nonceString.count % 4 - let paddedNonce = nonceRemainder == 0 ? nonceString : nonceString + String(repeating: "=", count: 4 - nonceRemainder) - print("🔍 [SyncEngine] Decoding nonce: \(nonceString) (padded: \(paddedNonce))") - guard let nonceData = Data(base64Encoded: paddedNonce) else { - print("❌ [SyncEngine] Failed to decode base64 nonce") - throw DecodingError.dataCorruptedError(forKey: .nonce, in: container, debugDescription: "Invalid Base64 string for nonce: \(nonceString)") + if nonceString.isEmpty { + print("⚠️ [SyncEngine] Empty nonce received - treating as plain text mode") + self.nonce = Data() + } else { + let nonceRemainder = nonceString.count % 4 + let paddedNonce = nonceRemainder == 0 ? nonceString : nonceString + String(repeating: "=", count: 4 - nonceRemainder) + print("🔍 [SyncEngine] Decoding nonce: \(nonceString) (padded: \(paddedNonce))") + guard let nonceData = Data(base64Encoded: paddedNonce) else { + print("❌ [SyncEngine] Failed to decode base64 nonce") + throw DecodingError.dataCorruptedError(forKey: .nonce, in: container, debugDescription: "Invalid Base64 string for nonce: \(nonceString)") + } + print("✅ [SyncEngine] Nonce decoded: \(nonceData.count) bytes") + self.nonce = nonceData } - print("✅ [SyncEngine] Nonce decoded: \(nonceData.count) bytes") - self.nonce = nonceData - + // Decode tag from base64 string (Android uses standard base64 without padding) let tagString = try container.decode(String.self, forKey: .tag) - let tagRemainder = tagString.count % 4 - let paddedTag = tagRemainder == 0 ? tagString : tagString + String(repeating: "=", count: 4 - tagRemainder) - print("🔍 [SyncEngine] Decoding tag: \(tagString) (padded: \(paddedTag))") - guard let tagData = Data(base64Encoded: paddedTag) else { - print("❌ [SyncEngine] Failed to decode base64 tag") - throw DecodingError.dataCorruptedError(forKey: .tag, in: container, debugDescription: "Invalid Base64 string for tag: \(tagString)") + if tagString.isEmpty { + print("⚠️ [SyncEngine] Empty tag received - treating as plain text mode") + self.tag = Data() + } else { + let tagRemainder = tagString.count % 4 + let paddedTag = tagRemainder == 0 ? tagString : tagString + String(repeating: "=", count: 4 - tagRemainder) + print("🔍 [SyncEngine] Decoding tag: \(tagString) (padded: \(paddedTag))") + guard let tagData = Data(base64Encoded: paddedTag) else { + print("❌ [SyncEngine] Failed to decode base64 tag") + throw DecodingError.dataCorruptedError(forKey: .tag, in: container, debugDescription: "Invalid Base64 string for tag: \(tagString)") + } + print("✅ [SyncEngine] Tag decoded: \(tagData.count) bytes") + self.tag = tagData } - print("✅ [SyncEngine] Tag decoded: \(tagData.count) bytes") - self.tag = tagData } } } diff --git a/macos/Sources/HypoApp/Services/TransportManager.swift b/macos/Sources/HypoApp/Services/TransportManager.swift index f4e35d5..e37abff 100644 --- a/macos/Sources/HypoApp/Services/TransportManager.swift +++ b/macos/Sources/HypoApp/Services/TransportManager.swift @@ -995,7 +995,24 @@ extension TransportManager: LanWebSocketServerDelegate { } } } - + + nonisolated public func server(_ server: LanWebSocketServer, didIdentifyDevice deviceId: String, for connection: UUID) { + #if canImport(os) + let connLogger = Logger(subsystem: "com.hypo.clipboard", category: "transport") + connLogger.info("WebSocket connection \(connection.uuidString) belongs to device: \(deviceId)") + #endif + Task { @MainActor in + NotificationCenter.default.post( + name: NSNotification.Name("DeviceConnectionStatusChanged"), + object: nil, + userInfo: [ + "deviceId": deviceId, + "isOnline": true + ] + ) + } + } + nonisolated public func server(_ server: LanWebSocketServer, didCloseConnection id: UUID) { #if canImport(os) let closeLogger = Logger(subsystem: "com.hypo.clipboard", category: "transport") From c12f1be7009d3741a3e39505fa97f5db70ca5098 Mon Sep 17 00:00:00 2001 From: Derek Zen Date: Wed, 19 Nov 2025 15:19:53 +0800 Subject: [PATCH 2/2] fix(macos): restore device connection probing --- .../Services/ConnectionStatusProber.swift | 118 ++++++++++++++++++ .../HypoApp/Services/TransportManager.swift | 76 ++++++----- 2 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 macos/Sources/HypoApp/Services/ConnectionStatusProber.swift diff --git a/macos/Sources/HypoApp/Services/ConnectionStatusProber.swift b/macos/Sources/HypoApp/Services/ConnectionStatusProber.swift new file mode 100644 index 0000000..161fea7 --- /dev/null +++ b/macos/Sources/HypoApp/Services/ConnectionStatusProber.swift @@ -0,0 +1,118 @@ +import Foundation + +@MainActor +public final class ConnectionStatusProber { + public struct Configuration { + public var pollInterval: TimeInterval + public var offlineGracePeriod: TimeInterval + + public init(pollInterval: TimeInterval = 15, offlineGracePeriod: TimeInterval = 120) { + self.pollInterval = max(1, pollInterval) + self.offlineGracePeriod = max(1, offlineGracePeriod) + } + } + + private let configuration: Configuration + private let notificationCenter: NotificationCenter + private let dateProvider: () -> Date + private var probeTask: Task? + private var lastSeenByDevice: [String: Date] = [:] + private var serviceToDeviceId: [String: String] = [:] + private var publishedState: [String: Bool] = [:] + private var manualOverrides: [String: Bool] = [:] + + public init( + configuration: Configuration = .init(), + notificationCenter: NotificationCenter = .default, + dateProvider: @escaping () -> Date = Date.init + ) { + self.configuration = configuration + self.notificationCenter = notificationCenter + self.dateProvider = dateProvider + } + + deinit { + stop() + } + + public func start() { + guard probeTask == nil else { return } + probeTask = Task { [weak self] in + guard let self else { return } + await self.runLoop() + } + } + + public func stop() { + probeTask?.cancel() + probeTask = nil + } + + public func recordLanPeerAdded(_ peer: DiscoveredPeer) { + guard let deviceId = Self.deviceId(from: peer.endpoint.metadata) else { return } + serviceToDeviceId[peer.serviceName] = deviceId + lastSeenByDevice[deviceId] = peer.lastSeen + manualOverrides.removeValue(forKey: deviceId) + publishIfNeeded(deviceId: deviceId, isOnline: true) + } + + public func recordLanPeerRemoved(serviceName: String) { + guard let deviceId = serviceToDeviceId.removeValue(forKey: serviceName) else { return } + lastSeenByDevice[deviceId] = lastSeenByDevice[deviceId] ?? dateProvider() + } + + public func recordActivity(deviceId: String, timestamp: Date? = nil) { + let activityTime = timestamp ?? dateProvider() + lastSeenByDevice[deviceId] = activityTime + manualOverrides.removeValue(forKey: deviceId) + publishIfNeeded(deviceId: deviceId, isOnline: true) + } + + public func publishImmediateStatus(deviceId: String, isOnline: Bool) { + if isOnline { + manualOverrides.removeValue(forKey: deviceId) + lastSeenByDevice[deviceId] = dateProvider() + } else { + manualOverrides[deviceId] = false + if lastSeenByDevice[deviceId] == nil { + lastSeenByDevice[deviceId] = .distantPast + } + } + publishIfNeeded(deviceId: deviceId, isOnline: isOnline) + } + + private func runLoop() async { + while !Task.isCancelled { + evaluateStatuses() + let nanoseconds = UInt64(configuration.pollInterval * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + } + + private func evaluateStatuses() { + let now = dateProvider() + for (deviceId, lastSeen) in lastSeenByDevice { + let elapsed = now.timeIntervalSince(lastSeen) + let inferredOnline = elapsed <= configuration.offlineGracePeriod + let effectiveState = manualOverrides[deviceId] ?? inferredOnline + publishIfNeeded(deviceId: deviceId, isOnline: effectiveState) + } + } + + private func publishIfNeeded(deviceId: String, isOnline: Bool) { + guard publishedState[deviceId] != isOnline else { return } + publishedState[deviceId] = isOnline + notificationCenter.post( + name: NSNotification.Name("DeviceConnectionStatusChanged"), + object: nil, + userInfo: [ + "deviceId": deviceId, + "isOnline": isOnline + ] + ) + } + + private static func deviceId(from metadata: [String: String]) -> String? { + metadata["device_id"] ?? metadata["deviceId"] + } +} diff --git a/macos/Sources/HypoApp/Services/TransportManager.swift b/macos/Sources/HypoApp/Services/TransportManager.swift index e37abff..77774a1 100644 --- a/macos/Sources/HypoApp/Services/TransportManager.swift +++ b/macos/Sources/HypoApp/Services/TransportManager.swift @@ -24,6 +24,7 @@ public final class TransportManager { private var lanConfiguration: BonjourPublisher.Configuration private let webSocketServer: LanWebSocketServer private let incomingHandler: IncomingClipboardHandler? + private let connectionStatusProber: ConnectionStatusProber private weak var historyViewModel: ClipboardHistoryViewModel? private var discoveryTask: Task? @@ -42,6 +43,7 @@ public final class TransportManager { private var manualRetryRequested = false private var networkChangeRequested = false private static let lanPairingKeyIdentifier = "lan-discovery-key" + private var notificationTokens: [NSObjectProtocol] = [] #if canImport(Combine) public var connectionStatePublisher: Published.Publisher { $connectionState } @@ -77,6 +79,13 @@ public final class TransportManager { self.lanConfiguration = lanConfiguration ?? TransportManager.defaultLanConfiguration() self.lastSeen = discoveryCache.load() self.webSocketServer = webSocketServer + self.connectionStatusProber = ConnectionStatusProber( + configuration: .init( + pollInterval: max(5, pruneInterval / 6), + offlineGracePeriod: max(30, stalePeerInterval) + ), + dateProvider: dateProvider + ) // Set up incoming clipboard handler if history store is provided if let historyStore = historyStore { @@ -99,6 +108,8 @@ public final class TransportManager { // Set up WebSocket server delegate webSocketServer.delegate = self + connectionStatusProber.start() + registerStatusObservers() #if canImport(AppKit) lifecycleObserver = ApplicationLifecycleObserver( @@ -124,6 +135,11 @@ public final class TransportManager { #endif } + deinit { + notificationTokens.forEach { NotificationCenter.default.removeObserver($0) } + connectionStatusProber.stop() + } + public func loadTransport() -> SyncTransport { let preference = currentPreference() return provider.preferredTransport(for: preference) @@ -620,9 +636,11 @@ public final class TransportManager { lanPeers[peer.serviceName] = peer lastSeen[peer.serviceName] = peer.lastSeen discoveryCache.save(lastSeen) + connectionStatusProber.recordLanPeerAdded(peer) case .removed(let serviceName): lanPeers.removeValue(forKey: serviceName) discoveryCache.save(lastSeen) + connectionStatusProber.recordLanPeerRemoved(serviceName: serviceName) } } @@ -937,13 +955,9 @@ extension TransportManager: LanWebSocketServerDelegate { ) // Also notify that device is now online - NotificationCenter.default.post( - name: NSNotification.Name("DeviceConnectionStatusChanged"), - object: nil, - userInfo: [ - "deviceId": challenge.androidDeviceId, - "isOnline": true - ] + connectionStatusProber.publishImmediateStatus( + deviceId: challenge.androidDeviceId, + isOnline: true ) print("✅ [TransportManager] PairingCompleted notification posted") try? "✅ [TransportManager] PairingCompleted notification posted\n".appendToFile(path: "/tmp/hypo_debug.log") @@ -980,18 +994,12 @@ extension TransportManager: LanWebSocketServerDelegate { connLogger.info("WebSocket connection established: \(id.uuidString)") #endif // Update device online status when connection is established - Task { @MainActor in + Task { [weak self] @MainActor in + guard let self else { return } // Try to find device ID from connection metadata if let metadata = server.connectionMetadata(for: id), let deviceId = metadata.deviceId { - NotificationCenter.default.post( - name: NSNotification.Name("DeviceConnectionStatusChanged"), - object: nil, - userInfo: [ - "deviceId": deviceId, - "isOnline": true - ] - ) + self.connectionStatusProber.publishImmediateStatus(deviceId: deviceId, isOnline: true) } } } @@ -1001,15 +1009,8 @@ extension TransportManager: LanWebSocketServerDelegate { let connLogger = Logger(subsystem: "com.hypo.clipboard", category: "transport") connLogger.info("WebSocket connection \(connection.uuidString) belongs to device: \(deviceId)") #endif - Task { @MainActor in - NotificationCenter.default.post( - name: NSNotification.Name("DeviceConnectionStatusChanged"), - object: nil, - userInfo: [ - "deviceId": deviceId, - "isOnline": true - ] - ) + Task { [weak self] @MainActor in + self?.connectionStatusProber.recordActivity(deviceId: deviceId) } } @@ -1019,19 +1020,26 @@ extension TransportManager: LanWebSocketServerDelegate { closeLogger.info("WebSocket connection closed: \(id.uuidString)") #endif // Update device online status when connection is closed - Task { @MainActor in + Task { [weak self] @MainActor in + guard let self else { return } // Try to find device ID from connection metadata before it's removed if let metadata = server.connectionMetadata(for: id), let deviceId = metadata.deviceId { - NotificationCenter.default.post( - name: NSNotification.Name("DeviceConnectionStatusChanged"), - object: nil, - userInfo: [ - "deviceId": deviceId, - "isOnline": false - ] - ) + self.connectionStatusProber.publishImmediateStatus(deviceId: deviceId, isOnline: false) } } } + + private func registerStatusObservers() { + let center = NotificationCenter.default + let clipboardToken = center.addObserver( + forName: NSNotification.Name("ClipboardReceivedFromDevice"), + object: nil, + queue: .main + ) { [weak self] notification in + guard let deviceId = notification.userInfo?["deviceId"] as? String else { return } + self?.connectionStatusProber.recordActivity(deviceId: deviceId) + } + notificationTokens.append(clipboardToken) + } }