twonly-app/ios/Runner/WebxdcHost.swift
2026-09-05 01:59:17 +02:00

470 lines
16 KiB
Swift

import Flutter
import UIKit
import UniformTypeIdentifiers
import WebKit
/// The Dart side of the webxdc runtime.
///
/// The webview is a platform view embedded in an ordinary Flutter route, so the
/// app runs inside twonly rather than on a screen of its own. Everything the
/// page asks for is answered by Dart, which asks Rust: nothing here reads a
/// bundle, decides a limit, or trusts a value the page produced.
///
/// Shares its channel name with the Android implementation, so Dart talks to
/// one channel on both platforms.
class WebxdcHostChannel: NSObject {
private static let channelName = "eu.twonly/webxdc"
static private(set) var shared: WebxdcHostChannel?
private var channel: FlutterMethodChannel?
static func register(with registry: FlutterPluginRegistry) {
guard let registrar = registry.registrar(forPlugin: "TwonlyWebxdc") else {
return
}
let host = WebxdcHostChannel()
let channel = FlutterMethodChannel(
name: channelName,
binaryMessenger: registrar.messenger()
)
host.channel = channel
channel.setMethodCallHandler { call, result in
host.handle(call, result: result)
}
shared = host
registrar.register(
WebxdcViewFactory(),
withId: WebxdcViewFactory.viewType
)
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
let arguments = call.arguments as? [String: Any] ?? [:]
switch call.method {
case "deliver":
if let instanceId = arguments["instanceId"] as? String,
let message = arguments["message"] as? String
{
WebxdcPlatformView.deliver(instanceId: instanceId, message: message)
}
result(nil)
case "clearOrigin":
// The update log is only half an app's state; the rest is whatever it put
// in localStorage or IndexedDB, which only WebKit can delete.
guard let origin = arguments["origin"] as? String else {
result(nil)
return
}
let store = WKWebsiteDataStore.default()
store.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
let matching = records.filter { $0.displayName.contains(origin) }
store.removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: matching
) {
result(nil)
}
}
default:
result(FlutterMethodNotImplemented)
}
}
/// Asks Dart for one file out of the bundle.
func serve(
instanceId: String, path: String, completion: @escaping ([String: Any]?) -> Void
) {
guard let channel = channel else {
completion(nil)
return
}
channel.invokeMethod("serve", arguments: ["instanceId": instanceId, "path": path]) { reply in
completion(reply as? [String: Any])
}
}
/// Forwards one `webxdc.js` call; the JSON reply goes back to the page.
func bridge(instanceId: String, message: String, completion: @escaping (String) -> Void) {
guard let channel = channel else {
completion("{\"error\":\"unavailable\"}")
return
}
channel.invokeMethod("bridge", arguments: ["instanceId": instanceId, "message": message]) {
reply in
completion(reply as? String ?? "{\"error\":\"unavailable\"}")
}
}
/// Hands a link the user tapped to Dart, which shows the whole URL and asks
/// before anything opens. Fire and forget: the page is not told.
func openLink(_ url: String) {
channel?.invokeMethod("openLink", arguments: ["url": url])
}
}
class WebxdcViewFactory: NSObject, FlutterPlatformViewFactory {
static let viewType = "eu.twonly/webxdc_webview"
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?
) -> FlutterPlatformView {
let params = args as? [String: Any] ?? [:]
return WebxdcPlatformView(
frame: frame,
instanceId: params["instanceId"] as? String ?? "",
origin: params["origin"] as? String ?? ""
)
}
}
/// The one WKWebView twonly ever creates, embedded in a Flutter route.
///
/// A webxdc app is third-party code, so this view is built around denying it
/// things:
///
/// - it is served from a custom scheme handled in-process, so every request it
/// makes is answered from the bundle or refused, and nothing reaches the
/// network,
/// - its host is unique per instance, so WebKit's own origin model keeps one
/// app's storage out of reach of every other app,
/// - navigation away from that origin is cancelled, and a link the user taps
/// goes to Dart for confirmation before it reaches Safari,
/// - `webxdc.js` is served, not injected, so it carries the same CSP as the
/// rest of the app and cannot race the page's own scripts.
///
/// Nothing here parses a bundle or decides a limit: the bytes and the headers
/// come from Rust, by way of Dart.
class WebxdcPlatformView: NSObject, FlutterPlatformView {
/// Handled in-process, so a request can never leave the device.
///
/// It is not `https`, which WebKit will not let an app handle, so pages here
/// are not a secure context. That is an accepted limitation: webxdc apps do
/// not require one.
static let scheme = "twonly-webxdc"
/// Refuses to read more than this in one import, however many files.
private static let maxImportBytes = 32 * 1024 * 1024
private static weak var current: WebxdcPlatformView?
let instanceId: String
private let origin: String
private var webView: WKWebView!
/// The `importFiles` call waiting on the picker, if one is open.
private var importCallId: Int?
private var importExtensions: [String] = []
init(frame: CGRect, instanceId: String, origin: String) {
self.instanceId = instanceId
self.origin = origin
super.init()
let configuration = WKWebViewConfiguration()
configuration.setURLSchemeHandler(
SchemeHandler(view: self), forURLScheme: WebxdcPlatformView.scheme)
configuration.userContentController.add(BridgeHandler(view: self), name: "twonlyWebxdc")
// Nothing plays without the user asking for it, and no page may take over
// the screen on its own.
configuration.mediaTypesRequiringUserActionForPlayback = .all
configuration.allowsInlineMediaPlayback = true
configuration.allowsPictureInPictureMediaPlayback = false
let webView = WKWebView(frame: frame, configuration: configuration)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
webView.navigationDelegate = self
webView.uiDelegate = self
webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = false
self.webView = webView
WebxdcPlatformView.current = self
webView.load(URLRequest(url: URL(string: "\(originUrl())/index.html")!))
}
func view() -> UIView {
webView
}
private func originUrl() -> String {
"\(WebxdcPlatformView.scheme)://\(origin)"
}
static func deliver(instanceId: String, message: String) {
guard let view = current, view.instanceId == instanceId else { return }
view.deliverToPage(message)
}
func deliverToPage(_ message: String) {
guard let data = try? JSONSerialization.data(withJSONObject: [message]),
let quoted = String(data: data, encoding: .utf8)
else { return }
// `quoted` is a JSON array of one string; taking element zero hands the page
// a correctly escaped literal without building one by hand.
webView.evaluateJavaScript(
"window.__twonlyWebxdcDeliver(JSON.parse((\(quoted))[0]))", completionHandler: nil)
}
fileprivate func isOwnOrigin(_ url: URL?) -> Bool {
url?.scheme == WebxdcPlatformView.scheme && url?.host == origin
}
fileprivate func askHostToServe(
path: String, completion: @escaping ([String: Any]?) -> Void
) {
guard let host = WebxdcHostChannel.shared else {
completion(nil)
return
}
host.serve(instanceId: instanceId, path: path, completion: completion)
}
fileprivate func askHostToBridge(_ message: String, completion: @escaping (String) -> Void) {
guard let host = WebxdcHostChannel.shared else {
completion("{\"error\":\"unavailable\"}")
return
}
host.bridge(instanceId: instanceId, message: message, completion: completion)
}
/// The controller a picker can be presented from. A platform view has no
/// controller of its own, so it borrows the one showing the Flutter route it
/// is embedded in.
private func presenter() -> UIViewController? {
var controller = UIApplication.shared.delegate?.window??.rootViewController
while let presented = controller?.presentedViewController {
controller = presented
}
return controller
}
/// Answers `importFiles` here rather than in Dart: the picker belongs to this
/// screen, and the bytes the user chose have no reason to travel any further
/// than the page that asked for them.
fileprivate func startImport(_ call: [String: Any]) {
let callId = call["id"] as? Int ?? 0
guard importCallId == nil else {
// One picker at a time; a second request while one is open is answered
// empty rather than queued.
deliverImportResult(callId: callId, files: [])
return
}
importCallId = callId
let params = call["params"] as? [String: Any] ?? [:]
importExtensions = (params["extensions"] as? [String] ?? []).map {
let lowered = $0.lowercased()
return lowered.hasPrefix(".") ? String(lowered.dropFirst()) : lowered
}
let mimeTypes = params["mimeTypes"] as? [String] ?? []
var types: [UTType] = mimeTypes.compactMap { UTType(mimeType: $0) }
types += importExtensions.compactMap { UTType(filenameExtension: $0) }
if types.isEmpty {
types = [.item]
}
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
picker.allowsMultipleSelection = params["multiple"] as? Bool ?? false
picker.delegate = self
guard let presenter = presenter() else {
finishImport(urls: [])
return
}
presenter.present(picker, animated: true)
}
fileprivate func finishImport(urls: [URL]) {
guard let callId = importCallId else { return }
importCallId = nil
var files: [[String: Any]] = []
var budget = WebxdcPlatformView.maxImportBytes
for url in urls {
let needsScope = url.startAccessingSecurityScopedResource()
defer { if needsScope { url.stopAccessingSecurityScopedResource() } }
// Only the file's own name; it must never read as a path.
let name = url.lastPathComponent
if !importExtensions.isEmpty
&& !importExtensions.contains(url.pathExtension.lowercased())
{
continue
}
guard let data = try? Data(contentsOf: url), data.count <= budget else { continue }
budget -= data.count
files.append([
"name": name,
"type": UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "",
"base64": data.base64EncodedString(),
])
if budget <= 0 { break }
}
deliverImportResult(callId: callId, files: files)
}
private func deliverImportResult(callId: Int, files: [[String: Any]]) {
let reply: [String: Any] = ["id": callId, "result": files]
guard let data = try? JSONSerialization.data(withJSONObject: reply),
let json = String(data: data, encoding: .utf8)
else { return }
deliverToPage(json)
}
}
extension WebxdcPlatformView: UIDocumentPickerDelegate {
func documentPicker(
_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]
) {
finishImport(urls: urls)
}
/// A cancelled picker resolves with nothing rather than rejecting: the app
/// asked to import, the user declined, and that is not an error.
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
finishImport(urls: [])
}
}
extension WebxdcPlatformView: WKNavigationDelegate {
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
let url = navigationAction.request.url
if isOwnOrigin(url) {
decisionHandler(.allow)
return
}
// A link out of the app. Never followed here: Dart shows the whole URL and
// says it leaves twonly before anything opens.
decisionHandler(.cancel)
if navigationAction.navigationType == .linkActivated, let url = url {
WebxdcHostChannel.shared?.openLink(url.absoluteString)
}
}
}
extension WebxdcPlatformView: WKUIDelegate {
/// No second window, ever: `target="_blank"` and `window.open` both end here.
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
nil
}
/// Camera and microphone, denied without a prompt.
@available(iOS 15.0, *)
func webView(
_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
decisionHandler(.deny)
}
}
/// Answers every request the page makes, or refuses it. There is no path from
/// here to the network.
private class SchemeHandler: NSObject, WKURLSchemeHandler {
private weak var view: WebxdcPlatformView?
init(view: WebxdcPlatformView) {
self.view = view
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let view = view, view.isOwnOrigin(urlSchemeTask.request.url) else {
finish(urlSchemeTask, status: 403, mime: "text/plain", headers: [:], body: Data())
return
}
let url = urlSchemeTask.request.url!
let path = url.path.isEmpty ? "/" : url.path
view.askHostToServe(path: path) { [weak self] served in
guard let self = self else { return }
guard let served = served else {
self.finish(urlSchemeTask, status: 500, mime: "text/plain", headers: [:], body: Data())
return
}
var headers: [String: String] = [:]
let names = served["headerNames"] as? [String] ?? []
let values = served["headerValues"] as? [String] ?? []
for (index, name) in names.enumerated() where index < values.count {
headers[name] = values[index]
}
let body = (served["body"] as? FlutterStandardTypedData)?.data ?? Data()
self.finish(
urlSchemeTask,
status: served["status"] as? Int ?? 500,
mime: served["mime"] as? String ?? "application/octet-stream",
headers: headers,
body: body
)
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
private func finish(
_ task: WKURLSchemeTask, status: Int, mime: String, headers: [String: String], body: Data
) {
var allHeaders = headers
allHeaders["Content-Type"] = mime
allHeaders["Content-Length"] = String(body.count)
guard let url = task.request.url,
let response = HTTPURLResponse(
url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: allHeaders)
else { return }
task.didReceive(response)
task.didReceive(body)
task.didFinish()
}
}
/// The single entry point from the page into twonly.
private class BridgeHandler: NSObject, WKScriptMessageHandler {
private weak var view: WebxdcPlatformView?
init(view: WebxdcPlatformView) {
self.view = view
}
func userContentController(
_ userContentController: WKUserContentController, didReceive message: WKScriptMessage
) {
guard let view = view, let body = message.body as? String else { return }
if let data = body.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
parsed["method"] as? String == "importFiles"
{
view.startImport(parsed)
return
}
// The instance is the one this view was created for. A page cannot name a
// different one, whatever it puts in the message.
view.askHostToBridge(body) { reply in
view.deliverToPage(reply)
}
}
}