diff --git a/Sources/WebViewBundle/Bridge.swift b/Sources/WebViewBundle/Bridge.swift new file mode 100644 index 0000000..e129567 --- /dev/null +++ b/Sources/WebViewBundle/Bridge.swift @@ -0,0 +1,190 @@ +import Foundation + +#if canImport(WebKit) + import WebKit + + /// The native bridge for communicate with WKWebView. + @MainActor + public final class Bridge: NSObject, WKScriptMessageHandler { + public typealias Handler = @MainActor (_ params: Any?) async throws -> Any? + + /// The `window.webkit.messageHandlers` name the web side posts to. + static let messageHandlerName = "wvbIos" + + private(set) var handlers: [String: Handler] = [:] + + public override init() { + super.init() + } + + @discardableResult + public func handler(_ name: String, _ handler: @escaping Handler) -> Bridge { + handlers[name] = handler + return self + } + + @discardableResult + public func add(_ handlers: any BridgeHandlers) -> Bridge { + handlers.register(on: self) + return self + } + + /// Registers this bridge on `configuration` as the `wvbIos` message handler. + func install(on configuration: WKWebViewConfiguration) { + configuration.userContentController.add(self, name: Self.messageHandlerName) + } + + public func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + // WebKit exposes the message handler to every frame, so an embedded + // (possibly third-party) iframe could invoke privileged native commands. + // Only the main frame is allowed; subframe messages are dropped. + guard message.frameInfo.isMainFrame else { + Log.bridge.error("invoke message dropped: not from the main frame") + return + } + guard let body = message.body as? [String: Any], + let successExpr = body["success"] as? String, + let errorExpr = body["error"] as? String + else { + // No callback to reply through: the message is dropped and the web + // `Promise` hangs with no other signal, so log it loudly. + let name = (message.body as? [String: Any])?["name"] as? String ?? "" + Log.bridge.error( + "invoke message dropped: missing success/error callback (command: \(name, privacy: .public))" + ) + return + } + let name = body["name"] as? String ?? "" + let params = body["params"] is NSNull ? nil : body["params"] + let webView = message.webView + let handler = handlers[name] + + // Inherits the main actor; `await` lets an async handler suspend without + // blocking the main thread, then resumes here to evaluate the reply. + Task { [weak webView] in + let js: String + do { + guard let handler else { + throw InvokeError.handlerNotFound(name) + } + let result = try await handler(params) + js = "\(successExpr)(\(try Self.encode(result)))" + } catch { + let payload = + (try? Self.encode(Self.errorJSON(error))) ?? "{\"message\":\"unknown error\"}" + js = "\(errorExpr)(\(payload))" + } + // Usually benign (page navigated, or WebKit's "unsupported type" on an + // `undefined` return from a successful reply), so a warning, not an error. + do { + _ = try await webView?.evaluateJavaScript(js) + } catch { + Log.bridge.warning( + "invoke reply eval failed (command: \(name, privacy: .public)): \(error.localizedDescription, privacy: .public)" + ) + } + } + } + + /// Builds the `{ code?, message }` error payload delivered to the webview. + static func errorJSON(_ error: Swift.Error) -> [String: Any] { + let message: String + let code: String? + if let failure = error as? BridgeFailure { + message = failure.message + code = failure.code + } else { + message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + code = nil + } + var json: [String: Any] = ["message": message] + if let code { json["code"] = code } + return json + } + + /// Serializes a handler result to a JSON literal for a callback argument. + static func encode(_ value: Any?) throws -> String { + guard let value, !(value is NSNull) else { return "null" } + guard isJSONEncodable(value), let json = jsonString(value) else { + throw InvokeError.unencodableResult + } + return json + } + + /// Whether `value` (and, recursively, its contents) is a finite JSON value + /// that `JSONSerialization` can encode without raising. + private static func isJSONEncodable(_ value: Any) -> Bool { + switch value { + case let number as NSNumber: + // Bool/Int/Double/Float all bridge to NSNumber; reject NaN/±Infinity. + return number.doubleValue.isFinite + case is String, is NSNull: + // JSON `null` decodes to NSNull and serializes back to `null`. + return true + case let array as [Any]: + return array.allSatisfy(isJSONEncodable) + case let object as [String: Any]: + return object.values.allSatisfy(isJSONEncodable) + default: + return false + } + } + + private static func jsonString(_ value: Any) -> String? { + guard + let data = try? JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]), + let json = String(data: data, encoding: .utf8) + else { + return nil + } + return escapeForJS(json) + } + + // JSON permits the raw line separators U+2028/U+2029 inside strings, but they + // are line terminators in JavaScript source and would break the + // `callback()` we evaluate. Built from scalars to keep the source ASCII. + private static let lineSeparator = String(UnicodeScalar(0x2028)!) + private static let paragraphSeparator = String(UnicodeScalar(0x2029)!) + + private static func escapeForJS(_ string: String) -> String { + string + .replacingOccurrences(of: lineSeparator, with: "\\u2028") + .replacingOccurrences(of: paragraphSeparator, with: "\\u2029") + } + } + + @MainActor + public protocol BridgeHandlers { + func register(on bridge: Bridge) + } + + /// Errors raised by the ``Bridge`` dispatch itself + public enum InvokeError: Swift.Error, LocalizedError, BridgeFailure, Equatable { + /// No handler was registered for the requested command name. + case handlerNotFound(String) + /// A handler returned a value that is not a JSON value (a custom type, or a + /// non-finite number), so it cannot be delivered to the web side. + case unencodableResult + + public var errorDescription: String? { + switch self { + case .handlerNotFound(let name): + return "no invoke handler registered for \"\(name)\"" + case .unencodableResult: + return "invoke handler returned a value that is not JSON-encodable" + } + } + + public var code: String? { + switch self { + case .handlerNotFound: return "handler_not_found" + case .unencodableResult: return "unencodable_result" + } + } + + public var message: String { errorDescription ?? "" } + } +#endif diff --git a/Sources/WebViewBundle/BridgeCodec.swift b/Sources/WebViewBundle/BridgeCodec.swift new file mode 100644 index 0000000..3a4e572 --- /dev/null +++ b/Sources/WebViewBundle/BridgeCodec.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Bridges raw `invoke()` payloads to typed Swift values via `Codable`. +enum BridgeCodec { + /// Decodes the `invoke()` params object into a typed arguments value. + static func decode(_ params: Any?, as type: T.Type) throws -> T { + let object = params ?? [String: Any]() + guard JSONSerialization.isValidJSONObject(object) else { + throw BridgeError(code: "invalid_params", message: "invoke params must be an object") + } + let data = try JSONSerialization.data(withJSONObject: object) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch let error as DecodingError { + throw BridgeError(code: "invalid_params", message: describe(error)) + } + } + + /// Encodes an `Encodable` response payload to a JSON-native value + /// (`[String: Any]` / `[Any]` / scalar) for the bridge reply. + static func jsonObject(_ value: T) throws -> Any { + let data = try JSONEncoder().encode(value) + return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } + + private static func describe(_ error: DecodingError) -> String { + switch error { + case .keyNotFound(let key, _): + return "missing required param \"\(key.stringValue)\"" + case .typeMismatch(_, let context), .valueNotFound(_, let context): + let path = context.codingPath.map(\.stringValue).joined(separator: ".") + return path.isEmpty ? context.debugDescription : "invalid param \"\(path)\"" + case .dataCorrupted(let context): + return context.debugDescription + @unknown default: + return "invalid invoke params" + } + } +} diff --git a/Sources/WebViewBundle/BridgeError.swift b/Sources/WebViewBundle/BridgeError.swift new file mode 100644 index 0000000..2017f19 --- /dev/null +++ b/Sources/WebViewBundle/BridgeError.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A Swift error that maps to the web-facing `{ code?, message }` bridge error +/// shape. Conform a custom error to deliver a `code` alongside its `message` +public protocol BridgeFailure: Swift.Error { + /// Optional machine-readable code, omitted from the payload when `nil`. + var code: String? { get } + /// Human-readable message, always present in the payload. + var message: String { get } +} + +/// The canonical `{ code?, message }` error thrown to reject an `invoke()` +/// command. Other thrown errors are encoded with their localized description as +/// `message` and no `code`. +public struct BridgeError: BridgeFailure, Equatable { + public var code: String? + public var message: String + + public init(code: String? = nil, message: String) { + self.code = code + self.message = message + } +} diff --git a/Sources/WebViewBundle/Conversions.swift b/Sources/WebViewBundle/Conversions.swift index 656785a..4ebe706 100644 --- a/Sources/WebViewBundle/Conversions.swift +++ b/Sources/WebViewBundle/Conversions.swift @@ -1,38 +1,38 @@ import Foundation extension HttpMethod { - /// Maps a `URLRequest.httpMethod` string to the FFI ``HttpMethod``. - /// Defaults to `.get` for unknown or missing methods. - static func from(_ method: String?) -> HttpMethod { - switch method?.uppercased() { - case "GET": return .get - case "HEAD": return .head - case "OPTIONS": return .options - case "POST": return .post - case "PUT": return .put - case "PATCH": return .patch - case "DELETE": return .delete - case "TRACE": return .trace - case "CONNECT": return .connect - default: return .get - } + /// Maps a `URLRequest.httpMethod` string to the FFI ``HttpMethod``. + /// Defaults to `.get` for unknown or missing methods. + static func from(_ method: String?) -> HttpMethod { + switch method?.uppercased() { + case "GET": return .get + case "HEAD": return .head + case "OPTIONS": return .options + case "POST": return .post + case "PUT": return .put + case "PATCH": return .patch + case "DELETE": return .delete + case "TRACE": return .trace + case "CONNECT": return .connect + default: return .get } + } } extension HttpResponse { - /// Builds an `HTTPURLResponse` for `url` from this response's status and - /// headers. - func makeURLResponse(url: URL) -> HTTPURLResponse { - HTTPURLResponse( - url: url, - statusCode: Int(status), - httpVersion: "HTTP/1.1", - headerFields: headers - ) ?? HTTPURLResponse( - url: url, - statusCode: Int(status), - httpVersion: "HTTP/1.1", - headerFields: nil - )! - } + /// Builds an `HTTPURLResponse` for `url` from this response's status and + /// headers. + func makeURLResponse(url: URL) -> HTTPURLResponse { + HTTPURLResponse( + url: url, + statusCode: Int(status), + httpVersion: "HTTP/1.1", + headerFields: headers + ) ?? HTTPURLResponse( + url: url, + statusCode: Int(status), + httpVersion: "HTTP/1.1", + headerFields: nil + )! + } } diff --git a/Sources/WebViewBundle/Log.swift b/Sources/WebViewBundle/Log.swift new file mode 100644 index 0000000..90aca54 --- /dev/null +++ b/Sources/WebViewBundle/Log.swift @@ -0,0 +1,48 @@ +import Foundation +import os + +/// Severity of an SDK log event; mirrors Rust `tracing` levels so core events +/// can forward into the same pipeline (see ``CoreLog``). +enum LogLevel: Int, Sendable, Comparable { + case trace, debug, info, warning, error + + static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rawValue < rhs.rawValue } + + /// `os` has no `warning`, so it folds into `.default`. + var osLogType: OSLogType { + switch self { + case .trace, .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + } + } +} + +/// Unified `os.Logger` channels. Filter on `subsystem == "webview-bundle"` to +/// see Swift-side and (once enabled) Rust-core logs together. +enum Log { + static let subsystem = "webview-bundle" + + static let bridge = Logger(subsystem: subsystem, category: "bridge") + static let core = Logger(subsystem: subsystem, category: "core") +} + +/// Seam for forwarding the Rust core's `tracing` into the unified `core` +/// channel. The level/route mapping is implemented; wiring it to an FFI +/// log-subscriber callback is pending (the FFI exposes none yet). +enum CoreLog { + /// `message` is already formatted by the core; `target` is its module path. + /// Logged `.public` since tracing is an explicit opt-in, so the core must not + /// emit user PII into tracing messages. + static func forward(level: LogLevel, target: String, message: String) { + Log.core.log( + level: level.osLogType, + "[\(target, privacy: .public)] \(message, privacy: .public)") + } +} + +extension WebViewBundle { + /// The unified-logging subsystem the SDK logs under. + public static let logSubsystem = Log.subsystem +} diff --git a/Sources/WebViewBundle/RequestHandler.swift b/Sources/WebViewBundle/RequestHandler.swift index 2ef508b..fa7a2b2 100644 --- a/Sources/WebViewBundle/RequestHandler.swift +++ b/Sources/WebViewBundle/RequestHandler.swift @@ -1,16 +1,12 @@ import Foundation /// Common shape of the UniFFI request handlers used by the WebView integration. -/// -/// Both ``BundleUrlHandler`` and ``LocalUrlHandler`` (generated into this module -/// by UniFFI) already expose this exact `handle` signature, so they conform -/// without extra code. protocol WebViewBundleRequestHandler: AnyObject, Sendable { - func handle( - method: HttpMethod, - uri: String, - headers: [String: String]? - ) async throws -> HttpResponse + func handle( + method: HttpMethod, + uri: String, + headers: [String: String]? + ) async throws -> HttpResponse } extension BundleUrlHandler: WebViewBundleRequestHandler {} diff --git a/Sources/WebViewBundle/Source.swift b/Sources/WebViewBundle/Source.swift index 6b044b1..c60c17d 100644 --- a/Sources/WebViewBundle/Source.swift +++ b/Sources/WebViewBundle/Source.swift @@ -1,76 +1,70 @@ import Foundation /// Options for building a ``BundleSource`` with sensible iOS/macOS defaults. -/// -/// Every field is optional and falls back to a platform default when omitted: -/// - `builtinDir` → `/bundles` (read-only, shipped with the app). -/// - `remoteDir` → `//bundles` (writable, holds -/// bundles downloaded at runtime). public struct SourceOptions: Sendable { - public var builtinDir: String? - public var remoteDir: String? - public var builtinManifestFilepath: String? - public var remoteManifestFilepath: String? + public var builtinDir: String? + public var remoteDir: String? + public var builtinManifestFilepath: String? + public var remoteManifestFilepath: String? - public init( - builtinDir: String? = nil, - remoteDir: String? = nil, - builtinManifestFilepath: String? = nil, - remoteManifestFilepath: String? = nil - ) { - self.builtinDir = builtinDir - self.remoteDir = remoteDir - self.builtinManifestFilepath = builtinManifestFilepath - self.remoteManifestFilepath = remoteManifestFilepath - } + public init( + builtinDir: String? = nil, + remoteDir: String? = nil, + builtinManifestFilepath: String? = nil, + remoteManifestFilepath: String? = nil + ) { + self.builtinDir = builtinDir + self.remoteDir = remoteDir + self.builtinManifestFilepath = builtinManifestFilepath + self.remoteManifestFilepath = remoteManifestFilepath + } } extension BundleSource { - /// Builds a ``BundleSource`` from ``SourceOptions``, filling in default - /// directories and creating the writable `remoteDir`. - public static func make(_ options: SourceOptions = SourceOptions()) throws -> BundleSource { - let builtinDir = options.builtinDir ?? defaultBuiltinDir() - let remoteDir = options.remoteDir ?? defaultRemoteDir() - try FileManager.default.createDirectory( - atPath: remoteDir, - withIntermediateDirectories: true - ) - return BundleSource(config: BundleSourceConfig( - builtinDir: builtinDir, - remoteDir: remoteDir, - builtinManifestFilepath: options.builtinManifestFilepath, - remoteManifestFilepath: options.remoteManifestFilepath - )) - } + /// Builds a ``BundleSource`` from ``SourceOptions``, filling in default + /// directories and creating the writable `remoteDir`. + public static func make(_ options: SourceOptions = SourceOptions()) throws -> BundleSource { + let builtinDir = options.builtinDir ?? defaultBuiltinDir() + let remoteDir = options.remoteDir ?? defaultRemoteDir() + try FileManager.default.createDirectory( + atPath: remoteDir, + withIntermediateDirectories: true + ) + return BundleSource( + config: BundleSourceConfig( + builtinDir: builtinDir, + remoteDir: remoteDir, + builtinManifestFilepath: options.builtinManifestFilepath, + remoteManifestFilepath: options.remoteManifestFilepath + )) + } - /// `/bundles` — the read-only directory shipped with the app. - public static func defaultBuiltinDir() -> String { - // `Foundation.Bundle` because unqualified `Bundle` resolves to the FFI's - // own bundle type in this module. - let base = Foundation.Bundle.main.resourceURL ?? Foundation.Bundle.main.bundleURL - return base.appendingPathComponent("bundles").path - } + /// `/bundles` — the read-only directory shipped with the app. + public static func defaultBuiltinDir() -> String { + // `Foundation.Bundle` because unqualified `Bundle` resolves to the FFI's + // own bundle type in this module. + let base = Foundation.Bundle.main.resourceURL ?? Foundation.Bundle.main.bundleURL + return base.appendingPathComponent("bundles").path + } - /// `//bundles` — the writable directory for - /// bundles downloaded at runtime. Falls back to caches/temporary if - /// Application Support is unavailable. - /// - /// Scoped under the bundle id because macOS's Application Support is shared - /// (unlike iOS's per-app sandbox), so two apps linking this SDK would - /// otherwise clobber one `bundles` directory. - public static func defaultRemoteDir() -> String { - let fm = FileManager.default - let base = (try? fm.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - )) ?? fm.urls(for: .cachesDirectory, in: .userDomainMask).first - ?? fm.temporaryDirectory - let appId = Foundation.Bundle.main.bundleIdentifier ?? "WebViewBundle" - return base - .appendingPathComponent(appId) - .appendingPathComponent("bundles") - .path - } + /// `//bundles` — the writable directory for + /// bundles downloaded at runtime. Falls back to caches/temporary if + /// Application Support is unavailable. + public static func defaultRemoteDir() -> String { + let fm = FileManager.default + let base = + (try? fm.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + )) ?? fm.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? fm.temporaryDirectory + let appId = Foundation.Bundle.main.bundleIdentifier ?? "WebViewBundle" + return + base + .appendingPathComponent(appId) + .appendingPathComponent("bundles") + .path + } } diff --git a/Sources/WebViewBundle/WebViewBundle.swift b/Sources/WebViewBundle/WebViewBundle.swift index a1d44d3..ac58143 100644 --- a/Sources/WebViewBundle/WebViewBundle.swift +++ b/Sources/WebViewBundle/WebViewBundle.swift @@ -4,21 +4,7 @@ import Foundation import WebKit #endif -/// Serves WebViewBundle resources to a system `WKWebView`. -/// -/// Wires one or more ``WebViewBundleProtocol``s to a `WKWebViewConfiguration` via -/// `WKURLSchemeHandler`: requests whose scheme matches a registered protocol are -/// resolved from the bundle ``source`` (or proxied to a local server) instead of -/// hitting the network. -/// -/// ```swift -/// let wvb = try webViewBundle(.init(protocols: [.bundle(scheme: "app")])) -/// let webView = wvb.makeWebView() -/// webView.load(URLRequest(url: URL(string: "app://app.wvb/index.html")!)) -/// ``` -/// -/// Keep a strong reference for the lifetime of the web view; it owns the scheme -/// handlers. +/// The primary class for integrating webview-bundle with your app. public final class WebViewBundle { public let source: BundleSource public let remote: Remote? @@ -35,13 +21,6 @@ public final class WebViewBundle { "http", "https", "file", "ftp", "ftps", "ws", "wss", "about", "blob", "data", "javascript", ] - /// - Parameters: - /// - source: the bundle source requests are served from. - /// - protocols: the protocols to register; each must use a unique, - /// non-reserved scheme. - /// - onError: optional observer invoked (on the main actor) when a scheme - /// handler fails to serve a request. - /// - Throws: ``WebViewBundleError`` if a scheme is empty, invalid, reserved, or duplicated. public init( source: BundleSource, protocols: [WebViewBundleProtocol], @@ -89,38 +68,73 @@ public final class WebViewBundle { #endif } + @MainActor private static var sharedInstance: WebViewBundle? + + /// Returns the process-wide ``WebViewBundle``, building it from `config` on the + /// first call. + @MainActor + public static func configure(_ config: WebViewBundleConfig) throws -> WebViewBundle { + if let existing = sharedInstance { + return existing + } + let bundle = try WebViewBundle(config: config) + sharedInstance = bundle + return bundle + } + + /// Returns the shared instance that was configured already. + /// + /// If not explicitly configured, precondition fails. + @MainActor + public static var shared: WebViewBundle { + guard let sharedInstance else { + preconditionFailure( + "WebViewBundle.shared was accessed before WebViewBundle.configure(_:). " + + "Call configure(_:) (or webViewBundle(_:) / wvb(_:)) during app setup first." + ) + } + return sharedInstance + } + + /// Safely returns the shared instance, or `nil` if not configured. + @MainActor + public static var safeShared: WebViewBundle? { + return sharedInstance + } + /// The schemes this instance intercepts. public var schemes: [String] { protocols.map(\.scheme) } #if canImport(WebKit) - /// Registers the bundle scheme handlers on `configuration`. + /// Registers the bundle scheme handlers on `configuration`, and bridges. @MainActor - public func install(on configuration: WKWebViewConfiguration) { + public func install(on configuration: WKWebViewConfiguration, bridge: Bridge? = nil) { for (scheme, handler) in schemeHandlers { configuration.setURLSchemeHandler(handler, forURLScheme: scheme) } + let bridge = bridge ?? Bridge() + bridge.add(WebViewBundleBridge(wvb: self)) + bridge.install(on: configuration) } - /// A fresh `WKWebViewConfiguration` with the scheme handlers installed. + /// Make `WKWebViewConfiguration` with the scheme handlers and invoke bridge + /// installed. @MainActor - public func makeConfiguration() -> WKWebViewConfiguration { + public func makeConfiguration(bridge: Bridge? = nil) -> WKWebViewConfiguration { let configuration = WKWebViewConfiguration() - install(on: configuration) + install(on: configuration, bridge: bridge) return configuration } - /// A fresh `WKWebView` configured to serve the registered bundles. + /// Make `WKWebView` configured to serve the registered bundles. @MainActor - public func makeWebView(frame: CGRect = .zero) -> WKWebView { - WKWebView(frame: frame, configuration: makeConfiguration()) + public func makeWebView(frame: CGRect = .zero, bridge: Bridge? = nil) -> WKWebView { + WKWebView(frame: frame, configuration: makeConfiguration(bridge: bridge)) } #endif } /// Errors thrown while constructing a ``WebViewBundle``. -// -// Spelled `Swift.Error` because unqualified `Error` resolves to the FFI's own -// error enum in this module. public enum WebViewBundleError: Swift.Error, Equatable { /// A protocol was given an empty scheme. case emptyScheme @@ -143,9 +157,6 @@ public struct WebViewBundleRemoteConfig: Sendable { } /// Updater configuration for ``WebViewBundleConfig``. -/// -/// When present, ``WebViewBundle/init(config:)`` builds a ``Remote`` from -/// ``remote`` and an ``Updater`` wired to the source. public struct WebViewBundleUpdaterConfig: Sendable { public var remote: WebViewBundleRemoteConfig /// Release channel (e.g. `"stable"`, `"beta"`). @@ -199,11 +210,6 @@ public struct WebViewBundleConfig: Sendable { } extension WebViewBundle { - /// Builds a ``WebViewBundle`` from a high-level ``WebViewBundleConfig``. - /// - /// The source is created via ``BundleSource/make(_:)``, and — when - /// ``WebViewBundleConfig/updater`` is set — a ``Remote`` and ``Updater`` are - /// wired to it. public convenience init(config: WebViewBundleConfig) throws { let source = try BundleSource.make(config.source) var remote: Remote? @@ -227,12 +233,14 @@ extension WebViewBundle { } } -/// Builds a ``WebViewBundle`` from a high-level ``WebViewBundleConfig``. +/// Returns the process-wide ``WebViewBundle``; alias for ``WebViewBundle/configure(_:)``. +@MainActor public func webViewBundle(_ config: WebViewBundleConfig) throws -> WebViewBundle { - try WebViewBundle(config: config) + try WebViewBundle.configure(config) } /// Short alias for ``webViewBundle(_:)``. +@MainActor public func wvb(_ config: WebViewBundleConfig) throws -> WebViewBundle { try webViewBundle(config) } diff --git a/Sources/WebViewBundle/WebViewBundleBridge.swift b/Sources/WebViewBundle/WebViewBundleBridge.swift new file mode 100644 index 0000000..f0b476e --- /dev/null +++ b/Sources/WebViewBundle/WebViewBundleBridge.swift @@ -0,0 +1,276 @@ +import Foundation + +// MARK: - Command arguments (Decodable) + +struct BundleNameArgs: Decodable { + let bundleName: String +} + +struct BundleVersionArgs: Decodable { + let bundleName: String + let version: String +} + +struct OptionalVersionArgs: Decodable { + let bundleName: String + let version: String? +} + +struct ChannelArgs: Decodable { + let channel: String? +} + +struct BundleChannelArgs: Decodable { + let bundleName: String + let channel: String? +} + +// MARK: - Response payloads (Encodable) + +extension BundleSourceKind { + /// `"builtin"` / `"remote"`, matching the web `BundleSourceKind` string union. + var bridgeValue: String { + switch self { + case .builtin: return "builtin" + case .remote: return "remote" + } + } +} + +struct ManifestMetadataPayload: Encodable { + let etag: String? + let integrity: String? + let signature: String? + let lastModified: String? + + init(_ metadata: BundleManifestMetadata) { + etag = metadata.etag + integrity = metadata.integrity + signature = metadata.signature + lastModified = metadata.lastModified + } +} + +struct SourceVersionPayload: Encodable { + let type: String + let version: String + + init(_ value: BundleSourceVersion) { + type = value.kind.bridgeValue + version = value.version + } +} + +struct ListBundleItemPayload: Encodable { + let type: String + let name: String + let version: String + let current: Bool + let metadata: ManifestMetadataPayload + + init(_ item: ListBundleItem) { + type = item.kind.bridgeValue + name = item.name + version = item.version + current = item.current + metadata = ManifestMetadataPayload(item.metadata) + } +} + +struct ListRemoteBundlePayload: Encodable { + let name: String + let version: String + + init(_ info: ListRemoteBundleInfo) { + name = info.name + version = info.version + } +} + +struct RemoteBundlePayload: Encodable { + let name: String + let version: String + let etag: String? + let integrity: String? + let signature: String? + let lastModified: String? + + init(_ info: RemoteBundleInfo) { + name = info.name + version = info.version + etag = info.etag + integrity = info.integrity + signature = info.signature + lastModified = info.lastModified + } +} + +struct UpdateInfoPayload: Encodable { + let name: String + let version: String + let localVersion: String? + let isAvailable: Bool + let etag: String? + let integrity: String? + let signature: String? + let lastModified: String? + + init(_ info: BundleUpdateInfo) { + name = info.name + version = info.version + localVersion = info.localVersion + isAvailable = info.isAvailable + etag = info.etag + integrity = info.integrity + signature = info.signature + lastModified = info.lastModified + } +} + +#if canImport(WebKit) + struct WebViewBundleBridge: BridgeHandlers { + private let source: BundleSource + private let remote: Remote? + private let updater: Updater? + + init(wvb: WebViewBundle) { + self.source = wvb.source + self.remote = wvb.remote + self.updater = wvb.updater + } + + func register(on bridge: Bridge) { + registerSource(on: bridge) + registerRemote(on: bridge) + registerUpdater(on: bridge) + } + + private func registerSource(on bridge: Bridge) { + let source = self.source + bridge.handler("sourceListBundles") { _ in + try await BridgeCodec.jsonObject(source.listBundles().map(ListBundleItemPayload.init)) + } + bridge.handler("sourceLoadVersion") { params in + let args = try BridgeCodec.decode(params, as: BundleNameArgs.self) + guard let version = try await source.loadVersion(bundleName: args.bundleName) else { + return nil + } + return try BridgeCodec.jsonObject(SourceVersionPayload(version)) + } + bridge.handler("sourceUpdateVersion") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + try await source.updateVersion(bundleName: args.bundleName, version: args.version) + return nil + } + bridge.handler("sourceResolveFilepath") { params in + let args = try BridgeCodec.decode(params, as: BundleNameArgs.self) + return try await source.resolveFilepath(bundleName: args.bundleName) + } + bridge.handler("sourceGetBuiltinBundleFilepath") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + return try source.getBuiltinBundleFilepath( + bundleName: args.bundleName, version: args.version) + } + bridge.handler("sourceGetRemoteBundleFilepath") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + return try source.getRemoteBundleFilepath( + bundleName: args.bundleName, version: args.version) + } + bridge.handler("sourceLoadBuiltinMetadata") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + guard + let metadata = try await source.loadBuiltinMetadata( + bundleName: args.bundleName, version: args.version) + else { return nil } + return try BridgeCodec.jsonObject(ManifestMetadataPayload(metadata)) + } + bridge.handler("sourceLoadRemoteMetadata") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + guard + let metadata = try await source.loadRemoteMetadata( + bundleName: args.bundleName, version: args.version) + else { return nil } + return try BridgeCodec.jsonObject(ManifestMetadataPayload(metadata)) + } + bridge.handler("sourceUnloadDescriptor") { params in + let args = try BridgeCodec.decode(params, as: BundleNameArgs.self) + return source.unloadDescriptor(bundleName: args.bundleName) + } + bridge.handler("sourceRemoveRemoteBundle") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + return try await source.removeRemoteBundle( + bundleName: args.bundleName, version: args.version) + } + bridge.handler("sourceRemoteRetainedVersions") { params in + let args = try BridgeCodec.decode(params, as: BundleNameArgs.self) + return try await source.remoteRetainedVersions(bundleName: args.bundleName) + } + bridge.handler("sourcePruneRemoteBundles") { params in + let args = try BridgeCodec.decode(params, as: BundleNameArgs.self) + return try await source.pruneRemoteBundles(bundleName: args.bundleName) + } + } + + private func registerRemote(on bridge: Bridge) { + let remote = self.remote + func require() throws -> Remote { + guard let remote else { + throw BridgeError(code: "remote_not_initialized", message: "remote is not initialized.") + } + return remote + } + bridge.handler("remoteListBundles") { params in + let args = try BridgeCodec.decode(params, as: ChannelArgs.self) + return try await BridgeCodec.jsonObject( + require().listBundles(channel: args.channel).map(ListRemoteBundlePayload.init)) + } + bridge.handler("remoteGetInfo") { params in + let args = try BridgeCodec.decode(params, as: BundleChannelArgs.self) + return try await BridgeCodec.jsonObject( + RemoteBundlePayload(require().getInfo(bundleName: args.bundleName, channel: args.channel)) + ) + } + bridge.handler("remoteDownload") { params in + let args = try BridgeCodec.decode(params, as: BundleChannelArgs.self) + let result = try await require().download( + bundleName: args.bundleName, channel: args.channel) + return try BridgeCodec.jsonObject(RemoteBundlePayload(result.info)) + } + bridge.handler("remoteDownloadVersion") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + let result = try await require().downloadVersion( + bundleName: args.bundleName, version: args.version) + return try BridgeCodec.jsonObject(RemoteBundlePayload(result.info)) + } + } + + private func registerUpdater(on bridge: Bridge) { + let updater = self.updater + func require() throws -> Updater { + guard let updater else { + throw BridgeError(code: "updater_not_initialized", message: "updater is not initialized.") + } + return updater + } + bridge.handler("updaterListRemotes") { _ in + try await BridgeCodec.jsonObject(require().listRemotes().map(ListRemoteBundlePayload.init)) + } + bridge.handler("updaterGetUpdate") { params in + let args = try BridgeCodec.decode(params, as: BundleNameArgs.self) + return try await BridgeCodec.jsonObject( + UpdateInfoPayload(require().getUpdate(bundleName: args.bundleName))) + } + bridge.handler("updaterDownload") { params in + let args = try BridgeCodec.decode(params, as: OptionalVersionArgs.self) + let info = try await require().downloadUpdate( + bundleName: args.bundleName, version: args.version) + return try BridgeCodec.jsonObject(RemoteBundlePayload(info)) + } + bridge.handler("updaterInstall") { params in + let args = try BridgeCodec.decode(params, as: BundleVersionArgs.self) + try await require().install(bundleName: args.bundleName, version: args.version) + return nil + } + } + } +#endif diff --git a/Sources/WebViewBundle/WebViewBundleProtocol.swift b/Sources/WebViewBundle/WebViewBundleProtocol.swift index 0fd401a..7d26c84 100644 --- a/Sources/WebViewBundle/WebViewBundleProtocol.swift +++ b/Sources/WebViewBundle/WebViewBundleProtocol.swift @@ -11,25 +11,25 @@ import Foundation /// `WKWebView` only allows scheme handlers for non-reserved schemes, so use a /// custom scheme (not `http`/`https`). public enum WebViewBundleProtocol: Sendable { - /// Serves entries from the WebViewBundle source, backed by a - /// ``BundleUrlHandler``. - case bundle(scheme: String) + /// Serves entries from the WebViewBundle source, backed by a + /// ``BundleUrlHandler``. + case bundle(scheme: String) - /// Proxies requests to local HTTP servers, backed by a ``LocalUrlHandler``. - /// - /// `hosts` maps a virtual host to a local base URL, e.g. - /// `["myapp": "http://localhost:8080"]`. Unlike ``bundle(scheme:)`` — where - /// the bundle name is only the *first label* of the host — the `hosts` key is - /// matched against the **entire** request URL host. So a request to - /// `local://myapp/index.html` requires the key `"myapp"`, while - /// `local://app.wvb/index.html` would require the key `"app.wvb"`. - case local(scheme: String, hosts: [String: String]) + /// Proxies requests to local HTTP servers, backed by a ``LocalUrlHandler``. + /// + /// `hosts` maps a virtual host to a local base URL, e.g. + /// `["myapp": "http://localhost:8080"]`. Unlike ``bundle(scheme:)`` — where + /// the bundle name is only the *first label* of the host — the `hosts` key is + /// matched against the **entire** request URL host. So a request to + /// `local://myapp/index.html` requires the key `"myapp"`, while + /// `local://app.wvb/index.html` would require the key `"app.wvb"`. + case local(scheme: String, hosts: [String: String]) - /// The URL scheme this protocol handles. - public var scheme: String { - switch self { - case let .bundle(scheme): return scheme - case let .local(scheme, _): return scheme - } + /// The URL scheme this protocol handles. + public var scheme: String { + switch self { + case .bundle(let scheme): return scheme + case .local(let scheme, _): return scheme } + } } diff --git a/Sources/WebViewBundle/WebViewBundleSchemeHandler.swift b/Sources/WebViewBundle/WebViewBundleSchemeHandler.swift index 7acb1d6..74bb284 100644 --- a/Sources/WebViewBundle/WebViewBundleSchemeHandler.swift +++ b/Sources/WebViewBundle/WebViewBundleSchemeHandler.swift @@ -1,17 +1,12 @@ import Foundation #if canImport(WebKit) -import WebKit + import WebKit -/// A `WKURLSchemeHandler` that serves WebViewBundle resources for a single -/// scheme by routing requests to a UniFFI handler. -/// -/// WebKit invokes the scheme-handler callbacks on the main actor. The -/// (suspending) FFI handler runs off the main thread; its result is delivered -/// back to the task on the main actor, and skipped if the task was already -/// stopped. -@MainActor -final class WebViewBundleSchemeHandler: NSObject, WKURLSchemeHandler { + /// A `WKURLSchemeHandler` that serves WebViewBundle resources for a single + /// scheme by routing requests to a UniFFI handler. + @MainActor + final class WebViewBundleSchemeHandler: NSObject, WKURLSchemeHandler { private let handler: any WebViewBundleRequestHandler private let onError: (@Sendable (any Swift.Error) -> Void)? @@ -21,63 +16,63 @@ final class WebViewBundleSchemeHandler: NSObject, WKURLSchemeHandler { private var activeTasks = Set() nonisolated init( - handler: any WebViewBundleRequestHandler, - onError: (@Sendable (any Swift.Error) -> Void)? = nil + handler: any WebViewBundleRequestHandler, + onError: (@Sendable (any Swift.Error) -> Void)? = nil ) { - self.handler = handler - self.onError = onError - super.init() + self.handler = handler + self.onError = onError + super.init() } func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - let id = ObjectIdentifier(urlSchemeTask) - activeTasks.insert(id) + let id = ObjectIdentifier(urlSchemeTask) + activeTasks.insert(id) - let request = urlSchemeTask.request - let method = HttpMethod.from(request.httpMethod) - let uri = request.url?.absoluteString ?? "" - let headers = request.allHTTPHeaderFields - let url = request.url ?? URL(string: "about:blank")! - let handler = self.handler + let request = urlSchemeTask.request + let method = HttpMethod.from(request.httpMethod) + let uri = request.url?.absoluteString ?? "" + let headers = request.allHTTPHeaderFields + let url = request.url ?? URL(string: "about:blank")! + let handler = self.handler - // Inherits the main actor; the `await` lets the FFI handler run off-main - // (it is `nonisolated`) and resumes here back on the main actor. - Task { - let result: Result - do { - let response = try await handler.handle(method: method, uri: uri, headers: headers) - result = .success(response) - } catch { - result = .failure(error) - } - self.complete(urlSchemeTask, id: id, url: url, result: result) + // Inherits the main actor; the `await` lets the FFI handler run off-main + // (it is `nonisolated`) and resumes here back on the main actor. + Task { + let result: Result + do { + let response = try await handler.handle(method: method, uri: uri, headers: headers) + result = .success(response) + } catch { + result = .failure(error) } + self.complete(urlSchemeTask, id: id, url: url, result: result) + } } func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { - activeTasks.remove(ObjectIdentifier(urlSchemeTask)) + activeTasks.remove(ObjectIdentifier(urlSchemeTask)) } private func complete( - _ task: WKURLSchemeTask, - id: ObjectIdentifier, - url: URL, - result: Result + _ task: WKURLSchemeTask, + id: ObjectIdentifier, + url: URL, + result: Result ) { - // Start, stop and completion are all serialized on the main actor, so - // this check is race-free: a stopped task is never fed. - guard activeTasks.remove(id) != nil else { return } - switch result { - case let .success(response): - task.didReceive(response.makeURLResponse(url: url)) - task.didReceive(response.body) - task.didFinish() - case let .failure(error): - // WebKit surfaces this as a load failure; `onError` is the - // observability hook. - onError?(error) - task.didFailWithError(error) - } + // Start, stop and completion are all serialized on the main actor, so + // this check is race-free: a stopped task is never fed. + guard activeTasks.remove(id) != nil else { return } + switch result { + case .success(let response): + task.didReceive(response.makeURLResponse(url: url)) + task.didReceive(response.body) + task.didFinish() + case .failure(let error): + // WebKit surfaces this as a load failure; `onError` is the + // observability hook. + onError?(error) + task.didFailWithError(error) + } } -} + } #endif diff --git a/TestApp/Fixtures/bundles/hacker-news/hacker-news_0.0.1.wvb b/TestApp/Fixtures/bundles/hacker-news/hacker-news_0.0.1.wvb index ff309bc..91eac0b 100644 Binary files a/TestApp/Fixtures/bundles/hacker-news/hacker-news_0.0.1.wvb and b/TestApp/Fixtures/bundles/hacker-news/hacker-news_0.0.1.wvb differ diff --git a/TestApp/Fixtures/bundles/manifest.json b/TestApp/Fixtures/bundles/manifest.json index f1c78a1..ffc24ae 100644 --- a/TestApp/Fixtures/bundles/manifest.json +++ b/TestApp/Fixtures/bundles/manifest.json @@ -1,7 +1,6 @@ { "manifestVersion": 1, "entries": { - "next": { "versions": { "1.0.0": {} }, "currentVersion": "1.0.0" }, "hacker-news": { "versions": { "0.0.1": {} }, "currentVersion": "0.0.1" } } } diff --git a/TestApp/Fixtures/bundles/next/next_1.0.0.wvb b/TestApp/Fixtures/bundles/next/next_1.0.0.wvb deleted file mode 100644 index 865fc2c..0000000 Binary files a/TestApp/Fixtures/bundles/next/next_1.0.0.wvb and /dev/null differ diff --git a/TestApp/TestApp/ContentView.swift b/TestApp/TestApp/ContentView.swift index b678c01..48715d3 100644 --- a/TestApp/TestApp/ContentView.swift +++ b/TestApp/TestApp/ContentView.swift @@ -22,12 +22,14 @@ final class WebViewModel: NSObject, ObservableObject, WKNavigationDelegate { override init() { var buildError: String? do { - let instance = try webViewBundle( + let instance = try WebViewBundle.configure( WebViewBundleConfig( protocols: [.bundle(scheme: "testapp")], )) self.wvb = instance - self.webView = instance.makeWebView() + let config = WKWebViewConfiguration() + instance.install(on: config) + self.webView = WKWebView(frame: .zero, configuration: config) } catch { self.wvb = nil self.webView = WKWebView() diff --git a/Tests/WebViewBundleTests/WebViewBundleTests.swift b/Tests/WebViewBundleTests/WebViewBundleTests.swift index 1b68889..848cfa5 100644 --- a/Tests/WebViewBundleTests/WebViewBundleTests.swift +++ b/Tests/WebViewBundleTests/WebViewBundleTests.swift @@ -1,160 +1,292 @@ import Foundation import Testing +import os @testable import WebViewBundle @Suite("WebViewBundle") struct WebViewBundleTests { - /// Builds a one-bundle source on disk: a `.wvb` with the given entries plus a - /// manifest pinning it as the current remote version. Returns the source. - private func makeSource( - bundleName: String = "app", - version: String = "1.0.0", - entries: [(path: String, data: Data, contentType: String)] - ) throws -> BundleSource { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("wvb-test-\(UUID().uuidString)") - let remote = tmp.appendingPathComponent("remote") - let builtin = tmp.appendingPathComponent("builtin") - let bundleDir = remote.appendingPathComponent(bundleName) - try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: builtin, withIntermediateDirectories: true) - - let manifest = #"{"manifestVersion":1,"entries":{"\#(bundleName)":{"versions":{"\#(version)":{}},"currentVersion":"\#(version)"}}}"# - try Data(manifest.utf8).write(to: remote.appendingPathComponent("manifest.json")) - - let builder = BundleBuilder(version: nil) - for entry in entries { - _ = try builder.insertEntry( - path: entry.path, - data: entry.data, - contentType: entry.contentType, - headers: nil - ) - } - let bundle = try builder.build(options: nil) - let bytes = try writeBundleToBytes(bundle: bundle) - try bytes.write(to: bundleDir.appendingPathComponent("\(bundleName)_\(version).wvb")) - - return BundleSource(config: BundleSourceConfig( - builtinDir: builtin.path, - remoteDir: remote.path, - builtinManifestFilepath: nil, - remoteManifestFilepath: nil - )) - } + /// Builds a one-bundle source on disk: a `.wvb` with the given entries plus a + /// manifest pinning it as the current remote version. Returns the source. + private func makeSource( + bundleName: String = "app", + version: String = "1.0.0", + entries: [(path: String, data: Data, contentType: String)] + ) throws -> BundleSource { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("wvb-test-\(UUID().uuidString)") + let remote = tmp.appendingPathComponent("remote") + let builtin = tmp.appendingPathComponent("builtin") + let bundleDir = remote.appendingPathComponent(bundleName) + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: builtin, withIntermediateDirectories: true) + + let manifest = + #"{"manifestVersion":1,"entries":{"\#(bundleName)":{"versions":{"\#(version)":{}},"currentVersion":"\#(version)"}}}"# + try Data(manifest.utf8).write(to: remote.appendingPathComponent("manifest.json")) - @Test("BundleUrlHandler serves an entry as 200") - func bundleHandlerServesEntry() async throws { - let html = "hi" - let source = try makeSource(entries: [ - (path: "/index.html", data: Data(html.utf8), contentType: "text/html") - ]) - let handler: any WebViewBundleRequestHandler = BundleUrlHandler(source: source) - - let response = try await handler.handle( - method: .get, - uri: "app://app.wvb/index.html", - headers: nil - ) - - #expect(response.status == 200) - #expect(String(decoding: response.body, as: UTF8.self) == html) - #expect(response.headers["content-type"]?.contains("text/html") == true) + let builder = BundleBuilder(version: nil) + for entry in entries { + _ = try builder.insertEntry( + path: entry.path, + data: entry.data, + contentType: entry.contentType, + headers: nil + ) } + let bundle = try builder.build(options: nil) + let bytes = try writeBundleToBytes(bundle: bundle) + try bytes.write(to: bundleDir.appendingPathComponent("\(bundleName)_\(version).wvb")) + + return BundleSource( + config: BundleSourceConfig( + builtinDir: builtin.path, + remoteDir: remote.path, + builtinManifestFilepath: nil, + remoteManifestFilepath: nil + )) + } + + @Test("BundleUrlHandler serves an entry as 200") + func bundleHandlerServesEntry() async throws { + let html = "hi" + let source = try makeSource(entries: [ + (path: "/index.html", data: Data(html.utf8), contentType: "text/html") + ]) + let handler: any WebViewBundleRequestHandler = BundleUrlHandler(source: source) + + let response = try await handler.handle( + method: .get, + uri: "app://app.wvb/index.html", + headers: nil + ) + + #expect(response.status == 200) + #expect(String(decoding: response.body, as: UTF8.self) == html) + #expect(response.headers["content-type"]?.contains("text/html") == true) + } + + @Test("Missing entry returns 404") + func missingEntryIs404() async throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + let handler: any WebViewBundleRequestHandler = BundleUrlHandler(source: source) - @Test("Missing entry returns 404") - func missingEntryIs404() async throws { - let source = try makeSource(entries: [ - (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") - ]) - let handler: any WebViewBundleRequestHandler = BundleUrlHandler(source: source) + let response = try await handler.handle( + method: .get, + uri: "app://app.wvb/missing.html", + headers: nil + ) - let response = try await handler.handle( - method: .get, - uri: "app://app.wvb/missing.html", - headers: nil - ) + #expect(response.status == 404) + } - #expect(response.status == 404) + @Test("Facade exposes its schemes") + func facadeSchemes() throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + let wvb = try WebViewBundle( + source: source, + protocols: [ + .bundle(scheme: "app"), .local(scheme: "local", hosts: ["myapp": "http://localhost:8080"]), + ] + ) + + #expect(wvb.schemes == ["app", "local"]) + #expect(wvb.remote == nil) + #expect(wvb.updater == nil) + } + + @Test("Duplicate scheme throws instead of trapping") + func duplicateSchemeThrows() throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + #expect(throws: WebViewBundleError.duplicateScheme("app")) { + _ = try WebViewBundle( + source: source, + protocols: [.bundle(scheme: "app"), .bundle(scheme: "app")] + ) } + } - @Test("Facade exposes its schemes") - func facadeSchemes() throws { - let source = try makeSource(entries: [ - (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") - ]) - let wvb = try WebViewBundle( - source: source, - protocols: [.bundle(scheme: "app"), .local(scheme: "local", hosts: ["myapp": "http://localhost:8080"])] - ) - - #expect(wvb.schemes == ["app", "local"]) - #expect(wvb.remote == nil) - #expect(wvb.updater == nil) + @Test("Empty scheme throws instead of trapping") + func emptySchemeThrows() throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + #expect(throws: WebViewBundleError.emptyScheme) { + _ = try WebViewBundle(source: source, protocols: [.bundle(scheme: "")]) } + } - @Test("Duplicate scheme throws instead of trapping") - func duplicateSchemeThrows() throws { - let source = try makeSource(entries: [ - (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") - ]) - #expect(throws: WebViewBundleError.duplicateScheme("app")) { - _ = try WebViewBundle( - source: source, - protocols: [.bundle(scheme: "app"), .bundle(scheme: "app")] - ) - } + @Test("Invalid scheme throws instead of trapping") + func invalidSchemeThrows() throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + #expect(throws: WebViewBundleError.invalidScheme("1app")) { + _ = try WebViewBundle(source: source, protocols: [.bundle(scheme: "1app")]) } + } - @Test("Empty scheme throws instead of trapping") - func emptySchemeThrows() throws { - let source = try makeSource(entries: [ - (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") - ]) - #expect(throws: WebViewBundleError.emptyScheme) { - _ = try WebViewBundle(source: source, protocols: [.bundle(scheme: "")]) - } + @Test("Reserved scheme throws instead of trapping") + func reservedSchemeThrows() throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + #expect(throws: WebViewBundleError.reservedScheme("https")) { + _ = try WebViewBundle(source: source, protocols: [.bundle(scheme: "https")]) } + } - @Test("Invalid scheme throws instead of trapping") - func invalidSchemeThrows() throws { - let source = try makeSource(entries: [ - (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") - ]) - #expect(throws: WebViewBundleError.invalidScheme("1app")) { - _ = try WebViewBundle(source: source, protocols: [.bundle(scheme: "1app")]) - } + @Test("Case-insensitive duplicate scheme throws") + func caseInsensitiveDuplicateSchemeThrows() throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + #expect(throws: WebViewBundleError.duplicateScheme("App")) { + _ = try WebViewBundle( + source: source, + protocols: [.bundle(scheme: "app"), .bundle(scheme: "App")] + ) } + } + + @Test("HttpMethod maps from request strings") + func httpMethodFrom() { + #expect(HttpMethod.from("get") == .get) + #expect(HttpMethod.from("POST") == .post) + #expect(HttpMethod.from(nil) == .get) + #expect(HttpMethod.from("weird") == .get) + } + + // MARK: - Bridge + + @Test("BridgeCodec decodes typed args and reports missing keys") + func bridgeCodecDecode() throws { + let args = try BridgeCodec.decode( + ["bundleName": "app", "version": "1.0.0"], as: BundleVersionArgs.self) + #expect(args.bundleName == "app") + #expect(args.version == "1.0.0") + + // An optional reads nil from an explicit JS `null`. + let channel = try BridgeCodec.decode(["channel": NSNull()], as: ChannelArgs.self) + #expect(channel.channel == nil) - @Test("Reserved scheme throws instead of trapping") - func reservedSchemeThrows() throws { - let source = try makeSource(entries: [ - (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") - ]) - #expect(throws: WebViewBundleError.reservedScheme("https")) { - _ = try WebViewBundle(source: source, protocols: [.bundle(scheme: "https")]) - } + #expect(throws: BridgeError.self) { + _ = try BridgeCodec.decode(["bundleName": "app"], as: BundleVersionArgs.self) } + } - @Test("Case-insensitive duplicate scheme throws") - func caseInsensitiveDuplicateSchemeThrows() throws { - let source = try makeSource(entries: [ - (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") - ]) - #expect(throws: WebViewBundleError.duplicateScheme("App")) { - _ = try WebViewBundle( - source: source, - protocols: [.bundle(scheme: "app"), .bundle(scheme: "App")] - ) - } + @Test("UpdateInfoPayload serializes required fields and omits nil optionals") + func updateInfoPayload() throws { + let info = BundleUpdateInfo( + name: "app", version: "2.0.0", localVersion: nil, isAvailable: true, + etag: "e", integrity: nil, signature: nil, lastModified: nil + ) + let object = try #require(try BridgeCodec.jsonObject(UpdateInfoPayload(info)) as? [String: Any]) + #expect(object["name"] as? String == "app") + #expect(object["version"] as? String == "2.0.0") + #expect(object["isAvailable"] as? Bool == true) + #expect(object["etag"] as? String == "e") + #expect(object["localVersion"] == nil) + #expect(object["integrity"] == nil) + } + + @MainActor + @Test("errorJSON encodes BridgeError as { code?, message }") + func errorJSONShape() { + let withCode = Bridge.errorJSON(BridgeError(code: "x", message: "boom")) + #expect(withCode["message"] as? String == "boom") + #expect(withCode["code"] as? String == "x") + + let withoutCode = Bridge.errorJSON(BridgeError(message: "plain")) + #expect(withoutCode["message"] as? String == "plain") + #expect(withoutCode["code"] == nil) + + struct Other: Swift.Error {} + let other = Bridge.errorJSON(Other()) + #expect(other["message"] != nil) + #expect(other["code"] == nil) + } + + @MainActor + @Test("Source commands dispatch and serialize results (kind keyed as `type`)") + func bridgeSourceCommands() async throws { + let html = "hi" + let source = try makeSource(entries: [ + (path: "/index.html", data: Data(html.utf8), contentType: "text/html") + ]) + let wvb = try WebViewBundle(source: source, protocols: [.bundle(scheme: "app")]) + let bridge = Bridge() + bridge.add(WebViewBundleBridge(wvb: wvb)) + + let listHandler = try #require(bridge.handlers["sourceListBundles"]) + let items = try #require(try await listHandler(nil) as? [[String: Any]]) + #expect(items.count == 1) + #expect(items[0]["name"] as? String == "app") + #expect(items[0]["version"] as? String == "1.0.0") + #expect(items[0]["type"] as? String == "remote") + #expect(items[0]["metadata"] is [String: Any]) + + let resolveHandler = try #require(bridge.handlers["sourceResolveFilepath"]) + let path = try await resolveHandler(["bundleName": "app"]) as? String + #expect(path?.hasSuffix(".wvb") == true) + } + + @MainActor + @Test("Remote/updater commands reject with BridgeError when not configured") + func bridgeRejectsWhenCapabilityMissing() async throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + let wvb = try WebViewBundle(source: source, protocols: [.bundle(scheme: "app")]) + let bridge = Bridge() + bridge.add(WebViewBundleBridge(wvb: wvb)) + + let remoteHandler = try #require(bridge.handlers["remoteGetInfo"]) + await #expect( + throws: BridgeError(code: "remote_not_initialized", message: "remote is not initialized.") + ) { + _ = try await remoteHandler(["bundleName": "app"]) + } + + let updaterHandler = try #require(bridge.handlers["updaterGetUpdate"]) + await #expect( + throws: BridgeError(code: "updater_not_initialized", message: "updater is not initialized.") + ) { + _ = try await updaterHandler(["bundleName": "app"]) } + } + + @MainActor + @Test("install auto-registers the standard bridge commands") + func installRegistersBridgeCommands() throws { + let source = try makeSource(entries: [ + (path: "/index.html", data: Data("ok".utf8), contentType: "text/html") + ]) + let wvb = try WebViewBundle(source: source, protocols: [.bundle(scheme: "app")]) + let bridge = Bridge().handler("ping") { _ in "pong" } + _ = wvb.makeConfiguration(bridge: bridge) - @Test("HttpMethod maps from request strings") - func httpMethodFrom() { - #expect(HttpMethod.from("get") == .get) - #expect(HttpMethod.from("POST") == .post) - #expect(HttpMethod.from(nil) == .get) - #expect(HttpMethod.from("weird") == .get) + // User command preserved alongside the auto-registered standard commands. + #expect(bridge.handlers["ping"] != nil) + for name in ["sourceListBundles", "remoteDownload", "updaterInstall"] { + #expect(bridge.handlers[name] != nil) } + } + + @Test("LogLevel maps to the expected OSLogType (the core-tracing contract)") + func logLevelMapping() { + #expect(LogLevel.trace.osLogType == .debug) + #expect(LogLevel.debug.osLogType == .debug) + #expect(LogLevel.info.osLogType == .info) + #expect(LogLevel.warning.osLogType == .default) + #expect(LogLevel.error.osLogType == .error) + // Ordering gates forwarded core events by severity. + #expect(LogLevel.trace < LogLevel.error) + } } diff --git a/e2e/.yarnrc.yml b/e2e/.yarnrc.yml index 446307c..4f11c57 100644 --- a/e2e/.yarnrc.yml +++ b/e2e/.yarnrc.yml @@ -1,3 +1,5 @@ nodeLinker: node-modules enableScripts: true -npmMinimalAgeGate: 0 +npmPreapprovedPackages: + - "@wvb/*" + - "@wvb-playground/*" diff --git a/e2e/package.json b/e2e/package.json index 91b831f..b15e615 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,8 +9,8 @@ "test": "vitest run" }, "dependencies": { - "@wvb-playground/testing": "^0.0.0", - "@wvb-playground/webview-hacker-news": "^0.0.0" + "@wvb-playground/testing": "0.0.1", + "@wvb-playground/webview-hacker-news": "0.0.0" }, "devDependencies": { "@types/node": "^25", diff --git a/e2e/yarn.lock b/e2e/yarn.lock index c0692fd..fd80d63 100644 --- a/e2e/yarn.lock +++ b/e2e/yarn.lock @@ -966,6 +966,24 @@ __metadata: languageName: node linkType: hard +"@wvb-playground/testing@npm:0.0.1": + version: 0.0.1 + resolution: "@wvb-playground/testing@npm:0.0.1" + peerDependencies: + playwright-core: ^1.40.0 + selenium-webdriver: ^4.0.0 + webdriverio: ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright-core: + optional: true + selenium-webdriver: + optional: true + webdriverio: + optional: true + checksum: 10c0/348f5199a5eea52b0af30c069d3d8687fb44abd3e420f856219d1911904b61715b1eb3d074c89d9fbc3826514ec72a8dcd6f15205574b77137c74137d9085759 + languageName: node + linkType: hard + "@wvb-playground/testing@npm:^0.0.0": version: 0.0.0 resolution: "@wvb-playground/testing@npm:0.0.0" @@ -984,7 +1002,7 @@ __metadata: languageName: node linkType: hard -"@wvb-playground/webview-hacker-news@npm:^0.0.0": +"@wvb-playground/webview-hacker-news@npm:0.0.0": version: 0.0.0 resolution: "@wvb-playground/webview-hacker-news@npm:0.0.0" dependencies: @@ -5539,8 +5557,8 @@ __metadata: resolution: "webview-bundle-ios-e2e@workspace:." dependencies: "@types/node": "npm:^25" - "@wvb-playground/testing": "npm:^0.0.0" - "@wvb-playground/webview-hacker-news": "npm:^0.0.0" + "@wvb-playground/testing": "npm:0.0.1" + "@wvb-playground/webview-hacker-news": "npm:0.0.0" appium: "npm:^3.5.0" execa: "npm:^9.5.2" tinyglobby: "npm:^0.2.10"