644 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			644 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
 | 
						|
    
 | 
						|
    init() {
 | 
						|
        let config = URLSessionConfiguration.ephemeral
 | 
						|
        config.waitsForConnectivity = true
 | 
						|
        session = URLSession(configuration: config)
 | 
						|
    }
 | 
						|
    
 | 
						|
    // 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
 | 
						|
    }
 | 
						|
}
 |