diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj b/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj index 9ac863c33..a62a22615 100644 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj +++ b/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; + A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHostWebSocketSession.swift; sourceTree = ""; }; + A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakScriptMessageHandler.swift; sourceTree = ""; }; 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon_LiquidGlass.icon; sourceTree = ""; }; 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBridgeViewController.swift; sourceTree = ""; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; @@ -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; }; diff --git a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift b/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift index bd899990f..45c4faf44 100644 --- a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift +++ b/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift @@ -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 diff --git a/apps/stage-pocket/ios/App/App/HostWebSocketBridge.swift b/apps/stage-pocket/ios/App/App/HostWebSocketBridge.swift new file mode 100644 index 000000000..2d2cc0b10 --- /dev/null +++ b/apps/stage-pocket/ios/App/App/HostWebSocketBridge.swift @@ -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) + } +} diff --git a/apps/stage-pocket/ios/App/App/Info.plist b/apps/stage-pocket/ios/App/App/Info.plist index 101151494..9843f6f3e 100644 --- a/apps/stage-pocket/ios/App/App/Info.plist +++ b/apps/stage-pocket/ios/App/App/Info.plist @@ -24,6 +24,8 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS + NSLocalNetworkUsageDescription + AIRI needs access to your local network to connect to your Tamagotchi host. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/apps/stage-pocket/ios/App/App/URLSessionHostWebSocketSession.swift b/apps/stage-pocket/ios/App/App/URLSessionHostWebSocketSession.swift new file mode 100644 index 000000000..0851a5f05 --- /dev/null +++ b/apps/stage-pocket/ios/App/App/URLSessionHostWebSocketSession.swift @@ -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? +} diff --git a/apps/stage-pocket/ios/App/App/WeakScriptMessageHandler.swift b/apps/stage-pocket/ios/App/App/WeakScriptMessageHandler.swift new file mode 100644 index 000000000..f2693386f --- /dev/null +++ b/apps/stage-pocket/ios/App/App/WeakScriptMessageHandler.swift @@ -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()) + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts index aa1d55def..9af9256af 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts @@ -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 { diff --git a/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue b/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue index 651236213..0a09606e6 100644 --- a/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue +++ b/packages/stage-pages/src/pages/settings/connection/ConnectionSettings.vue @@ -24,6 +24,9 @@ const websocketUrlModel = computed({