Make a broke websocket on watchOS (w.i.p)

This commit is contained in:
2025-10-30 22:37:41 +08:00
parent 8ba55eb1be
commit 402bb3fe04
7 changed files with 408 additions and 32 deletions

View File

@@ -2,16 +2,53 @@
// NetworkService.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29.
//
// 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)
@@ -77,7 +114,7 @@ class NetworkService {
throw URLError(.badURL)
}
var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)!
var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))]
let queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))]
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
@@ -148,7 +185,7 @@ class NetworkService {
}
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
// Check if there\'s already a customized status
let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl)
let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST"
@@ -167,7 +204,7 @@ class NetworkService {
var body: [String: Any] = [
"attitude": attitude,
"is_invisible": isInvisible,
"is_not_disturb": isNotDisturb
"is_not_disturb": isNotDisturb,
]
if let label = label, !label.isEmpty {
@@ -338,7 +375,7 @@ class NetworkService {
resolvingAgainstBaseURL: false
)!
var queryItems = [
URLQueryItem(name: "take", value: String(take))
URLQueryItem(name: "take", value: String(take)),
]
if let before = before {
queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before)))
@@ -352,48 +389,248 @@ class NetworkService {
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 DecodingError.dataCorrupted(let context) {
print(context)
return []
} catch DecodingError.keyNotFound(let key, let context) {
print("[watchOS] Message decode failed: Key '\(key)' not found:", context.debugDescription)
print("[watchOS] Message decode failed: codingPath:", context.codingPath)
return []
} catch DecodingError.valueNotFound(let value, let context) {
print("[watchOS] Message decode failed: Value '\(value)' not found:", context.debugDescription)
print("[watchOS] Message decode failed: codingPath:", context.codingPath)
return []
} catch DecodingError.typeMismatch(let type, let context) {
print("[watchOS] Message decode failed: Type '\(type)' mismatch:", context.debugDescription)
print("[watchOS] Message decode failed: codingPath:", context.codingPath)
return []
} 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) {
connectLock.lock()
defer { connectLock.unlock() }
webSocketQueue.async { [weak self] in
guard let self = self else { return }
// Prevent redundant connection attempts
if self.currentConnectionState == .connecting || self.currentConnectionState == .connected {
print("[WebSocket] Already connecting or connected, ignoring new connect request.")
return
}
// 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
}
print("[WebSocket] Trying connecting to \(url)")
self.currentConnectionState = .connecting
var request = URLRequest(url: url)
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
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
}
}