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() } } }