NetNewsWire/Shared/Defaults/AppDefaults.swift

663 lines
15 KiB
Swift

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