125 lines
4.1 KiB
Swift
125 lines
4.1 KiB
Swift
//
|
|
// WebSocketClient.swift
|
|
//
|
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
|
|
import Foundation
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public actor WebSocketClient {
|
|
private var task: URLSessionWebSocketTask?
|
|
private let session: URLSession
|
|
private let serverURL: URL
|
|
private let token: String
|
|
private var isConnected = false
|
|
|
|
public init(serverURL: URL, token: String) {
|
|
self.serverURL = serverURL
|
|
self.token = token
|
|
self.session = URLSession(configuration: .default)
|
|
}
|
|
|
|
public func connect() {
|
|
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
|
|
components.scheme = serverURL.scheme == "https" ? "wss" : "ws"
|
|
components.path = "/socket"
|
|
components.queryItems = [.init(name: "api_key", value: token)]
|
|
guard let url = components.url else { return }
|
|
task = session.webSocketTask(with: url)
|
|
task?.resume()
|
|
isConnected = true
|
|
receiveMessage()
|
|
}
|
|
|
|
public func disconnect() {
|
|
task?.cancel(with: .goingAway, reason: nil)
|
|
isConnected = false
|
|
}
|
|
|
|
private func receiveMessage() {
|
|
task?.receive { [weak self] result in
|
|
Task { [weak self] in
|
|
switch result {
|
|
case .success(let message):
|
|
await self?.handleMessage(message)
|
|
await self?.receiveMessage()
|
|
case .failure:
|
|
await self?.reconnect()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
|
|
switch message {
|
|
case .string(let text):
|
|
guard let data = text.data(using: .utf8),
|
|
let event = try? JSONDecoder().decode(WebSocketEvent.self, from: data)
|
|
else { return }
|
|
Task { @MainActor in
|
|
NotificationCenter.default.post(
|
|
name: .init(event.messageType),
|
|
object: event
|
|
)
|
|
}
|
|
case .data:
|
|
break
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func reconnect() async {
|
|
guard isConnected else { return }
|
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
|
connect()
|
|
}
|
|
}
|
|
|
|
public struct WebSocketEvent: Decodable {
|
|
public let messageType: String
|
|
public let data: [String: AnyDecodable]?
|
|
}
|
|
|
|
public struct AnyDecodable: Decodable {
|
|
public var value: Any
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if let intVal = try? container.decode(Int.self) {
|
|
value = intVal
|
|
} else if let doubleVal = try? container.decode(Double.self) {
|
|
value = doubleVal
|
|
} else if let boolVal = try? container.decode(Bool.self) {
|
|
value = boolVal
|
|
} else if let stringVal = try? container.decode(String.self) {
|
|
value = stringVal
|
|
} else if let arrayVal = try? container.decode([AnyDecodable].self) {
|
|
value = arrayVal.map(\.value)
|
|
} else if let dictVal = try? container.decode([String: AnyDecodable].self) {
|
|
value = dictVal.mapValues(\.value)
|
|
} else {
|
|
throw DecodingError.dataCorrupted(
|
|
.init(codingPath: container.codingPath, debugDescription: "AnyDecodable error"))
|
|
}
|
|
}
|
|
}
|