CallKit/PushKit интеграция + фикс PacketPushNotification (tokenType, deviceId)

This commit is contained in:
2026-04-01 00:39:34 +05:00
parent 0470b306a9
commit 8f69781a66
16 changed files with 1058 additions and 63 deletions

View File

@@ -98,6 +98,12 @@ final class ProtocolManager: @unchecked Sendable {
// Saved credentials for auto-reconnect
private var savedPublicKey: String?
private var savedPrivateHash: String?
/// Pre-built handshake packet for instant send on socket open.
/// Built once in connect() on MainActor (safe UIDevice access), reused across reconnects.
private var cachedHandshakeData: Data?
/// Timestamp of last successful authentication used to decide whether to reset backoff.
/// If connection was short-lived (<10s), don't reset backoff counter (server RST loop).
private var lastAuthenticatedTime: CFAbsoluteTime = 0
var publicKey: String? { savedPublicKey }
var privateHash: String? { savedPrivateHash }
@@ -118,6 +124,7 @@ final class ProtocolManager: @unchecked Sendable {
savedPublicKey = publicKey
savedPrivateHash = privateKeyHash
cachedHandshakeData = buildHandshakeData()
switch connectionState {
case .authenticated, .handshaking, .deviceVerificationRequired:
@@ -163,6 +170,8 @@ final class ProtocolManager: @unchecked Sendable {
connectionState = .disconnected
savedPublicKey = nil
savedPrivateHash = nil
cachedHandshakeData = nil
lastAuthenticatedTime = 0
Task { @MainActor in
TransportManager.shared.reset()
}
@@ -194,6 +203,8 @@ final class ProtocolManager: @unchecked Sendable {
handshakeComplete = false
heartbeatTask?.cancel()
searchRouter.resetPending()
// User-initiated foreground allow fast retry on next disconnect.
lastAuthenticatedTime = 0
connectionState = .connecting
client.forceReconnect()
}
@@ -419,8 +430,16 @@ final class ProtocolManager: @unchecked Sendable {
self.connectionState = .connected
}
// Auto-handshake with saved credentials
if let pk = savedPublicKey, let hash = savedPrivateHash {
// Send pre-built handshake immediately no packet construction on critical path.
if let data = cachedHandshakeData {
Self.logger.info("⚡ Sending pre-built handshake packet")
Task { @MainActor in
self.connectionState = .handshaking
}
client.send(data)
startHandshakeTimeout()
} else if let pk = savedPublicKey, let hash = savedPrivateHash {
// Fallback: build handshake on the fly
startHandshake(publicKey: pk, privateHash: hash)
}
}
@@ -468,6 +487,27 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Handshake
/// Build serialized handshake packet from saved credentials.
/// Called from MainActor context safe to access UIDevice.
private func buildHandshakeData() -> Data? {
guard let pk = savedPublicKey, let hash = savedPrivateHash else { return nil }
let device = HandshakeDevice(
deviceId: DeviceIdentityManager.shared.currentDeviceId(),
deviceName: UIDevice.current.name,
deviceOs: "iOS \(UIDevice.current.systemVersion)"
)
let handshake = PacketHandshake(
privateKey: hash,
publicKey: pk,
protocolVersion: 1,
heartbeatInterval: 15,
device: device,
handshakeState: .needDeviceVerification
)
return PacketRegistry.encode(handshake)
}
/// Fallback handshake builds packet on the fly when cached data is unavailable.
private func startHandshake(publicKey: String, privateHash: String) {
Self.logger.info("Starting handshake for \(publicKey.prefix(20))...")
@@ -491,24 +531,25 @@ final class ProtocolManager: @unchecked Sendable {
)
sendPacketDirect(handshake)
startHandshakeTimeout()
}
// Timeout force reconnect instead of permanent disconnect.
// `client.disconnect()` sets `isManuallyClosed = true` which kills all
// future reconnection attempts. Use `forceReconnect()` to retry.
private func startHandshakeTimeout() {
// 5s is generous for a single packet round-trip. Faster detection
// means faster recovery via instant first retry (0ms backoff).
handshakeTimeoutTask?.cancel()
handshakeTimeoutTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
return
}
guard let self, !Task.isCancelled else { return }
if !self.handshakeComplete {
Self.logger.error("Handshake timeout — forcing reconnect")
Self.logger.error("Handshake timeout (5s) — forcing reconnect")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
// Guard: only downgrade to .connecting if reconnect hasn't already progressed.
let s = self.connectionState
if s != .authenticated && s != .handshaking && s != .connected {
self.connectionState = .connecting
@@ -719,8 +760,17 @@ final class ProtocolManager: @unchecked Sendable {
switch packet.handshakeState {
case .completed:
handshakeComplete = true
// Android parity: reset backoff counter on successful authentication.
client.resetReconnectAttempts()
// Reset backoff only if previous connection was stable (>10s).
// Prevents tight reconnect loop when server/proxy RSTs connections
// shortly after sync. Without this, resetReconnectAttempts on every auth
// means backoff always starts at 1s (attempt #1) = infinite 1s loop.
let connectionAge = CFAbsoluteTimeGetCurrent() - lastAuthenticatedTime
if lastAuthenticatedTime == 0 || connectionAge > 10 {
client.resetReconnectAttempts()
} else {
Self.logger.info("Short-lived connection (\(Int(connectionAge))s) — keeping backoff counter")
}
lastAuthenticatedTime = CFAbsoluteTimeGetCurrent()
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
flushPacketQueue()
@@ -758,13 +808,20 @@ final class ProtocolManager: @unchecked Sendable {
private func startHeartbeat(interval: Int) {
heartbeatTask?.cancel()
// Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive).
let intervalNs = UInt64(interval) * 1_000_000_000 / 3
// Send heartbeat every 5 seconds aggressive keep-alive to prevent
// server/proxy idle timeouts. Server timeout is heartbeat*2 = 60s,
// so 5s gives 12× safety margin.
let intervalNs: UInt64 = 5_000_000_000
// Send first heartbeat SYNCHRONOUSLY on current thread (URLSession delegate queue).
// This bypasses the connectionState race: startHeartbeat() is called BEFORE
// the MainActor task sets .authenticated, so sendHeartbeat()'s guard would
// skip the first heartbeat. Direct sendText avoids this.
if client.isConnected {
client.sendText("heartbeat")
}
heartbeatTask = Task { [weak self] in
// Send first heartbeat immediately
self?.sendHeartbeat()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: intervalNs)
guard !Task.isCancelled else { break }
@@ -773,10 +830,11 @@ final class ProtocolManager: @unchecked Sendable {
}
}
/// Android parity: send heartbeat and trigger disconnect on failure.
/// Send heartbeat and trigger disconnect on failure.
private func sendHeartbeat() {
let state = connectionState
guard state == .authenticated || state == .deviceVerificationRequired else { return }
// Allow heartbeat when handshake is complete (covers the gap before
// MainActor sets .authenticated) or in device verification.
guard handshakeComplete || connectionState == .deviceVerificationRequired else { return }
guard client.isConnected else {
Self.logger.warning("💔 Heartbeat failed: socket not connected — triggering reconnect")
handleHeartbeatFailure()