🚚 Rename watchOS project to proper one
This commit is contained in:
95
ios/Solian Watch App/Services/ImageLoader.swift
Normal file
95
ios/Solian Watch App/Services/ImageLoader.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// ImageLoader.swift
|
||||
// WatchRunner Watch App
|
||||
//
|
||||
// Created by LittleSheep on 2025/10/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
import KingfisherWebP
|
||||
import Combine
|
||||
|
||||
// MARK: - Image Loader
|
||||
|
||||
@MainActor
|
||||
class ImageLoader: ObservableObject {
|
||||
@Published var image: Image?
|
||||
@Published var errorMessage: String?
|
||||
@Published var isLoading = false
|
||||
|
||||
private var currentTask: DownloadTask?
|
||||
|
||||
init() {}
|
||||
|
||||
deinit {
|
||||
currentTask?.cancel()
|
||||
}
|
||||
|
||||
func loadImage(from initialUrl: URL, token: String) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
image = nil
|
||||
|
||||
// Create request modifier for authorization
|
||||
let modifier = AnyModifier { request in
|
||||
var r = request
|
||||
r.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
r.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
||||
return r
|
||||
}
|
||||
|
||||
// Use WebP processor as default since the app seems to handle WebP images
|
||||
let processor = WebPProcessor.default
|
||||
|
||||
// Use KingfisherManager to retrieve image with caching
|
||||
currentTask = KingfisherManager.shared.retrieveImage(
|
||||
with: initialUrl,
|
||||
options: [
|
||||
.requestModifier(modifier),
|
||||
.processor(processor),
|
||||
.cacheOriginalImage, // Cache the original image data
|
||||
.loadDiskFileSynchronously // Load from disk cache synchronously if available
|
||||
]
|
||||
) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
self.image = Image(uiImage: value.image)
|
||||
self.isLoading = false
|
||||
case .failure(_):
|
||||
// If WebP processor fails (likely due to format), try with default processor
|
||||
let defaultProcessor = DefaultImageProcessor.default
|
||||
self.currentTask = KingfisherManager.shared.retrieveImage(
|
||||
with: initialUrl,
|
||||
options: [
|
||||
.requestModifier(modifier),
|
||||
.processor(defaultProcessor),
|
||||
.cacheOriginalImage,
|
||||
.loadDiskFileSynchronously
|
||||
]
|
||||
) { [weak self] fallbackResult in
|
||||
guard let self = self else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
switch fallbackResult {
|
||||
case .success(let value):
|
||||
self.image = Image(uiImage: value.image)
|
||||
case .failure(let fallbackError):
|
||||
self.errorMessage = fallbackError.localizedDescription
|
||||
print("[watchOS] Image loading failed: \(fallbackError.localizedDescription)")
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
currentTask?.cancel()
|
||||
}
|
||||
}
|
||||
643
ios/Solian Watch App/Services/NetworkService.swift
Normal file
643
ios/Solian Watch App/Services/NetworkService.swift
Normal file
@@ -0,0 +1,643 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user