From 0692e5130213fa374108166f21a993984350888a Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 13 Nov 2023 21:34:05 +0100 Subject: [PATCH 01/24] Fix opening new tabs on macos(#518) --- App/CompactViewController.swift | 2 +- Model/Utilities/URL.swift | 6 ++- ViewModel/BrowserViewModel.swift | 52 +++++++++++++++---- Views/BrowserTab.swift | 2 +- Views/ViewModifiers/ExternalLinkHandler.swift | 7 ++- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/App/CompactViewController.swift b/App/CompactViewController.swift index 9cb0a84e8..a13487265 100644 --- a/App/CompactViewController.swift +++ b/App/CompactViewController.swift @@ -130,7 +130,7 @@ private struct Content: View { .focusedSceneValue(\.browserViewModel, browser) .focusedSceneValue(\.canGoBack, browser.canGoBack) .focusedSceneValue(\.canGoForward, browser.canGoForward) - .modifier(ExternalLinkHandler()) + .modifier(ExternalLinkHandler(externalURL: browser.externalURL)) .onAppear { browser.updateLastOpened() } diff --git a/Model/Utilities/URL.swift b/Model/Utilities/URL.swift index a8a468f11..fed817edc 100644 --- a/Model/Utilities/URL.swift +++ b/Model/Utilities/URL.swift @@ -16,6 +16,10 @@ extension URL { var isKiwixURL: Bool { return scheme?.caseInsensitiveCompare("kiwix") == .orderedSame } - + + var isExternal: Bool { + ["http", "https"].contains(scheme) + } + static let documentDirectory = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) } diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 2354f6b4d..87efe7e6c 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -13,9 +13,9 @@ import WebKit import OrderedCollections -class BrowserViewModel: NSObject, ObservableObject, - WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, - NSFetchedResultsControllerDelegate +final class BrowserViewModel: NSObject, ObservableObject, + WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, + NSFetchedResultsControllerDelegate { static private var cache = OrderedDictionary() @@ -45,14 +45,17 @@ class BrowserViewModel: NSObject, ObservableObject, @Published private(set) var outlineItems = [OutlineItem]() @Published private(set) var outlineItemTree = [OutlineItem]() @Published private(set) var url: URL? - + @Published var externalURL: URL? + let tabID: NSManagedObjectID? let webView: WKWebView private var canGoBackObserver: NSKeyValueObservation? private var canGoForwardObserver: NSKeyValueObservation? private var titleURLObserver: AnyCancellable? private var bookmarkFetchedResultsController: NSFetchedResultsController? - + /// A temporary placeholder for the url that should be opened in a new tab, set on macOS only + private static var urlForNewTab: URL? + // MARK: - Lifecycle init(tabID: NSManagedObjectID? = nil) { @@ -66,7 +69,11 @@ class BrowserViewModel: NSObject, ObservableObject, webView.interactionState = tab.interactionState url = webView.url } - + if let urlForNewTab = Self.urlForNewTab { + url = urlForNewTab + load(url: urlForNewTab) + } + // configure web view webView.allowsBackForwardNavigationGestures = true webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work @@ -178,8 +185,8 @@ class BrowserViewModel: NSObject, ObservableObject, decisionHandler(.cancel) } else if url.isKiwixURL { decisionHandler(.allow) - } else if url.scheme == "http" || url.scheme == "https" { - NotificationCenter.default.post(name: .externalLink, object: nil, userInfo: ["url": url]) + } else if url.isExternal { + externalURL = url decisionHandler(.cancel) } else if url.scheme == "geo" { if FeatureFlags.map { @@ -234,7 +241,34 @@ class BrowserViewModel: NSObject, ObservableObject, } // MARK: - WKUIDelegate - +#if os(macOS) + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + + guard navigationAction.targetFrame == nil else { return nil } + guard let newUrl = navigationAction.request.url else { return nil } + + // open external link in default browser + guard newUrl.isExternal == false else { + externalURL = newUrl + return nil + } + + // create new tab + guard let currentWindow = NSApp.keyWindow, + let windowController = currentWindow.windowController else { return nil } + // store the new url in a static way + Self.urlForNewTab = newUrl + // this creates a new BrowserViewModel + windowController.newWindowForTab(self) + // now reset the static url to nil, as the new BrowserViewModel already has it + Self.urlForNewTab = nil + guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } + currentWindow.addTabbedWindow(newWindow, ordered: .above) + return nil + } +#endif + #if os(iOS) func webView(_ webView: WKWebView, contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, diff --git a/Views/BrowserTab.swift b/Views/BrowserTab.swift index 9a080449e..2ade71025 100644 --- a/Views/BrowserTab.swift +++ b/Views/BrowserTab.swift @@ -38,7 +38,7 @@ struct BrowserTab: View { .focusedSceneValue(\.browserViewModel, browser) .focusedSceneValue(\.canGoBack, browser.canGoBack) .focusedSceneValue(\.canGoForward, browser.canGoForward) - .modifier(ExternalLinkHandler()) + .modifier(ExternalLinkHandler(externalURL: $browser.externalURL)) .searchable(text: $search.searchText, placement: .toolbar) .modify { view in #if os(macOS) diff --git a/Views/ViewModifiers/ExternalLinkHandler.swift b/Views/ViewModifiers/ExternalLinkHandler.swift index a3a1d0cb4..e35e83fc9 100644 --- a/Views/ViewModifiers/ExternalLinkHandler.swift +++ b/Views/ViewModifiers/ExternalLinkHandler.swift @@ -14,8 +14,7 @@ struct ExternalLinkHandler: ViewModifier { @State private var isAlertPresented = false @State private var activeAlert: ActiveAlert? @State private var activeSheet: ActiveSheet? - - private let externalLink = NotificationCenter.default.publisher(for: .externalLink) + @Binding var externalURL: URL? enum ActiveAlert { case ask(url: URL) @@ -28,8 +27,8 @@ struct ExternalLinkHandler: ViewModifier { } func body(content: Content) -> some View { - content.onReceive(externalLink) { notification in - guard let url = notification.userInfo?["url"] as? URL else { return } + content.onChange(of: externalURL) { url in + guard let url else { return } switch Defaults[.externalLinkLoadingPolicy] { case .alwaysAsk: isAlertPresented = true From a5ac1b1af5f86b1c63ea78c7e9a926c1f8edfb4e Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 00:52:28 +0100 Subject: [PATCH 02/24] Create dedicated script handler and web delegates --- Kiwix.xcodeproj/project.pbxproj | 12 ++ ViewModel/BrowserNavDelegate.swift | 67 ++++++++ ViewModel/BrowserScriptHandler.swift | 85 ++++++++++ ViewModel/BrowserUIDelegate.swift | 90 ++++++++++ ViewModel/BrowserViewModel.swift | 238 +++------------------------ 5 files changed, 278 insertions(+), 214 deletions(-) create mode 100644 ViewModel/BrowserNavDelegate.swift create mode 100644 ViewModel/BrowserScriptHandler.swift create mode 100644 ViewModel/BrowserUIDelegate.swift diff --git a/Kiwix.xcodeproj/project.pbxproj b/Kiwix.xcodeproj/project.pbxproj index 922d260db..1c98f136e 100644 --- a/Kiwix.xcodeproj/project.pbxproj +++ b/Kiwix.xcodeproj/project.pbxproj @@ -98,6 +98,9 @@ 97E88F4D2AE407350037F0E5 /* CoreKiwix.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97E88F4C2AE407320037F0E5 /* CoreKiwix.xcframework */; }; 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F3332E28AFC1A2007FF53C /* SearchResults.swift */; }; 97FB4ECE28B4E221003FB524 /* SwiftUIBackports in Frameworks */ = {isa = PBXBuildFile; productRef = 97FB4ECD28B4E221003FB524 /* SwiftUIBackports */; }; + 983ED6DC2B02E89300409078 /* BrowserScriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */; }; + 983ED6DE2B02ED4000409078 /* BrowserNavDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */; }; + 983ED6E02B02EF1E00409078 /* BrowserUIDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -231,6 +234,9 @@ 97F6CC5020BD960F005CDBD2 /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = System/Library/Frameworks/MapKit.framework; sourceTree = SDKROOT; }; 97FB4B0A27B819A90055F86E /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; 97FD2F5E251EA07B0034927C /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; + 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserScriptHandler.swift; sourceTree = ""; }; + 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserNavDelegate.swift; sourceTree = ""; }; + 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserUIDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -317,6 +323,9 @@ 97176AD12A4FBD710093E3B0 /* BrowserViewModel.swift */, 97C13787284572AC00386C04 /* SearchViewModel.swift */, 97DE2BA1283A8E5C00C63D9B /* LibraryViewModel.swift */, + 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */, + 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */, + 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */, ); path = ViewModel; sourceTree = ""; @@ -789,6 +798,7 @@ 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */, 972727AE2A897FAA00BCAF75 /* GridSection.swift in Sources */, 9709C0982A8E4C5700E4564C /* Commands.swift in Sources */, + 983ED6E02B02EF1E00409078 /* BrowserUIDelegate.swift in Sources */, 972727BF2A8A52DC00BCAF75 /* AlertHandler.swift in Sources */, 972096E72AE421C300B378B0 /* Attribute.swift in Sources */, 97341C6E2852248500BC273E /* DownloadTaskCell.swift in Sources */, @@ -806,6 +816,7 @@ 976D90DB281584BF00CC7D29 /* FlavorTag.swift in Sources */, 972DE4BD2814A5BE004FD9B9 /* OPDSParser.mm in Sources */, 972DE4B52814A502004FD9B9 /* Entities.swift in Sources */, + 983ED6DC2B02E89300409078 /* BrowserScriptHandler.swift in Sources */, 9721BBBB28427A93005C910D /* Bookmarks.swift in Sources */, 974E7EE92930201500BDF59C /* ZimFileService.swift in Sources */, 973A0DFD283100C300B41E71 /* ZimFilesOpened.swift in Sources */, @@ -820,6 +831,7 @@ 972727B12A898B9700BCAF75 /* NavigationButtons.swift in Sources */, 976F5EC62A97909100938490 /* BrowserTab.swift in Sources */, 9724FC3028D5F5BE001B7DD2 /* BookmarkContextMenu.swift in Sources */, + 983ED6DE2B02ED4000409078 /* BrowserNavDelegate.swift in Sources */, 976BAEBE284905760049404F /* SearchViewModel.swift in Sources */, 973A0DE7281DC8F400B41E71 /* DownloadService.swift in Sources */, 9721BBB72841C16D005C910D /* Message.swift in Sources */, diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift new file mode 100644 index 000000000..70c323a21 --- /dev/null +++ b/ViewModel/BrowserNavDelegate.swift @@ -0,0 +1,67 @@ +// +// BrowserNavHandler.swift +// Kiwix +// +// Copyright © 2023 Chris Li. All rights reserved. +// + +import WebKit +import CoreLocation + +final class BrowserNavDelegate: NSObject, WKNavigationDelegate { + + @Published private(set) var externalURL: URL? + + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } + if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { + DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } + decisionHandler(.cancel) + } else if url.isKiwixURL { + decisionHandler(.allow) + } else if url.isExternal { + externalURL = url + decisionHandler(.cancel) + } else if url.scheme == "geo" { + if FeatureFlags.map { + let _: CLLocation? = { + let parts = url.absoluteString.replacingOccurrences(of: "geo:", with: "").split(separator: ",") + guard let latitudeString = parts.first, + let longitudeString = parts.last, + let latitude = Double(latitudeString), + let longitude = Double(longitudeString) else { return nil } + return CLLocation(latitude: latitude, longitude: longitude) + }() + } else { + let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") + if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { + #if os(macOS) + NSWorkspace.shared.open(url) + #elseif os(iOS) + UIApplication.shared.open(url) + #endif + } + } + decisionHandler(.cancel) + } else { + decisionHandler(.cancel) + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") + #if os(iOS) + webView.adjustTextSize() + #endif + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + let error = error as NSError + guard error.code != NSURLErrorCancelled else { return } + NotificationCenter.default.post( + name: .alert, object: nil, userInfo: ["rawValue": ActiveAlert.articleFailedToLoad.rawValue] + ) + } +} diff --git a/ViewModel/BrowserScriptHandler.swift b/ViewModel/BrowserScriptHandler.swift new file mode 100644 index 000000000..696abaf40 --- /dev/null +++ b/ViewModel/BrowserScriptHandler.swift @@ -0,0 +1,85 @@ +// +// BrowserScriptHandler.swift +// Kiwix +// +// Copyright © 2023 Chris Li. All rights reserved. +// + +import WebKit + +final class BrowserScriptHandler: NSObject, WKScriptMessageHandler { + @Published private(set) var outlineItems = [OutlineItem]() + @Published private(set) var outlineItemTree = [OutlineItem]() + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "headings", let headings = message.body as? [[String: String]] { + DispatchQueue.global(qos: .userInitiated).async { + self.generateOutlineList(headings: headings) + self.generateOutlineTree(headings: headings) + } + } + } + + /// Convert flattened heading element data to a list of OutlineItems. + /// - Parameter headings: list of heading element data retrieved from webview + private func generateOutlineList(headings: [[String: String]]) { + let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } + let offset = allLevels.filter({ $0 == 1 }).count == 1 ? 2 : allLevels.min() ?? 0 + let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in + guard let id = heading["id"], + let text = heading["text"], + let tag = heading["tag"], + let level = Int(tag.suffix(1)) else { return nil } + return OutlineItem(id: id, index: index, text: text, level: max(level - offset, 0)) + } + DispatchQueue.main.async { + self.outlineItems = outlineItems + } + } + + /// Convert flattened heading element data to a tree of OutlineItems. + /// - Parameter headings: list of heading element data retrieved from webview + private func generateOutlineTree(headings: [[String: String]]) { + let root = OutlineItem(index: -1, text: "", level: 0) + var stack: [OutlineItem] = [root] + var all = [String: OutlineItem]() + + headings.enumerated().forEach { index, heading in + guard let id = heading["id"], + let text = heading["text"], + let tag = heading["tag"], let level = Int(tag.suffix(1)) else { return } + let item = OutlineItem(id: id, index: index, text: text, level: level) + all[item.id] = item + + // get last item in stack + // if last item is child of item's sibling, unwind stack until a sibling is found + guard var lastItem = stack.last else { return } + while lastItem.level > item.level { + stack.removeLast() + lastItem = stack[stack.count - 1] + } + + // if item is last item's sibling, add item to parent and replace last item with itself in stack + // if item is last item's child, add item to parent and add item to stack + if lastItem.level == item.level { + stack[stack.count - 2].addChild(item) + stack[stack.count - 1] = item + } else if lastItem.level < item.level { + stack[stack.count - 1].addChild(item) + stack.append(item) + } + } + + // if there is only one h1, flatten one level + if let rootChildren = root.children, rootChildren.count == 1, let rootFirstChild = rootChildren.first { + let children = rootFirstChild.removeAllChildren() + DispatchQueue.main.async { + self.outlineItemTree = [rootFirstChild] + children + } + } else { + DispatchQueue.main.async { + self.outlineItemTree = root.children ?? [] + } + } + } +} diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift new file mode 100644 index 000000000..0953382a6 --- /dev/null +++ b/ViewModel/BrowserUIDelegate.swift @@ -0,0 +1,90 @@ +// +// BrowserUIDelegate.swift +// Kiwix +// +// Copyright © 2023 Chris Li. All rights reserved. +// + +import WebKit + +final class BrowserUIDelegate: NSObject, WKUIDelegate { + + @Published private(set) var externalURL: URL? + +#if os(macOS) + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + + guard navigationAction.targetFrame == nil else { return nil } + guard let newUrl = navigationAction.request.url else { return nil } + + // open external link in default browser + guard newUrl.isExternal == false else { + externalURL = newUrl + return nil + } + + // create new tab + guard let currentWindow = NSApp.keyWindow, + let windowController = currentWindow.windowController else { return nil } + // store the new url in a static way + BrowserViewModel.urlForNewTab = newUrl + // this creates a new BrowserViewModel + windowController.newWindowForTab(self) + // now reset the static url to nil, as the new BrowserViewModel already has it + BrowserViewModel.urlForNewTab = nil + guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } + currentWindow.addTabbedWindow(newWindow, ordered: .above) + return nil + } +#endif + +#if os(iOS) + func webView(_ webView: WKWebView, + contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, + completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { + guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } + let configuration = UIContextMenuConfiguration( + previewProvider: { + let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + webView.load(URLRequest(url: url)) + return WebViewController(webView: webView) + }, actionProvider: { suggestedActions in + var actions = [UIAction]() + + // open url + actions.append( + UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in + webView.load(URLRequest(url: url)) + } + ) + actions.append( + UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in + NotificationCenter.openURL(url, inNewTab: true) + } + ) + + // bookmark + let bookmarkAction: UIAction = { + let context = Database.viewContext + let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) + let request = Bookmark.fetchRequest(predicate: predicate) + if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { + return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in + self.deleteBookmark(url: url) + } + } else { + return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in + self.createBookmark(url: url) + } + } + }() + actions.append(bookmarkAction) + + return UIMenu(children: actions) + } + ) + completionHandler(configuration) + } +#endif +} diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 87efe7e6c..5aeb0b8a8 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -14,7 +14,6 @@ import WebKit import OrderedCollections final class BrowserViewModel: NSObject, ObservableObject, - WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, NSFetchedResultsControllerDelegate { static private var cache = OrderedDictionary() @@ -53,16 +52,34 @@ final class BrowserViewModel: NSObject, ObservableObject, private var canGoForwardObserver: NSKeyValueObservation? private var titleURLObserver: AnyCancellable? private var bookmarkFetchedResultsController: NSFetchedResultsController? + private let scriptHandler: BrowserScriptHandler + private let navDelegate: BrowserNavDelegate + private let uiDelegate: BrowserUIDelegate /// A temporary placeholder for the url that should be opened in a new tab, set on macOS only - private static var urlForNewTab: URL? + static var urlForNewTab: URL? + private var cancellables: Set = [] // MARK: - Lifecycle init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID self.webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + scriptHandler = BrowserScriptHandler() + navDelegate = BrowserNavDelegate() + uiDelegate = BrowserUIDelegate() super.init() - + + scriptHandler.$outlineItems.assign(to: \.outlineItems, on: self) + .store(in: &cancellables) + scriptHandler.$outlineItemTree.assign(to: \.outlineItemTree, on: self) + .store(in: &cancellables) + + navDelegate.$externalURL.assign(to: \.externalURL, on: self) + .store(in: &cancellables) + + uiDelegate.$externalURL.assign(to: \.externalURL, on: self) + .store(in: &cancellables) + // restore webview state, and set url before observer call back // note: optionality of url determines what to show in a tab, so it should be set before tab is on screen if let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { @@ -78,10 +95,10 @@ final class BrowserViewModel: NSObject, ObservableObject, webView.allowsBackForwardNavigationGestures = true webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work webView.configuration.userContentController.removeScriptMessageHandler(forName: "headings") - webView.configuration.userContentController.add(self, name: "headings") - webView.navigationDelegate = self - webView.uiDelegate = self - + webView.configuration.userContentController.add(scriptHandler, name: "headings") + webView.navigationDelegate = navDelegate + webView.uiDelegate = uiDelegate + // get outline items if something is already loaded if webView.url != nil { webView.evaluateJavaScript("getOutlineItems();") @@ -174,150 +191,6 @@ final class BrowserViewModel: NSObject, ObservableObject, load(url: url) } - // MARK: - WKNavigationDelegate - - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } - if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { - DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } - decisionHandler(.cancel) - } else if url.isKiwixURL { - decisionHandler(.allow) - } else if url.isExternal { - externalURL = url - decisionHandler(.cancel) - } else if url.scheme == "geo" { - if FeatureFlags.map { - let _: CLLocation? = { - let parts = url.absoluteString.replacingOccurrences(of: "geo:", with: "").split(separator: ",") - guard let latitudeString = parts.first, - let longitudeString = parts.last, - let latitude = Double(latitudeString), - let longitude = Double(longitudeString) else { return nil } - return CLLocation(latitude: latitude, longitude: longitude) - }() - } else { - let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") - if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { - #if os(macOS) - NSWorkspace.shared.open(url) - #elseif os(iOS) - UIApplication.shared.open(url) - #endif - } - } - decisionHandler(.cancel) - } else { - decisionHandler(.cancel) - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") - #if os(iOS) - webView.adjustTextSize() - #endif - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - let error = error as NSError - guard error.code != NSURLErrorCancelled else { return } - NotificationCenter.default.post( - name: .alert, object: nil, userInfo: ["rawValue": ActiveAlert.articleFailedToLoad.rawValue] - ) - } - - // MARK: - WKScriptMessageHandler - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "headings", let headings = message.body as? [[String: String]] { - DispatchQueue.global(qos: .userInitiated).async { - self.generateOutlineList(headings: headings) - self.generateOutlineTree(headings: headings) - } - } - } - - // MARK: - WKUIDelegate -#if os(macOS) - func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { - - guard navigationAction.targetFrame == nil else { return nil } - guard let newUrl = navigationAction.request.url else { return nil } - - // open external link in default browser - guard newUrl.isExternal == false else { - externalURL = newUrl - return nil - } - - // create new tab - guard let currentWindow = NSApp.keyWindow, - let windowController = currentWindow.windowController else { return nil } - // store the new url in a static way - Self.urlForNewTab = newUrl - // this creates a new BrowserViewModel - windowController.newWindowForTab(self) - // now reset the static url to nil, as the new BrowserViewModel already has it - Self.urlForNewTab = nil - guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } - currentWindow.addTabbedWindow(newWindow, ordered: .above) - return nil - } -#endif - - #if os(iOS) - func webView(_ webView: WKWebView, - contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, - completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { - guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } - let configuration = UIContextMenuConfiguration( - previewProvider: { - let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - webView.load(URLRequest(url: url)) - return WebViewController(webView: webView) - }, actionProvider: { suggestedActions in - var actions = [UIAction]() - - // open url - actions.append( - UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in - webView.load(URLRequest(url: url)) - } - ) - actions.append( - UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in - NotificationCenter.openURL(url, inNewTab: true) - } - ) - - // bookmark - let bookmarkAction: UIAction = { - let context = Database.viewContext - let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) - let request = Bookmark.fetchRequest(predicate: predicate) - if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { - return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in - self.deleteBookmark(url: url) - } - } else { - return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in - self.createBookmark(url: url) - } - } - }() - actions.append(bookmarkAction) - - return UIMenu(children: actions) - } - ) - completionHandler(configuration) - } - #endif - // MARK: - Bookmark func controller(_ controller: NSFetchedResultsController, @@ -362,67 +235,4 @@ final class BrowserViewModel: NSObject, ObservableObject, func scrollTo(outlineItemID: String) { webView.evaluateJavaScript("scrollToHeading('\(outlineItemID)')") } - - /// Convert flattened heading element data to a list of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineList(headings: [[String: String]]) { - let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } - let offset = allLevels.filter({ $0 == 1 }).count == 1 ? 2 : allLevels.min() ?? 0 - let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], - let level = Int(tag.suffix(1)) else { return nil } - return OutlineItem(id: id, index: index, text: text, level: max(level - offset, 0)) - } - DispatchQueue.main.async { - self.outlineItems = outlineItems - } - } - - /// Convert flattened heading element data to a tree of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineTree(headings: [[String: String]]) { - let root = OutlineItem(index: -1, text: "", level: 0) - var stack: [OutlineItem] = [root] - var all = [String: OutlineItem]() - - headings.enumerated().forEach { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], let level = Int(tag.suffix(1)) else { return } - let item = OutlineItem(id: id, index: index, text: text, level: level) - all[item.id] = item - - // get last item in stack - // if last item is child of item's sibling, unwind stack until a sibling is found - guard var lastItem = stack.last else { return } - while lastItem.level > item.level { - stack.removeLast() - lastItem = stack[stack.count - 1] - } - - // if item is last item's sibling, add item to parent and replace last item with itself in stack - // if item is last item's child, add item to parent and add item to stack - if lastItem.level == item.level { - stack[stack.count - 2].addChild(item) - stack[stack.count - 1] = item - } else if lastItem.level < item.level { - stack[stack.count - 1].addChild(item) - stack.append(item) - } - } - - // if there is only one h1, flatten one level - if let rootChildren = root.children, rootChildren.count == 1, let rootFirstChild = rootChildren.first { - let children = rootFirstChild.removeAllChildren() - DispatchQueue.main.async { - self.outlineItemTree = [rootFirstChild] + children - } - } else { - DispatchQueue.main.async { - self.outlineItemTree = root.children ?? [] - } - } - } } From fdac0fce0cc62eab8d697ba7e1f6f3ce94801291 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 09:52:07 +0100 Subject: [PATCH 03/24] Format --- ViewModel/BrowserUIDelegate.swift | 136 +++++++++++++++--------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index 0953382a6..1cfc6128d 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -8,83 +8,83 @@ import WebKit final class BrowserUIDelegate: NSObject, WKUIDelegate { - @Published private(set) var externalURL: URL? -#if os(macOS) - func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + #if os(macOS) + func webView(_: WKWebView, createWebViewWith _: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures _: WKWindowFeatures) -> WKWebView? + { + guard navigationAction.targetFrame == nil else { return nil } + guard let newUrl = navigationAction.request.url else { return nil } - guard navigationAction.targetFrame == nil else { return nil } - guard let newUrl = navigationAction.request.url else { return nil } + // open external link in default browser + guard newUrl.isExternal == false else { + externalURL = newUrl + return nil + } - // open external link in default browser - guard newUrl.isExternal == false else { - externalURL = newUrl + // create new tab + guard let currentWindow = NSApp.keyWindow, + let windowController = currentWindow.windowController else { return nil } + // store the new url in a static way + BrowserViewModel.urlForNewTab = newUrl + // this creates a new BrowserViewModel + windowController.newWindowForTab(self) + // now reset the static url to nil, as the new BrowserViewModel already has it + BrowserViewModel.urlForNewTab = nil + guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } + currentWindow.addTabbedWindow(newWindow, ordered: .above) return nil } + #endif - // create new tab - guard let currentWindow = NSApp.keyWindow, - let windowController = currentWindow.windowController else { return nil } - // store the new url in a static way - BrowserViewModel.urlForNewTab = newUrl - // this creates a new BrowserViewModel - windowController.newWindowForTab(self) - // now reset the static url to nil, as the new BrowserViewModel already has it - BrowserViewModel.urlForNewTab = nil - guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } - currentWindow.addTabbedWindow(newWindow, ordered: .above) - return nil - } -#endif - -#if os(iOS) - func webView(_ webView: WKWebView, - contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, - completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { - guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } - let configuration = UIContextMenuConfiguration( - previewProvider: { - let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - webView.load(URLRequest(url: url)) - return WebViewController(webView: webView) - }, actionProvider: { suggestedActions in - var actions = [UIAction]() - - // open url - actions.append( - UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in - webView.load(URLRequest(url: url)) - } - ) - actions.append( - UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in - NotificationCenter.openURL(url, inNewTab: true) - } - ) + #if os(iOS) + func webView(_ webView: WKWebView, + contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, + completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) + { + guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } + let configuration = UIContextMenuConfiguration( + previewProvider: { + let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + webView.load(URLRequest(url: url)) + return WebViewController(webView: webView) + }, actionProvider: { _ in + var actions = [UIAction]() - // bookmark - let bookmarkAction: UIAction = { - let context = Database.viewContext - let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) - let request = Bookmark.fetchRequest(predicate: predicate) - if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { - return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in - self.deleteBookmark(url: url) + // open url + actions.append( + UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in + webView.load(URLRequest(url: url)) } - } else { - return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in - self.createBookmark(url: url) + ) + actions.append( + UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in + NotificationCenter.openURL(url, inNewTab: true) } - } - }() - actions.append(bookmarkAction) + ) - return UIMenu(children: actions) - } - ) - completionHandler(configuration) - } -#endif + // bookmark + let bookmarkAction: UIAction = { + let context = Database.viewContext + let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) + let request = Bookmark.fetchRequest(predicate: predicate) + if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { + return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in + self.deleteBookmark(url: url) + } + } else { + return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in + self.createBookmark(url: url) + } + } + }() + actions.append(bookmarkAction) + + return UIMenu(children: actions) + } + ) + completionHandler(configuration) + } + #endif } From 6df6e9ba9ea118103745f40cae3cb3c58304fb65 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 09:54:57 +0100 Subject: [PATCH 04/24] Format --- ViewModel/BrowserNavDelegate.swift | 16 ++++---- ViewModel/BrowserScriptHandler.swift | 4 +- ViewModel/BrowserViewModel.swift | 60 ++++++++++++++-------------- 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift index 70c323a21..527948cfc 100644 --- a/ViewModel/BrowserNavDelegate.swift +++ b/ViewModel/BrowserNavDelegate.swift @@ -5,16 +5,16 @@ // Copyright © 2023 Chris Li. All rights reserved. // -import WebKit import CoreLocation +import WebKit final class BrowserNavDelegate: NSObject, WKNavigationDelegate { - @Published private(set) var externalURL: URL? func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) + { guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } @@ -38,9 +38,9 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { #if os(macOS) - NSWorkspace.shared.open(url) + NSWorkspace.shared.open(url) #elseif os(iOS) - UIApplication.shared.open(url) + UIApplication.shared.open(url) #endif } } @@ -50,14 +50,14 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { } } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") #if os(iOS) - webView.adjustTextSize() + webView.adjustTextSize() #endif } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) { let error = error as NSError guard error.code != NSURLErrorCancelled else { return } NotificationCenter.default.post( diff --git a/ViewModel/BrowserScriptHandler.swift b/ViewModel/BrowserScriptHandler.swift index 696abaf40..1a2230a5e 100644 --- a/ViewModel/BrowserScriptHandler.swift +++ b/ViewModel/BrowserScriptHandler.swift @@ -11,7 +11,7 @@ final class BrowserScriptHandler: NSObject, WKScriptMessageHandler { @Published private(set) var outlineItems = [OutlineItem]() @Published private(set) var outlineItemTree = [OutlineItem]() - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "headings", let headings = message.body as? [[String: String]] { DispatchQueue.global(qos: .userInitiated).async { self.generateOutlineList(headings: headings) @@ -24,7 +24,7 @@ final class BrowserScriptHandler: NSObject, WKScriptMessageHandler { /// - Parameter headings: list of heading element data retrieved from webview private func generateOutlineList(headings: [[String: String]]) { let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } - let offset = allLevels.filter({ $0 == 1 }).count == 1 ? 2 : allLevels.min() ?? 0 + let offset = allLevels.filter { $0 == 1 }.count == 1 ? 2 : allLevels.min() ?? 0 let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in guard let id = heading["id"], let text = heading["text"], diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 5aeb0b8a8..a1aae6c7b 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -14,17 +14,17 @@ import WebKit import OrderedCollections final class BrowserViewModel: NSObject, ObservableObject, - NSFetchedResultsControllerDelegate + NSFetchedResultsControllerDelegate { - static private var cache = OrderedDictionary() - + private static var cache = OrderedDictionary() + static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel { let viewModel = cache[tabID] ?? BrowserViewModel(tabID: tabID) cache.removeValue(forKey: tabID) cache[tabID] = viewModel return viewModel } - + static func purgeCache() { guard cache.count > 10 else { return } let range = 0 ..< cache.count - 5 @@ -33,9 +33,9 @@ final class BrowserViewModel: NSObject, ObservableObject, } cache.removeSubrange(range) } - + // MARK: - Properties - + @Published private(set) var canGoBack = false @Published private(set) var canGoForward = false @Published private(set) var articleTitle: String = "" @@ -60,10 +60,10 @@ final class BrowserViewModel: NSObject, ObservableObject, private var cancellables: Set = [] // MARK: - Lifecycle - + init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID - self.webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) scriptHandler = BrowserScriptHandler() navDelegate = BrowserNavDelegate() uiDelegate = BrowserUIDelegate() @@ -93,7 +93,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // configure web view webView.allowsBackForwardNavigationGestures = true - webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work + webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work webView.configuration.userContentController.removeScriptMessageHandler(forName: "headings") webView.configuration.userContentController.add(scriptHandler, name: "headings") webView.navigationDelegate = navDelegate @@ -103,7 +103,7 @@ final class BrowserViewModel: NSObject, ObservableObject, if webView.url != nil { webView.evaluateJavaScript("getOutlineItems();") } - + // setup web view property observers canGoBackObserver = webView.observe(\.canGoBack, options: .initial) { [weak self] webView, _ in self?.canGoBack = webView.canGoBack @@ -129,24 +129,25 @@ final class BrowserViewModel: NSObject, ObservableObject, guard let url, let zimFileID = UUID(uuidString: url.host ?? "") else { return nil } return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first }() - + // update view model self?.articleTitle = title ?? "" self?.zimFileName = zimFile?.name ?? "" self?.url = url - + // update tab data if let tabID = self?.tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab, - let title { + let title + { tab.title = title tab.zimFile = zimFile } - + // setup bookmark fetched results controller self?.bookmarkFetchedResultsController = NSFetchedResultsController( fetchRequest: Bookmark.fetchRequest(predicate: { - if let url = url { + if let url { return NSPredicate(format: "articleURL == %@", url as CVarArg) } else { return NSPredicate(format: "articleURL == nil") @@ -160,44 +161,45 @@ final class BrowserViewModel: NSObject, ObservableObject, try? self?.bookmarkFetchedResultsController?.performFetch() } } - + func updateLastOpened() { guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } tab.lastOpened = Date() } - + func persistState() { guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } tab.interactionState = webView.interactionState as? Data try? Database.viewContext.save() } - + // MARK: - Content Loading - + func load(url: URL) { guard webView.url != url else { return } webView.load(URLRequest(url: url)) } - + func loadRandomArticle(zimFileID: UUID? = nil) { let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return } load(url: url) } - + func loadMainArticle(zimFileID: UUID? = nil) { let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return } load(url: url) } - + // MARK: - Bookmark - - func controller(_ controller: NSFetchedResultsController, - didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + + func controller(_: NSFetchedResultsController, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) + { articleBookmarked = !snapshot.itemIdentifiers.isEmpty } - + func createBookmark(url: URL? = nil) { guard let url = url ?? webView.url else { return } Database.performBackgroundTask { context in @@ -217,7 +219,7 @@ final class BrowserViewModel: NSObject, ObservableObject, try? context.save() } } - + func deleteBookmark(url: URL? = nil) { guard let url = url ?? webView.url else { return } Database.performBackgroundTask { context in @@ -227,9 +229,9 @@ final class BrowserViewModel: NSObject, ObservableObject, try? context.save() } } - + // MARK: - Outline - + /// Scroll to an outline item /// - Parameter outlineItemID: ID of the outline item to scroll to func scrollTo(outlineItemID: String) { From 9063505a8fe6b553cbe4b42c262c79d8805726d3 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 10:37:17 +0100 Subject: [PATCH 05/24] Fix format --- ViewModel/BrowserUIDelegate.swift | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index 1cfc6128d..4c5209563 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -11,9 +11,12 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { @Published private(set) var externalURL: URL? #if os(macOS) - func webView(_: WKWebView, createWebViewWith _: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, windowFeatures _: WKWindowFeatures) -> WKWebView? - { + func webView( + _: WKWebView, + createWebViewWith _: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures _: WKWindowFeatures + ) -> WKWebView? { guard navigationAction.targetFrame == nil else { return nil } guard let newUrl = navigationAction.request.url else { return nil } @@ -39,10 +42,11 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { #endif #if os(iOS) - func webView(_ webView: WKWebView, - contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, - completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) - { + func webView( + _ webView: WKWebView, + contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, + completionHandler: @escaping (UIContextMenuConfiguration?) -> Void + ) { guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } let configuration = UIContextMenuConfiguration( previewProvider: { @@ -70,7 +74,9 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) let request = Bookmark.fetchRequest(predicate: predicate) if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { - return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in + return UIAction(title: "Remove Bookmark", + image: UIImage(systemName: "star.slash.fill")) + { _ in self.deleteBookmark(url: url) } } else { From 4f090a7b88574f557fbc78cba7ffc84fde632979 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 10:54:33 +0100 Subject: [PATCH 06/24] Format --- ViewModel/BrowserNavDelegate.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift index 527948cfc..a1740ee15 100644 --- a/ViewModel/BrowserNavDelegate.swift +++ b/ViewModel/BrowserNavDelegate.swift @@ -11,10 +11,11 @@ import WebKit final class BrowserNavDelegate: NSObject, WKNavigationDelegate { @Published private(set) var externalURL: URL? - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) - { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } @@ -57,7 +58,11 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { #endif } - func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) { + func webView( + _: WKWebView, + didFailProvisionalNavigation _: WKNavigation!, + withError error: Error + ) { let error = error as NSError guard error.code != NSURLErrorCancelled else { return } NotificationCenter.default.post( From 7ef2f0e8de7265592e5fecf63acf736d5ef9164d Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 11:02:35 +0100 Subject: [PATCH 07/24] Reformat --- ViewModel/BrowserUIDelegate.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index 4c5209563..f1c5cb90b 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -75,8 +75,7 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { let request = Bookmark.fetchRequest(predicate: predicate) if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { return UIAction(title: "Remove Bookmark", - image: UIImage(systemName: "star.slash.fill")) - { _ in + image: UIImage(systemName: "star.slash.fill")) { _ in self.deleteBookmark(url: url) } } else { From a4c930c034c30187585024fa8ff404dfb0d1dbcc Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 11:05:20 +0100 Subject: [PATCH 08/24] Reformat ViewModel --- ViewModel/BrowserViewModel.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index a1aae6c7b..8a8a9c7bf 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -138,8 +138,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // update tab data if let tabID = self?.tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab, - let title - { + let title { tab.title = title tab.zimFile = zimFile } @@ -195,8 +194,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // MARK: - Bookmark func controller(_: NSFetchedResultsController, - didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) - { + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { articleBookmarked = !snapshot.itemIdentifiers.isEmpty } From fa16cfa28fe8245ab4da468e3a4e8f5b4ba774c0 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 13 Nov 2023 21:34:05 +0100 Subject: [PATCH 09/24] Fix opening new tabs on macos(#518) --- App/CompactViewController.swift | 2 +- Model/Utilities/URL.swift | 6 ++- ViewModel/BrowserViewModel.swift | 52 +++++++++++++++---- Views/BrowserTab.swift | 2 +- Views/ViewModifiers/ExternalLinkHandler.swift | 7 ++- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/App/CompactViewController.swift b/App/CompactViewController.swift index 9cb0a84e8..a13487265 100644 --- a/App/CompactViewController.swift +++ b/App/CompactViewController.swift @@ -130,7 +130,7 @@ private struct Content: View { .focusedSceneValue(\.browserViewModel, browser) .focusedSceneValue(\.canGoBack, browser.canGoBack) .focusedSceneValue(\.canGoForward, browser.canGoForward) - .modifier(ExternalLinkHandler()) + .modifier(ExternalLinkHandler(externalURL: browser.externalURL)) .onAppear { browser.updateLastOpened() } diff --git a/Model/Utilities/URL.swift b/Model/Utilities/URL.swift index a8a468f11..fed817edc 100644 --- a/Model/Utilities/URL.swift +++ b/Model/Utilities/URL.swift @@ -16,6 +16,10 @@ extension URL { var isKiwixURL: Bool { return scheme?.caseInsensitiveCompare("kiwix") == .orderedSame } - + + var isExternal: Bool { + ["http", "https"].contains(scheme) + } + static let documentDirectory = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) } diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 2354f6b4d..87efe7e6c 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -13,9 +13,9 @@ import WebKit import OrderedCollections -class BrowserViewModel: NSObject, ObservableObject, - WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, - NSFetchedResultsControllerDelegate +final class BrowserViewModel: NSObject, ObservableObject, + WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, + NSFetchedResultsControllerDelegate { static private var cache = OrderedDictionary() @@ -45,14 +45,17 @@ class BrowserViewModel: NSObject, ObservableObject, @Published private(set) var outlineItems = [OutlineItem]() @Published private(set) var outlineItemTree = [OutlineItem]() @Published private(set) var url: URL? - + @Published var externalURL: URL? + let tabID: NSManagedObjectID? let webView: WKWebView private var canGoBackObserver: NSKeyValueObservation? private var canGoForwardObserver: NSKeyValueObservation? private var titleURLObserver: AnyCancellable? private var bookmarkFetchedResultsController: NSFetchedResultsController? - + /// A temporary placeholder for the url that should be opened in a new tab, set on macOS only + private static var urlForNewTab: URL? + // MARK: - Lifecycle init(tabID: NSManagedObjectID? = nil) { @@ -66,7 +69,11 @@ class BrowserViewModel: NSObject, ObservableObject, webView.interactionState = tab.interactionState url = webView.url } - + if let urlForNewTab = Self.urlForNewTab { + url = urlForNewTab + load(url: urlForNewTab) + } + // configure web view webView.allowsBackForwardNavigationGestures = true webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work @@ -178,8 +185,8 @@ class BrowserViewModel: NSObject, ObservableObject, decisionHandler(.cancel) } else if url.isKiwixURL { decisionHandler(.allow) - } else if url.scheme == "http" || url.scheme == "https" { - NotificationCenter.default.post(name: .externalLink, object: nil, userInfo: ["url": url]) + } else if url.isExternal { + externalURL = url decisionHandler(.cancel) } else if url.scheme == "geo" { if FeatureFlags.map { @@ -234,7 +241,34 @@ class BrowserViewModel: NSObject, ObservableObject, } // MARK: - WKUIDelegate - +#if os(macOS) + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + + guard navigationAction.targetFrame == nil else { return nil } + guard let newUrl = navigationAction.request.url else { return nil } + + // open external link in default browser + guard newUrl.isExternal == false else { + externalURL = newUrl + return nil + } + + // create new tab + guard let currentWindow = NSApp.keyWindow, + let windowController = currentWindow.windowController else { return nil } + // store the new url in a static way + Self.urlForNewTab = newUrl + // this creates a new BrowserViewModel + windowController.newWindowForTab(self) + // now reset the static url to nil, as the new BrowserViewModel already has it + Self.urlForNewTab = nil + guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } + currentWindow.addTabbedWindow(newWindow, ordered: .above) + return nil + } +#endif + #if os(iOS) func webView(_ webView: WKWebView, contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, diff --git a/Views/BrowserTab.swift b/Views/BrowserTab.swift index 9a080449e..2ade71025 100644 --- a/Views/BrowserTab.swift +++ b/Views/BrowserTab.swift @@ -38,7 +38,7 @@ struct BrowserTab: View { .focusedSceneValue(\.browserViewModel, browser) .focusedSceneValue(\.canGoBack, browser.canGoBack) .focusedSceneValue(\.canGoForward, browser.canGoForward) - .modifier(ExternalLinkHandler()) + .modifier(ExternalLinkHandler(externalURL: $browser.externalURL)) .searchable(text: $search.searchText, placement: .toolbar) .modify { view in #if os(macOS) diff --git a/Views/ViewModifiers/ExternalLinkHandler.swift b/Views/ViewModifiers/ExternalLinkHandler.swift index a3a1d0cb4..e35e83fc9 100644 --- a/Views/ViewModifiers/ExternalLinkHandler.swift +++ b/Views/ViewModifiers/ExternalLinkHandler.swift @@ -14,8 +14,7 @@ struct ExternalLinkHandler: ViewModifier { @State private var isAlertPresented = false @State private var activeAlert: ActiveAlert? @State private var activeSheet: ActiveSheet? - - private let externalLink = NotificationCenter.default.publisher(for: .externalLink) + @Binding var externalURL: URL? enum ActiveAlert { case ask(url: URL) @@ -28,8 +27,8 @@ struct ExternalLinkHandler: ViewModifier { } func body(content: Content) -> some View { - content.onReceive(externalLink) { notification in - guard let url = notification.userInfo?["url"] as? URL else { return } + content.onChange(of: externalURL) { url in + guard let url else { return } switch Defaults[.externalLinkLoadingPolicy] { case .alwaysAsk: isAlertPresented = true From 8f3cd6f371465cc632c0679a190824b2aad53e25 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 00:52:28 +0100 Subject: [PATCH 10/24] Create dedicated script handler and web delegates --- Kiwix.xcodeproj/project.pbxproj | 12 ++ ViewModel/BrowserNavDelegate.swift | 67 ++++++++ ViewModel/BrowserScriptHandler.swift | 85 ++++++++++ ViewModel/BrowserUIDelegate.swift | 90 ++++++++++ ViewModel/BrowserViewModel.swift | 238 +++------------------------ 5 files changed, 278 insertions(+), 214 deletions(-) create mode 100644 ViewModel/BrowserNavDelegate.swift create mode 100644 ViewModel/BrowserScriptHandler.swift create mode 100644 ViewModel/BrowserUIDelegate.swift diff --git a/Kiwix.xcodeproj/project.pbxproj b/Kiwix.xcodeproj/project.pbxproj index 2e2bc8ae8..004dc2e55 100644 --- a/Kiwix.xcodeproj/project.pbxproj +++ b/Kiwix.xcodeproj/project.pbxproj @@ -98,6 +98,9 @@ 97E88F4D2AE407350037F0E5 /* CoreKiwix.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97E88F4C2AE407320037F0E5 /* CoreKiwix.xcframework */; }; 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F3332E28AFC1A2007FF53C /* SearchResults.swift */; }; 97FB4ECE28B4E221003FB524 /* SwiftUIBackports in Frameworks */ = {isa = PBXBuildFile; productRef = 97FB4ECD28B4E221003FB524 /* SwiftUIBackports */; }; + 983ED6DC2B02E89300409078 /* BrowserScriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */; }; + 983ED6DE2B02ED4000409078 /* BrowserNavDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */; }; + 983ED6E02B02EF1E00409078 /* BrowserUIDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -231,6 +234,9 @@ 97F6CC5020BD960F005CDBD2 /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = System/Library/Frameworks/MapKit.framework; sourceTree = SDKROOT; }; 97FB4B0A27B819A90055F86E /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; 97FD2F5E251EA07B0034927C /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; + 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserScriptHandler.swift; sourceTree = ""; }; + 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserNavDelegate.swift; sourceTree = ""; }; + 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserUIDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -317,6 +323,9 @@ 97176AD12A4FBD710093E3B0 /* BrowserViewModel.swift */, 97C13787284572AC00386C04 /* SearchViewModel.swift */, 97DE2BA1283A8E5C00C63D9B /* LibraryViewModel.swift */, + 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */, + 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */, + 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */, ); path = ViewModel; sourceTree = ""; @@ -789,6 +798,7 @@ 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */, 972727AE2A897FAA00BCAF75 /* GridSection.swift in Sources */, 9709C0982A8E4C5700E4564C /* Commands.swift in Sources */, + 983ED6E02B02EF1E00409078 /* BrowserUIDelegate.swift in Sources */, 972727BF2A8A52DC00BCAF75 /* AlertHandler.swift in Sources */, 972096E72AE421C300B378B0 /* Attribute.swift in Sources */, 97341C6E2852248500BC273E /* DownloadTaskCell.swift in Sources */, @@ -806,6 +816,7 @@ 976D90DB281584BF00CC7D29 /* FlavorTag.swift in Sources */, 972DE4BD2814A5BE004FD9B9 /* OPDSParser.mm in Sources */, 972DE4B52814A502004FD9B9 /* Entities.swift in Sources */, + 983ED6DC2B02E89300409078 /* BrowserScriptHandler.swift in Sources */, 9721BBBB28427A93005C910D /* Bookmarks.swift in Sources */, 974E7EE92930201500BDF59C /* ZimFileService.swift in Sources */, 973A0DFD283100C300B41E71 /* ZimFilesOpened.swift in Sources */, @@ -820,6 +831,7 @@ 972727B12A898B9700BCAF75 /* NavigationButtons.swift in Sources */, 976F5EC62A97909100938490 /* BrowserTab.swift in Sources */, 9724FC3028D5F5BE001B7DD2 /* BookmarkContextMenu.swift in Sources */, + 983ED6DE2B02ED4000409078 /* BrowserNavDelegate.swift in Sources */, 976BAEBE284905760049404F /* SearchViewModel.swift in Sources */, 973A0DE7281DC8F400B41E71 /* DownloadService.swift in Sources */, 9721BBB72841C16D005C910D /* Message.swift in Sources */, diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift new file mode 100644 index 000000000..70c323a21 --- /dev/null +++ b/ViewModel/BrowserNavDelegate.swift @@ -0,0 +1,67 @@ +// +// BrowserNavHandler.swift +// Kiwix +// +// Copyright © 2023 Chris Li. All rights reserved. +// + +import WebKit +import CoreLocation + +final class BrowserNavDelegate: NSObject, WKNavigationDelegate { + + @Published private(set) var externalURL: URL? + + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } + if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { + DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } + decisionHandler(.cancel) + } else if url.isKiwixURL { + decisionHandler(.allow) + } else if url.isExternal { + externalURL = url + decisionHandler(.cancel) + } else if url.scheme == "geo" { + if FeatureFlags.map { + let _: CLLocation? = { + let parts = url.absoluteString.replacingOccurrences(of: "geo:", with: "").split(separator: ",") + guard let latitudeString = parts.first, + let longitudeString = parts.last, + let latitude = Double(latitudeString), + let longitude = Double(longitudeString) else { return nil } + return CLLocation(latitude: latitude, longitude: longitude) + }() + } else { + let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") + if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { + #if os(macOS) + NSWorkspace.shared.open(url) + #elseif os(iOS) + UIApplication.shared.open(url) + #endif + } + } + decisionHandler(.cancel) + } else { + decisionHandler(.cancel) + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") + #if os(iOS) + webView.adjustTextSize() + #endif + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + let error = error as NSError + guard error.code != NSURLErrorCancelled else { return } + NotificationCenter.default.post( + name: .alert, object: nil, userInfo: ["rawValue": ActiveAlert.articleFailedToLoad.rawValue] + ) + } +} diff --git a/ViewModel/BrowserScriptHandler.swift b/ViewModel/BrowserScriptHandler.swift new file mode 100644 index 000000000..696abaf40 --- /dev/null +++ b/ViewModel/BrowserScriptHandler.swift @@ -0,0 +1,85 @@ +// +// BrowserScriptHandler.swift +// Kiwix +// +// Copyright © 2023 Chris Li. All rights reserved. +// + +import WebKit + +final class BrowserScriptHandler: NSObject, WKScriptMessageHandler { + @Published private(set) var outlineItems = [OutlineItem]() + @Published private(set) var outlineItemTree = [OutlineItem]() + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "headings", let headings = message.body as? [[String: String]] { + DispatchQueue.global(qos: .userInitiated).async { + self.generateOutlineList(headings: headings) + self.generateOutlineTree(headings: headings) + } + } + } + + /// Convert flattened heading element data to a list of OutlineItems. + /// - Parameter headings: list of heading element data retrieved from webview + private func generateOutlineList(headings: [[String: String]]) { + let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } + let offset = allLevels.filter({ $0 == 1 }).count == 1 ? 2 : allLevels.min() ?? 0 + let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in + guard let id = heading["id"], + let text = heading["text"], + let tag = heading["tag"], + let level = Int(tag.suffix(1)) else { return nil } + return OutlineItem(id: id, index: index, text: text, level: max(level - offset, 0)) + } + DispatchQueue.main.async { + self.outlineItems = outlineItems + } + } + + /// Convert flattened heading element data to a tree of OutlineItems. + /// - Parameter headings: list of heading element data retrieved from webview + private func generateOutlineTree(headings: [[String: String]]) { + let root = OutlineItem(index: -1, text: "", level: 0) + var stack: [OutlineItem] = [root] + var all = [String: OutlineItem]() + + headings.enumerated().forEach { index, heading in + guard let id = heading["id"], + let text = heading["text"], + let tag = heading["tag"], let level = Int(tag.suffix(1)) else { return } + let item = OutlineItem(id: id, index: index, text: text, level: level) + all[item.id] = item + + // get last item in stack + // if last item is child of item's sibling, unwind stack until a sibling is found + guard var lastItem = stack.last else { return } + while lastItem.level > item.level { + stack.removeLast() + lastItem = stack[stack.count - 1] + } + + // if item is last item's sibling, add item to parent and replace last item with itself in stack + // if item is last item's child, add item to parent and add item to stack + if lastItem.level == item.level { + stack[stack.count - 2].addChild(item) + stack[stack.count - 1] = item + } else if lastItem.level < item.level { + stack[stack.count - 1].addChild(item) + stack.append(item) + } + } + + // if there is only one h1, flatten one level + if let rootChildren = root.children, rootChildren.count == 1, let rootFirstChild = rootChildren.first { + let children = rootFirstChild.removeAllChildren() + DispatchQueue.main.async { + self.outlineItemTree = [rootFirstChild] + children + } + } else { + DispatchQueue.main.async { + self.outlineItemTree = root.children ?? [] + } + } + } +} diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift new file mode 100644 index 000000000..0953382a6 --- /dev/null +++ b/ViewModel/BrowserUIDelegate.swift @@ -0,0 +1,90 @@ +// +// BrowserUIDelegate.swift +// Kiwix +// +// Copyright © 2023 Chris Li. All rights reserved. +// + +import WebKit + +final class BrowserUIDelegate: NSObject, WKUIDelegate { + + @Published private(set) var externalURL: URL? + +#if os(macOS) + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + + guard navigationAction.targetFrame == nil else { return nil } + guard let newUrl = navigationAction.request.url else { return nil } + + // open external link in default browser + guard newUrl.isExternal == false else { + externalURL = newUrl + return nil + } + + // create new tab + guard let currentWindow = NSApp.keyWindow, + let windowController = currentWindow.windowController else { return nil } + // store the new url in a static way + BrowserViewModel.urlForNewTab = newUrl + // this creates a new BrowserViewModel + windowController.newWindowForTab(self) + // now reset the static url to nil, as the new BrowserViewModel already has it + BrowserViewModel.urlForNewTab = nil + guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } + currentWindow.addTabbedWindow(newWindow, ordered: .above) + return nil + } +#endif + +#if os(iOS) + func webView(_ webView: WKWebView, + contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, + completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { + guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } + let configuration = UIContextMenuConfiguration( + previewProvider: { + let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + webView.load(URLRequest(url: url)) + return WebViewController(webView: webView) + }, actionProvider: { suggestedActions in + var actions = [UIAction]() + + // open url + actions.append( + UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in + webView.load(URLRequest(url: url)) + } + ) + actions.append( + UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in + NotificationCenter.openURL(url, inNewTab: true) + } + ) + + // bookmark + let bookmarkAction: UIAction = { + let context = Database.viewContext + let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) + let request = Bookmark.fetchRequest(predicate: predicate) + if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { + return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in + self.deleteBookmark(url: url) + } + } else { + return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in + self.createBookmark(url: url) + } + } + }() + actions.append(bookmarkAction) + + return UIMenu(children: actions) + } + ) + completionHandler(configuration) + } +#endif +} diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 87efe7e6c..5aeb0b8a8 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -14,7 +14,6 @@ import WebKit import OrderedCollections final class BrowserViewModel: NSObject, ObservableObject, - WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, NSFetchedResultsControllerDelegate { static private var cache = OrderedDictionary() @@ -53,16 +52,34 @@ final class BrowserViewModel: NSObject, ObservableObject, private var canGoForwardObserver: NSKeyValueObservation? private var titleURLObserver: AnyCancellable? private var bookmarkFetchedResultsController: NSFetchedResultsController? + private let scriptHandler: BrowserScriptHandler + private let navDelegate: BrowserNavDelegate + private let uiDelegate: BrowserUIDelegate /// A temporary placeholder for the url that should be opened in a new tab, set on macOS only - private static var urlForNewTab: URL? + static var urlForNewTab: URL? + private var cancellables: Set = [] // MARK: - Lifecycle init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID self.webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + scriptHandler = BrowserScriptHandler() + navDelegate = BrowserNavDelegate() + uiDelegate = BrowserUIDelegate() super.init() - + + scriptHandler.$outlineItems.assign(to: \.outlineItems, on: self) + .store(in: &cancellables) + scriptHandler.$outlineItemTree.assign(to: \.outlineItemTree, on: self) + .store(in: &cancellables) + + navDelegate.$externalURL.assign(to: \.externalURL, on: self) + .store(in: &cancellables) + + uiDelegate.$externalURL.assign(to: \.externalURL, on: self) + .store(in: &cancellables) + // restore webview state, and set url before observer call back // note: optionality of url determines what to show in a tab, so it should be set before tab is on screen if let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { @@ -78,10 +95,10 @@ final class BrowserViewModel: NSObject, ObservableObject, webView.allowsBackForwardNavigationGestures = true webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work webView.configuration.userContentController.removeScriptMessageHandler(forName: "headings") - webView.configuration.userContentController.add(self, name: "headings") - webView.navigationDelegate = self - webView.uiDelegate = self - + webView.configuration.userContentController.add(scriptHandler, name: "headings") + webView.navigationDelegate = navDelegate + webView.uiDelegate = uiDelegate + // get outline items if something is already loaded if webView.url != nil { webView.evaluateJavaScript("getOutlineItems();") @@ -174,150 +191,6 @@ final class BrowserViewModel: NSObject, ObservableObject, load(url: url) } - // MARK: - WKNavigationDelegate - - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } - if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { - DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } - decisionHandler(.cancel) - } else if url.isKiwixURL { - decisionHandler(.allow) - } else if url.isExternal { - externalURL = url - decisionHandler(.cancel) - } else if url.scheme == "geo" { - if FeatureFlags.map { - let _: CLLocation? = { - let parts = url.absoluteString.replacingOccurrences(of: "geo:", with: "").split(separator: ",") - guard let latitudeString = parts.first, - let longitudeString = parts.last, - let latitude = Double(latitudeString), - let longitude = Double(longitudeString) else { return nil } - return CLLocation(latitude: latitude, longitude: longitude) - }() - } else { - let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") - if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { - #if os(macOS) - NSWorkspace.shared.open(url) - #elseif os(iOS) - UIApplication.shared.open(url) - #endif - } - } - decisionHandler(.cancel) - } else { - decisionHandler(.cancel) - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") - #if os(iOS) - webView.adjustTextSize() - #endif - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - let error = error as NSError - guard error.code != NSURLErrorCancelled else { return } - NotificationCenter.default.post( - name: .alert, object: nil, userInfo: ["rawValue": ActiveAlert.articleFailedToLoad.rawValue] - ) - } - - // MARK: - WKScriptMessageHandler - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "headings", let headings = message.body as? [[String: String]] { - DispatchQueue.global(qos: .userInitiated).async { - self.generateOutlineList(headings: headings) - self.generateOutlineTree(headings: headings) - } - } - } - - // MARK: - WKUIDelegate -#if os(macOS) - func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { - - guard navigationAction.targetFrame == nil else { return nil } - guard let newUrl = navigationAction.request.url else { return nil } - - // open external link in default browser - guard newUrl.isExternal == false else { - externalURL = newUrl - return nil - } - - // create new tab - guard let currentWindow = NSApp.keyWindow, - let windowController = currentWindow.windowController else { return nil } - // store the new url in a static way - Self.urlForNewTab = newUrl - // this creates a new BrowserViewModel - windowController.newWindowForTab(self) - // now reset the static url to nil, as the new BrowserViewModel already has it - Self.urlForNewTab = nil - guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } - currentWindow.addTabbedWindow(newWindow, ordered: .above) - return nil - } -#endif - - #if os(iOS) - func webView(_ webView: WKWebView, - contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, - completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { - guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } - let configuration = UIContextMenuConfiguration( - previewProvider: { - let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - webView.load(URLRequest(url: url)) - return WebViewController(webView: webView) - }, actionProvider: { suggestedActions in - var actions = [UIAction]() - - // open url - actions.append( - UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in - webView.load(URLRequest(url: url)) - } - ) - actions.append( - UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in - NotificationCenter.openURL(url, inNewTab: true) - } - ) - - // bookmark - let bookmarkAction: UIAction = { - let context = Database.viewContext - let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) - let request = Bookmark.fetchRequest(predicate: predicate) - if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { - return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in - self.deleteBookmark(url: url) - } - } else { - return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in - self.createBookmark(url: url) - } - } - }() - actions.append(bookmarkAction) - - return UIMenu(children: actions) - } - ) - completionHandler(configuration) - } - #endif - // MARK: - Bookmark func controller(_ controller: NSFetchedResultsController, @@ -362,67 +235,4 @@ final class BrowserViewModel: NSObject, ObservableObject, func scrollTo(outlineItemID: String) { webView.evaluateJavaScript("scrollToHeading('\(outlineItemID)')") } - - /// Convert flattened heading element data to a list of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineList(headings: [[String: String]]) { - let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } - let offset = allLevels.filter({ $0 == 1 }).count == 1 ? 2 : allLevels.min() ?? 0 - let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], - let level = Int(tag.suffix(1)) else { return nil } - return OutlineItem(id: id, index: index, text: text, level: max(level - offset, 0)) - } - DispatchQueue.main.async { - self.outlineItems = outlineItems - } - } - - /// Convert flattened heading element data to a tree of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineTree(headings: [[String: String]]) { - let root = OutlineItem(index: -1, text: "", level: 0) - var stack: [OutlineItem] = [root] - var all = [String: OutlineItem]() - - headings.enumerated().forEach { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], let level = Int(tag.suffix(1)) else { return } - let item = OutlineItem(id: id, index: index, text: text, level: level) - all[item.id] = item - - // get last item in stack - // if last item is child of item's sibling, unwind stack until a sibling is found - guard var lastItem = stack.last else { return } - while lastItem.level > item.level { - stack.removeLast() - lastItem = stack[stack.count - 1] - } - - // if item is last item's sibling, add item to parent and replace last item with itself in stack - // if item is last item's child, add item to parent and add item to stack - if lastItem.level == item.level { - stack[stack.count - 2].addChild(item) - stack[stack.count - 1] = item - } else if lastItem.level < item.level { - stack[stack.count - 1].addChild(item) - stack.append(item) - } - } - - // if there is only one h1, flatten one level - if let rootChildren = root.children, rootChildren.count == 1, let rootFirstChild = rootChildren.first { - let children = rootFirstChild.removeAllChildren() - DispatchQueue.main.async { - self.outlineItemTree = [rootFirstChild] + children - } - } else { - DispatchQueue.main.async { - self.outlineItemTree = root.children ?? [] - } - } - } } From 21b33a1c76c17398019ed7a02a514f12094599cf Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 09:52:07 +0100 Subject: [PATCH 11/24] Format --- ViewModel/BrowserUIDelegate.swift | 136 +++++++++++++++--------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index 0953382a6..1cfc6128d 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -8,83 +8,83 @@ import WebKit final class BrowserUIDelegate: NSObject, WKUIDelegate { - @Published private(set) var externalURL: URL? -#if os(macOS) - func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + #if os(macOS) + func webView(_: WKWebView, createWebViewWith _: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures _: WKWindowFeatures) -> WKWebView? + { + guard navigationAction.targetFrame == nil else { return nil } + guard let newUrl = navigationAction.request.url else { return nil } - guard navigationAction.targetFrame == nil else { return nil } - guard let newUrl = navigationAction.request.url else { return nil } + // open external link in default browser + guard newUrl.isExternal == false else { + externalURL = newUrl + return nil + } - // open external link in default browser - guard newUrl.isExternal == false else { - externalURL = newUrl + // create new tab + guard let currentWindow = NSApp.keyWindow, + let windowController = currentWindow.windowController else { return nil } + // store the new url in a static way + BrowserViewModel.urlForNewTab = newUrl + // this creates a new BrowserViewModel + windowController.newWindowForTab(self) + // now reset the static url to nil, as the new BrowserViewModel already has it + BrowserViewModel.urlForNewTab = nil + guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } + currentWindow.addTabbedWindow(newWindow, ordered: .above) return nil } + #endif - // create new tab - guard let currentWindow = NSApp.keyWindow, - let windowController = currentWindow.windowController else { return nil } - // store the new url in a static way - BrowserViewModel.urlForNewTab = newUrl - // this creates a new BrowserViewModel - windowController.newWindowForTab(self) - // now reset the static url to nil, as the new BrowserViewModel already has it - BrowserViewModel.urlForNewTab = nil - guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } - currentWindow.addTabbedWindow(newWindow, ordered: .above) - return nil - } -#endif - -#if os(iOS) - func webView(_ webView: WKWebView, - contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, - completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { - guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } - let configuration = UIContextMenuConfiguration( - previewProvider: { - let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - webView.load(URLRequest(url: url)) - return WebViewController(webView: webView) - }, actionProvider: { suggestedActions in - var actions = [UIAction]() - - // open url - actions.append( - UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in - webView.load(URLRequest(url: url)) - } - ) - actions.append( - UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in - NotificationCenter.openURL(url, inNewTab: true) - } - ) + #if os(iOS) + func webView(_ webView: WKWebView, + contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, + completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) + { + guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } + let configuration = UIContextMenuConfiguration( + previewProvider: { + let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + webView.load(URLRequest(url: url)) + return WebViewController(webView: webView) + }, actionProvider: { _ in + var actions = [UIAction]() - // bookmark - let bookmarkAction: UIAction = { - let context = Database.viewContext - let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) - let request = Bookmark.fetchRequest(predicate: predicate) - if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { - return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in - self.deleteBookmark(url: url) + // open url + actions.append( + UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in + webView.load(URLRequest(url: url)) } - } else { - return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in - self.createBookmark(url: url) + ) + actions.append( + UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in + NotificationCenter.openURL(url, inNewTab: true) } - } - }() - actions.append(bookmarkAction) + ) - return UIMenu(children: actions) - } - ) - completionHandler(configuration) - } -#endif + // bookmark + let bookmarkAction: UIAction = { + let context = Database.viewContext + let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) + let request = Bookmark.fetchRequest(predicate: predicate) + if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { + return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in + self.deleteBookmark(url: url) + } + } else { + return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in + self.createBookmark(url: url) + } + } + }() + actions.append(bookmarkAction) + + return UIMenu(children: actions) + } + ) + completionHandler(configuration) + } + #endif } From bc9f59238d3152389e73afe3bceacebb2b6b428a Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 09:54:57 +0100 Subject: [PATCH 12/24] Format --- ViewModel/BrowserNavDelegate.swift | 16 ++++---- ViewModel/BrowserScriptHandler.swift | 4 +- ViewModel/BrowserViewModel.swift | 60 ++++++++++++++-------------- 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift index 70c323a21..527948cfc 100644 --- a/ViewModel/BrowserNavDelegate.swift +++ b/ViewModel/BrowserNavDelegate.swift @@ -5,16 +5,16 @@ // Copyright © 2023 Chris Li. All rights reserved. // -import WebKit import CoreLocation +import WebKit final class BrowserNavDelegate: NSObject, WKNavigationDelegate { - @Published private(set) var externalURL: URL? func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) + { guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } @@ -38,9 +38,9 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { #if os(macOS) - NSWorkspace.shared.open(url) + NSWorkspace.shared.open(url) #elseif os(iOS) - UIApplication.shared.open(url) + UIApplication.shared.open(url) #endif } } @@ -50,14 +50,14 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { } } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") #if os(iOS) - webView.adjustTextSize() + webView.adjustTextSize() #endif } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) { let error = error as NSError guard error.code != NSURLErrorCancelled else { return } NotificationCenter.default.post( diff --git a/ViewModel/BrowserScriptHandler.swift b/ViewModel/BrowserScriptHandler.swift index 696abaf40..1a2230a5e 100644 --- a/ViewModel/BrowserScriptHandler.swift +++ b/ViewModel/BrowserScriptHandler.swift @@ -11,7 +11,7 @@ final class BrowserScriptHandler: NSObject, WKScriptMessageHandler { @Published private(set) var outlineItems = [OutlineItem]() @Published private(set) var outlineItemTree = [OutlineItem]() - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "headings", let headings = message.body as? [[String: String]] { DispatchQueue.global(qos: .userInitiated).async { self.generateOutlineList(headings: headings) @@ -24,7 +24,7 @@ final class BrowserScriptHandler: NSObject, WKScriptMessageHandler { /// - Parameter headings: list of heading element data retrieved from webview private func generateOutlineList(headings: [[String: String]]) { let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } - let offset = allLevels.filter({ $0 == 1 }).count == 1 ? 2 : allLevels.min() ?? 0 + let offset = allLevels.filter { $0 == 1 }.count == 1 ? 2 : allLevels.min() ?? 0 let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in guard let id = heading["id"], let text = heading["text"], diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 5aeb0b8a8..a1aae6c7b 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -14,17 +14,17 @@ import WebKit import OrderedCollections final class BrowserViewModel: NSObject, ObservableObject, - NSFetchedResultsControllerDelegate + NSFetchedResultsControllerDelegate { - static private var cache = OrderedDictionary() - + private static var cache = OrderedDictionary() + static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel { let viewModel = cache[tabID] ?? BrowserViewModel(tabID: tabID) cache.removeValue(forKey: tabID) cache[tabID] = viewModel return viewModel } - + static func purgeCache() { guard cache.count > 10 else { return } let range = 0 ..< cache.count - 5 @@ -33,9 +33,9 @@ final class BrowserViewModel: NSObject, ObservableObject, } cache.removeSubrange(range) } - + // MARK: - Properties - + @Published private(set) var canGoBack = false @Published private(set) var canGoForward = false @Published private(set) var articleTitle: String = "" @@ -60,10 +60,10 @@ final class BrowserViewModel: NSObject, ObservableObject, private var cancellables: Set = [] // MARK: - Lifecycle - + init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID - self.webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) scriptHandler = BrowserScriptHandler() navDelegate = BrowserNavDelegate() uiDelegate = BrowserUIDelegate() @@ -93,7 +93,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // configure web view webView.allowsBackForwardNavigationGestures = true - webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work + webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work webView.configuration.userContentController.removeScriptMessageHandler(forName: "headings") webView.configuration.userContentController.add(scriptHandler, name: "headings") webView.navigationDelegate = navDelegate @@ -103,7 +103,7 @@ final class BrowserViewModel: NSObject, ObservableObject, if webView.url != nil { webView.evaluateJavaScript("getOutlineItems();") } - + // setup web view property observers canGoBackObserver = webView.observe(\.canGoBack, options: .initial) { [weak self] webView, _ in self?.canGoBack = webView.canGoBack @@ -129,24 +129,25 @@ final class BrowserViewModel: NSObject, ObservableObject, guard let url, let zimFileID = UUID(uuidString: url.host ?? "") else { return nil } return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first }() - + // update view model self?.articleTitle = title ?? "" self?.zimFileName = zimFile?.name ?? "" self?.url = url - + // update tab data if let tabID = self?.tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab, - let title { + let title + { tab.title = title tab.zimFile = zimFile } - + // setup bookmark fetched results controller self?.bookmarkFetchedResultsController = NSFetchedResultsController( fetchRequest: Bookmark.fetchRequest(predicate: { - if let url = url { + if let url { return NSPredicate(format: "articleURL == %@", url as CVarArg) } else { return NSPredicate(format: "articleURL == nil") @@ -160,44 +161,45 @@ final class BrowserViewModel: NSObject, ObservableObject, try? self?.bookmarkFetchedResultsController?.performFetch() } } - + func updateLastOpened() { guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } tab.lastOpened = Date() } - + func persistState() { guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } tab.interactionState = webView.interactionState as? Data try? Database.viewContext.save() } - + // MARK: - Content Loading - + func load(url: URL) { guard webView.url != url else { return } webView.load(URLRequest(url: url)) } - + func loadRandomArticle(zimFileID: UUID? = nil) { let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return } load(url: url) } - + func loadMainArticle(zimFileID: UUID? = nil) { let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return } load(url: url) } - + // MARK: - Bookmark - - func controller(_ controller: NSFetchedResultsController, - didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + + func controller(_: NSFetchedResultsController, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) + { articleBookmarked = !snapshot.itemIdentifiers.isEmpty } - + func createBookmark(url: URL? = nil) { guard let url = url ?? webView.url else { return } Database.performBackgroundTask { context in @@ -217,7 +219,7 @@ final class BrowserViewModel: NSObject, ObservableObject, try? context.save() } } - + func deleteBookmark(url: URL? = nil) { guard let url = url ?? webView.url else { return } Database.performBackgroundTask { context in @@ -227,9 +229,9 @@ final class BrowserViewModel: NSObject, ObservableObject, try? context.save() } } - + // MARK: - Outline - + /// Scroll to an outline item /// - Parameter outlineItemID: ID of the outline item to scroll to func scrollTo(outlineItemID: String) { From 97a3940ce072c5f2e545c9a6b115ecb5ebccb0cc Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 10:37:17 +0100 Subject: [PATCH 13/24] Fix format --- ViewModel/BrowserUIDelegate.swift | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index 1cfc6128d..4c5209563 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -11,9 +11,12 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { @Published private(set) var externalURL: URL? #if os(macOS) - func webView(_: WKWebView, createWebViewWith _: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, windowFeatures _: WKWindowFeatures) -> WKWebView? - { + func webView( + _: WKWebView, + createWebViewWith _: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures _: WKWindowFeatures + ) -> WKWebView? { guard navigationAction.targetFrame == nil else { return nil } guard let newUrl = navigationAction.request.url else { return nil } @@ -39,10 +42,11 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { #endif #if os(iOS) - func webView(_ webView: WKWebView, - contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, - completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) - { + func webView( + _ webView: WKWebView, + contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, + completionHandler: @escaping (UIContextMenuConfiguration?) -> Void + ) { guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } let configuration = UIContextMenuConfiguration( previewProvider: { @@ -70,7 +74,9 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) let request = Bookmark.fetchRequest(predicate: predicate) if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { - return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { _ in + return UIAction(title: "Remove Bookmark", + image: UIImage(systemName: "star.slash.fill")) + { _ in self.deleteBookmark(url: url) } } else { From 0db61a180e4621e9d41582f7d1a50bd23b1d6393 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 10:54:33 +0100 Subject: [PATCH 14/24] Format --- ViewModel/BrowserNavDelegate.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift index 527948cfc..a1740ee15 100644 --- a/ViewModel/BrowserNavDelegate.swift +++ b/ViewModel/BrowserNavDelegate.swift @@ -11,10 +11,11 @@ import WebKit final class BrowserNavDelegate: NSObject, WKNavigationDelegate { @Published private(set) var externalURL: URL? - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) - { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } @@ -57,7 +58,11 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { #endif } - func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) { + func webView( + _: WKWebView, + didFailProvisionalNavigation _: WKNavigation!, + withError error: Error + ) { let error = error as NSError guard error.code != NSURLErrorCancelled else { return } NotificationCenter.default.post( From 7b4e574d0fc8ceed8188c2e18b8534f502dc80be Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 11:02:35 +0100 Subject: [PATCH 15/24] Reformat --- ViewModel/BrowserUIDelegate.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index 4c5209563..f1c5cb90b 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -75,8 +75,7 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { let request = Bookmark.fetchRequest(predicate: predicate) if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { return UIAction(title: "Remove Bookmark", - image: UIImage(systemName: "star.slash.fill")) - { _ in + image: UIImage(systemName: "star.slash.fill")) { _ in self.deleteBookmark(url: url) } } else { From d489a2f591fbbe21dd2364be8e12d8875929c1af Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Tue, 14 Nov 2023 11:05:20 +0100 Subject: [PATCH 16/24] Reformat ViewModel --- ViewModel/BrowserViewModel.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index a1aae6c7b..8a8a9c7bf 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -138,8 +138,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // update tab data if let tabID = self?.tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab, - let title - { + let title { tab.title = title tab.zimFile = zimFile } @@ -195,8 +194,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // MARK: - Bookmark func controller(_: NSFetchedResultsController, - didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) - { + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { articleBookmarked = !snapshot.itemIdentifiers.isEmpty } From 9c7779ac243cf61739706bc09eb301760ab3446c Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Sat, 18 Nov 2023 15:12:28 +0100 Subject: [PATCH 17/24] Project fixes for iOS build on device --- App/CompactViewController.swift | 2 +- Kiwix.xcodeproj/project.pbxproj | 2 ++ Model/Utilities/URL.swift | 1 + ViewModel/BrowserNavDelegate.swift | 4 +++- ViewModel/BrowserUIDelegate.swift | 10 ++++++---- ViewModel/BrowserViewModel.swift | 30 +++++++++++++----------------- ViewModel/LibraryViewModel.swift | 10 ++++++---- Views/BrowserTab.swift | 5 +++-- 8 files changed, 35 insertions(+), 29 deletions(-) diff --git a/App/CompactViewController.swift b/App/CompactViewController.swift index a13487265..0aabeae76 100644 --- a/App/CompactViewController.swift +++ b/App/CompactViewController.swift @@ -130,7 +130,7 @@ private struct Content: View { .focusedSceneValue(\.browserViewModel, browser) .focusedSceneValue(\.canGoBack, browser.canGoBack) .focusedSceneValue(\.canGoForward, browser.canGoForward) - .modifier(ExternalLinkHandler(externalURL: browser.externalURL)) + .modifier(ExternalLinkHandler(externalURL: $browser.externalURL)) .onAppear { browser.updateLastOpened() } diff --git a/Kiwix.xcodeproj/project.pbxproj b/Kiwix.xcodeproj/project.pbxproj index 004dc2e55..9b44e1e30 100644 --- a/Kiwix.xcodeproj/project.pbxproj +++ b/Kiwix.xcodeproj/project.pbxproj @@ -101,6 +101,7 @@ 983ED6DC2B02E89300409078 /* BrowserScriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */; }; 983ED6DE2B02ED4000409078 /* BrowserNavDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */; }; 983ED6E02B02EF1E00409078 /* BrowserUIDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */; }; + 983ED7192B08AFE700409078 /* Kiwix-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5D02456796A00F6F6FF /* Kiwix-Bridging-Header.h */; platformFilter = ios; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -761,6 +762,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 983ED7192B08AFE700409078 /* Kiwix-Bridging-Header.h in Sources */, 972DE4BB2814A5A4004FD9B9 /* Errors.swift in Sources */, 9790CA5A28A05EBB00D39FC6 /* ZimFilesCategories.swift in Sources */, 97486D08284A42B90096E4DD /* SearchResultRow.swift in Sources */, diff --git a/Model/Utilities/URL.swift b/Model/Utilities/URL.swift index fed817edc..a39baa35f 100644 --- a/Model/Utilities/URL.swift +++ b/Model/Utilities/URL.swift @@ -5,6 +5,7 @@ // Created by Chris Li on 11/6/21. // Copyright © 2021 Chris Li. All rights reserved. // +import Foundation extension URL { init?(zimFileID: String, contentPath: String) { diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift index a1740ee15..59191b9f6 100644 --- a/ViewModel/BrowserNavDelegate.swift +++ b/ViewModel/BrowserNavDelegate.swift @@ -18,7 +18,9 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { ) { guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { - DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } + if webView.url != redirectedURL { + DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } + } decisionHandler(.cancel) } else if url.isKiwixURL { decisionHandler(.allow) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index f1c5cb90b..a88d0a1b5 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -9,6 +9,8 @@ import WebKit final class BrowserUIDelegate: NSObject, WKUIDelegate { @Published private(set) var externalURL: URL? + @Published private(set) var createBookMark: URL? + @Published private(set) var deleteBookMark: URL? #if os(macOS) func webView( @@ -75,12 +77,12 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { let request = Bookmark.fetchRequest(predicate: predicate) if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { return UIAction(title: "Remove Bookmark", - image: UIImage(systemName: "star.slash.fill")) { _ in - self.deleteBookmark(url: url) + image: UIImage(systemName: "star.slash.fill")) { [weak self] _ in + self?.deleteBookMark = url } } else { - return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { _ in - self.createBookmark(url: url) + return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { [weak self] _ in + self?.createBookMark = url } } }() diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 8a8a9c7bf..1d65dada3 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -80,6 +80,14 @@ final class BrowserViewModel: NSObject, ObservableObject, uiDelegate.$externalURL.assign(to: \.externalURL, on: self) .store(in: &cancellables) + uiDelegate.$createBookMark.sink { [weak self] url in + self?.createBookmark(url: url) + }.store(in: &cancellables) + + uiDelegate.$deleteBookMark.sink { [weak self] url in + self?.deleteBookmark(url: url) + }.store(in: &cancellables) + // restore webview state, and set url before observer call back // note: optionality of url determines what to show in a tab, so it should be set before tab is on screen if let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { @@ -115,30 +123,22 @@ final class BrowserViewModel: NSObject, ObservableObject, webView.publisher(for: \.title, options: .initial), webView.publisher(for: \.url, options: .initial) ) - .debounce(for: 0.1, scheduler: DispatchQueue.global()) .receive(on: DispatchQueue.main) .sink { [weak self] title, url in - let title: String? = { - if let title, !title.isEmpty { - return title - } else { - return nil - } - }() + guard let title, let url else { return } let zimFile: ZimFile? = { - guard let url, let zimFileID = UUID(uuidString: url.host ?? "") else { return nil } + guard let zimFileID = UUID(uuidString: url.host ?? "") else { return nil } return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first }() // update view model - self?.articleTitle = title ?? "" + self?.articleTitle = title self?.zimFileName = zimFile?.name ?? "" self?.url = url // update tab data if let tabID = self?.tabID, - let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab, - let title { + let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { tab.title = title tab.zimFile = zimFile } @@ -146,11 +146,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // setup bookmark fetched results controller self?.bookmarkFetchedResultsController = NSFetchedResultsController( fetchRequest: Bookmark.fetchRequest(predicate: { - if let url { - return NSPredicate(format: "articleURL == %@", url as CVarArg) - } else { - return NSPredicate(format: "articleURL == nil") - } + return NSPredicate(format: "articleURL == %@", url as CVarArg) }()), managedObjectContext: Database.viewContext, sectionNameKeyPath: nil, diff --git a/ViewModel/LibraryViewModel.swift b/ViewModel/LibraryViewModel.swift index 657c0ff3e..14059c1af 100644 --- a/ViewModel/LibraryViewModel.swift +++ b/ViewModel/LibraryViewModel.swift @@ -42,9 +42,11 @@ public class LibraryViewModel: ObservableObject { defer { isInProgress = false } // decide if refresh should proceed - let isStale = (Defaults[.libraryLastRefresh]?.timeIntervalSinceNow ?? -3600) <= -3600 - guard isUserInitiated || (Defaults[.libraryAutoRefresh] && isStale) else { return } - + let lastRefresh: Date? = Defaults[.libraryLastRefresh] + let hasAutoRefresh: Bool = Defaults[.libraryAutoRefresh] + let isStale = (lastRefresh?.timeIntervalSinceNow ?? -3600) <= -3600 + guard isUserInitiated || (hasAutoRefresh && isStale) else { return } + // refresh library guard let data = try await fetchData() else { return } let parser = try await parse(data: data) @@ -57,7 +59,7 @@ public class LibraryViewModel: ObservableObject { if Defaults[.libraryLanguageCodes].isEmpty, let currentLanguageCode = Locale.current.languageCode { Defaults[.libraryLanguageCodes] = [currentLanguageCode] } - + // reset error error = nil diff --git a/Views/BrowserTab.swift b/Views/BrowserTab.swift index 2ade71025..419370468 100644 --- a/Views/BrowserTab.swift +++ b/Views/BrowserTab.swift @@ -8,10 +8,11 @@ import SwiftUI -struct BrowserTab: View { +struct BrowserTab: View, Identifiable { @EnvironmentObject private var browser: BrowserViewModel @StateObject private var search = SearchViewModel() - + var id: Int = UUID().hashValue + var body: some View { Content().toolbar { #if os(macOS) From b7be0b44b2fe2121d24f8dda259db9864d6e48d8 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Sun, 19 Nov 2023 23:45:48 +0100 Subject: [PATCH 18/24] MacOS window restoration fixes --- App/App_macOS.swift | 44 +++++++- Model/Utilities/WebKitHandler.swift | 64 ++++++------ SwiftUI/Model/DefaultKeys.swift | 5 + ViewModel/BrowserNavDelegate.swift | 3 + ViewModel/BrowserViewModel.swift | 152 ++++++++++++++++++++++++---- ViewModel/NavigationViewModel.swift | 18 +++- 6 files changed, 230 insertions(+), 56 deletions(-) diff --git a/App/App_macOS.swift b/App/App_macOS.swift index ce6efe2cf..7fcc0ecaa 100644 --- a/App/App_macOS.swift +++ b/App/App_macOS.swift @@ -8,14 +8,15 @@ import SwiftUI import UserNotifications +import Combine +import Defaults #if os(macOS) @main struct Kiwix: App { @StateObject private var libraryRefreshViewModel = LibraryViewModel() - private let notificationCenterDelegate = NotificationCenterDelegate() - + init() { UNUserNotificationCenter.current().delegate = notificationCenterDelegate LibraryOperations.reopen() @@ -63,7 +64,7 @@ struct Kiwix: App { .environmentObject(libraryRefreshViewModel) } } - + private class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { /// Handling file download complete notification func userNotificationCenter(_ center: UNUserNotificationCenter, @@ -86,7 +87,8 @@ struct RootView: View { private let primaryItems: [NavigationItem] = [.reading, .bookmarks] private let libraryItems: [NavigationItem] = [.opened, .categories, .downloads, .new] private let openURL = NotificationCenter.default.publisher(for: .openURL) - + private let appTerminates = NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification) + var body: some View { NavigationView { List(selection: $navigation.currentItem) { @@ -111,6 +113,12 @@ struct RootView: View { switch navigation.currentItem { case .reading: BrowserTab().environmentObject(browser) + .withHostingWindow { window in + if let windowNumber = window?.windowNumber { + browser.restoreByWindowNumber(windowNumber: windowNumber, + urlToTabIdConverter: navigation.tabIDFor(url:)) + } + } case .bookmarks: Bookmarks() case .opened: @@ -139,9 +147,35 @@ struct RootView: View { } .onReceive(openURL) { notification in guard controlActiveState == .key, let url = notification.userInfo?["url"] as? URL else { return } - browser.load(url: url) navigation.currentItem = .reading + browser.load(url: url) + } + .onReceive(appTerminates) { _ in + browser.persistAllTabIdsFromWindows() } } } + +// MARK: helpers to capture the window + +extension View { + func withHostingWindow(_ callback: @escaping (NSWindow?) -> Void) -> some View { + self.background(HostingWindowFinder(callback: callback)) + } +} + +struct HostingWindowFinder: NSViewRepresentable { + typealias NSViewType = NSView + var callback: (NSWindow?) -> () + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { [weak view] in + self.callback(view?.window) + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + #endif diff --git a/Model/Utilities/WebKitHandler.swift b/Model/Utilities/WebKitHandler.swift index 85b8008dd..2d15c1860 100644 --- a/Model/Utilities/WebKitHandler.swift +++ b/Model/Utilities/WebKitHandler.swift @@ -20,37 +20,41 @@ class KiwixURLSchemeHandler: NSObject, WKURLSchemeHandler { return } - objCTryBlock { - /// Skipping handling for HTTP 206 Partial Content - /// For video playback, WebKit makes a large amount of requests with small byte range (e.g. 8 bytes) to retrieve content of the video. - /// As a result of the large volume of small requests, CPU usage will be very high, which can result in app or webpage frozen. - /// To mitigate, opting for the less "broken" behavior of ignoring Range header until WebKit behavior is changed. - //if let range = urlSchemeTask.request.allHTTPHeaderFields?["Range"] as? String { - // let parts = range.components(separatedBy: ["=", "-"]) - // guard parts.count >= 2, let start = UInt(parts[1]) else { - // self.sendHTTP400Response(urlSchemeTask, url: url) - // return - // } - // let end = parts.count == 3 ? UInt(parts[2]) ?? 0 : 0 - // guard let content = ZimFileService.shared.getURLContent( - // url: url, start: start, end: end - // ) else { - // self.sendHTTP404Response(urlSchemeTask, url: url) - // return - // } - // self.sendHTTP206Response(urlSchemeTask, url: url, content: content) - //} else { - // guard let content = ZimFileService.shared.getURLContent(url: url) else { - // self.sendHTTP404Response(urlSchemeTask, url: url) - // return - // } - // self.sendHTTP200Response(urlSchemeTask, url: url, content: content) - //} - guard let content = ZimFileService.shared.getURLContent(url: url) else { - self.sendHTTP404Response(urlSchemeTask, url: url) - return + do { + objCTryBlock { + /// Skipping handling for HTTP 206 Partial Content + /// For video playback, WebKit makes a large amount of requests with small byte range (e.g. 8 bytes) to retrieve content of the video. + /// As a result of the large volume of small requests, CPU usage will be very high, which can result in app or webpage frozen. + /// To mitigate, opting for the less "broken" behavior of ignoring Range header until WebKit behavior is changed. + //if let range = urlSchemeTask.request.allHTTPHeaderFields?["Range"] as? String { + // let parts = range.components(separatedBy: ["=", "-"]) + // guard parts.count >= 2, let start = UInt(parts[1]) else { + // self.sendHTTP400Response(urlSchemeTask, url: url) + // return + // } + // let end = parts.count == 3 ? UInt(parts[2]) ?? 0 : 0 + // guard let content = ZimFileService.shared.getURLContent( + // url: url, start: start, end: end + // ) else { + // self.sendHTTP404Response(urlSchemeTask, url: url) + // return + // } + // self.sendHTTP206Response(urlSchemeTask, url: url, content: content) + //} else { + // guard let content = ZimFileService.shared.getURLContent(url: url) else { + // self.sendHTTP404Response(urlSchemeTask, url: url) + // return + // } + // self.sendHTTP200Response(urlSchemeTask, url: url, content: content) + //} + guard let content = ZimFileService.shared.getURLContent(url: url) else { + self.sendHTTP404Response(urlSchemeTask, url: url) + return + } + self.sendHTTP200Response(urlSchemeTask, url: url, content: content) } - self.sendHTTP200Response(urlSchemeTask, url: url, content: content) + } catch(let error) { + debugPrint(error) } } } diff --git a/SwiftUI/Model/DefaultKeys.swift b/SwiftUI/Model/DefaultKeys.swift index f332dc827..ec0704336 100644 --- a/SwiftUI/Model/DefaultKeys.swift +++ b/SwiftUI/Model/DefaultKeys.swift @@ -37,6 +37,11 @@ extension Defaults.Keys { static let downloadUsingCellular = Key("downloadUsingCellular", default: false) static let backupDocumentDirectory = Key("backupDocumentDirectory", default: false) + + #if os(macOS) + // window management: + static let windowURLs = Key<[URL]>("windowURLs", default: []) + #endif } extension Defaults.Serializable where Self: Codable { diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift index 59191b9f6..a846ce2a8 100644 --- a/ViewModel/BrowserNavDelegate.swift +++ b/ViewModel/BrowserNavDelegate.swift @@ -10,6 +10,7 @@ import WebKit final class BrowserNavDelegate: NSObject, WKNavigationDelegate { @Published private(set) var externalURL: URL? + @Published private(set) var didLoadContent: Bool? func webView( _ webView: WKWebView, @@ -57,6 +58,8 @@ final class BrowserNavDelegate: NSObject, WKNavigationDelegate { webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") #if os(iOS) webView.adjustTextSize() + #else + didLoadContent = true #endif } diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 1d65dada3..45f7f4710 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -10,6 +10,7 @@ import Combine import CoreData import CoreLocation import WebKit +import Defaults import OrderedCollections @@ -46,7 +47,20 @@ final class BrowserViewModel: NSObject, ObservableObject, @Published private(set) var url: URL? @Published var externalURL: URL? - let tabID: NSManagedObjectID? + private(set) var tabID: NSManagedObjectID? { + didSet { + #if os(macOS) + if let tabID, tabID != oldValue { + storeTabIDInCurrentWindow() + } + #endif + } + } + #if os(macOS) + private var windowURLs: [URL] { + UserDefaults.standard[.windowURLs] + } + #endif let webView: WKWebView private var canGoBackObserver: NSKeyValueObservation? private var canGoForwardObserver: NSKeyValueObservation? @@ -77,6 +91,12 @@ final class BrowserViewModel: NSObject, ObservableObject, navDelegate.$externalURL.assign(to: \.externalURL, on: self) .store(in: &cancellables) + navDelegate.$didLoadContent.sink { [weak self] didLoad in + if didLoad == true { + self?.persistState() + } + }.store(in: &cancellables) + uiDelegate.$externalURL.assign(to: \.externalURL, on: self) .store(in: &cancellables) @@ -90,14 +110,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // restore webview state, and set url before observer call back // note: optionality of url determines what to show in a tab, so it should be set before tab is on screen - if let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { - webView.interactionState = tab.interactionState - url = webView.url - } - if let urlForNewTab = Self.urlForNewTab { - url = urlForNewTab - load(url: urlForNewTab) - } + // configure web view webView.allowsBackForwardNavigationGestures = true @@ -107,6 +120,14 @@ final class BrowserViewModel: NSObject, ObservableObject, webView.navigationDelegate = navDelegate webView.uiDelegate = uiDelegate + if let tabID { + restoreBy(tabID: tabID) + } + if let urlForNewTab = Self.urlForNewTab { + url = urlForNewTab + load(url: urlForNewTab) + } + // get outline items if something is already loaded if webView.url != nil { webView.evaluateJavaScript("getOutlineItems();") @@ -131,20 +152,28 @@ final class BrowserViewModel: NSObject, ObservableObject, return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first }() - // update view model - self?.articleTitle = title - self?.zimFileName = zimFile?.name ?? "" - self?.url = url + guard let strongSelf = self else { return } + // update view model + strongSelf.articleTitle = title + strongSelf.zimFileName = zimFile?.name ?? "" + strongSelf.url = url + + let currentTabID: NSManagedObjectID + if let tabID = strongSelf.tabID { + currentTabID = tabID + } else { + currentTabID = strongSelf.createNewTabID() + strongSelf.tabID = currentTabID + } // update tab data - if let tabID = self?.tabID, - let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { + if let tab = try? Database.viewContext.existingObject(with: currentTabID) as? Tab { tab.title = title tab.zimFile = zimFile } // setup bookmark fetched results controller - self?.bookmarkFetchedResultsController = NSFetchedResultsController( + strongSelf.bookmarkFetchedResultsController = NSFetchedResultsController( fetchRequest: Bookmark.fetchRequest(predicate: { return NSPredicate(format: "articleURL == %@", url as CVarArg) }()), @@ -152,8 +181,8 @@ final class BrowserViewModel: NSObject, ObservableObject, sectionNameKeyPath: nil, cacheName: nil ) - self?.bookmarkFetchedResultsController?.delegate = self - try? self?.bookmarkFetchedResultsController?.performFetch() + strongSelf.bookmarkFetchedResultsController?.delegate = self + try? strongSelf.bookmarkFetchedResultsController?.performFetch() } } @@ -163,7 +192,10 @@ final class BrowserViewModel: NSObject, ObservableObject, } func persistState() { - guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } + guard let tabID, + let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { + return + } tab.interactionState = webView.interactionState as? Data try? Database.viewContext.save() } @@ -187,6 +219,88 @@ final class BrowserViewModel: NSObject, ObservableObject, load(url: url) } + private func restoreBy(tabID: NSManagedObjectID) { + if let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { + webView.interactionState = tab.interactionState + url = webView.url + } + } + + // MARK: - TabID management via NSWindow for macOS + + #if os(macOS) + private (set) var windowNumber: Int? + + // RESTORATION + func restoreByWindowNumber( + windowNumber currentNumber: Int, + urlToTabIdConverter: @escaping (URL?) -> NSManagedObjectID + ) { + windowNumber = currentNumber + let windows = NSApplication.shared.windows + let tabURL: URL? + + guard let currentWindow = windowBy(number: currentNumber), + let index = windows.firstIndex(of: currentWindow) else { return } + + // find the url for this window in user defaults, by pure index + if 0 <= index, + index < windowURLs.count { + tabURL = windowURLs[index] + } else { + tabURL = nil + } + let tabID = urlToTabIdConverter(tabURL) // if url is nil it will create a new tab + self.tabID = tabID + restoreBy(tabID: tabID) + } + + private func indexOf(windowNumber number: Int, in windows: [NSWindow]) -> Int? { + let windowNumbers = windows.map { $0.windowNumber } + guard windowNumbers.contains(number), + let index = windowNumbers.firstIndex(of: number) else { + return nil + } + return index + } + + // PERSISTENCE: + func persistAllTabIdsFromWindows() { + let urls = NSApplication.shared.windows.compactMap { window in + window.accessibilityURL() + } + UserDefaults.standard[.windowURLs] = urls + } + + private func storeTabIDInCurrentWindow() { + guard let tabID, + let windowNumber, + let currentWindow = windowBy(number: windowNumber) else { + return + } + let url = tabID.uriRepresentation() + currentWindow.setAccessibilityURL(url) + } + + private func windowBy(number: Int) -> NSWindow? { + NSApplication.shared.windows.first { $0.windowNumber == number } + } + #endif + + + private func createNewTabID() -> NSManagedObjectID { + if let tabID { + return tabID + } + let context = Database.viewContext + let tab = Tab(context: context) + tab.created = Date() + tab.lastOpened = Date() + try? context.obtainPermanentIDs(for: [tab]) + try? context.save() + return tab.objectID + } + // MARK: - Bookmark func controller(_: NSFetchedResultsController, diff --git a/ViewModel/NavigationViewModel.swift b/ViewModel/NavigationViewModel.swift index a0b54572a..3178a2455 100644 --- a/ViewModel/NavigationViewModel.swift +++ b/ViewModel/NavigationViewModel.swift @@ -12,7 +12,8 @@ import WebKit @MainActor class NavigationViewModel: ObservableObject { @Published var currentItem: NavigationItem? - + @Published var readingURL: URL? + init() { #if os(macOS) currentItem = .reading @@ -46,10 +47,23 @@ class NavigationViewModel: ObservableObject { let tab = self.makeTab(context: context) try? context.obtainPermanentIDs(for: [tab]) try? context.save() + #if !os(macOS) //TODO: maybe we don't need this for iOS either currentItem = NavigationItem.tab(objectID: tab.objectID) + #endif return tab.objectID } - + + @MainActor + func tabIDFor(url: URL?) -> NSManagedObjectID { + guard let url else { + return createTab() + } + guard let tabID = Database.viewContext.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) else { + return createTab() + } + return tabID + } + /// Delete a single tab, and select another tab /// - Parameter tabID: ID of the tab to delete func deleteTab(tabID: NSManagedObjectID) { From 1508b68f98c860631c389881b5fde3a88036f547 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 20 Nov 2023 00:18:15 +0100 Subject: [PATCH 19/24] Fixlint --- Model/Utilities/WebKitHandler.swift | 35 ++------- SwiftUI/Model/DefaultKeys.swift | 3 +- SwiftUI/Model/Enum.swift | 2 +- ViewModel/BrowserViewModel.swift | 117 ++++++++++++---------------- ViewModel/NavigationViewModel.swift | 7 +- 5 files changed, 62 insertions(+), 102 deletions(-) diff --git a/Model/Utilities/WebKitHandler.swift b/Model/Utilities/WebKitHandler.swift index 2d15c1860..8901ec697 100644 --- a/Model/Utilities/WebKitHandler.swift +++ b/Model/Utilities/WebKitHandler.swift @@ -9,6 +9,13 @@ import os import WebKit +/// Skipping handling for HTTP 206 Partial Content +/// For video playback, WebKit makes a large amount of requests with small byte range (e.g. 8 bytes) +/// to retrieve content of the video. +/// As a result of the large volume of small requests, CPU usage will be very high, +/// which can result in app or webpage frozen. +/// To mitigate, opting for the less "broken" behavior of ignoring Range header +/// until WebKit behavior is changed. class KiwixURLSchemeHandler: NSObject, WKURLSchemeHandler { private var urls = Set() private var queue = DispatchQueue(label: "org.kiwix.webContent", qos: .userInitiated) @@ -19,41 +26,15 @@ class KiwixURLSchemeHandler: NSObject, WKURLSchemeHandler { urlSchemeTask.didFailWithError(URLError(.unsupportedURL)) return } - do { objCTryBlock { - /// Skipping handling for HTTP 206 Partial Content - /// For video playback, WebKit makes a large amount of requests with small byte range (e.g. 8 bytes) to retrieve content of the video. - /// As a result of the large volume of small requests, CPU usage will be very high, which can result in app or webpage frozen. - /// To mitigate, opting for the less "broken" behavior of ignoring Range header until WebKit behavior is changed. - //if let range = urlSchemeTask.request.allHTTPHeaderFields?["Range"] as? String { - // let parts = range.components(separatedBy: ["=", "-"]) - // guard parts.count >= 2, let start = UInt(parts[1]) else { - // self.sendHTTP400Response(urlSchemeTask, url: url) - // return - // } - // let end = parts.count == 3 ? UInt(parts[2]) ?? 0 : 0 - // guard let content = ZimFileService.shared.getURLContent( - // url: url, start: start, end: end - // ) else { - // self.sendHTTP404Response(urlSchemeTask, url: url) - // return - // } - // self.sendHTTP206Response(urlSchemeTask, url: url, content: content) - //} else { - // guard let content = ZimFileService.shared.getURLContent(url: url) else { - // self.sendHTTP404Response(urlSchemeTask, url: url) - // return - // } - // self.sendHTTP200Response(urlSchemeTask, url: url, content: content) - //} guard let content = ZimFileService.shared.getURLContent(url: url) else { self.sendHTTP404Response(urlSchemeTask, url: url) return } self.sendHTTP200Response(urlSchemeTask, url: url, content: content) } - } catch(let error) { + } catch let error { debugPrint(error) } } diff --git a/SwiftUI/Model/DefaultKeys.swift b/SwiftUI/Model/DefaultKeys.swift index ec0704336..fc1e4e46e 100644 --- a/SwiftUI/Model/DefaultKeys.swift +++ b/SwiftUI/Model/DefaultKeys.swift @@ -24,8 +24,7 @@ extension Defaults.Keys { // // // search static let recentSearchTexts = Key<[String]>("recentSearchTexts", default: []) - - + // library static let libraryLanguageCodes = Key>("libraryLanguageCodes", default: Set()) static let libraryLanguageSortingMode = Key( diff --git a/SwiftUI/Model/Enum.swift b/SwiftUI/Model/Enum.swift index ead0c2d87..f6561af88 100644 --- a/SwiftUI/Model/Enum.swift +++ b/SwiftUI/Model/Enum.swift @@ -229,7 +229,7 @@ enum NavigationItem: Hashable, Identifiable { } -enum SearchResultSnippetMode: String, CaseIterable, Identifiable, Defaults.Serializable { +enum SearchResultSnippetMode: String, CaseIterable, Identifiable, Defaults.Serializable { case disabled, firstParagraph, firstSentence, matches var id: String { rawValue } diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 45f7f4710..33def6232 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -14,9 +14,7 @@ import Defaults import OrderedCollections -final class BrowserViewModel: NSObject, ObservableObject, - NSFetchedResultsControllerDelegate -{ +final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { private static var cache = OrderedDictionary() static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel { @@ -83,13 +81,10 @@ final class BrowserViewModel: NSObject, ObservableObject, uiDelegate = BrowserUIDelegate() super.init() - scriptHandler.$outlineItems.assign(to: \.outlineItems, on: self) - .store(in: &cancellables) - scriptHandler.$outlineItemTree.assign(to: \.outlineItemTree, on: self) - .store(in: &cancellables) + scriptHandler.$outlineItems.assign(to: \.outlineItems, on: self).store(in: &cancellables) + scriptHandler.$outlineItemTree.assign(to: \.outlineItemTree, on: self).store(in: &cancellables) - navDelegate.$externalURL.assign(to: \.externalURL, on: self) - .store(in: &cancellables) + navDelegate.$externalURL.assign(to: \.externalURL, on: self).store(in: &cancellables) navDelegate.$didLoadContent.sink { [weak self] didLoad in if didLoad == true { @@ -97,20 +92,9 @@ final class BrowserViewModel: NSObject, ObservableObject, } }.store(in: &cancellables) - uiDelegate.$externalURL.assign(to: \.externalURL, on: self) - .store(in: &cancellables) - - uiDelegate.$createBookMark.sink { [weak self] url in - self?.createBookmark(url: url) - }.store(in: &cancellables) - - uiDelegate.$deleteBookMark.sink { [weak self] url in - self?.deleteBookmark(url: url) - }.store(in: &cancellables) - - // restore webview state, and set url before observer call back - // note: optionality of url determines what to show in a tab, so it should be set before tab is on screen - + uiDelegate.$externalURL.assign(to: \.externalURL, on: self).store(in: &cancellables) + uiDelegate.$createBookMark.sink { [weak self] url in self?.createBookmark(url: url) }.store(in: &cancellables) + uiDelegate.$deleteBookMark.sink { [weak self] url in self?.deleteBookmark(url: url) }.store(in: &cancellables) // configure web view webView.allowsBackForwardNavigationGestures = true @@ -147,43 +131,41 @@ final class BrowserViewModel: NSObject, ObservableObject, .receive(on: DispatchQueue.main) .sink { [weak self] title, url in guard let title, let url else { return } - let zimFile: ZimFile? = { - guard let zimFileID = UUID(uuidString: url.host ?? "") else { return nil } - return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first - }() - - guard let strongSelf = self else { return } - - // update view model - strongSelf.articleTitle = title - strongSelf.zimFileName = zimFile?.name ?? "" - strongSelf.url = url - - let currentTabID: NSManagedObjectID - if let tabID = strongSelf.tabID { - currentTabID = tabID - } else { - currentTabID = strongSelf.createNewTabID() - strongSelf.tabID = currentTabID - } - // update tab data - if let tab = try? Database.viewContext.existingObject(with: currentTabID) as? Tab { - tab.title = title - tab.zimFile = zimFile - } + self?.didUpdate(title: title, url: url) + } + bookmarkFetchedResultsController?.delegate = self + } + + private func didUpdate(title: String, url: URL) { + let zimFile: ZimFile? = { + guard let zimFileID = UUID(uuidString: url.host ?? "") else { return nil } + return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first + }() - // setup bookmark fetched results controller - strongSelf.bookmarkFetchedResultsController = NSFetchedResultsController( - fetchRequest: Bookmark.fetchRequest(predicate: { - return NSPredicate(format: "articleURL == %@", url as CVarArg) - }()), - managedObjectContext: Database.viewContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - strongSelf.bookmarkFetchedResultsController?.delegate = self - try? strongSelf.bookmarkFetchedResultsController?.performFetch() + // update view model + articleTitle = title + zimFileName = zimFile?.name ?? "" + self.url = url + + let currentTabID: NSManagedObjectID = tabID ?? createNewTabID() + tabID = currentTabID + + // update tab data + if let tab = try? Database.viewContext.existingObject(with: currentTabID) as? Tab { + tab.title = title + tab.zimFile = zimFile } + + // setup bookmark fetched results controller + bookmarkFetchedResultsController = NSFetchedResultsController( + fetchRequest: Bookmark.fetchRequest(predicate: { + return NSPredicate(format: "articleURL == %@", url as CVarArg) + }()), + managedObjectContext: Database.viewContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + try? bookmarkFetchedResultsController?.performFetch() } func updateLastOpened() { @@ -208,14 +190,12 @@ final class BrowserViewModel: NSObject, ObservableObject, } func loadRandomArticle(zimFileID: UUID? = nil) { - let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") - guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return } + guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimIdOf(zimFileID)) else { return } load(url: url) } func loadMainArticle(zimFileID: UUID? = nil) { - let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") - guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return } + guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimIdOf(zimFileID)) else { return } load(url: url) } @@ -226,6 +206,10 @@ final class BrowserViewModel: NSObject, ObservableObject, } } + private func zimIdOf(_ uuid: UUID? = nil) -> UUID? { + uuid ?? UUID(uuidString: webView.url?.host ?? "") + } + // MARK: - TabID management via NSWindow for macOS #if os(macOS) @@ -287,11 +271,8 @@ final class BrowserViewModel: NSObject, ObservableObject, } #endif - private func createNewTabID() -> NSManagedObjectID { - if let tabID { - return tabID - } + if let tabID { return tabID } let context = Database.viewContext let tab = Tab(context: context) tab.created = Date() @@ -342,7 +323,5 @@ final class BrowserViewModel: NSObject, ObservableObject, /// Scroll to an outline item /// - Parameter outlineItemID: ID of the outline item to scroll to - func scrollTo(outlineItemID: String) { - webView.evaluateJavaScript("scrollToHeading('\(outlineItemID)')") - } + func scrollTo(outlineItemID: String) { webView.evaluateJavaScript("scrollToHeading('\(outlineItemID)')") } } diff --git a/ViewModel/NavigationViewModel.swift b/ViewModel/NavigationViewModel.swift index 3178a2455..3538ab650 100644 --- a/ViewModel/NavigationViewModel.swift +++ b/ViewModel/NavigationViewModel.swift @@ -42,12 +42,12 @@ class NavigationViewModel: ObservableObject { } @discardableResult - func createTab() -> NSManagedObjectID{ + func createTab() -> NSManagedObjectID { let context = Database.viewContext let tab = self.makeTab(context: context) try? context.obtainPermanentIDs(for: [tab]) try? context.save() - #if !os(macOS) //TODO: maybe we don't need this for iOS either + #if !os(macOS) currentItem = NavigationItem.tab(objectID: tab.objectID) #endif return tab.objectID @@ -58,7 +58,8 @@ class NavigationViewModel: ObservableObject { guard let url else { return createTab() } - guard let tabID = Database.viewContext.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) else { + let coordinator = Database.viewContext.persistentStoreCoordinator + guard let tabID = coordinator?.managedObjectID(forURIRepresentation: url) else { return createTab() } return tabID From 0ebf5313b3b3915eb4946a88f8a79fb3e87a46d7 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 20 Nov 2023 00:28:07 +0100 Subject: [PATCH 20/24] Rename and revert --- ViewModel/BrowserUIDelegate.swift | 8 ++++---- ViewModel/BrowserViewModel.swift | 4 ++-- Views/BrowserTab.swift | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift index a88d0a1b5..0221524f7 100644 --- a/ViewModel/BrowserUIDelegate.swift +++ b/ViewModel/BrowserUIDelegate.swift @@ -9,8 +9,8 @@ import WebKit final class BrowserUIDelegate: NSObject, WKUIDelegate { @Published private(set) var externalURL: URL? - @Published private(set) var createBookMark: URL? - @Published private(set) var deleteBookMark: URL? + @Published private(set) var createBookmark: URL? + @Published private(set) var deleteBookmark: URL? #if os(macOS) func webView( @@ -78,11 +78,11 @@ final class BrowserUIDelegate: NSObject, WKUIDelegate { if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { return UIAction(title: "Remove Bookmark", image: UIImage(systemName: "star.slash.fill")) { [weak self] _ in - self?.deleteBookMark = url + self?.deleteBookmark = url } } else { return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { [weak self] _ in - self?.createBookMark = url + self?.createBookmark = url } } }() diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index 33def6232..f0c6274fa 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -93,8 +93,8 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro }.store(in: &cancellables) uiDelegate.$externalURL.assign(to: \.externalURL, on: self).store(in: &cancellables) - uiDelegate.$createBookMark.sink { [weak self] url in self?.createBookmark(url: url) }.store(in: &cancellables) - uiDelegate.$deleteBookMark.sink { [weak self] url in self?.deleteBookmark(url: url) }.store(in: &cancellables) + uiDelegate.$createBookmark.sink { [weak self] url in self?.createBookmark(url: url) }.store(in: &cancellables) + uiDelegate.$deleteBookmark.sink { [weak self] url in self?.deleteBookmark(url: url) }.store(in: &cancellables) // configure web view webView.allowsBackForwardNavigationGestures = true diff --git a/Views/BrowserTab.swift b/Views/BrowserTab.swift index 419370468..955ae9e9a 100644 --- a/Views/BrowserTab.swift +++ b/Views/BrowserTab.swift @@ -8,10 +8,9 @@ import SwiftUI -struct BrowserTab: View, Identifiable { +struct BrowserTab: View { @EnvironmentObject private var browser: BrowserViewModel @StateObject private var search = SearchViewModel() - var id: Int = UUID().hashValue var body: some View { Content().toolbar { From 7be833cf26897d3a2879b6b439cbd6b74dd8262b Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 20 Nov 2023 00:29:25 +0100 Subject: [PATCH 21/24] Fix lint --- App/App_macOS.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/App_macOS.swift b/App/App_macOS.swift index 7fcc0ecaa..526af25a4 100644 --- a/App/App_macOS.swift +++ b/App/App_macOS.swift @@ -166,7 +166,7 @@ extension View { struct HostingWindowFinder: NSViewRepresentable { typealias NSViewType = NSView - var callback: (NSWindow?) -> () + var callback: (NSWindow?) -> Void func makeNSView(context: Context) -> NSView { let view = NSView() DispatchQueue.main.async { [weak view] in From b66288b6314d95c06534422752dc49b48d86e18c Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 20 Nov 2023 10:51:24 +0100 Subject: [PATCH 22/24] Revert delegate split --- .swiftlint.yml | 3 +- Kiwix.xcodeproj/project.pbxproj | 12 -- Model/Utilities/WebKitHandler.swift | 14 +- ViewModel/BrowserNavDelegate.swift | 77 ------- ViewModel/BrowserScriptHandler.swift | 85 -------- ViewModel/BrowserUIDelegate.swift | 97 --------- ViewModel/BrowserViewModel.swift | 289 +++++++++++++++++++++++---- 7 files changed, 255 insertions(+), 322 deletions(-) delete mode 100644 ViewModel/BrowserNavDelegate.swift delete mode 100644 ViewModel/BrowserScriptHandler.swift delete mode 100644 ViewModel/BrowserUIDelegate.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index e75411cc2..2cdc81f8d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,4 +1,5 @@ disabled_rules: - trailing_whitespace included: - - Views/Settings/ \ No newline at end of file + - Views/Settings/ + - ViewModel \ No newline at end of file diff --git a/Kiwix.xcodeproj/project.pbxproj b/Kiwix.xcodeproj/project.pbxproj index 9b44e1e30..4ba85ed85 100644 --- a/Kiwix.xcodeproj/project.pbxproj +++ b/Kiwix.xcodeproj/project.pbxproj @@ -98,9 +98,6 @@ 97E88F4D2AE407350037F0E5 /* CoreKiwix.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97E88F4C2AE407320037F0E5 /* CoreKiwix.xcframework */; }; 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F3332E28AFC1A2007FF53C /* SearchResults.swift */; }; 97FB4ECE28B4E221003FB524 /* SwiftUIBackports in Frameworks */ = {isa = PBXBuildFile; productRef = 97FB4ECD28B4E221003FB524 /* SwiftUIBackports */; }; - 983ED6DC2B02E89300409078 /* BrowserScriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */; }; - 983ED6DE2B02ED4000409078 /* BrowserNavDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */; }; - 983ED6E02B02EF1E00409078 /* BrowserUIDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */; }; 983ED7192B08AFE700409078 /* Kiwix-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5D02456796A00F6F6FF /* Kiwix-Bridging-Header.h */; platformFilter = ios; }; /* End PBXBuildFile section */ @@ -235,9 +232,6 @@ 97F6CC5020BD960F005CDBD2 /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = System/Library/Frameworks/MapKit.framework; sourceTree = SDKROOT; }; 97FB4B0A27B819A90055F86E /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; 97FD2F5E251EA07B0034927C /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; - 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserScriptHandler.swift; sourceTree = ""; }; - 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserNavDelegate.swift; sourceTree = ""; }; - 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserUIDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -324,9 +318,6 @@ 97176AD12A4FBD710093E3B0 /* BrowserViewModel.swift */, 97C13787284572AC00386C04 /* SearchViewModel.swift */, 97DE2BA1283A8E5C00C63D9B /* LibraryViewModel.swift */, - 983ED6DB2B02E89300409078 /* BrowserScriptHandler.swift */, - 983ED6DD2B02ED4000409078 /* BrowserNavDelegate.swift */, - 983ED6DF2B02EF1E00409078 /* BrowserUIDelegate.swift */, ); path = ViewModel; sourceTree = ""; @@ -800,7 +791,6 @@ 97F3333028AFC1A2007FF53C /* SearchResults.swift in Sources */, 972727AE2A897FAA00BCAF75 /* GridSection.swift in Sources */, 9709C0982A8E4C5700E4564C /* Commands.swift in Sources */, - 983ED6E02B02EF1E00409078 /* BrowserUIDelegate.swift in Sources */, 972727BF2A8A52DC00BCAF75 /* AlertHandler.swift in Sources */, 972096E72AE421C300B378B0 /* Attribute.swift in Sources */, 97341C6E2852248500BC273E /* DownloadTaskCell.swift in Sources */, @@ -818,7 +808,6 @@ 976D90DB281584BF00CC7D29 /* FlavorTag.swift in Sources */, 972DE4BD2814A5BE004FD9B9 /* OPDSParser.mm in Sources */, 972DE4B52814A502004FD9B9 /* Entities.swift in Sources */, - 983ED6DC2B02E89300409078 /* BrowserScriptHandler.swift in Sources */, 9721BBBB28427A93005C910D /* Bookmarks.swift in Sources */, 974E7EE92930201500BDF59C /* ZimFileService.swift in Sources */, 973A0DFD283100C300B41E71 /* ZimFilesOpened.swift in Sources */, @@ -833,7 +822,6 @@ 972727B12A898B9700BCAF75 /* NavigationButtons.swift in Sources */, 976F5EC62A97909100938490 /* BrowserTab.swift in Sources */, 9724FC3028D5F5BE001B7DD2 /* BookmarkContextMenu.swift in Sources */, - 983ED6DE2B02ED4000409078 /* BrowserNavDelegate.swift in Sources */, 976BAEBE284905760049404F /* SearchViewModel.swift in Sources */, 973A0DE7281DC8F400B41E71 /* DownloadService.swift in Sources */, 9721BBB72841C16D005C910D /* Message.swift in Sources */, diff --git a/Model/Utilities/WebKitHandler.swift b/Model/Utilities/WebKitHandler.swift index 8901ec697..cb78375e9 100644 --- a/Model/Utilities/WebKitHandler.swift +++ b/Model/Utilities/WebKitHandler.swift @@ -26,16 +26,12 @@ class KiwixURLSchemeHandler: NSObject, WKURLSchemeHandler { urlSchemeTask.didFailWithError(URLError(.unsupportedURL)) return } - do { - objCTryBlock { - guard let content = ZimFileService.shared.getURLContent(url: url) else { - self.sendHTTP404Response(urlSchemeTask, url: url) - return - } - self.sendHTTP200Response(urlSchemeTask, url: url, content: content) + objCTryBlock { [weak self] in + guard let content = ZimFileService.shared.getURLContent(url: url) else { + self?.sendHTTP404Response(urlSchemeTask, url: url) + return } - } catch let error { - debugPrint(error) + self?.sendHTTP200Response(urlSchemeTask, url: url, content: content) } } } diff --git a/ViewModel/BrowserNavDelegate.swift b/ViewModel/BrowserNavDelegate.swift deleted file mode 100644 index a846ce2a8..000000000 --- a/ViewModel/BrowserNavDelegate.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// BrowserNavHandler.swift -// Kiwix -// -// Copyright © 2023 Chris Li. All rights reserved. -// - -import CoreLocation -import WebKit - -final class BrowserNavDelegate: NSObject, WKNavigationDelegate { - @Published private(set) var externalURL: URL? - @Published private(set) var didLoadContent: Bool? - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } - if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { - if webView.url != redirectedURL { - DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } - } - decisionHandler(.cancel) - } else if url.isKiwixURL { - decisionHandler(.allow) - } else if url.isExternal { - externalURL = url - decisionHandler(.cancel) - } else if url.scheme == "geo" { - if FeatureFlags.map { - let _: CLLocation? = { - let parts = url.absoluteString.replacingOccurrences(of: "geo:", with: "").split(separator: ",") - guard let latitudeString = parts.first, - let longitudeString = parts.last, - let latitude = Double(latitudeString), - let longitude = Double(longitudeString) else { return nil } - return CLLocation(latitude: latitude, longitude: longitude) - }() - } else { - let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") - if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { - #if os(macOS) - NSWorkspace.shared.open(url) - #elseif os(iOS) - UIApplication.shared.open(url) - #endif - } - } - decisionHandler(.cancel) - } else { - decisionHandler(.cancel) - } - } - - func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { - webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") - #if os(iOS) - webView.adjustTextSize() - #else - didLoadContent = true - #endif - } - - func webView( - _: WKWebView, - didFailProvisionalNavigation _: WKNavigation!, - withError error: Error - ) { - let error = error as NSError - guard error.code != NSURLErrorCancelled else { return } - NotificationCenter.default.post( - name: .alert, object: nil, userInfo: ["rawValue": ActiveAlert.articleFailedToLoad.rawValue] - ) - } -} diff --git a/ViewModel/BrowserScriptHandler.swift b/ViewModel/BrowserScriptHandler.swift deleted file mode 100644 index 1a2230a5e..000000000 --- a/ViewModel/BrowserScriptHandler.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// BrowserScriptHandler.swift -// Kiwix -// -// Copyright © 2023 Chris Li. All rights reserved. -// - -import WebKit - -final class BrowserScriptHandler: NSObject, WKScriptMessageHandler { - @Published private(set) var outlineItems = [OutlineItem]() - @Published private(set) var outlineItemTree = [OutlineItem]() - - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "headings", let headings = message.body as? [[String: String]] { - DispatchQueue.global(qos: .userInitiated).async { - self.generateOutlineList(headings: headings) - self.generateOutlineTree(headings: headings) - } - } - } - - /// Convert flattened heading element data to a list of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineList(headings: [[String: String]]) { - let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } - let offset = allLevels.filter { $0 == 1 }.count == 1 ? 2 : allLevels.min() ?? 0 - let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], - let level = Int(tag.suffix(1)) else { return nil } - return OutlineItem(id: id, index: index, text: text, level: max(level - offset, 0)) - } - DispatchQueue.main.async { - self.outlineItems = outlineItems - } - } - - /// Convert flattened heading element data to a tree of OutlineItems. - /// - Parameter headings: list of heading element data retrieved from webview - private func generateOutlineTree(headings: [[String: String]]) { - let root = OutlineItem(index: -1, text: "", level: 0) - var stack: [OutlineItem] = [root] - var all = [String: OutlineItem]() - - headings.enumerated().forEach { index, heading in - guard let id = heading["id"], - let text = heading["text"], - let tag = heading["tag"], let level = Int(tag.suffix(1)) else { return } - let item = OutlineItem(id: id, index: index, text: text, level: level) - all[item.id] = item - - // get last item in stack - // if last item is child of item's sibling, unwind stack until a sibling is found - guard var lastItem = stack.last else { return } - while lastItem.level > item.level { - stack.removeLast() - lastItem = stack[stack.count - 1] - } - - // if item is last item's sibling, add item to parent and replace last item with itself in stack - // if item is last item's child, add item to parent and add item to stack - if lastItem.level == item.level { - stack[stack.count - 2].addChild(item) - stack[stack.count - 1] = item - } else if lastItem.level < item.level { - stack[stack.count - 1].addChild(item) - stack.append(item) - } - } - - // if there is only one h1, flatten one level - if let rootChildren = root.children, rootChildren.count == 1, let rootFirstChild = rootChildren.first { - let children = rootFirstChild.removeAllChildren() - DispatchQueue.main.async { - self.outlineItemTree = [rootFirstChild] + children - } - } else { - DispatchQueue.main.async { - self.outlineItemTree = root.children ?? [] - } - } - } -} diff --git a/ViewModel/BrowserUIDelegate.swift b/ViewModel/BrowserUIDelegate.swift deleted file mode 100644 index 0221524f7..000000000 --- a/ViewModel/BrowserUIDelegate.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// BrowserUIDelegate.swift -// Kiwix -// -// Copyright © 2023 Chris Li. All rights reserved. -// - -import WebKit - -final class BrowserUIDelegate: NSObject, WKUIDelegate { - @Published private(set) var externalURL: URL? - @Published private(set) var createBookmark: URL? - @Published private(set) var deleteBookmark: URL? - - #if os(macOS) - func webView( - _: WKWebView, - createWebViewWith _: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures _: WKWindowFeatures - ) -> WKWebView? { - guard navigationAction.targetFrame == nil else { return nil } - guard let newUrl = navigationAction.request.url else { return nil } - - // open external link in default browser - guard newUrl.isExternal == false else { - externalURL = newUrl - return nil - } - - // create new tab - guard let currentWindow = NSApp.keyWindow, - let windowController = currentWindow.windowController else { return nil } - // store the new url in a static way - BrowserViewModel.urlForNewTab = newUrl - // this creates a new BrowserViewModel - windowController.newWindowForTab(self) - // now reset the static url to nil, as the new BrowserViewModel already has it - BrowserViewModel.urlForNewTab = nil - guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } - currentWindow.addTabbedWindow(newWindow, ordered: .above) - return nil - } - #endif - - #if os(iOS) - func webView( - _ webView: WKWebView, - contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, - completionHandler: @escaping (UIContextMenuConfiguration?) -> Void - ) { - guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } - let configuration = UIContextMenuConfiguration( - previewProvider: { - let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - webView.load(URLRequest(url: url)) - return WebViewController(webView: webView) - }, actionProvider: { _ in - var actions = [UIAction]() - - // open url - actions.append( - UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in - webView.load(URLRequest(url: url)) - } - ) - actions.append( - UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in - NotificationCenter.openURL(url, inNewTab: true) - } - ) - - // bookmark - let bookmarkAction: UIAction = { - let context = Database.viewContext - let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) - let request = Bookmark.fetchRequest(predicate: predicate) - if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { - return UIAction(title: "Remove Bookmark", - image: UIImage(systemName: "star.slash.fill")) { [weak self] _ in - self?.deleteBookmark = url - } - } else { - return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { [weak self] _ in - self?.createBookmark = url - } - } - }() - actions.append(bookmarkAction) - - return UIMenu(children: actions) - } - ) - completionHandler(configuration) - } - #endif -} diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index f0c6274fa..72201c7ec 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -14,7 +14,11 @@ import Defaults import OrderedCollections -final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { +// swiftlint:disable file_length +// swiftlint:disable:next type_body_length +final class BrowserViewModel: NSObject, ObservableObject, + WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, + NSFetchedResultsControllerDelegate { private static var cache = OrderedDictionary() static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel { @@ -47,26 +51,23 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro private(set) var tabID: NSManagedObjectID? { didSet { - #if os(macOS) +#if os(macOS) if let tabID, tabID != oldValue { storeTabIDInCurrentWindow() } - #endif +#endif } } - #if os(macOS) +#if os(macOS) private var windowURLs: [URL] { UserDefaults.standard[.windowURLs] } - #endif +#endif let webView: WKWebView private var canGoBackObserver: NSKeyValueObservation? private var canGoForwardObserver: NSKeyValueObservation? private var titleURLObserver: AnyCancellable? private var bookmarkFetchedResultsController: NSFetchedResultsController? - private let scriptHandler: BrowserScriptHandler - private let navDelegate: BrowserNavDelegate - private let uiDelegate: BrowserUIDelegate /// A temporary placeholder for the url that should be opened in a new tab, set on macOS only static var urlForNewTab: URL? private var cancellables: Set = [] @@ -76,33 +77,15 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) - scriptHandler = BrowserScriptHandler() - navDelegate = BrowserNavDelegate() - uiDelegate = BrowserUIDelegate() super.init() - scriptHandler.$outlineItems.assign(to: \.outlineItems, on: self).store(in: &cancellables) - scriptHandler.$outlineItemTree.assign(to: \.outlineItemTree, on: self).store(in: &cancellables) - - navDelegate.$externalURL.assign(to: \.externalURL, on: self).store(in: &cancellables) - - navDelegate.$didLoadContent.sink { [weak self] didLoad in - if didLoad == true { - self?.persistState() - } - }.store(in: &cancellables) - - uiDelegate.$externalURL.assign(to: \.externalURL, on: self).store(in: &cancellables) - uiDelegate.$createBookmark.sink { [weak self] url in self?.createBookmark(url: url) }.store(in: &cancellables) - uiDelegate.$deleteBookmark.sink { [weak self] url in self?.deleteBookmark(url: url) }.store(in: &cancellables) - // configure web view webView.allowsBackForwardNavigationGestures = true webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile // for font adjustment to work webView.configuration.userContentController.removeScriptMessageHandler(forName: "headings") - webView.configuration.userContentController.add(scriptHandler, name: "headings") - webView.navigationDelegate = navDelegate - webView.uiDelegate = uiDelegate + webView.configuration.userContentController.add(self, name: "headings") + webView.navigationDelegate = self + webView.uiDelegate = self if let tabID { restoreBy(tabID: tabID) @@ -175,7 +158,7 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro func persistState() { guard let tabID, - let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { + let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return } tab.interactionState = webView.interactionState as? Data @@ -190,29 +173,188 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro } func loadRandomArticle(zimFileID: UUID? = nil) { - guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimIdOf(zimFileID)) else { return } + let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") + guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return } load(url: url) } func loadMainArticle(zimFileID: UUID? = nil) { - guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimIdOf(zimFileID)) else { return } + let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") + guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return } load(url: url) } private func restoreBy(tabID: NSManagedObjectID) { if let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { - webView.interactionState = tab.interactionState - url = webView.url - } + webView.interactionState = tab.interactionState + url = webView.url + } + } + + // MARK: - WKNavigationDelegate + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } + if url.isKiwixURL, let redirectedURL = ZimFileService.shared.getRedirectedURL(url: url) { + if webView.url != redirectedURL { + DispatchQueue.main.async { webView.load(URLRequest(url: redirectedURL)) } + } + decisionHandler(.cancel) + } else if url.isKiwixURL { + decisionHandler(.allow) + } else if url.isExternal { + externalURL = url + decisionHandler(.cancel) + } else if url.scheme == "geo" { + if FeatureFlags.map { + let _: CLLocation? = { + let parts = url.absoluteString.replacingOccurrences(of: "geo:", with: "").split(separator: ",") + guard let latitudeString = parts.first, + let longitudeString = parts.last, + let latitude = Double(latitudeString), + let longitude = Double(longitudeString) else { return nil } + return CLLocation(latitude: latitude, longitude: longitude) + }() + } else { + let coordinate = url.absoluteString.replacingOccurrences(of: "geo:", with: "") + if let url = URL(string: "http://maps.apple.com/?ll=\(coordinate)") { +#if os(macOS) + NSWorkspace.shared.open(url) +#elseif os(iOS) + UIApplication.shared.open(url) +#endif + } + } + decisionHandler(.cancel) + } else { + decisionHandler(.cancel) + } + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + webView.evaluateJavaScript("expandAllDetailTags(); getOutlineItems();") +#if os(iOS) + webView.adjustTextSize() +#else + persistState() +#endif + } + + func webView( + _: WKWebView, + didFailProvisionalNavigation _: WKNavigation!, + withError error: Error + ) { + let error = error as NSError + guard error.code != NSURLErrorCancelled else { return } + NotificationCenter.default.post( + name: .alert, object: nil, userInfo: ["rawValue": ActiveAlert.articleFailedToLoad.rawValue] + ) + } + + // MARK: - WKScriptMessageHandler + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "headings", let headings = message.body as? [[String: String]] { + DispatchQueue.global(qos: .userInitiated).async { + self.generateOutlineList(headings: headings) + self.generateOutlineTree(headings: headings) + } + } } - private func zimIdOf(_ uuid: UUID? = nil) -> UUID? { - uuid ?? UUID(uuidString: webView.url?.host ?? "") + // MARK: - WKUIDelegate + +#if os(macOS) + func webView( + _: WKWebView, + createWebViewWith _: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures _: WKWindowFeatures + ) -> WKWebView? { + guard navigationAction.targetFrame == nil else { return nil } + guard let newUrl = navigationAction.request.url else { return nil } + + // open external link in default browser + guard newUrl.isExternal == false else { + externalURL = newUrl + return nil + } + + // create new tab + guard let currentWindow = NSApp.keyWindow, + let windowController = currentWindow.windowController else { return nil } + // store the new url in a static way + BrowserViewModel.urlForNewTab = newUrl + // this creates a new BrowserViewModel + windowController.newWindowForTab(self) + // now reset the static url to nil, as the new BrowserViewModel already has it + BrowserViewModel.urlForNewTab = nil + guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return nil } + currentWindow.addTabbedWindow(newWindow, ordered: .above) + return nil } +#endif + +#if os(iOS) + func webView( + _ webView: WKWebView, + contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, + completionHandler: @escaping (UIContextMenuConfiguration?) -> Void + ) { + guard let url = elementInfo.linkURL, url.isKiwixURL else { completionHandler(nil); return } + let configuration = UIContextMenuConfiguration( + previewProvider: { + let webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) + webView.load(URLRequest(url: url)) + return WebViewController(webView: webView) + }, actionProvider: { _ in + var actions = [UIAction]() + + // open url + actions.append( + UIAction(title: "Open", image: UIImage(systemName: "doc.text")) { _ in + webView.load(URLRequest(url: url)) + } + ) + actions.append( + UIAction(title: "Open in New Tab", image: UIImage(systemName: "doc.badge.plus")) { _ in + NotificationCenter.openURL(url, inNewTab: true) + } + ) + + // bookmark + let bookmarkAction: UIAction = { + let context = Database.viewContext + let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg) + let request = Bookmark.fetchRequest(predicate: predicate) + if let bookmarks = try? context.fetch(request), !bookmarks.isEmpty { + return UIAction(title: "Remove Bookmark", + image: UIImage(systemName: "star.slash.fill")) { [weak self] _ in + self?.deleteBookmark(url: url) + } + } else { + return UIAction(title: "Bookmark", image: UIImage(systemName: "star")) { [weak self] _ in + self?.createBookmark(url: url) + } + } + }() + actions.append(bookmarkAction) + + return UIMenu(children: actions) + } + ) + completionHandler(configuration) + } +#endif // MARK: - TabID management via NSWindow for macOS - #if os(macOS) +#if os(macOS) private (set) var windowNumber: Int? // RESTORATION @@ -258,7 +400,7 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro private func storeTabIDInCurrentWindow() { guard let tabID, - let windowNumber, + let windowNumber, let currentWindow = windowBy(number: windowNumber) else { return } @@ -269,7 +411,7 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro private func windowBy(number: Int) -> NSWindow? { NSApplication.shared.windows.first { $0.windowNumber == number } } - #endif +#endif private func createNewTabID() -> NSManagedObjectID { if let tabID { return tabID } @@ -323,5 +465,70 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsContro /// Scroll to an outline item /// - Parameter outlineItemID: ID of the outline item to scroll to - func scrollTo(outlineItemID: String) { webView.evaluateJavaScript("scrollToHeading('\(outlineItemID)')") } + func scrollTo(outlineItemID: String) { + webView.evaluateJavaScript("scrollToHeading('\(outlineItemID)')") + } + + /// Convert flattened heading element data to a list of OutlineItems. + /// - Parameter headings: list of heading element data retrieved from webview + private func generateOutlineList(headings: [[String: String]]) { + let allLevels = headings.compactMap { Int($0["tag"]?.suffix(1) ?? "") } + let offset = allLevels.filter { $0 == 1 }.count == 1 ? 2 : allLevels.min() ?? 0 + let outlineItems: [OutlineItem] = headings.enumerated().compactMap { index, heading in + guard let id = heading["id"], + let text = heading["text"], + let tag = heading["tag"], + let level = Int(tag.suffix(1)) else { return nil } + return OutlineItem(id: id, index: index, text: text, level: max(level - offset, 0)) + } + DispatchQueue.main.async { + self.outlineItems = outlineItems + } + } + + /// Convert flattened heading element data to a tree of OutlineItems. + /// - Parameter headings: list of heading element data retrieved from webview + private func generateOutlineTree(headings: [[String: String]]) { + let root = OutlineItem(index: -1, text: "", level: 0) + var stack: [OutlineItem] = [root] + var all = [String: OutlineItem]() + + headings.enumerated().forEach { index, heading in + guard let id = heading["id"], + let text = heading["text"], + let tag = heading["tag"], let level = Int(tag.suffix(1)) else { return } + let item = OutlineItem(id: id, index: index, text: text, level: level) + all[item.id] = item + + // get last item in stack + // if last item is child of item's sibling, unwind stack until a sibling is found + guard var lastItem = stack.last else { return } + while lastItem.level > item.level { + stack.removeLast() + lastItem = stack[stack.count - 1] + } + + // if item is last item's sibling, add item to parent and replace last item with itself in stack + // if item is last item's child, add item to parent and add item to stack + if lastItem.level == item.level { + stack[stack.count - 2].addChild(item) + stack[stack.count - 1] = item + } else if lastItem.level < item.level { + stack[stack.count - 1].addChild(item) + stack.append(item) + } + } + + // if there is only one h1, flatten one level + if let rootChildren = root.children, rootChildren.count == 1, let rootFirstChild = rootChildren.first { + let children = rootFirstChild.removeAllChildren() + DispatchQueue.main.async { + self.outlineItemTree = [rootFirstChild] + children + } + } else { + DispatchQueue.main.async { + self.outlineItemTree = root.children ?? [] + } + } + } } From 3c8672e5e148aff2dc1006eb0c0a7eaae35ce27b Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 20 Nov 2023 10:52:39 +0100 Subject: [PATCH 23/24] Revert file --- .swiftlint.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 2cdc81f8d..e75411cc2 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,4 @@ disabled_rules: - trailing_whitespace included: - - Views/Settings/ - - ViewModel \ No newline at end of file + - Views/Settings/ \ No newline at end of file From d5ebdecb2597fbb544bc7888cc00ede73d0b96a5 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Mon, 20 Nov 2023 11:54:19 +0100 Subject: [PATCH 24/24] Updated to binding --- Views/ViewModifiers/ExternalLinkHandler.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Views/ViewModifiers/ExternalLinkHandler.swift b/Views/ViewModifiers/ExternalLinkHandler.swift index 79fc82891..e35e83fc9 100644 --- a/Views/ViewModifiers/ExternalLinkHandler.swift +++ b/Views/ViewModifiers/ExternalLinkHandler.swift @@ -11,7 +11,6 @@ import SwiftUI import Defaults struct ExternalLinkHandler: ViewModifier { - @EnvironmentObject private var browserViewModel: BrowserViewModel @State private var isAlertPresented = false @State private var activeAlert: ActiveAlert? @State private var activeSheet: ActiveSheet?