// // AppDefaults.swift // NetNewsWire // // Created by Brent Simmons on 1/25/25. // Copyright © 2025 Ranchero Software. All rights reserved. // import Foundation #if os(macOS) import AppKit #endif struct AppDefaults { enum Key: String { case firstRunDate case lastImageCacheFlushDate case timelineGroupByFeed case timelineSortDirection case addFeedAccountID case addFeedFolderName case addFolderAccountID case currentThemeName case articleContentJavascriptEnabled #if os(macOS) case windowState case sidebarFontSize case timelineFontSize case detailFontSize case openInBrowserInBackground case subscribeToFeedsInDefaultBrowser case articleTextSize case refreshInterval case importOPMLAccountID case exportOPMLAccountID case defaultBrowserID case webInspectorEnabled = "WebInspectorEnabled" case webInspectorStartsAttached = "__WebInspectorPageGroupLevel1__.WebKit2InspectorStartsAttached" // Hidden prefs case showDebugMenu case timelineShowsSeparators = "CorreiaSeparators" case showTitleOnMainWindow = "KafasisTitleMode" case feedDoubleClickMarkAsRead = "GruberFeedDoubleClickMarkAsRead" case suppressSyncOnLaunch = "DevroeSuppressSyncOnLaunch" #elseif os(iOS) case userInterfaceColorPalette case refreshClearsReadArticles case timelineNumberOfLines case timelineIconDimension = "timelineIconSize" case articleFullscreenAvailable case articleFullscreenEnabled case confirmMarkAllAsRead case lastRefresh case useSystemBrowser #endif } static let defaultThemeName = "Default" static let isDeveloperBuild: Bool = { if let dev = Bundle.main.object(forInfoDictionaryKey: "DeveloperEntitlements") as? String, dev == "-dev" { return true } return false }() static let isFirstRun: Bool = { if firstRunDate == nil { firstRunDate = Date() return true } return false }() #if os(macOS) static func registerDefaults() { #if DEBUG let showDebugMenu = true #else let showDebugMenu = false #endif let defaults: [String: Any] = [ Key.sidebarFontSize.rawValue: FontSize.medium.rawValue, Key.timelineFontSize.rawValue: FontSize.medium.rawValue, Key.detailFontSize.rawValue: FontSize.medium.rawValue, Key.timelineSortDirection.rawValue: ComparisonResult.orderedDescending.rawValue, Key.timelineGroupByFeed.rawValue: false, Key.refreshInterval.rawValue: RefreshInterval.everyHour.rawValue, Key.showDebugMenu.rawValue: showDebugMenu, Key.currentThemeName.rawValue: Self.defaultThemeName, Key.articleContentJavascriptEnabled.rawValue: true, "NSScrollViewShouldScrollUnderTitlebar": false ] UserDefaults.standard.register(defaults: defaults) } #elseif os(iOS) static func registerDefaults() { // TODO: migrate all (or as many as possible) out of shared let sharedDefaults: [String: Any] = [ Key.userInterfaceColorPalette.rawValue: UserInterfaceColorPalette.automatic.rawValue, Key.timelineGroupByFeed.rawValue: false, Key.refreshClearsReadArticles.rawValue: false, Key.timelineNumberOfLines.rawValue: 2, Key.timelineIconDimension.rawValue: IconSize.medium.rawValue, Key.timelineSortDirection.rawValue: ComparisonResult.orderedDescending.rawValue, Key.articleFullscreenAvailable.rawValue: false, Key.articleFullscreenEnabled.rawValue: false, Key.confirmMarkAllAsRead.rawValue: true ] appGroupStorage.register(defaults: sharedDefaults) let defaults: [String: Any] = [ Key.currentThemeName.rawValue: Self.defaultThemeName, Key.articleContentJavascriptEnabled.rawValue: true ] UserDefaults.standard.register(defaults: defaults) } #endif static var addFeedAccountID: String? { get { string(key: .addFeedAccountID) } set { setString(newValue, key: .addFeedAccountID) } } static var addFeedFolderName: String? { get { string(key: .addFeedFolderName) } set { setString(newValue, key: .addFeedFolderName) } } static var addFolderAccountID: String? { get { string(key: .addFolderAccountID) } set { setString(newValue, key: .addFolderAccountID) } } static var currentThemeName: String? { get { string(key: .currentThemeName) } set { setString(newValue, key: .currentThemeName) } } static var isArticleContentJavascriptEnabled: Bool { get { bool(key: .articleContentJavascriptEnabled) } set { setBool(newValue, key: .articleContentJavascriptEnabled) } } #if os(macOS) static var timelineGroupByFeed: Bool { get { bool(key: .timelineGroupByFeed) } set { setBool(newValue, key: .timelineGroupByFeed) } } #elseif os(iOS) static var timelineGroupByFeed: Bool { // TODO: migrate to not shared get { sharedBool(key: .timelineGroupByFeed) } set { setSharedBool(newValue, key: .timelineGroupByFeed) } } #endif #if os(macOS) static var timelineSortDirection: ComparisonResult { get { sortDirection(key: .timelineSortDirection) } set { setSortDirection(newValue, key: .timelineSortDirection) } } #else static var timelineSortDirection: ComparisonResult { // TODO: migrate to not shared get { sharedSortDirection(key: .timelineSortDirection) } set { setSharedSortDirection(newValue, key: .timelineSortDirection) } } #endif #if os(macOS) static var lastImageCacheFlushDate: Date? { get { date(key: .lastImageCacheFlushDate) } set { setDate(newValue, key: .lastImageCacheFlushDate) } } #else static var lastImageCacheFlushDate: Date? { // TODO: migrate to not shared get { sharedDate(key: .lastImageCacheFlushDate) } set { setSharedDate(newValue, key: .lastImageCacheFlushDate) } } #endif } // MARK: - Mac-only Defaults #if os(macOS) extension AppDefaults { static var windowState: [AnyHashable: Any]? { get { UserDefaults.standard.object(forKey: Key.windowState.rawValue) as? [AnyHashable: Any] } set { UserDefaults.standard.set(newValue, forKey: Key.windowState.rawValue) } } static var sidebarFontSize: FontSize { get { fontSize(key: .sidebarFontSize) } set { setFontSize(newValue, key: .sidebarFontSize) } } static var timelineFontSize: FontSize { get { fontSize(key: .timelineFontSize) } set { setFontSize(newValue, key: .timelineFontSize) } } static var detailFontSize: FontSize { get { fontSize(key: .detailFontSize) } set { setFontSize(newValue, key: .detailFontSize) } } static var importOPMLAccountID: String? { get { string(key: .importOPMLAccountID) } set { setString(newValue, key: .importOPMLAccountID) } } static var exportOPMLAccountID: String? { get { string(key: .exportOPMLAccountID) } set { setString(newValue, key: .exportOPMLAccountID) } } static var defaultBrowserID: String? { get { string(key: .defaultBrowserID) } set { setString(newValue, key: .defaultBrowserID) } } static var refreshInterval: RefreshInterval { get { let rawValue = int(key: .refreshInterval) return RefreshInterval(rawValue: rawValue) ?? RefreshInterval.everyHour } set { setInt(newValue.rawValue, key: .refreshInterval) } } static var openInBrowserInBackground: Bool { get { bool(key: .openInBrowserInBackground) } set { setBool(newValue, key: .openInBrowserInBackground) } } /// Shared with Subscribe to Feed Safari extension. static var subscribeToFeedsInDefaultBrowser: Bool { get { sharedBool(key: .subscribeToFeedsInDefaultBrowser) } set { setSharedBool(newValue, key: .subscribeToFeedsInDefaultBrowser) } } static var articleTextSize: ArticleTextSize { get { let rawValue = int(key: .articleTextSize) return ArticleTextSize(rawValue: rawValue) ?? ArticleTextSize.large } set { setInt(newValue.rawValue, key: .articleTextSize) } } static var webInspectorEnabled: Bool { get { bool(key: .webInspectorEnabled) } set { setBool(newValue, key: .webInspectorEnabled) } } static var webInspectorStartsAttached: Bool { get { bool(key: .webInspectorStartsAttached) } set { setBool(newValue, key: .webInspectorStartsAttached) } } private static let smallestFontSizeRawValue = FontSize.small.rawValue private static let largestFontSizeRawValue = FontSize.veryLarge.rawValue static func fontSize(key: Key) -> FontSize { // Punted till after 1.0. return .medium // var rawFontSize = int(for: key) // if rawFontSize < smallestFontSizeRawValue { // rawFontSize = smallestFontSizeRawValue // } // if rawFontSize > largestFontSizeRawValue { // rawFontSize = largestFontSizeRawValue // } // return FontSize(rawValue: rawFontSize)! } static func setFontSize(_ fontSize: FontSize, key: Key) { setInt(fontSize.rawValue, key: key) } static func actualFontSize(for fontSize: FontSize) -> CGFloat { switch fontSize { case .small: return NSFont.systemFontSize case .medium: return actualFontSize(for: .small) + 1.0 case .large: return actualFontSize(for: .medium) + 4.0 case .veryLarge: return actualFontSize(for: .large) + 8.0 } } // MARK: - Hidden prefs static var showTitleOnMainWindow: Bool { bool(key: .showTitleOnMainWindow) } static var showDebugMenu: Bool { bool(key: .showDebugMenu) } static var suppressSyncOnLaunch: Bool { bool(key: .suppressSyncOnLaunch) } static var timelineShowsSeparators: Bool { bool(key: .timelineShowsSeparators) } static var feedDoubleClickMarkAsRead: Bool { bool(key: .feedDoubleClickMarkAsRead) } } #endif // MARK: - iOS-only Defaults #if os(iOS) extension AppDefaults { static var userInterfaceColorPalette: UserInterfaceColorPalette { // TODO: migrate to not shared get { if let result = UserInterfaceColorPalette(rawValue: sharedInt(key: .userInterfaceColorPalette)) { return result } return .automatic } set { setSharedInt(newValue.rawValue, key: .userInterfaceColorPalette) } } static var refreshClearsReadArticles: Bool { // TODO: migrate to not shared get { sharedBool(key: .refreshClearsReadArticles) } set { setSharedBool(newValue, key: .refreshClearsReadArticles) } } static var useSystemBrowser: Bool { get { bool(key: .useSystemBrowser) } set { setBool(newValue, key: .useSystemBrowser) } } static var timelineIconSize: IconSize { // TODO: migrate to not shared get { let rawValue = sharedInt(key: .timelineIconDimension) return IconSize(rawValue: rawValue) ?? IconSize.medium } set { setSharedInt(newValue.rawValue, key: .timelineIconDimension) } } static var articleFullscreenAvailable: Bool { // TODO: migrate to not shared get { sharedBool(key: .articleFullscreenAvailable) } set { setSharedBool(newValue, key: .articleFullscreenAvailable) } } static var articleFullscreenEnabled: Bool { // TODO: migrate to not shared get { sharedBool(key: .articleFullscreenEnabled) } set { setSharedBool(newValue, key: .articleFullscreenEnabled) } } static var logicalArticleFullscreenEnabled: Bool { articleFullscreenAvailable && articleFullscreenEnabled } static var confirmMarkAllAsRead: Bool { // TODO: migrate to not shared get { sharedBool(key: .confirmMarkAllAsRead) } set { setSharedBool(newValue, key: .confirmMarkAllAsRead) } } static var lastRefresh: Date? { // TODO: migrate to not shared get { sharedDate(key: .lastRefresh) } set { setSharedDate(newValue, key: .lastRefresh) } } static var timelineNumberOfLines: Int { // TODO: migrate to not shared get { sharedInt(key: .timelineNumberOfLines) } set { setSharedInt(newValue, key: .timelineNumberOfLines) } } } #endif // MARK: - Private private extension AppDefaults { #if os(macOS) static var firstRunDate: Date? { get { date(key: .firstRunDate) } set { setDate(newValue, key: .firstRunDate) } } #elseif os(iOS) static var firstRunDate: Date? { get { sharedDate(key: .firstRunDate) } set { setSharedDate(newValue, key: .firstRunDate) } } #endif static func bool(key: Key) -> Bool { UserDefaults.standard.bool(forKey: key.rawValue) } static func setBool(_ flag: Bool, key: Key) { UserDefaults.standard.set(flag, forKey: key.rawValue) } static func int(key: Key) -> Int { UserDefaults.standard.integer(forKey: key.rawValue) } static func setInt(_ x: Int, key: Key) { UserDefaults.standard.set(x, forKey: key.rawValue) } static func date(key: Key) -> Date? { UserDefaults.standard.object(forKey: key.rawValue) as? Date } static func setDate(_ date: Date?, key: Key) { UserDefaults.standard.set(date, forKey: key.rawValue) } static func string(key: Key) -> String? { return UserDefaults.standard.string(forKey: key.rawValue) } static func setString(_ value: String?, key: Key) { UserDefaults.standard.set(value, forKey: key.rawValue) } static func sortDirection(key: Key) -> ComparisonResult { let rawInt = int(key: key) if rawInt == ComparisonResult.orderedAscending.rawValue { return .orderedAscending } return .orderedDescending } static func setSortDirection(_ value: ComparisonResult, key: Key) { if value == .orderedAscending { setInt(ComparisonResult.orderedAscending.rawValue, key: key) } else { setInt(ComparisonResult.orderedDescending.rawValue, key: key) } } } // MARK: - App Group Storage // These are for preferences that are shared between the app and extensions and widgets. // These are to be used *only* for preferences for that are actually shared, which should be rare. private extension AppDefaults { static var appGroupStorage: UserDefaults = { #if os(macOS) let appGroupSuiteName = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String #elseif os(iOS) let appIdentifierPrefix = Bundle.main.object(forInfoDictionaryKey: "AppIdentifierPrefix") as! String let appGroupSuiteName = "\(appIdentifierPrefix)group.\(Bundle.main.bundleIdentifier!)" #endif return UserDefaults(suiteName: appGroupSuiteName)! }() static func sharedBool(key: Key) -> Bool { appGroupStorage.bool(forKey: key.rawValue) } static func setSharedBool(_ flag: Bool, key: Key) { appGroupStorage.set(flag, forKey: key.rawValue) } #if os(iOS) static func sharedInt(key: Key) -> Int { appGroupStorage.integer(forKey: key.rawValue) } static func setSharedInt(_ x: Int, key: Key) { appGroupStorage.set(x, forKey: key.rawValue) } static func sharedDate(key: Key) -> Date? { appGroupStorage.object(forKey: key.rawValue) as? Date } static func setSharedDate(_ date: Date?, key: Key) { appGroupStorage.set(date, forKey: key.rawValue) } static func sharedSortDirection(key: Key) -> ComparisonResult { let rawInt = sharedInt(key: key) if rawInt == ComparisonResult.orderedAscending.rawValue { return .orderedAscending } return .orderedDescending } static func setSharedSortDirection(_ value: ComparisonResult, key: Key) { if value == .orderedAscending { setSharedInt(ComparisonResult.orderedAscending.rawValue, key: key) } else { setSharedInt(ComparisonResult.orderedDescending.rawValue, key: key) } } #endif }