MacOS window restoration fixes

This commit is contained in:
Balazs Perlaki-Horvath 2023-11-19 23:45:48 +01:00
parent 9c7779ac24
commit b7be0b44b2
6 changed files with 230 additions and 56 deletions

View File

@ -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

View File

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

View File

@ -37,6 +37,11 @@ extension Defaults.Keys {
static let downloadUsingCellular = Key<Bool>("downloadUsingCellular", default: false)
static let backupDocumentDirectory = Key<Bool>("backupDocumentDirectory", default: false)
#if os(macOS)
// window management:
static let windowURLs = Key<[URL]>("windowURLs", default: [])
#endif
}
extension Defaults.Serializable where Self: Codable {

View File

@ -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
}

View File

@ -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<NSFetchRequestResult>,

View File

@ -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) {