638 lines
26 KiB
Swift
638 lines
26 KiB
Swift
//
|
|
// NetworkService.swift
|
|
// WatchRunner Watch App
|
|
//
|
|
// Created by LittleSheep on 2025/10/29. //
|
|
|
|
import Combine
|
|
import Foundation
|
|
|
|
// MARK: - WebSocket Data Structures
|
|
|
|
enum WebSocketState: Equatable {
|
|
case connected
|
|
case connecting
|
|
case disconnected
|
|
case serverDown
|
|
case duplicateDevice
|
|
case error(String)
|
|
|
|
// Equatable conformance
|
|
static func == (lhs: WebSocketState, rhs: WebSocketState) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.connected, .connected),
|
|
(.connecting, .connecting),
|
|
(.disconnected, .disconnected),
|
|
(.serverDown, .serverDown),
|
|
(.duplicateDevice, .duplicateDevice):
|
|
return true
|
|
case let (.error(a), .error(b)):
|
|
return a == b
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WebSocketPacket {
|
|
let type: String
|
|
let data: [String: Any]?
|
|
let endpoint: String?
|
|
let errorMessage: String?
|
|
}
|
|
|
|
// MARK: - Network Service
|
|
|
|
class NetworkService {
|
|
private let session = URLSession.shared
|
|
|
|
// Add a serial queue for WebSocket operations
|
|
private let webSocketQueue = DispatchQueue(label: "com.solian.websocketQueue")
|
|
|
|
func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)!
|
|
var queryItems = [URLQueryItem(name: "take", value: "20")]
|
|
if filter.lowercased() != "explore" {
|
|
queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased()))
|
|
}
|
|
if let cursor = cursor {
|
|
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
|
}
|
|
components.queryItems = queryItems
|
|
|
|
var request = URLRequest(url: components.url!)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, _) = try await session.data(for: request)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
let activities = try decoder.decode([SnActivity].self, from: data)
|
|
|
|
let hasMore = (activities.first?.type ?? "empty") != "empty"
|
|
let nextCursor = activities.isEmpty ? nil : activities.map { $0.createdAt }.min()?.ISO8601Format()
|
|
|
|
return ActivityResponse(activities: activities, hasMore: hasMore, nextCursor: nextCursor)
|
|
}
|
|
|
|
func createPost(title: String, content: String, token: String, serverUrl: String) async throws {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/sphere/posts")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let body: [String: Any] = ["title": title, "content": content]
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 {
|
|
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
|
print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
|
|
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
|
}
|
|
}
|
|
|
|
func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)!
|
|
let queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))]
|
|
components.queryItems = queryItems
|
|
|
|
var request = URLRequest(url: components.url!)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
let notifications = try decoder.decode([SnNotification].self, from: data)
|
|
|
|
let httpResponse = response as? HTTPURLResponse
|
|
let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0
|
|
let hasMore = offset + notifications.count < total
|
|
|
|
return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore)
|
|
}
|
|
|
|
func fetchUserProfile(token: String, serverUrl: String) async throws -> SnAccount {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/pass/accounts/me")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, _) = try await session.data(for: request)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
return try decoder.decode(SnAccount.self, from: data)
|
|
}
|
|
|
|
func fetchAccountStatus(token: String, serverUrl: String) async throws -> SnAccountStatus? {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
|
|
return nil
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
return try decoder.decode(SnAccountStatus.self, from: data)
|
|
}
|
|
|
|
func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus {
|
|
// Check if there\'s already a customized status
|
|
let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl)
|
|
let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST"
|
|
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
var body: [String: Any] = [
|
|
"attitude": attitude,
|
|
"is_invisible": isInvisible,
|
|
"is_not_disturb": isNotDisturb,
|
|
]
|
|
|
|
if let label = label, !label.isEmpty {
|
|
body["label"] = label
|
|
}
|
|
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 && httpResponse.statusCode != 200 {
|
|
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
|
print("[watchOS] createOrUpdateStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
|
|
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
return try decoder.decode(SnAccountStatus.self, from: data)
|
|
}
|
|
|
|
func clearStatus(token: String, serverUrl: String) async throws {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "DELETE"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 204 {
|
|
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
|
print("[watchOS] clearStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
|
|
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
|
}
|
|
}
|
|
|
|
// MARK: - Chat API Methods
|
|
|
|
func fetchChatRooms(token: String, serverUrl: String) async throws -> ChatRoomsResponse {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/sphere/chat")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, _) = try await session.data(for: request)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
let rooms = try decoder.decode([SnChatRoom].self, from: data)
|
|
return ChatRoomsResponse(rooms: rooms)
|
|
}
|
|
|
|
func fetchChatRoom(identifier: String, token: String, serverUrl: String) async throws -> SnChatRoom {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/sphere/chat/\(identifier)")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
|
|
throw URLError(.resourceUnavailable)
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
return try decoder.decode(SnChatRoom.self, from: data)
|
|
}
|
|
|
|
func fetchChatInvites(token: String, serverUrl: String) async throws -> ChatInvitesResponse {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/sphere/chat/invites")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, _) = try await session.data(for: request)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
let invites = try decoder.decode([SnChatMember].self, from: data)
|
|
return ChatInvitesResponse(invites: invites)
|
|
}
|
|
|
|
func acceptChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/accept")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
|
|
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
|
print("[watchOS] acceptChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
|
|
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
|
}
|
|
}
|
|
|
|
func declineChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/decline")
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
|
|
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
|
print("[watchOS] declineChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
|
|
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
|
}
|
|
}
|
|
|
|
// MARK: - Message API Methods
|
|
|
|
func fetchChatMessages(chatRoomId: String, token: String, serverUrl: String, before: Date? = nil, take: Int = 50) async throws -> [SnChatMessage] {
|
|
guard let baseURL = URL(string: serverUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
|
|
// Try a different pattern: /sphere/chat/messages with roomId as query param
|
|
var components = URLComponents(
|
|
url: baseURL.appendingPathComponent("/sphere/chat/\(chatRoomId)/messages"),
|
|
resolvingAgainstBaseURL: false
|
|
)!
|
|
var queryItems = [
|
|
URLQueryItem(name: "take", value: String(take)),
|
|
]
|
|
if let before = before {
|
|
queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before)))
|
|
}
|
|
components.queryItems = queryItems
|
|
|
|
var request = URLRequest(url: components.url!)
|
|
request.httpMethod = "GET"
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
_ = String(data: data, encoding: .utf8) ?? "Unable to decode response body"
|
|
|
|
if httpResponse.statusCode != 200 {
|
|
print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)")
|
|
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
|
}
|
|
}
|
|
|
|
// Check if data is empty
|
|
if data.isEmpty {
|
|
print("[watchOS] fetchChatMessages received empty response data")
|
|
return []
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
do {
|
|
let messages = try decoder.decode([SnChatMessage].self, from: data)
|
|
print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages")
|
|
return messages
|
|
} catch {
|
|
print("error: ", error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - WebSocket
|
|
|
|
private var webSocketTask: URLSessionWebSocketTask?
|
|
private var heartbeatTimer: Timer?
|
|
private var reconnectTimer: Timer?
|
|
private var isDisconnectingManually = false
|
|
|
|
private var lastToken: String?
|
|
private var lastServerUrl: String?
|
|
|
|
private var heartbeatAt: Date?
|
|
var heartbeatDelay: TimeInterval?
|
|
|
|
private let connectLock = NSLock()
|
|
|
|
private let packetSubject = PassthroughSubject<WebSocketPacket, Error>()
|
|
private let stateSubject = CurrentValueSubject<WebSocketState, Never>(.disconnected) // Changed to CurrentValueSubject
|
|
|
|
private var currentConnectionState: WebSocketState = .disconnected { // New property
|
|
didSet {
|
|
// Only send updates if the state has actually changed
|
|
if oldValue != currentConnectionState {
|
|
stateSubject.send(currentConnectionState)
|
|
}
|
|
}
|
|
}
|
|
|
|
var packetStream: AnyPublisher<WebSocketPacket, Error> {
|
|
packetSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
var stateStream: AnyPublisher<WebSocketState, Never> {
|
|
stateSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
func connectWebSocket(token: String, serverUrl: String) {
|
|
webSocketQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
self.connectLock.lock()
|
|
defer { self.connectLock.unlock() }
|
|
|
|
// Prevent redundant connection attempts
|
|
if self.currentConnectionState == .connecting || self.currentConnectionState == .connected {
|
|
print("[WebSocket] Already connecting or connected, ignoring new connect request.")
|
|
return
|
|
}
|
|
|
|
self.currentConnectionState = .connecting
|
|
|
|
// Ensure any existing task is cancelled before starting a new one
|
|
self.webSocketTask?.cancel(with: .goingAway, reason: nil)
|
|
self.webSocketTask = nil
|
|
|
|
self.isDisconnectingManually = false // Reset this flag for a new connection attempt
|
|
|
|
self.lastToken = token
|
|
self.lastServerUrl = serverUrl
|
|
|
|
guard var urlComponents = URLComponents(string: serverUrl) else {
|
|
self.currentConnectionState = .error("Invalid server URL")
|
|
return
|
|
}
|
|
|
|
urlComponents.scheme = urlComponents.scheme?.replacingOccurrences(of: "http", with: "ws")
|
|
urlComponents.path = "/ws"
|
|
urlComponents.queryItems = [URLQueryItem(name: "deviceAlt", value: "watch")]
|
|
|
|
guard let url = urlComponents.url else {
|
|
self.currentConnectionState = .error("Invalid WebSocket URL")
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
print("[WebSocket] Trying connecting to \(url)")
|
|
|
|
self.webSocketTask = self.session.webSocketTask(with: request)
|
|
self.webSocketTask?.resume()
|
|
|
|
self.listenForWebSocketMessages()
|
|
self.scheduleHeartbeat()
|
|
self.currentConnectionState = .connected
|
|
}
|
|
}
|
|
|
|
private func listenForWebSocketMessages() {
|
|
// Ensure webSocketTask is still valid before attempting to receive
|
|
guard let task = webSocketTask else {
|
|
print("[WebSocket] listenForWebSocketMessages: webSocketTask is nil, stopping listen.")
|
|
return
|
|
}
|
|
|
|
task.receive { [weak self] result in
|
|
guard let self = self else { return }
|
|
|
|
switch result {
|
|
case .failure(let error):
|
|
print("[WebSocket] Error in receiving message: \(error)")
|
|
// Only attempt to reconnect if not manually disconnecting
|
|
if !self.isDisconnectingManually {
|
|
self.currentConnectionState = .error(error.localizedDescription)
|
|
self.scheduleReconnect()
|
|
} else {
|
|
// If manually disconnecting, just ensure state is disconnected
|
|
self.currentConnectionState = .disconnected
|
|
}
|
|
case .success(let message):
|
|
switch message {
|
|
case .string(let text):
|
|
self.handleWebSocketMessage(text: text)
|
|
case .data(let data):
|
|
if let text = String(data: data, encoding: .utf8) {
|
|
self.handleWebSocketMessage(text: text)
|
|
}
|
|
@unknown default:
|
|
break
|
|
}
|
|
// Continue listening for next message only if task is still valid
|
|
if self.webSocketTask === task { // Check if it's the same task
|
|
self.listenForWebSocketMessages()
|
|
} else {
|
|
print("[WebSocket] listenForWebSocketMessages: Task changed, stopping listen for old task.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleWebSocketMessage(text: String) {
|
|
guard let data = text.data(using: .utf8) else {
|
|
print("[WebSocket] Could not convert message to data")
|
|
return
|
|
}
|
|
|
|
do {
|
|
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
|
let type = json["type"] as? String
|
|
{
|
|
let packet = WebSocketPacket(
|
|
type: type,
|
|
data: json["data"] as? [String: Any],
|
|
endpoint: json["endpoint"] as? String,
|
|
errorMessage: json["errorMessage"] as? String
|
|
)
|
|
|
|
print("[WebSocket] Received packet: \(packet.type) \(packet.errorMessage ?? "")")
|
|
|
|
if packet.type == "error.dupe" {
|
|
self.currentConnectionState = .duplicateDevice
|
|
self.disconnectWebSocket()
|
|
return
|
|
}
|
|
|
|
if packet.type == "pong" {
|
|
if let beatAt = self.heartbeatAt {
|
|
let now = Date()
|
|
self.heartbeatDelay = now.timeIntervalSince(beatAt)
|
|
print("[WebSocket] Server respond last heartbeat for \((self.heartbeatDelay ?? 0) * 1000) ms")
|
|
}
|
|
}
|
|
|
|
self.packetSubject.send(packet)
|
|
}
|
|
} catch {
|
|
print("[WebSocket] Could not parse message json: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func scheduleReconnect() {
|
|
reconnectTimer?.invalidate()
|
|
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
|
guard let self = self, let token = self.lastToken, let serverUrl = self.lastServerUrl else { return }
|
|
print("[WebSocket] Attempting to reconnect...")
|
|
|
|
// No need to call disconnectWebSocket here, connectWebSocket will handle cancelling old task
|
|
self.isDisconnectingManually = false // Reset for the new connection attempt
|
|
|
|
self.connectWebSocket(token: token, serverUrl: serverUrl)
|
|
}
|
|
}
|
|
|
|
private func scheduleHeartbeat() {
|
|
heartbeatTimer?.invalidate()
|
|
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
|
|
self?.beatTheHeart()
|
|
}
|
|
}
|
|
|
|
private func beatTheHeart() {
|
|
heartbeatAt = Date()
|
|
print("[WebSocket] We\'re beating the heart! \(String(describing: self.heartbeatAt))")
|
|
sendWebSocketMessage(message: "{\"type\":\"ping\"}")
|
|
}
|
|
|
|
func sendWebSocketMessage(message: String) {
|
|
webSocketTask?.send(.string(message)) { error in
|
|
if let error = error {
|
|
print("[WebSocket] Error sending message: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func disconnectWebSocket() {
|
|
isDisconnectingManually = true
|
|
reconnectTimer?.invalidate()
|
|
heartbeatTimer?.invalidate()
|
|
|
|
// Cancel the task and then nil it out
|
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
|
webSocketTask = nil // Set to nil immediately after cancelling
|
|
|
|
self.currentConnectionState = .disconnected
|
|
}
|
|
}
|