mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 12:14:07 +00:00
470 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|