SHA256
Ignore late picker callbacks that no longer match the active download. Add an Apple-platform regression; Swift formatting, parsing and seven portable policy tests pass locally, but the new WKWebView regression requires Xcode and has not run here.
311 lines
12 KiB
Swift
311 lines
12 KiB
Swift
import SwiftUI
|
|
import WebKit
|
|
|
|
struct WebDestination: Identifiable {
|
|
let id = UUID()
|
|
let url: URL
|
|
}
|
|
|
|
@MainActor
|
|
final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate {
|
|
@Published var loading = false
|
|
@Published var canGoBack = false
|
|
@Published var error: String?
|
|
@Published var externalURL: URL?
|
|
@Published var authentication: WebDestination?
|
|
@Published var authenticationHost = "Sign in"
|
|
@Published var download: WebDestination?
|
|
private(set) var server: ServerAddress?
|
|
private(set) var web: WKWebView
|
|
private var observations: [NSKeyValueObservation] = []
|
|
private var deadline: Timer?
|
|
private var activeDownload: WKDownload?
|
|
private var downloadLocation: URL?
|
|
|
|
override init() {
|
|
web = Self.makeWebView()
|
|
super.init()
|
|
configureWebView()
|
|
// Only app-owned temporary exports are removed, never user-saved documents.
|
|
let folder = Self.exportDirectory
|
|
try? FileManager.default.removeItem(at: folder)
|
|
}
|
|
|
|
private static func makeWebView() -> WKWebView {
|
|
let configuration = WKWebViewConfiguration()
|
|
configuration.websiteDataStore = .default()
|
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
|
configuration.allowsInlineMediaPlayback = true
|
|
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
|
return WKWebView(frame: .zero, configuration: configuration)
|
|
}
|
|
|
|
private func configureWebView() {
|
|
web.navigationDelegate = self
|
|
web.uiDelegate = self
|
|
web.allowsBackForwardNavigationGestures = true
|
|
web.isOpaque = false
|
|
if #available(iOS 16.4, *) { web.isInspectable = false }
|
|
observations.append(
|
|
web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
|
|
self?.canGoBack = view.canGoBack
|
|
})
|
|
}
|
|
|
|
static var exportDirectory: URL {
|
|
FileManager.default.temporaryDirectory.appendingPathComponent("YuvomiExports", isDirectory: true)
|
|
}
|
|
|
|
func open(_ server: ServerAddress) {
|
|
self.server = server
|
|
let path = UserDefaults.standard.string(forKey: "path") ?? "/"
|
|
let restored = URL(string: path, relativeTo: server.url)?.absoluteURL
|
|
let url = restored.flatMap { server.owns($0) ? $0 : nil } ?? server.url
|
|
web.load(URLRequest(url: url))
|
|
}
|
|
|
|
func rememberPath() {
|
|
guard let url = web.url, server?.owns(url) == true else { return }
|
|
let path = url.path
|
|
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) })
|
|
else { return }
|
|
UserDefaults.standard.set(path, forKey: "path")
|
|
}
|
|
|
|
func erase(completion: @escaping () -> Void) {
|
|
deadline?.invalidate()
|
|
web.stopLoading()
|
|
activeDownload?.cancel { _ in }
|
|
activeDownload = nil
|
|
authentication = nil
|
|
removeDownload()
|
|
server = nil
|
|
web.navigationDelegate = nil
|
|
web.uiDelegate = nil
|
|
observations.removeAll()
|
|
WKWebsiteDataStore.default().removeData(
|
|
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
|
|
modifiedSince: .distantPast
|
|
) { [weak self] in
|
|
guard let self else { return }
|
|
self.web = Self.makeWebView()
|
|
self.configureWebView()
|
|
self.canGoBack = false
|
|
self.loading = false
|
|
self.error = nil
|
|
completion()
|
|
}
|
|
}
|
|
|
|
func startAuthentication(_ url: URL) {
|
|
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
|
|
authenticationHost = url.host ?? "Sign in"
|
|
authentication = WebDestination(url: url)
|
|
}
|
|
|
|
func removeDownload(expected: URL? = nil) {
|
|
if let expected, download?.url != expected, downloadLocation != expected { return }
|
|
download = nil
|
|
if let location = downloadLocation {
|
|
try? FileManager.default.removeItem(at: location.deletingLastPathComponent())
|
|
}
|
|
downloadLocation = nil
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
|
) {
|
|
guard let url = navigationAction.request.url else {
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
if server?.owns(url) == true || (navigationAction.shouldPerformDownload && server?.ownsDownload(url) == true) {
|
|
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
|
|
} else {
|
|
decisionHandler(.cancel)
|
|
if navigationAction.targetFrame?.isMainFrame != false {
|
|
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
|
|
externalURL = url
|
|
} else {
|
|
error = "This link type is blocked for your security."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
|
) {
|
|
if let response = navigationResponse.response as? HTTPURLResponse,
|
|
navigationResponse.isForMainFrame, response.statusCode >= 400
|
|
{
|
|
error = "The server is unavailable (HTTP \(response.statusCode))."
|
|
}
|
|
guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
if !navigationResponse.canShowMIMEType {
|
|
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
|
|
error = "This file exceeds the 100 MB download limit."
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
decisionHandler(.download)
|
|
} else {
|
|
decisionHandler(.allow)
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
|
loading = true
|
|
deadline?.invalidate()
|
|
deadline = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.loading = false
|
|
self?.error = "The server is taking too long. Check your connection or try again."
|
|
}
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
loading = false
|
|
deadline?.invalidate()
|
|
rememberPath()
|
|
}
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
failed(error)
|
|
}
|
|
private func failed(_ failure: Error) {
|
|
loading = false
|
|
deadline?.invalidate()
|
|
guard (failure as NSError).code != NSURLErrorCancelled else { return }
|
|
error =
|
|
"Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
|
|
}
|
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
|
|
loading = false
|
|
}
|
|
func webView(
|
|
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
|
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
|
|
) -> WKWebView? {
|
|
if let url = navigationAction.request.url {
|
|
if server?.owns(url) == true {
|
|
web.load(navigationAction.request)
|
|
} else if url.scheme == "https", url.user == nil, url.password == nil {
|
|
externalURL = url
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func webView(
|
|
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
|
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
|
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
|
) {
|
|
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
|
|
decisionHandler(.deny)
|
|
}
|
|
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
|
attach(download)
|
|
}
|
|
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
|
attach(download)
|
|
}
|
|
private func attach(_ download: WKDownload) {
|
|
guard activeDownload == nil, self.download == nil else {
|
|
download.cancel { _ in }
|
|
error = "Finish the current download before starting another."
|
|
return
|
|
}
|
|
activeDownload = download
|
|
download.delegate = self
|
|
}
|
|
func download(
|
|
_ download: WKDownload, decideDestinationUsing response: URLResponse,
|
|
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
|
|
) {
|
|
guard let url = response.url, server?.ownsDownload(url) == true,
|
|
response.expectedContentLength <= 100 * 1024 * 1024
|
|
else {
|
|
completionHandler(nil)
|
|
return
|
|
}
|
|
do {
|
|
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(
|
|
at: folder, withIntermediateDirectories: true,
|
|
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
|
|
var values = URLResourceValues()
|
|
values.isExcludedFromBackup = true
|
|
var directory = folder
|
|
try directory.setResourceValues(values)
|
|
let name = String(
|
|
suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
|
|
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
|
|
downloadLocation = folder.appendingPathComponent(safeName)
|
|
completionHandler(downloadLocation)
|
|
} catch {
|
|
completionHandler(nil)
|
|
self.error = "Could not prepare the download."
|
|
}
|
|
}
|
|
func download(
|
|
_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
|
|
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
|
|
) {
|
|
// Do not replay session credentials to a redirect target.
|
|
decisionHandler(.cancel)
|
|
}
|
|
func downloadDidFinish(_ download: WKDownload) {
|
|
guard activeDownload === download else { return }
|
|
activeDownload = nil
|
|
guard let location = downloadLocation,
|
|
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
|
size <= 100 * 1024 * 1024
|
|
else {
|
|
removeDownload()
|
|
error = "Download exceeded the size limit."
|
|
return
|
|
}
|
|
self.download = WebDestination(url: location)
|
|
}
|
|
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
|
guard activeDownload === download else { return }
|
|
activeDownload = nil
|
|
removeDownload()
|
|
self.error = "Download failed. Check your connection and sign-in, then retry."
|
|
}
|
|
}
|
|
|
|
struct BrowserView: UIViewRepresentable {
|
|
@ObservedObject var model: BrowserModel
|
|
func makeUIView(context: Context) -> WKWebView { model.web }
|
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
|
}
|
|
|
|
struct ExportView: UIViewControllerRepresentable {
|
|
let url: URL
|
|
let completion: () -> Void
|
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
|
|
picker.delegate = context.coordinator
|
|
return picker
|
|
}
|
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
func makeCoordinator() -> Coordinator { Coordinator(completion) }
|
|
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
|
let completion: () -> Void
|
|
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
completion()
|
|
}
|
|
}
|
|
}
|