SHA256
Introduce real SwiftUI auth/dashboard/task/note/shopping screens, a bounded origin-bound URLSession transport and Keychain-backed native sessions. Archive the rejected WebKit renderer rather than embedding it for unfinished modules. Sixteen portable URL/contract/transport tests and Swift format/parsing pass on Linux; Apple SDK application and UI validation remains mandatory. This is an incomplete native slice, not full PWA parity or a release.
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()
|
|
}
|
|
}
|
|
}
|