Skip to content
Open
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
118 changes: 118 additions & 0 deletions macos/Sources/HypoApp/Services/ConnectionStatusProber.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?
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"]
}
}
31 changes: 21 additions & 10 deletions macos/Sources/HypoApp/Services/LanWebSocketServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
46 changes: 28 additions & 18 deletions macos/Sources/HypoApp/Services/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
77 changes: 51 additions & 26 deletions macos/Sources/HypoApp/Services/TransportManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
Expand All @@ -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<ConnectionState>.Publisher { $connectionState }
Expand Down Expand Up @@ -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 {
Expand All @@ -99,6 +108,8 @@ public final class TransportManager {

// Set up WebSocket server delegate
webSocketServer.delegate = self
connectionStatusProber.start()
registerStatusObservers()

#if canImport(AppKit)
lifecycleObserver = ApplicationLifecycleObserver(
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -980,41 +994,52 @@ 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)
}
}
}


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 { [weak self] @MainActor in
self?.connectionStatusProber.recordActivity(deviceId: deviceId)
}
}

nonisolated public func server(_ server: LanWebSocketServer, didCloseConnection id: UUID) {
#if canImport(os)
let closeLogger = Logger(subsystem: "com.hypo.clipboard", category: "transport")
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)
}
}