diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 44828bb4..3e0e6fad 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -148,6 +148,13 @@ /* End PBXFileReference 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 */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -182,6 +189,9 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7355265E2EB3A8870013AFE4 /* Exceptions for "WatchRunner Watch App" folder in "WatchRunner Watch App" target */, + ); path = "WatchRunner Watch App"; sourceTree = ""; }; @@ -1092,6 +1102,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; @@ -1140,6 +1151,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; @@ -1185,6 +1197,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index d09b4428..5d5b0f79 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -22,7 +22,9 @@ struct ContentView: View { var body: some View { NavigationSplitView { List(selection: $selection) { - AppInfoHeaderView().listRowBackground(Color.clear) + AppInfoHeaderView() + .listRowBackground(Color.clear) + .environmentObject(appState) Label("Explore", systemImage: "globe.fill").tag(Panel.explore) Label("Chat", systemImage: "message.fill").tag(Panel.chat) diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 04a9a3df..efae0a8f 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -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() + private let stateSubject = CurrentValueSubject(.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 { + packetSubject.eraseToAnyPublisher() + } + + var stateStream: AnyPublisher { + 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 + } } diff --git a/ios/WatchRunner Watch App/State/AppState.swift b/ios/WatchRunner Watch App/State/AppState.swift index e8c69ae2..2c6392ec 100644 --- a/ios/WatchRunner Watch App/State/AppState.swift +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -26,10 +26,15 @@ class AppState: ObservableObject { .sink { [weak self] token, serverUrl in self?.token = token self?.serverUrl = serverUrl - if token != nil && serverUrl != nil { - self?.isReady = true - } - } + if let token = token, let serverUrl = serverUrl { + 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) } diff --git a/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift index 93719238..9384a5dd 100644 --- a/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift +++ b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift @@ -5,9 +5,14 @@ // Created by LittleSheep on 2025/10/30. // +import Combine import SwiftUI 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() // For managing subscriptions + var body: some View { VStack(alignment: .leading) { HStack(spacing: 12) { @@ -18,8 +23,40 @@ struct AppInfoHeaderView : View { VStack(alignment: .leading) { Text("Solian").font(.headline) 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) } } diff --git a/ios/WatchRunner Watch App/Views/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatView.swift index c7102b67..d5fc71d7 100644 --- a/ios/WatchRunner Watch App/Views/ChatView.swift +++ b/ios/WatchRunner Watch App/Views/ChatView.swift @@ -196,7 +196,7 @@ struct ChatRoomListItem: View { .resizable() .frame(width: 32, height: 32) .clipShape(Circle()) - } else if let errorMessage = avatarLoader.errorMessage { + } else if avatarLoader.errorMessage != nil { // Error state - show fallback Circle() .fill(Color.gray.opacity(0.3)) @@ -250,15 +250,28 @@ struct ChatRoomListItem: View { } } +import Combine +import SwiftUI + struct ChatRoomView: View { let room: SnChatRoom @EnvironmentObject var appState: AppState @State private var messages: [SnChatMessage] = [] @State private var isLoading = false @State private var error: Error? + @State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status + + @State private var cancellables = Set() // For managing subscriptions var body: some View { VStack { + // Display WebSocket connection status + Text(webSocketStatusMessage) + .font(.caption2) + .foregroundColor(.secondary) + .padding(.vertical, 2) + .animation(.easeInOut, value: webSocketConnectionState) // Animate status changes + if isLoading { ProgressView() } else if error != nil { @@ -313,6 +326,24 @@ struct ChatRoomView: View { .task { 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 { @@ -336,6 +367,47 @@ struct ChatRoomView: View { 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 { diff --git a/ios/WatchRunner-Watch-App-Info.plist b/ios/WatchRunner-Watch-App-Info.plist new file mode 100644 index 00000000..ca9a074a --- /dev/null +++ b/ios/WatchRunner-Watch-App-Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + remote-notification + + +