diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 3848f9fbf..795a7fcd1 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -76,10 +76,10 @@ final class AppDefaults { firstRunDate = Date() return true }() - - var windowState: [AnyHashable : Any]? { + + var windowState: Data? { get { - return UserDefaults.standard.object(forKey: Key.windowState) as? [AnyHashable : Any] + return UserDefaults.standard.object(forKey: Key.windowState) as? Data } set { UserDefaults.standard.set(newValue, forKey: Key.windowState) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 237849b52..912b1b723 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -308,6 +308,10 @@ var appDelegate: AppDelegate! return false } + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } + func applicationDidBecomeActive(_ notification: Notification) { fireOldTimers() } diff --git a/Mac/MainWindow/Detail/DetailViewController.swift b/Mac/MainWindow/Detail/DetailViewController.swift index fb1aa1917..43a2bb660 100644 --- a/Mac/MainWindow/Detail/DetailViewController.swift +++ b/Mac/MainWindow/Detail/DetailViewController.swift @@ -25,6 +25,10 @@ enum DetailState: Equatable { @IBOutlet var containerView: DetailContainerView! @IBOutlet var statusBarView: DetailStatusBarView! + var windowState: DetailWindowState { + return currentWebViewController.windowState + } + lazy var regularWebViewController = { return createWebViewController() }() @@ -89,12 +93,6 @@ enum DetailState: Equatable { window.makeFirstResponderUnlessDescendantIsFirstResponder(currentWebViewController.webView) } - // MARK: State Restoration - - func saveState(to state: inout [AnyHashable : Any]) { - currentWebViewController.saveState(to: &state) - } - // MARK: Find in Article private var didLoadTextFinder = false diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index c5b6e20c2..1ed8d2ddd 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -35,6 +35,10 @@ protocol DetailWebViewControllerDelegate: AnyObject { } } + var windowState: DetailWindowState { + return DetailWindowState(isShowingExtractedArticle: isShowingExtractedArticle, windowScrollY: windowScrollY ?? 0) + } + var article: Article? { switch state { case .article(let article, _): @@ -191,13 +195,6 @@ protocol DetailWebViewControllerDelegate: AnyObject { webView.scrollPageUp(sender) } - // MARK: State Restoration - - func saveState(to state: inout [AnyHashable : Any]) { - state[UserInfoKey.isShowingExtractedArticle] = isShowingExtractedArticle - state[UserInfoKey.articleWindowScrollY] = windowScrollY - } - // MARK: Find in Article var canFindInArticle: Bool { diff --git a/Mac/MainWindow/Detail/DetailWindowState.swift b/Mac/MainWindow/Detail/DetailWindowState.swift new file mode 100644 index 000000000..f76814353 --- /dev/null +++ b/Mac/MainWindow/Detail/DetailWindowState.swift @@ -0,0 +1,33 @@ +// +// DetailWindowState.swift +// NetNewsWire +// +// Created by Maurice Parker on 12/16/23. +// Copyright © 2023 Ranchero Software. All rights reserved. +// + +import Foundation + +class DetailWindowState: NSObject, NSSecureCoding { + + static var supportsSecureCoding = true + + let isShowingExtractedArticle: Bool + let windowScrollY: CGFloat + + internal init(isShowingExtractedArticle: Bool, windowScrollY: CGFloat) { + self.isShowingExtractedArticle = isShowingExtractedArticle + self.windowScrollY = windowScrollY + } + + required init?(coder: NSCoder) { + isShowingExtractedArticle = coder.decodeBool(forKey: "isShowingExtractedArticle") + windowScrollY = coder.decodeObject(of: NSNumber.self, forKey: "windowScrollY") as? CGFloat ?? 0 + } + + func encode(with coder: NSCoder) { + coder.encode(isShowingExtractedArticle, forKey: "isShowingExtractedArticle") + coder.encode(windowScrollY, forKey: "windowScrollY") + } + +} diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index 32d57548a..77f9ac1c9 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -133,12 +133,14 @@ enum TimelineSourceMode { } func saveStateToUserDefaults() { - AppDefaults.shared.windowState = savableState() + let data = try? NSKeyedArchiver.archivedData(withRootObject: savableState(), requiringSecureCoding: true) + AppDefaults.shared.windowState = data window?.saveFrame(usingName: windowAutosaveName) } func restoreStateFromUserDefaults() { - if let state = AppDefaults.shared.windowState { + if let data = AppDefaults.shared.windowState, + let state = try? NSKeyedUnarchiver.unarchivedObject(ofClass: MainWindowState.self, from: data) { restoreState(from: state) window?.setFrameUsingName(windowAutosaveName, force: true) } @@ -630,7 +632,7 @@ extension MainWindowController: NSWindowDelegate { } func window(_ window: NSWindow, didDecodeRestorableState coder: NSCoder) { - guard let state = try? coder.decodeTopLevelObject(forKey: UserInfoKey.windowState) as? [AnyHashable : Any] else { return } + guard let state = coder.decodeObject(of: MainWindowState.self, forKey: UserInfoKey.windowState) else { return } restoreState(from: state) } @@ -1087,31 +1089,39 @@ private extension MainWindowController { // MARK: - State Restoration - func savableState() -> [AnyHashable : Any] { - var state = [AnyHashable : Any]() - state[UserInfoKey.windowFullScreenState] = window?.styleMask.contains(.fullScreen) ?? false - saveSplitViewState(to: &state) - sidebarViewController?.saveState(to: &state) - timelineContainerViewController?.saveState(to: &state) - detailViewController?.saveState(to: &state) - return state + func savableState() -> MainWindowState { + let isFullScreen = window?.styleMask.contains(.fullScreen) ?? false + + let splitViewWidths: [Int] + if let splitView = splitViewController?.splitView { + splitViewWidths = splitView.arrangedSubviews.map{ Int(floor($0.frame.width)) } + } else { + splitViewWidths = [] + } + + let isSidebarHidden = sidebarSplitViewItem?.isCollapsed ?? false + + return MainWindowState(isFullScreen: isFullScreen, + splitViewWidths: splitViewWidths, + isSidebarHidden: isSidebarHidden, + sidebarWindowState: sidebarViewController?.windowState, + timelineWindowState: timelineContainerViewController?.windowState, + detailWindowState: detailViewController?.windowState) } - func restoreState(from state: [AnyHashable : Any]) { - if let fullScreen = state[UserInfoKey.windowFullScreenState] as? Bool, fullScreen { + func restoreState(from state: MainWindowState) { + if state.isFullScreen { window?.toggleFullScreen(self) } restoreSplitViewState(from: state) - sidebarViewController?.restoreState(from: state) + sidebarViewController?.restoreState(from: state.sidebarWindowState) - let articleWindowScrollY = state[UserInfoKey.articleWindowScrollY] as? CGFloat - restoreArticleWindowScrollY = articleWindowScrollY - timelineContainerViewController?.restoreState(from: state) - - let isShowingExtractedArticle = state[UserInfoKey.isShowingExtractedArticle] as? Bool ?? false + timelineContainerViewController?.restoreState(from: state.timelineWindowState) + restoreArticleWindowScrollY = state.detailWindowState?.windowScrollY + + let isShowingExtractedArticle = state.detailWindowState?.isShowingExtractedArticle as? Bool ?? false if isShowingExtractedArticle { - restoreArticleWindowScrollY = articleWindowScrollY startArticleExtractorForCurrentLink() } @@ -1379,29 +1389,17 @@ private extension MainWindowController { } } - func saveSplitViewState(to state: inout [AnyHashable : Any]) { - guard let splitView = splitViewController?.splitView else { - return - } - - let widths = splitView.arrangedSubviews.map{ Int(floor($0.frame.width)) } - state[MainWindowController.mainWindowWidthsStateKey] = widths - - state[UserInfoKey.isSidebarHidden] = sidebarSplitViewItem?.isCollapsed - } - - func restoreSplitViewState(from state: [AnyHashable : Any]) { + func restoreSplitViewState(from state: MainWindowState) { guard let splitView = splitViewController?.splitView, - let widths = state[MainWindowController.mainWindowWidthsStateKey] as? [Int], - widths.count == 3, - let window = window else { - return + state.splitViewWidths.count == 3, + let window = window else { + return } let windowWidth = Int(floor(window.frame.width)) let dividerThickness: Int = Int(splitView.dividerThickness) - let sidebarWidth: Int = widths[0] - let timelineWidth: Int = widths[1] + let sidebarWidth: Int = state.splitViewWidths[0] + let timelineWidth: Int = state.splitViewWidths[1] // Make sure the detail view has its minimum thickness, at least. if windowWidth < sidebarWidth + dividerThickness + timelineWidth + dividerThickness + MainWindowController.detailViewMinimumThickness { @@ -1410,11 +1408,9 @@ private extension MainWindowController { splitView.setPosition(CGFloat(sidebarWidth), ofDividerAt: 0) splitView.setPosition(CGFloat(sidebarWidth + dividerThickness + timelineWidth), ofDividerAt: 1) - - let isSidebarHidden = state[UserInfoKey.isSidebarHidden] as? Bool ?? false - - if !(sidebarSplitViewItem?.isCollapsed ?? false) && isSidebarHidden { - sidebarSplitViewItem?.isCollapsed = true + + Task { @MainActor in + sidebarSplitViewItem?.isCollapsed = state.isSidebarHidden } } diff --git a/Mac/MainWindow/MainWindowState.swift b/Mac/MainWindow/MainWindowState.swift new file mode 100644 index 000000000..b7fafd32b --- /dev/null +++ b/Mac/MainWindow/MainWindowState.swift @@ -0,0 +1,50 @@ +// +// MainWindowState.swift +// NetNewsWire +// +// Created by Maurice Parker on 12/16/23. +// Copyright © 2023 Ranchero Software. All rights reserved. +// + +import Foundation + +class MainWindowState: NSObject, NSSecureCoding { + + static var supportsSecureCoding = true + + let isFullScreen: Bool + let splitViewWidths: [Int] + let isSidebarHidden: Bool + let sidebarWindowState: SidebarWindowState? + let timelineWindowState: TimelineWindowState? + let detailWindowState: DetailWindowState? + + init(isFullScreen: Bool, splitViewWidths: [Int], isSidebarHidden: Bool, sidebarWindowState: SidebarWindowState? = nil, timelineWindowState: TimelineWindowState? = nil, detailWindowState: DetailWindowState? = nil) { + self.isFullScreen = isFullScreen + self.splitViewWidths = splitViewWidths + self.isSidebarHidden = isSidebarHidden + self.sidebarWindowState = sidebarWindowState + self.timelineWindowState = timelineWindowState + self.detailWindowState = detailWindowState + } + + required init?(coder: NSCoder) { + isFullScreen = coder.decodeBool(forKey: "isFullScreen") + splitViewWidths = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "splitViewWidths") as? [Int] ?? [] + isSidebarHidden = coder.decodeBool(forKey: "isSidebarHidden") + sidebarWindowState = coder.decodeObject(of: SidebarWindowState.self, forKey: "sidebarWindowState") + timelineWindowState = coder.decodeObject(of: TimelineWindowState.self, forKey: "timelineWindowState") + detailWindowState = coder.decodeObject(of: DetailWindowState.self, forKey: "detailWindowState") + } + + + func encode(with coder: NSCoder) { + coder.encode(isFullScreen, forKey: "isFullScreen") + coder.encode(splitViewWidths, forKey: "splitViewWidths") + coder.encode(isSidebarHidden, forKey: "isSidebarHidden") + coder.encode(sidebarWindowState, forKey: "sidebarWindowState") + coder.encode(timelineWindowState, forKey: "timelineWindowState") + coder.encode(detailWindowState, forKey: "detailWindowState") + } + +} diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index a859e374e..03fd5d3a6 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -31,6 +31,12 @@ protocol SidebarDelegate: AnyObject { weak var splitViewItem: NSSplitViewItem? + var windowState: SidebarWindowState { + let expandedContainers = expandedTable.compactMap { $0.userInfo as? [String: String] } + let selectedFeeds = selectedFeeds.compactMap { $0.itemID?.userInfo as? [String: String] } + return SidebarWindowState(isReadFiltered: isReadFiltered, expandedContainers: expandedContainers, selectedFeeds: selectedFeeds) + } + private let rebuildTreeAndRestoreSelectionQueue = CoalescingQueue(name: "Rebuild Tree Queue", interval: 1.0) let treeControllerDelegate = FeedTreeControllerDelegate() lazy var treeController: TreeController = { @@ -97,27 +103,14 @@ protocol SidebarDelegate: AnyObject { // MARK: State Restoration - func saveState(to state: inout [AnyHashable : Any]) { - state[UserInfoKey.readFeedsFilterState] = isReadFiltered - state[UserInfoKey.containerExpandedWindowState] = expandedTable.map { $0.userInfo } - state[UserInfoKey.selectedFeedsState] = selectedFeeds.compactMap { $0.itemID?.userInfo } - } - - func restoreState(from state: [AnyHashable : Any]) { + func restoreState(from state: SidebarWindowState?) { + guard let state else { return } - if let containerExpandedWindowState = state[UserInfoKey.containerExpandedWindowState] as? [[AnyHashable: AnyHashable]] { - let containerIdentifers = containerExpandedWindowState.compactMap( { ContainerIdentifier(userInfo: $0) }) - expandedTable = Set(containerIdentifers) - } + let containerIdentifers = state.expandedContainers.compactMap( { ContainerIdentifier(userInfo: $0) }) + expandedTable = Set(containerIdentifers) - guard let selectedFeedsState = state[UserInfoKey.selectedFeedsState] as? [[AnyHashable: AnyHashable]] else { - return - } - - let selectedItemIdentifers = Set(selectedFeedsState.compactMap( { ItemIdentifier(userInfo: $0) })) - for selectedItemIdentifier in selectedItemIdentifers { - treeControllerDelegate.addFilterException(selectedItemIdentifier) - } + let selectedItemIdentifers = Set(state.selectedFeeds.compactMap( { ItemIdentifier(userInfo: $0) })) + selectedItemIdentifers.forEach { treeControllerDelegate.addFilterException($0) } rebuildTreeAndReloadDataIfNeeded() @@ -135,9 +128,7 @@ protocol SidebarDelegate: AnyObject { outlineView.selectRowIndexes(selectIndexes, byExtendingSelection: false) focus() - if let readFeedsFilterState = state[UserInfoKey.readFeedsFilterState] as? Bool { - isReadFiltered = readFeedsFilterState - } + isReadFiltered = state.isReadFiltered } // MARK: - Notifications diff --git a/Mac/MainWindow/Sidebar/SidebarWindowState.swift b/Mac/MainWindow/Sidebar/SidebarWindowState.swift new file mode 100644 index 000000000..2011362bb --- /dev/null +++ b/Mac/MainWindow/Sidebar/SidebarWindowState.swift @@ -0,0 +1,37 @@ +// +// SidebarWindowState.swift +// NetNewsWire +// +// Created by Maurice Parker on 12/16/23. +// Copyright © 2023 Ranchero Software. All rights reserved. +// + +import Foundation + +class SidebarWindowState: NSObject, NSSecureCoding { + + static var supportsSecureCoding = true + + let isReadFiltered: Bool + let expandedContainers: [[String: String]] + let selectedFeeds: [[String: String]] + + init(isReadFiltered: Bool, expandedContainers: [[String : String]], selectedFeeds: [[String : String]]) { + self.isReadFiltered = isReadFiltered + self.expandedContainers = expandedContainers + self.selectedFeeds = selectedFeeds + } + + required init?(coder: NSCoder) { + isReadFiltered = coder.decodeBool(forKey: "isReadFiltered") + expandedContainers = coder.decodeObject(of: [NSArray.self, NSDictionary.self, NSString.self], forKey: "expandedContainers") as? [[String: String]] ?? [] + selectedFeeds = coder.decodeObject(of: [NSArray.self, NSDictionary.self, NSString.self], forKey: "selectedFeeds") as? [[String: String]] ?? [] + } + + func encode(with coder: NSCoder) { + coder.encode(isReadFiltered, forKey: "isReadFiltered") + coder.encode(expandedContainers, forKey: "expandedContainers") + coder.encode(selectedFeeds, forKey: "selectedFeeds") + } + +} diff --git a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift index 4ad5c16b5..e6b1bee2b 100644 --- a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift @@ -37,6 +37,10 @@ protocol TimelineContainerViewControllerDelegate: AnyObject { view?.window?.recalculateKeyViewLoop() } } + + var windowState: TimelineWindowState? { + return currentTimelineViewController?.windowState + } weak var delegate: TimelineContainerViewControllerDelegate? @@ -125,11 +129,9 @@ protocol TimelineContainerViewControllerDelegate: AnyObject { // MARK: State Restoration - func saveState(to state: inout [AnyHashable : Any]) { - regularTimelineViewController.saveState(to: &state) - } - - func restoreState(from state: [AnyHashable : Any]) { + func restoreState(from state: TimelineWindowState?) { + guard let state else { return } + regularTimelineViewController.restoreState(from: state) updateReadFilterButton() } diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index ea753ac16..759a6c44f 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -79,6 +79,24 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } } + var windowState: TimelineWindowState { + let readArticlesFilterStateKeys = readFilterEnabledTable.keys.compactMap { $0.userInfo as? [String: String] } + let readArticlesFilterStateValues = readFilterEnabledTable.values.compactMap( { $0 }) + + if selectedArticles.count == 1 { + let path = selectedArticles.first!.pathUserInfo + return TimelineWindowState(readArticlesFilterStateKeys: readArticlesFilterStateKeys, + readArticlesFilterStateValues: readArticlesFilterStateValues, + selectedAccountID: path[ArticlePathKey.accountID] as? String, + selectedArticleID: path[ArticlePathKey.articleID] as? String) + } else { + return TimelineWindowState(readArticlesFilterStateKeys: readArticlesFilterStateKeys, + readArticlesFilterStateValues: readArticlesFilterStateValues, + selectedAccountID: nil, + selectedArticleID: nil) + } + + } weak var delegate: TimelineDelegate? var sharingServiceDelegate: NSSharingServiceDelegate? @@ -289,36 +307,21 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr // MARK: State Restoration - func saveState(to state: inout [AnyHashable : Any]) { - state[UserInfoKey.readArticlesFilterStateKeys] = readFilterEnabledTable.keys.compactMap { $0.userInfo } - state[UserInfoKey.readArticlesFilterStateValues] = readFilterEnabledTable.values.compactMap( { $0 }) - - if selectedArticles.count == 1 { - state[UserInfoKey.articlePath] = selectedArticles.first!.pathUserInfo - } - } - - func restoreState(from state: [AnyHashable : Any]) { - guard let readArticlesFilterStateKeys = state[UserInfoKey.readArticlesFilterStateKeys] as? [[AnyHashable: AnyHashable]], - let readArticlesFilterStateValues = state[UserInfoKey.readArticlesFilterStateValues] as? [Bool] else { - return - } - - for i in 0..