From b7be0b44b2fe2121d24f8dda259db9864d6e48d8 Mon Sep 17 00:00:00 2001 From: Balazs Perlaki-Horvath Date: Sun, 19 Nov 2023 23:45:48 +0100 Subject: [PATCH] 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 ce6efe2c..7fcc0eca 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 85b8008d..2d15c186 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 f332dc82..ec070433 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 59191b9f..a846ce2a 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 1d65dada..45f7f471 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 a0b54572..3178a245 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) {