feat(stage-pocket-ios): websocket bridge

This commit is contained in:
LemonNeko
2026-04-06 23:39:54 +08:00
parent aa9b6fec5f
commit 7690dc08c9
10 changed files with 540 additions and 12 deletions
@@ -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)
}
}
+2
View File
@@ -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()