🦁

iOSのBrowserEngineKitで独自ブラウザアプリを作る方法を調べた

2024/05/05に公開

BrowserEngineKitはiOS 17.4以降に含まれる新しいフレームワーク。WebKit以外の独自ブラウザエンジンを使ったアプリを開発できるように追加された

https://developer.apple.com/documentation/browserenginekit

BrowserExample

以下のページでBrowserEngineKit と XPC を使用して、独自のブラウザエンジンを実装するWebブラウザアプリのコードが公開されている

https://developer.apple.com/documentation/browserenginekit/developing-a-browser-app-that-uses-an-alternative-browser-engine

このアプリはヘッダにあるテキストフィールドにURLを入力するとそのサイトのコンテンツを読み込みんでViewに表示する

BrowserKit対応アプリは

  • ブラウザエンジンのコアモジュール
  • ブラウザエンジンを利用する本体アプリ
  • NetworkingExtension
  • WebContentExtension
  • RenderingExtension

で構成されていて各ExtensionとXPCでメッセージングして連携する

なのでBrowserExampleの実装を順番に読んでBrowserEngineKitの使い方を理解した

※プロジェクトのターゲット一覧

BrowserApp

メインのSwiftUI App部分のソースコード

タブを作成して、そのタブの中にブラウザのViewができるという構成

ここは一般的なアプリ実装なので所見なし

CustomBrowserEngine

BrowserAppとは独立したブラウザエンジンのコアモジュール。各Extensionからリンクされる

アプリに組込みカスタムWebView(UIView)の実装がここにある

サンプルのカスタムWebViewでは単にUITextViewをaddSubview()して画面に表示するという機能を持つ

※実行例。TextViewなので画像やCSSは描画されていない

ポイントとしては*ProcessディレクトリにあるProxyクラスが以降の各Extensionに対応しているという知識があると読みやすい

BrowserProcessPoolでページごとにこの3つのProccessが管理されているのが分かる

public class BrowserProcessPool {
// ...
  private(set) var renderingProcess: RenderingProcess? = nil
  private(set) var networkProcess: NetworkingProcess? = nil
  private(set) var webContentProcesses: [PageID: WebContentProcess] = [:]
// ...
}

WebContentExtension

NetworkingExtensionとRenderingExtensionを仲介するような役割を持つExtension

読み込んだページごとにプロセス化して並列に処理する

ExtensionごとにXPCのメッセージングtypeがTask化されていてそれに応じた処理が実行される

  public func launchProcesses(id: PageID) async throws -> WebContentExtensionProxy {
    
    // 1. Launch a new web content process instance.
    let contentProcess = try await getOrLaunchContentProcess(pageID: id)
    let contentConnection = try contentProcess.makeLibXPCConnection()
    let contentProxy = WebContentExtensionProxy(connection: contentConnection)
    try contentProxy.applyRestrictedSandbox(version: lockdownVersion)
    
    // 2. Get the shared rendering process.
    let renderingProcess = try await getOrLaunchRenderingProcess()
    let renderingConnection = try renderingProcess.makeLibXPCConnection()
    let renderingProxy = RenderingExtensionProxy(connection: renderingConnection)
    let renderingEndpoint = try await renderingProxy.getEndpoint()
    try renderingProxy.applyRestrictedSandbox(version: lockdownVersion)
    
    // 3. Get the shared networking process.
    let networkProcess = try await getOrLaunchNetworkProcess()
    let networkConnection = try networkProcess.makeLibXPCConnection()
    let networkProxy = NetworkingExtensionProxy(connection: networkConnection)
    let networkEndpoint = try await networkProxy.getEndpoint()
    try networkProxy.applyRestrictedSandbox(version: lockdownVersion)
    
    // 4. Perform the bootstrap process.
    try await contentProxy.bootstrap(renderingExtension: renderingEndpoint, networkExtension: networkEndpoint)
    
    webContentProcesses[id] = contentProcess
    return contentProxy
  }

keyword: Anonymous Endpoint

EndpointというのはXPCのエンドポイント。メッセージ送信先

サンプルではBrowserExtensionというExtensionの基盤コードを定義している場所に出てくる

Extension AがAnonymousなEndpointを作ってそこにExtension Bがメッセージを送るという設計にすることでExtension間の依存をなくしている

  public func makeAnonymousEndpoint(label: String, handler: @escaping XPCConnectionEventHandler) -> xpc_endpoint_t {
    let emptyConnection = xpc_connection_create(nil, nil)
    emptyConnection.setEventHandler(label: label, handler)
    emptyConnection.activate()
    return xpc_endpoint_create(emptyConnection)
  }
  public func handle(event: xpc_object_t, from connection: xpc_connection_t) {
    log.log("handling xpc event: \(String(describing: event))")
    guard let rawMessageType = xpc_dictionary_get_string(event, XPCMessageType) else { return }
    let messageType = String(cString: rawMessageType)
    handleMessage(type: messageType, with: event, from: connection)
  }
  
  func handleMessage(type: String, with event: xpc_object_t, from connection: xpc_connection_t) {
    switch type {
    case BrowserExtensionTask.messageType:
      handleBrowserExtensionTask(event, from: connection)
    case WebContentExtensionBootstrapCommand.messageType:
      handleBootstrapCommand(with: event, from: connection)
    case WebContentExtensionTask.messageType:
      handleWebContentExtensionTask(event, from: connection)
    case GetXPCEndpointMessage.messageType:
      let endpoint = makeAnonymousEndpoint(label: "content-ext", handler: handle(event:from:))
      sendEndpoint(endpoint, to: connection, replyingTo: event)
    default:
      log.error("unrecognized message type: \(type)")
    }
  }

NetworkingExtension

ブラウザアプリのネットワーク処理を実装するExtension。通信結果をXPCで送信することで独立している

サンプルではNetworkSession内でURLSessionを使ってページの内容をダウンロードする

HTTPプロキシなどブラウザ側に持つ機能を作る時に活用することになる

RenderingExtension

ブラウザアプリの描画処理を実装するExtension。通信と同じように、ここでは描画処理の結果をXPCで送信する

ここが一般的に想像する「独自ブラウザアプリ」の役割となる。開発者は自分のレンダリングエンジンを呼び出す

サンプルではHTMLをテキストブラウザとしてパースして装飾付きテキストとして表示する

カスタムWebViewの実装はWebContentViewで、ダウンロードしたHTMLをパースして変換する実装がある

  public func load(_ destination: WebViewDestination) async throws {
    let result = try await webContentProxy.load(destination: destination, pageID: self.id)
    let displayString = parse(result: result)
    textView.attributedText = displayString
    textView.scrollRangeToVisible(.init(location: 0, length: 0)) // scroll to top
  }

  private func parse(result: NetworkTaskResult) -> NSAttributedString {
    if let data = result.data {
      do {
        return try NSAttributedString(data: data, options: [
          .documentType: NSAttributedString.DocumentType.html,
          .characterEncoding: String.Encoding.utf8.rawValue
        ], documentAttributes: nil)
      } catch let error {
        return .init(string: "Failed to parse result: \(String(describing: error))")
      }
    } else if let error = result.error {
      return .init(string: String(describing: error))
    } else {
      return .init(string: "?")
    }
  }

Discussion