✨ Make a broke websocket on watchOS (w.i.p)
This commit is contained in:
		| @@ -148,6 +148,13 @@ | |||||||
| /* End PBXFileReference section */ | /* End PBXFileReference section */ | ||||||
|  |  | ||||||
| /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||||||
|  | 		7355265E2EB3A8870013AFE4 /* Exceptions for "WatchRunner Watch App" folder in "WatchRunner Watch App" target */ = { | ||||||
|  | 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||||
|  | 			membershipExceptions = ( | ||||||
|  | 				"WatchRunner-Watch-App-Info.plist", | ||||||
|  | 			); | ||||||
|  | 			target = 7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */; | ||||||
|  | 		}; | ||||||
| 		73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { | 		73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { | ||||||
| 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||||
| 			membershipExceptions = ( | 			membershipExceptions = ( | ||||||
| @@ -182,6 +189,9 @@ | |||||||
| /* Begin PBXFileSystemSynchronizedRootGroup section */ | /* Begin PBXFileSystemSynchronizedRootGroup section */ | ||||||
| 		7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { | 		7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { | ||||||
| 			isa = PBXFileSystemSynchronizedRootGroup; | 			isa = PBXFileSystemSynchronizedRootGroup; | ||||||
|  | 			exceptions = ( | ||||||
|  | 				7355265E2EB3A8870013AFE4 /* Exceptions for "WatchRunner Watch App" folder in "WatchRunner Watch App" target */, | ||||||
|  | 			); | ||||||
| 			path = "WatchRunner Watch App"; | 			path = "WatchRunner Watch App"; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| 		}; | 		}; | ||||||
| @@ -1092,6 +1102,7 @@ | |||||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
| 				GENERATE_INFOPLIST_FILE = YES; | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||||
| 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||||
| 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||||
| @@ -1140,6 +1151,7 @@ | |||||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
| 				GENERATE_INFOPLIST_FILE = YES; | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||||
| 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||||
| 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||||
| @@ -1185,6 +1197,7 @@ | |||||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
| 				GENERATE_INFOPLIST_FILE = YES; | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||||
| 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||||
| 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||||
|   | |||||||
| @@ -22,7 +22,9 @@ struct ContentView: View { | |||||||
|     var body: some View { |     var body: some View { | ||||||
|         NavigationSplitView { |         NavigationSplitView { | ||||||
|             List(selection: $selection) { |             List(selection: $selection) { | ||||||
|                 AppInfoHeaderView().listRowBackground(Color.clear) |                 AppInfoHeaderView() | ||||||
|  |                     .listRowBackground(Color.clear) | ||||||
|  |                     .environmentObject(appState) | ||||||
|                  |                  | ||||||
|                 Label("Explore", systemImage: "globe.fill").tag(Panel.explore) |                 Label("Explore", systemImage: "globe.fill").tag(Panel.explore) | ||||||
|                 Label("Chat", systemImage: "message.fill").tag(Panel.chat) |                 Label("Chat", systemImage: "message.fill").tag(Panel.chat) | ||||||
|   | |||||||
| @@ -2,16 +2,53 @@ | |||||||
| //  NetworkService.swift | //  NetworkService.swift | ||||||
| //  WatchRunner Watch App | //  WatchRunner Watch App | ||||||
| // | // | ||||||
| //  Created by LittleSheep on 2025/10/29. | //  Created by LittleSheep on 2025/10/29. // | ||||||
| // |  | ||||||
|  |  | ||||||
|  | import Combine | ||||||
| import Foundation | 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 | // MARK: - Network Service | ||||||
|  |  | ||||||
| class NetworkService { | class NetworkService { | ||||||
|     private let session = URLSession.shared |     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 { |     func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse { | ||||||
|         guard let baseURL = URL(string: serverUrl) else { |         guard let baseURL = URL(string: serverUrl) else { | ||||||
|             throw URLError(.badURL) |             throw URLError(.badURL) | ||||||
| @@ -77,7 +114,7 @@ class NetworkService { | |||||||
|             throw URLError(.badURL) |             throw URLError(.badURL) | ||||||
|         } |         } | ||||||
|         var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! |         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 |         components.queryItems = queryItems | ||||||
|          |          | ||||||
|         var request = URLRequest(url: components.url!) |         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 { |     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 existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) | ||||||
|         let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST" |         let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST" | ||||||
|          |          | ||||||
| @@ -167,7 +204,7 @@ class NetworkService { | |||||||
|         var body: [String: Any] = [ |         var body: [String: Any] = [ | ||||||
|             "attitude": attitude, |             "attitude": attitude, | ||||||
|             "is_invisible": isInvisible, |             "is_invisible": isInvisible, | ||||||
|             "is_not_disturb": isNotDisturb |             "is_not_disturb": isNotDisturb, | ||||||
|         ] |         ] | ||||||
|          |          | ||||||
|         if let label = label, !label.isEmpty { |         if let label = label, !label.isEmpty { | ||||||
| @@ -338,7 +375,7 @@ class NetworkService { | |||||||
|             resolvingAgainstBaseURL: false |             resolvingAgainstBaseURL: false | ||||||
|         )! |         )! | ||||||
|         var queryItems = [ |         var queryItems = [ | ||||||
|             URLQueryItem(name: "take", value: String(take)) |             URLQueryItem(name: "take", value: String(take)), | ||||||
|         ] |         ] | ||||||
|         if let before = before { |         if let before = before { | ||||||
|             queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: 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") |         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") | ||||||
|          |          | ||||||
|         let (data, response) = try await session.data(for: request) |         let (data, response) = try await session.data(for: request) | ||||||
|  |          | ||||||
|         if let httpResponse = response as? HTTPURLResponse { |         if let httpResponse = response as? HTTPURLResponse { | ||||||
|             _ = String(data: data, encoding: .utf8) ?? "Unable to decode response body" |             _ = String(data: data, encoding: .utf8) ?? "Unable to decode response body" | ||||||
|  |              | ||||||
|             if httpResponse.statusCode != 200 { |             if httpResponse.statusCode != 200 { | ||||||
|                 print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)") |                 print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)") | ||||||
|                 throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) |                 throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |          | ||||||
|         // Check if data is empty |         // Check if data is empty | ||||||
|         if data.isEmpty { |         if data.isEmpty { | ||||||
|             print("[watchOS] fetchChatMessages received empty response data") |             print("[watchOS] fetchChatMessages received empty response data") | ||||||
|             return [] |             return [] | ||||||
|         } |         } | ||||||
|  |          | ||||||
|         let decoder = JSONDecoder() |         let decoder = JSONDecoder() | ||||||
|         decoder.dateDecodingStrategy = .iso8601 |         decoder.dateDecodingStrategy = .iso8601 | ||||||
|         decoder.keyDecodingStrategy = .convertFromSnakeCase |         decoder.keyDecodingStrategy = .convertFromSnakeCase | ||||||
|  |          | ||||||
|         do { |         do { | ||||||
|             let messages = try decoder.decode([SnChatMessage].self, from: data) |             let messages = try decoder.decode([SnChatMessage].self, from: data) | ||||||
|             print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages") |             print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages") | ||||||
|             return 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 { |         } catch { | ||||||
|             print("error: ", error) |             print("error: ", error) | ||||||
|             throw 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 | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -26,10 +26,15 @@ class AppState: ObservableObject { | |||||||
|             .sink { [weak self] token, serverUrl in |             .sink { [weak self] token, serverUrl in | ||||||
|                 self?.token = token |                 self?.token = token | ||||||
|                 self?.serverUrl = serverUrl |                 self?.serverUrl = serverUrl | ||||||
|                 if token != nil && serverUrl != nil { |             if let token = token, let serverUrl = serverUrl { | ||||||
|                     self?.isReady = true |                 self?.isReady = true | ||||||
|                 } |                 // Auto-connect WebSocket here | ||||||
|             } |                 self?.networkService.connectWebSocket(token: token, serverUrl: serverUrl) | ||||||
|  |             } else { | ||||||
|  |                 self?.isReady = false | ||||||
|  |                 // Disconnect WebSocket if token or serverUrl become nil | ||||||
|  |                 self?.networkService.disconnectWebSocket() | ||||||
|  |             }            } | ||||||
|             .store(in: &cancellables) |             .store(in: &cancellables) | ||||||
|     } |     } | ||||||
|      |      | ||||||
|   | |||||||
| @@ -5,9 +5,14 @@ | |||||||
| //  Created by LittleSheep on 2025/10/30. | //  Created by LittleSheep on 2025/10/30. | ||||||
| // | // | ||||||
|  |  | ||||||
|  | import Combine | ||||||
| import SwiftUI | import SwiftUI | ||||||
|  |  | ||||||
| struct AppInfoHeaderView : View { | struct AppInfoHeaderView : View { | ||||||
|  |     @EnvironmentObject var appState: AppState // Access AppState | ||||||
|  |     @State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status | ||||||
|  |     @State private var cancellables = Set<AnyCancellable>() // For managing subscriptions | ||||||
|  |  | ||||||
|     var body: some View { |     var body: some View { | ||||||
|         VStack(alignment: .leading) { |         VStack(alignment: .leading) { | ||||||
|             HStack(spacing: 12) { |             HStack(spacing: 12) { | ||||||
| @@ -18,8 +23,40 @@ struct AppInfoHeaderView : View { | |||||||
|                 VStack(alignment: .leading) { |                 VStack(alignment: .leading) { | ||||||
|                     Text("Solian").font(.headline) |                     Text("Solian").font(.headline) | ||||||
|                     Text("for Apple Watch").font(.system(size: 11)) |                     Text("for Apple Watch").font(.system(size: 11)) | ||||||
|  |                      | ||||||
|  |                     // Display WebSocket connection status | ||||||
|  |                     Text(webSocketStatusMessage) | ||||||
|  |                         .font(.caption2) | ||||||
|  |                         .foregroundColor(.secondary) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |         .onAppear { | ||||||
|  |             setupWebSocketListeners() | ||||||
|  |         } | ||||||
|  |         .onDisappear { | ||||||
|  |             cancellables.forEach { $0.cancel() } | ||||||
|  |             cancellables.removeAll() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private var webSocketStatusMessage: String { | ||||||
|  |         switch webSocketConnectionState { | ||||||
|  |         case .connected: return "Connected" | ||||||
|  |         case .connecting: return "Connecting..." | ||||||
|  |         case .disconnected: return "Disconnected" | ||||||
|  |         case .serverDown: return "Server Down" | ||||||
|  |         case .duplicateDevice: return "Duplicate Device" | ||||||
|  |         case .error(let msg): return "Error: \(msg)" | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private func setupWebSocketListeners() { | ||||||
|  |         appState.networkService.stateStream | ||||||
|  |             .receive(on: DispatchQueue.main) | ||||||
|  |             .sink { state in | ||||||
|  |                 webSocketConnectionState = state | ||||||
|  |             } | ||||||
|  |             .store(in: &cancellables) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -196,7 +196,7 @@ struct ChatRoomListItem: View { | |||||||
|                             .resizable() |                             .resizable() | ||||||
|                             .frame(width: 32, height: 32) |                             .frame(width: 32, height: 32) | ||||||
|                             .clipShape(Circle()) |                             .clipShape(Circle()) | ||||||
|                     } else if let errorMessage = avatarLoader.errorMessage { |                     } else if avatarLoader.errorMessage != nil { | ||||||
|                         // Error state - show fallback |                         // Error state - show fallback | ||||||
|                         Circle() |                         Circle() | ||||||
|                             .fill(Color.gray.opacity(0.3)) |                             .fill(Color.gray.opacity(0.3)) | ||||||
| @@ -250,15 +250,28 @@ struct ChatRoomListItem: View { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | import Combine | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
| struct ChatRoomView: View { | struct ChatRoomView: View { | ||||||
|     let room: SnChatRoom |     let room: SnChatRoom | ||||||
|     @EnvironmentObject var appState: AppState |     @EnvironmentObject var appState: AppState | ||||||
|     @State private var messages: [SnChatMessage] = [] |     @State private var messages: [SnChatMessage] = [] | ||||||
|     @State private var isLoading = false |     @State private var isLoading = false | ||||||
|     @State private var error: Error? |     @State private var error: Error? | ||||||
|  |     @State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status | ||||||
|  |  | ||||||
|  |     @State private var cancellables = Set<AnyCancellable>() // For managing subscriptions | ||||||
|  |  | ||||||
|     var body: some View { |     var body: some View { | ||||||
|         VStack { |         VStack { | ||||||
|  |             // Display WebSocket connection status | ||||||
|  |             Text(webSocketStatusMessage) | ||||||
|  |                 .font(.caption2) | ||||||
|  |                 .foregroundColor(.secondary) | ||||||
|  |                 .padding(.vertical, 2) | ||||||
|  |                 .animation(.easeInOut, value: webSocketConnectionState) // Animate status changes | ||||||
|  |  | ||||||
|             if isLoading { |             if isLoading { | ||||||
|                 ProgressView() |                 ProgressView() | ||||||
|             } else if error != nil { |             } else if error != nil { | ||||||
| @@ -313,6 +326,24 @@ struct ChatRoomView: View { | |||||||
|         .task { |         .task { | ||||||
|             await loadMessages() |             await loadMessages() | ||||||
|         } |         } | ||||||
|  |         .onAppear { | ||||||
|  |             setupWebSocketListeners() | ||||||
|  |         } | ||||||
|  |         .onDisappear { | ||||||
|  |             cancellables.forEach { $0.cancel() } | ||||||
|  |             cancellables.removeAll() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private var webSocketStatusMessage: String { | ||||||
|  |         switch webSocketConnectionState { | ||||||
|  |         case .connected: return "Connected" | ||||||
|  |         case .connecting: return "Connecting..." | ||||||
|  |         case .disconnected: return "Disconnected" | ||||||
|  |         case .serverDown: return "Server Down" | ||||||
|  |         case .duplicateDevice: return "Duplicate Device" | ||||||
|  |         case .error(let msg): return "Error: \(msg)" | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private func loadMessages() async { |     private func loadMessages() async { | ||||||
| @@ -336,6 +367,47 @@ struct ChatRoomView: View { | |||||||
|  |  | ||||||
|         isLoading = false |         isLoading = false | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private func setupWebSocketListeners() { | ||||||
|  |         // Listen for WebSocket packets (new messages) | ||||||
|  |         appState.networkService.packetStream | ||||||
|  |             .receive(on: DispatchQueue.main) // Ensure UI updates on main thread | ||||||
|  |             .sink(receiveCompletion: { completion in | ||||||
|  |                 if case .failure(let err) = completion { | ||||||
|  |                     print("[ChatRoomView] WebSocket packet stream error: \(err.localizedDescription)") | ||||||
|  |                 } | ||||||
|  |             }, receiveValue: { packet in | ||||||
|  |                 // Assuming 'message.created' is the type for new messages | ||||||
|  |                 if packet.type == "message.created", | ||||||
|  |                    let messageData = packet.data { | ||||||
|  |                     do { | ||||||
|  |                         let jsonData = try JSONSerialization.data(withJSONObject: messageData, options: []) | ||||||
|  |                         let decoder = JSONDecoder() | ||||||
|  |                         decoder.dateDecodingStrategy = .iso8601 | ||||||
|  |                         decoder.keyDecodingStrategy = .convertFromSnakeCase | ||||||
|  |                         let newMessage = try decoder.decode(SnChatMessage.self, from: jsonData) | ||||||
|  |  | ||||||
|  |                         if newMessage.chatRoomId == room.id { | ||||||
|  |                             // Avoid adding duplicates | ||||||
|  |                             if !messages.contains(where: { $0.id == newMessage.id }) { | ||||||
|  |                                 messages.append(newMessage) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } catch { | ||||||
|  |                         print("[ChatRoomView] Error decoding message from websocket: \(error.localizedDescription)") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .store(in: &cancellables) | ||||||
|  |  | ||||||
|  |         // Listen for WebSocket connection state changes | ||||||
|  |         appState.networkService.stateStream | ||||||
|  |             .receive(on: DispatchQueue.main) // Ensure UI updates on main thread | ||||||
|  |             .sink { state in | ||||||
|  |                 webSocketConnectionState = state | ||||||
|  |             } | ||||||
|  |             .store(in: &cancellables) | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| struct ChatMessageItem: View { | struct ChatMessageItem: View { | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								ios/WatchRunner-Watch-App-Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								ios/WatchRunner-Watch-App-Info.plist
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8"?> | ||||||
|  | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||||
|  | <plist version="1.0"> | ||||||
|  | <dict> | ||||||
|  | 	<key>UIBackgroundModes</key> | ||||||
|  | 	<array> | ||||||
|  | 		<string>remote-notification</string> | ||||||
|  | 	</array> | ||||||
|  | </dict> | ||||||
|  | </plist> | ||||||
		Reference in New Issue
	
	Block a user