✨ 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)))
|
||||||
@@ -376,24 +413,224 @@ class NetworkService {
|
|||||||
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