feat(stage-pocket-ios): websocket bridge
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */; };
|
||||
A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */; };
|
||||
A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */; };
|
||||
0DF9DE312F0E0A42008AB01F /* AppIcon_LiquidGlass.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */; };
|
||||
29ABB4BA2F03F2B400285F7F /* DevBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
@@ -20,6 +23,9 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostWebSocketBridge.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHostWebSocketSession.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakScriptMessageHandler.swift; sourceTree = "<group>"; };
|
||||
0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon_LiquidGlass.icon; sourceTree = "<group>"; };
|
||||
29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
@@ -67,6 +73,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */,
|
||||
A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */,
|
||||
A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */,
|
||||
A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
@@ -163,6 +172,9 @@
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
29ABB4BA2F03F2B400285F7F /* DevBridgeViewController.swift in Sources */,
|
||||
A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */,
|
||||
A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */,
|
||||
A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -3,9 +3,24 @@ import Capacitor
|
||||
import WebKit
|
||||
|
||||
class DevBridgeViewController: CAPBridgeViewController {
|
||||
private let hostBridgeName = "airiHostBridge"
|
||||
private lazy var webSocketBridge = HostWebSocketBridge(
|
||||
sessionFactory: URLSessionHostWebSocketSession.init,
|
||||
eventSink: { [weak self] payload in
|
||||
self?.dispatchWebSocketBridgeEvent(payload)
|
||||
}
|
||||
)
|
||||
private lazy var hostBridgeMessageHandler = WeakScriptMessageHandler(delegate: self)
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
super.capacitorDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
installWebSocketBridge()
|
||||
}
|
||||
|
||||
deinit {
|
||||
bridge?.webView?.configuration.userContentController.removeScriptMessageHandler(forName: hostBridgeName)
|
||||
webSocketBridge.dispose()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@@ -19,6 +34,44 @@ class DevBridgeViewController: CAPBridgeViewController {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func installWebSocketBridge() {
|
||||
guard let webView = bridge?.webView else {
|
||||
print("[HostBridge] Warning: WebView not available during bridge installation")
|
||||
return
|
||||
}
|
||||
|
||||
webView.configuration.userContentController.add(hostBridgeMessageHandler, name: hostBridgeName)
|
||||
}
|
||||
|
||||
private func dispatchWebSocketBridgeEvent(_ payload: String) {
|
||||
guard let webView = bridge?.webView else {
|
||||
return
|
||||
}
|
||||
|
||||
let script = "window.__airiHostBridge?.onNativeMessage(\(payload.javaScriptEscapedStringLiteral))"
|
||||
DispatchQueue.main.async {
|
||||
webView.evaluateJavaScript(script, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DevBridgeViewController: WKScriptMessageHandler {
|
||||
func userContentController(
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
guard message.name == hostBridgeName else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let payload = message.body as? String else {
|
||||
print("[HostBridge] Warning: Unsupported message payload: \(type(of: message.body))")
|
||||
return
|
||||
}
|
||||
|
||||
webSocketBridge.handleCommand(payload)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
|
||||
protocol HostWebSocketSession {
|
||||
func send(text: String)
|
||||
func close(code: Int?, reason: String?)
|
||||
}
|
||||
|
||||
enum HostWebSocketEvent {
|
||||
case open
|
||||
case message(String)
|
||||
case error(String)
|
||||
case close(Int?, String?)
|
||||
}
|
||||
|
||||
private struct HostBridgeCommand: Decodable {
|
||||
let kind: String
|
||||
let id: String
|
||||
let url: String?
|
||||
let data: String?
|
||||
let code: Int?
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
private struct HostBridgeEventPayload: Encodable {
|
||||
let kind: String
|
||||
let id: String
|
||||
let data: String?
|
||||
let message: String?
|
||||
let code: Int?
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
final class HostWebSocketBridge {
|
||||
typealias SessionFactory =
|
||||
(_ url: String, _ emit: @escaping (HostWebSocketEvent) -> Void) throws -> HostWebSocketSession
|
||||
|
||||
private let sessionFactory: SessionFactory
|
||||
private let eventSink: (String) -> Void
|
||||
private var sessions: [String: HostWebSocketSession] = [:]
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
init(
|
||||
sessionFactory: @escaping SessionFactory,
|
||||
eventSink: @escaping (String) -> Void
|
||||
) {
|
||||
self.sessionFactory = sessionFactory
|
||||
self.eventSink = eventSink
|
||||
}
|
||||
|
||||
func handleCommand(_ payload: String) {
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let command = try? decoder.decode(HostBridgeCommand.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
switch command.kind {
|
||||
case "connect":
|
||||
self.handleConnect(command)
|
||||
case "send":
|
||||
self.handleSend(command)
|
||||
case "close":
|
||||
self.handleClose(command)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
DispatchQueue.main.async {
|
||||
self.sessions.values.forEach { $0.close(code: nil, reason: "Bridge disposed") }
|
||||
self.sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnect(_ command: HostBridgeCommand) {
|
||||
guard let url = command.url else {
|
||||
emitEvent(kind: "error", id: command.id, message: "Missing websocket url")
|
||||
emitEvent(kind: "close", id: command.id, reason: "Missing websocket url")
|
||||
return
|
||||
}
|
||||
|
||||
print("[HostWebSocketBridge] connect id=\(command.id) url=\(url)")
|
||||
|
||||
do {
|
||||
let session = try sessionFactory(url) { [weak self] event in
|
||||
self?.handleSessionEvent(id: command.id, event: event)
|
||||
}
|
||||
sessions[command.id] = session
|
||||
} catch {
|
||||
print("[HostWebSocketBridge] connect failed id=\(command.id) error=\(error.localizedDescription)")
|
||||
let message = error.localizedDescription
|
||||
emitEvent(kind: "error", id: command.id, message: message)
|
||||
emitEvent(kind: "close", id: command.id, reason: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSend(_ command: HostBridgeCommand) {
|
||||
guard let data = command.data else {
|
||||
return
|
||||
}
|
||||
|
||||
sessions[command.id]?.send(text: data)
|
||||
}
|
||||
|
||||
private func handleClose(_ command: HostBridgeCommand) {
|
||||
sessions[command.id]?.close(code: command.code, reason: command.reason)
|
||||
}
|
||||
|
||||
private func handleSessionEvent(id: String, event: HostWebSocketEvent) {
|
||||
DispatchQueue.main.async {
|
||||
print("[HostWebSocketBridge] event id=\(id) \(String(describing: event))")
|
||||
switch event {
|
||||
case .open:
|
||||
self.emitEvent(kind: "open", id: id)
|
||||
case .message(let data):
|
||||
self.emitEvent(kind: "message", id: id, data: data)
|
||||
case .error(let message):
|
||||
self.emitEvent(kind: "error", id: id, message: message)
|
||||
case .close(let code, let reason):
|
||||
self.sessions.removeValue(forKey: id)
|
||||
self.emitEvent(kind: "close", id: id, code: code, reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func emitEvent(
|
||||
kind: String,
|
||||
id: String,
|
||||
data: String? = nil,
|
||||
message: String? = nil,
|
||||
code: Int? = nil,
|
||||
reason: String? = nil
|
||||
) {
|
||||
let payload = HostBridgeEventPayload(
|
||||
kind: kind,
|
||||
id: id,
|
||||
data: data,
|
||||
message: message,
|
||||
code: code,
|
||||
reason: reason
|
||||
)
|
||||
|
||||
guard let encoded = try? encoder.encode(payload),
|
||||
let string = String(data: encoded, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
eventSink(string)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>AIRI needs access to your local network to connect to your Tamagotchi host.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
final class URLSessionHostWebSocketSession: NSObject, HostWebSocketSession {
|
||||
private let url: URL
|
||||
private let emit: (HostWebSocketEvent) -> Void
|
||||
private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||
private lazy var task = session.webSocketTask(with: url)
|
||||
private var closed = false
|
||||
|
||||
init(url: String, emit: @escaping (HostWebSocketEvent) -> Void) throws {
|
||||
guard let parsedURL = URL(string: url) else {
|
||||
throw HostWebSocketSessionError.invalidURL(url)
|
||||
}
|
||||
|
||||
guard isSupportedWebSocketURL(parsedURL) else {
|
||||
throw HostWebSocketSessionError.unsupportedScheme(parsedURL.scheme)
|
||||
}
|
||||
|
||||
self.url = parsedURL
|
||||
self.emit = emit
|
||||
super.init()
|
||||
|
||||
print("[URLSessionHostWebSocketSession] start url=\(parsedURL.absoluteString)")
|
||||
task.resume()
|
||||
receiveNextMessage()
|
||||
}
|
||||
|
||||
deinit {
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
|
||||
func send(text: String) {
|
||||
task.send(.string(text)) { [emit] error in
|
||||
guard let error else {
|
||||
return
|
||||
}
|
||||
|
||||
emit(.error(describeWebSocketError(error)))
|
||||
}
|
||||
}
|
||||
|
||||
func close(code: Int?, reason: String?) {
|
||||
print("[URLSessionHostWebSocketSession] close code=\(String(describing: code)) reason=\(String(describing: reason)) url=\(url.absoluteString)")
|
||||
let closeCode = code.flatMap(URLSessionWebSocketTask.CloseCode.init(rawValue:))
|
||||
?? .normalClosure
|
||||
task.cancel(with: closeCode, reason: reason?.data(using: .utf8))
|
||||
}
|
||||
|
||||
private func receiveNextMessage() {
|
||||
task.receive { [weak self] result in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(.string(let text)):
|
||||
self.emit(.message(text))
|
||||
self.receiveNextMessage()
|
||||
case .success(.data):
|
||||
self.emit(.error("Binary frames are not supported"))
|
||||
self.receiveNextMessage()
|
||||
case .failure:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func closeIfNeeded(code: Int?, reason: String?) {
|
||||
guard !closed else {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
emit(.close(code, reason))
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension URLSessionHostWebSocketSession: URLSessionWebSocketDelegate, URLSessionTaskDelegate {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didOpenWithProtocol negotiatedProtocol: String?
|
||||
) {
|
||||
print("[URLSessionHostWebSocketSession] didOpen url=\(url.absoluteString) protocol=\(negotiatedProtocol ?? "nil")")
|
||||
emit(.open)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||
reason: Data?
|
||||
) {
|
||||
let resolvedReason = reason.flatMap { String(data: $0, encoding: .utf8) }
|
||||
print("[URLSessionHostWebSocketSession] didClose url=\(url.absoluteString) code=\(closeCode.rawValue) reason=\(resolvedReason ?? "nil")")
|
||||
closeIfNeeded(code: Int(closeCode.rawValue), reason: resolvedReason)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: Error?
|
||||
) {
|
||||
guard let error else {
|
||||
return
|
||||
}
|
||||
|
||||
if isCancelledWebSocketError(error) {
|
||||
print("[URLSessionHostWebSocketSession] didComplete cancelled url=\(url.absoluteString)")
|
||||
closeIfNeeded(code: nil, reason: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let message = describeWebSocketError(error)
|
||||
print("[URLSessionHostWebSocketSession] didComplete error url=\(url.absoluteString) message=\(message)")
|
||||
emit(.error(message))
|
||||
closeIfNeeded(code: nil, reason: message)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
print("[URLSessionHostWebSocketSession] challenge host=\(challenge.protectionSpace.host) method=\(challenge.protectionSpace.authenticationMethod)")
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var trustError: CFError?
|
||||
if SecTrustEvaluateWithError(trust, &trustError) {
|
||||
print("[URLSessionHostWebSocketSession] challenge accepted by system host=\(challenge.protectionSpace.host)")
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
|
||||
if trustLooksLikeAiriServerCertificate(trust) {
|
||||
print("[URLSessionHostWebSocketSession] challenge accepted by AIRI fallback host=\(challenge.protectionSpace.host)")
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
|
||||
print("[URLSessionHostWebSocketSession] challenge rejected host=\(challenge.protectionSpace.host) trustError=\(String(describing: trustError))")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private enum HostWebSocketSessionError: LocalizedError {
|
||||
case invalidURL(String)
|
||||
case unsupportedScheme(String?)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let url):
|
||||
return "Invalid websocket url: \(url)"
|
||||
case .unsupportedScheme(let scheme):
|
||||
return "Unsupported websocket url scheme: \(scheme ?? "nil")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isSupportedWebSocketURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return scheme == "ws" || scheme == "wss"
|
||||
}
|
||||
|
||||
private func describeWebSocketError(_ error: Error) -> String {
|
||||
let nsError = error as NSError
|
||||
var details = ["\(nsError.localizedDescription) [\(nsError.domain):\(nsError.code)]"]
|
||||
|
||||
if let failingURL = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
|
||||
details.append("url=\(failingURL.absoluteString)")
|
||||
} else if let failingURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
|
||||
details.append("url=\(failingURL)")
|
||||
}
|
||||
|
||||
if let failureReason = nsError.localizedFailureReason, !failureReason.isEmpty {
|
||||
details.append("reason=\(failureReason)")
|
||||
}
|
||||
|
||||
if let recoverySuggestion = nsError.localizedRecoverySuggestion, !recoverySuggestion.isEmpty {
|
||||
details.append("suggestion=\(recoverySuggestion)")
|
||||
}
|
||||
|
||||
return details.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func isCancelledWebSocketError(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
|
||||
}
|
||||
|
||||
private func trustLooksLikeAiriServerCertificate(_ trust: SecTrust) -> Bool {
|
||||
guard let leaf = SecTrustGetCertificateAtIndex(trust, 0),
|
||||
certificateSummary(leaf) == "localhost" else {
|
||||
return false
|
||||
}
|
||||
|
||||
let issuerIndex = SecTrustGetCertificateCount(trust) - 1
|
||||
guard issuerIndex >= 1,
|
||||
let issuer = SecTrustGetCertificateAtIndex(trust, issuerIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return certificateSummary(issuer) == "AIRI"
|
||||
}
|
||||
|
||||
private func certificateSummary(_ certificate: SecCertificate) -> String? {
|
||||
SecCertificateCopySubjectSummary(certificate) as String?
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
weak var delegate: WKScriptMessageHandler?
|
||||
|
||||
init(delegate: WKScriptMessageHandler) {
|
||||
self.delegate = delegate
|
||||
super.init()
|
||||
}
|
||||
|
||||
func userContentController(
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
delegate?.userContentController(userContentController, didReceive: message)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var javaScriptEscapedStringLiteral: String {
|
||||
let json = try? JSONSerialization.data(withJSONObject: [self])
|
||||
guard let json else {
|
||||
return "\"\""
|
||||
}
|
||||
|
||||
let serialized = String(bytes: json, encoding: .utf8) ?? "[\"\"]"
|
||||
return String(serialized.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,21 @@ function getCertificateDomains(): string[] {
|
||||
]))
|
||||
}
|
||||
|
||||
function getCertificatePaths() {
|
||||
const userDataPath = app.getPath('userData')
|
||||
|
||||
return {
|
||||
certPath: join(userDataPath, 'websocket-cert.pem'),
|
||||
keyPath: join(userDataPath, 'websocket-key.pem'),
|
||||
caCertPath: join(userDataPath, 'websocket-ca-cert.pem'),
|
||||
caKeyPath: join(userDataPath, 'websocket-ca-key.pem'),
|
||||
}
|
||||
}
|
||||
|
||||
function withCertificateChain(cert: string, caCert?: string) {
|
||||
return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert
|
||||
}
|
||||
|
||||
function certHasAllDomains(certPem: string, domains: string[]): boolean {
|
||||
try {
|
||||
const cert = new X509Certificate(certPem)
|
||||
@@ -166,8 +181,7 @@ function configureServerChannelCertificateTrust() {
|
||||
}
|
||||
|
||||
async function installCACertificate(caCert: string) {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const caCertPath = join(userDataPath, 'websocket-ca-cert.pem')
|
||||
const { caCertPath } = getCertificatePaths()
|
||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||
writeFileSync(caCertPath, caCert)
|
||||
|
||||
@@ -205,9 +219,7 @@ async function installCACertificate(caCert: string) {
|
||||
}
|
||||
|
||||
async function generateCertificate() {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const caCertPath = join(userDataPath, 'websocket-ca-cert.pem')
|
||||
const caKeyPath = join(userDataPath, 'websocket-ca-key.pem')
|
||||
const { caCertPath, caKeyPath } = getCertificatePaths()
|
||||
|
||||
let ca: { key: string, cert: string }
|
||||
|
||||
@@ -246,16 +258,15 @@ async function generateCertificate() {
|
||||
}
|
||||
|
||||
async function getOrCreateCertificate() {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const certPath = join(userDataPath, 'websocket-cert.pem')
|
||||
const keyPath = join(userDataPath, 'websocket-key.pem')
|
||||
const { certPath, keyPath, caCertPath } = getCertificatePaths()
|
||||
const expectedDomains = getCertificateDomains()
|
||||
|
||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||
const cert = readFileSync(certPath, 'utf-8')
|
||||
const key = readFileSync(keyPath, 'utf-8')
|
||||
if (certHasAllDomains(cert, expectedDomains)) {
|
||||
return { cert, key }
|
||||
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
|
||||
return { cert: withCertificateChain(cert, caCert), key }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +274,8 @@ async function getOrCreateCertificate() {
|
||||
writeFileSync(certPath, cert)
|
||||
writeFileSync(keyPath, key)
|
||||
|
||||
return { cert, key }
|
||||
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
|
||||
return { cert: withCertificateChain(cert, caCert), key }
|
||||
}
|
||||
|
||||
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
|
||||
|
||||
@@ -24,6 +24,9 @@ const websocketUrlModel = computed({
|
||||
<template>
|
||||
<div :class="['rounded-lg', 'bg-neutral-50', 'p-4', 'dark:bg-neutral-800', 'flex flex-col', 'gap-4']">
|
||||
<!-- // TODO: Make this array, support to connect to multiple WebSocket server -->
|
||||
<!-- // TODO: Investigate iOS-only field desync on page entry. The persisted websocketUrl stays correct,
|
||||
but the input can render as if it fell back to the default value when this page mounts. Keep this local
|
||||
until the FieldInput/Input mount-time model sync is fully understood. -->
|
||||
<FieldInput
|
||||
v-model="websocketUrlModel"
|
||||
:label="t('settings.pages.connection.websocket-url.label')"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const serverSdkMocks = vi.hoisted(() => {
|
||||
class MockClient {
|
||||
@@ -348,4 +349,24 @@ describe('channel-server store reconnect', () => {
|
||||
}),
|
||||
]))
|
||||
})
|
||||
|
||||
it('does not reconnect while the url scheme is not valid', async () => {
|
||||
const store = useModsServerChannelStore()
|
||||
|
||||
const initializePromise = store.initialize({ token: 'secret' })
|
||||
const firstClient = serverSdkMocks.MockClient.instances[0]
|
||||
|
||||
firstClient.simulateAuthenticated()
|
||||
await initializePromise
|
||||
|
||||
store.websocketUrl = 'wss:'
|
||||
await nextTick()
|
||||
|
||||
expect(serverSdkMocks.MockClient.instances).toHaveLength(1)
|
||||
|
||||
store.websocketUrl = 'wss://192.168.123.112:6121/ws'
|
||||
await nextTick()
|
||||
|
||||
expect(serverSdkMocks.MockClient.instances).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@proj-airi/server-sdk'
|
||||
import type { CommonContentPart } from '@xsai/shared-chat'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Client, WebSocketEventSource } from '@proj-airi/server-sdk'
|
||||
import { isStageTamagotchi, isStageWeb } from '@proj-airi/stage-shared'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
@@ -24,6 +25,20 @@ interface ChannelListenerEntry {
|
||||
boundClient?: Client
|
||||
}
|
||||
|
||||
function hasReconnectableWebSocketScheme(url: string | undefined) {
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
return parsedUrl.protocol === 'ws:' || parsedUrl.protocol === 'wss:'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const REPLAYABLE_EVENT_TYPES = new Set<keyof WebSocketEvents>([
|
||||
'module:announced',
|
||||
'module:de-announced',
|
||||
@@ -116,8 +131,10 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
|
||||
// Do not clear listeners or replay cache here.
|
||||
// onError may be recoverable while the SDK is reconnecting.
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('WebSocket server connection error:', error)
|
||||
console.info('WebSocket server connection error:', {
|
||||
message: errorMessageFrom(error) ?? 'Unknown websocket error',
|
||||
error,
|
||||
})
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
@@ -307,6 +324,9 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
|
||||
if (newUrl === oldUrl)
|
||||
return
|
||||
|
||||
if (!hasReconnectableWebSocketScheme(newUrl))
|
||||
return
|
||||
|
||||
if (client.value || initializing.value) {
|
||||
dispose()
|
||||
void initialize()
|
||||
|
||||
Reference in New Issue
Block a user