// swiftlint:disable file_length import SwiftUI @MainActor class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_length // MARK: - Stored Properties @AppStorage("detailViewQuality") var detailViewQuality: BooruPostFileType = .original @AppStorage("thumbnailQuality") private var _thumbnailQuality: BooruPostFileType = .preview @AppStorage("searchSuggestionsMode") var searchSuggestionsMode: SettingsSearchSuggestionsMode = .disabled @AppStorage("thumbnailGridColumns") var thumbnailGridColumns = 2 @AppStorage("enableShareShortcut") var enableShareShortcut = false @AppStorage("displayDetailsInformationBar") var displayDetailsInformationBar = true @AppStorage("preloadedCarouselImages") var preloadedCarouselImages = 3 @AppStorage("enableSync") var enableSync: Bool = false @AppStorage("alternativeThumbnailGrid") var alternativeThumbnailGrid = false @AppStorage("uniformThumbnailGrid") private var _uniformThumbnailGrid: Bool = false @preconcurrency private var syncObservation: (any NSObjectProtocol & Sendable)? #if os(macOS) @AppStorage("saveTagsToFile") var saveTagsToFile = false #endif // MARK: - Private Properties private var bookmarksCache: [SettingsBookmark] = [] private var searchHistoryCache: [BooruSearchQuery] = [] private var blurRatingsCache: [BooruRating] = [] private var displayRatingsCache: [BooruRating] = [] private var uniformThumbnailGridCache: Bool = false private var thumbnailQualityCache: BooruPostFileType = .preview private var isUpdatingCache = false private var pendingSyncKeys: Set = [] // MARK: - Codable Properties @AppStorage("bookmarks") private var bookmarksData = Data() @AppStorage("displayRatings") private var displayRatingsData = SettingsManager.encode(BooruRating.allCases) ?? Data() @AppStorage("blurRatings") private var blurRatingsData = SettingsManager.encode([.explicit as BooruRating]) ?? Data() @AppStorage("searchHistory") private var searchHistoryData = Data() @AppStorage("preferredBooru") private var preferredBooruData = Data() @AppStorage("customProviders") private var customProvidersData = Data() @AppStorage("folders") private var foldersData = Data() @AppStorage("providerCredentials") private var providerCredentialsData = Data() // MARK: - Computed Properties var bookmarks: [SettingsBookmark] { get { bookmarksCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "bookmarks", localData: $bookmarksData, newValue: newValue ) { $0.sorted { $0.date > $1.date } } bookmarksCache = newValue.sorted { $0.date > $1.date } pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } } var uniformThumbnailGrid: Bool { get { uniformThumbnailGridCache } set { _uniformThumbnailGrid = newValue uniformThumbnailGridCache = newValue } } var thumbnailQuality: BooruPostFileType { get { thumbnailQualityCache } set { _thumbnailQuality = newValue thumbnailQualityCache = newValue } } var displayRatings: [BooruRating] { get { displayRatingsCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } displayRatingsData = Self.encode(newValue) ?? displayRatingsData displayRatingsCache = newValue } } var blurRatings: [BooruRating] { get { blurRatingsCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } blurRatingsData = Self.encode(newValue) ?? blurRatingsData blurRatingsCache = newValue } } var searchHistory: [BooruSearchQuery] { get { searchHistoryCache } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "searchHistory", localData: $searchHistoryData, newValue: newValue, ) { $0.sorted { $0.date > $1.date } } searchHistoryCache = newValue.sorted { $0.date > $1.date } pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } } var preferredBooru: BooruProvider { get { Self.decode(BooruProvider.self, from: preferredBooruData) ?? .safebooru } set { preferredBooruData = Self.encode(newValue) ?? preferredBooruData } } var customProviders: [BooruProviderCustom] { get { syncableData( key: "customProviders", localData: customProvidersData, sort: { $0 }, identifier: { $0.id } ) } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "customProviders", localData: $customProvidersData, newValue: newValue, ) { $0 } pendingSyncKeys.insert(.customProviders) triggerBatchedSync() } } var folders: [SettingsFolder] { get { syncableData( key: "folders", localData: foldersData, sort: { $0 }, identifier: { $0.id } ) } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "folders", localData: $foldersData, newValue: newValue, ) { $0 } } } // MARK: Provider Credentials var providerCredentials: [BooruProviderCredentials] { get { syncableData( key: "providerAPIKeys", localData: providerCredentialsData, sort: { $0 }, identifier: { $0.id } ) } set { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let existingCredentials: [BooruProviderCredentials] = Self.decode([BooruProviderCredentials].self, from: providerCredentialsData) ?? [] let rawCredentials = newValue.map { credentials in (provider: credentials.provider, apiKey: credentials.apiKey, userID: credentials.userID) } let mergedCredentials = BooruProviderCredentials.from( rawCredentials, existingCredentials: existingCredentials ) syncableData( key: "providerAPIKeys", localData: $providerCredentialsData, newValue: mergedCredentials, ) { $0 } } } private func mergedCredentialValues( extract: (BooruProviderCredentials) -> Value, isDefault: (Value) -> Bool ) -> [BooruProvider: Value] { providerCredentials.reduce(into: [BooruProvider: Value]()) { dictionary, credentials in let key = credentials.provider let value = extract(credentials) if let existing = dictionary[key] { if isDefault(existing) && !isDefault(value) { dictionary[key] = value } } else { dictionary[key] = value } } } var providerAPIKeys: [BooruProvider: String] { mergedCredentialValues( extract: { $0.apiKey }, isDefault: { $0.isEmpty } ) } var providerUserIDs: [BooruProvider: Int] { mergedCredentialValues( extract: { $0.userID }, isDefault: { $0 == 0 } ) } // MARK: - Initialisation init() { syncObservation = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default, queue: .main ) { [weak self] _ in Task { @MainActor in self?.syncFromCloud() } } loadBookmarksCache() loadSearchHistoryCache() loadDisplayRatingsCache() loadBlurRatingsCache() uniformThumbnailGridCache = _uniformThumbnailGrid thumbnailQualityCache = _thumbnailQuality } func updateBookmarks(_ newValue: [SettingsBookmark]) async { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "bookmarks", localData: $bookmarksData, newValue: newValue ) { $0.sorted { $0.date > $1.date } } bookmarksCache = newValue.sorted { $0.date > $1.date } await backupBookmarks() pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } func updateSearchHistory(_ newValue: [BooruSearchQuery]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } syncableData( key: "searchHistory", localData: $searchHistoryData, newValue: newValue, ) { $0.sorted { $0.date > $1.date } } searchHistoryCache = newValue.sorted { $0.date > $1.date } pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } func updateDisplayRatings(_ newValue: [BooruRating]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } displayRatingsData = Self.encode(newValue) ?? displayRatingsData displayRatingsCache = newValue } func updateBlurRatings(_ newValue: [BooruRating]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } blurRatingsData = Self.encode(newValue) ?? blurRatingsData blurRatingsCache = newValue } func updateProviderCredentials(_ newValue: [BooruProviderCredentials]) { guard !isUpdatingCache else { return } isUpdatingCache = true defer { isUpdatingCache = false } let existingCredentials: [BooruProviderCredentials] = Self.decode([BooruProviderCredentials].self, from: providerCredentialsData) ?? [] let rawCredentials = newValue.map { credentials in (provider: credentials.provider, apiKey: credentials.apiKey, userID: credentials.userID) } let mergedCredentials = BooruProviderCredentials.from( rawCredentials, existingCredentials: existingCredentials ) syncableData( key: "providerAPIKeys", localData: $providerCredentialsData, newValue: mergedCredentials, ) { $0 } } // MARK: - Private Helpers private static func encode(_ value: T) -> Data? { try? JSONEncoder().encode(value) } private static func decode(_ type: T.Type, from data: Data) -> T? { try? JSONDecoder().decode(type, from: data) } private func syncableData( key: String, localData: Data, sort: ([T]) -> [T], identifier: (T) -> UUID ) -> [T] { if enableSync { if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: key) { if let iCloudValues = Self.decode([T].self, from: iCloudData) { let localValues = Self.decode([T].self, from: localData) ?? [] var seenValues = Set() let mergedValues = (localValues + iCloudValues).filter { value in seenValues.insert(identifier(value)).inserted } return sort(mergedValues) } } } let localValues = Self.decode([T].self, from: localData) ?? [] return sort(localValues) } private func syncableData( key: String, localData: Binding, newValue: [T], sort: ([T]) -> [T] ) { let sortedValues = sort(newValue) guard let encoded = Self.encode(sortedValues) else { localData.wrappedValue = Data() return } localData.wrappedValue = encoded if enableSync { NSUbiquitousKeyValueStore.default.set(encoded, forKey: key) NSUbiquitousKeyValueStore.default.synchronize() } } private func triggerBatchedSync() { guard !pendingSyncKeys.isEmpty else { return } let keysToSync = pendingSyncKeys pendingSyncKeys.removeAll() Task.detached { [weak self] in await self?.performBatchedSync(for: keysToSync) } } private func performBatchedSync(for keys: Set) async { for key in keys { await triggerSyncIfNeeded(for: key) } } private func backupBookmarks() async { await Task.detached { guard let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) .first else { return } let backupDirectory = cachesDirectory.appendingPathComponent("bookmarks_backups") let fileManager = FileManager.default try? fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true) let timestamp = Int(Date().timeIntervalSince1970) let backupFile = backupDirectory.appendingPathComponent("bookmarks_backup_\(timestamp).json") if let data = await Self.encode(self.bookmarksCache) { try? data.write(to: backupFile) } if let files = try? fileManager.contentsOfDirectory( at: backupDirectory, includingPropertiesForKeys: [.contentModificationDateKey], options: .skipsHiddenFiles ) { let jsonBackups = files.filter { file in file.lastPathComponent.hasPrefix("bookmarks_backup_") && file.pathExtension == "json" } let sortedBackups = jsonBackups.sorted { firstFile, secondFile in let firstDate = (try? firstFile.resourceValues(forKeys: [.contentModificationDateKey]) .contentModificationDate) ?? .distantPast let secondDate = (try? secondFile.resourceValues(forKeys: [.contentModificationDateKey]) .contentModificationDate) ?? .distantPast return firstDate > secondDate } if sortedBackups.count > 10 { for url in sortedBackups[10...] { try? fileManager.removeItem(at: url) } } } }.value } // swiftlint:disable:next cyclomatic_complexity private func triggerSyncIfNeeded(for key: SettingsSyncKey) { guard enableSync else { return } Task.detached { [weak self] in guard let self else { return } switch key { case .bookmarks: var iCloudBookmarks: [SettingsBookmark] = [] if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { iCloudBookmarks = await Self .decode([SettingsBookmark].self, from: iCloudData) ?? [] } let localBookmarks = await Self.decode([SettingsBookmark].self, from: bookmarksData) ?? [] let mergedBookmarksDict = (localBookmarks + iCloudBookmarks).reduce( into: [UUID: SettingsBookmark]() ) { dict, bookmark in if let existing = dict[bookmark.id] { if bookmark.date > existing.date { dict[bookmark.id] = bookmark } } else { dict[bookmark.id] = bookmark } } let mergedBookmarks = Array(mergedBookmarksDict.values).sorted { $0.date > $1.date } if let encoded = await Self.encode(mergedBookmarks) { NSUbiquitousKeyValueStore.default.set(encoded, forKey: "bookmarks") await MainActor.run { self.bookmarksData = encoded } } case .searchHistory: var iCloudHistory: [BooruSearchQuery] = [] if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { iCloudHistory = await Self.decode([BooruSearchQuery].self, from: iCloudData) ?? [] } let localHistory = await Self.decode([BooruSearchQuery].self, from: searchHistoryData) ?? [] let mergedHistoryDict = (localHistory + iCloudHistory).reduce( into: [UUID: BooruSearchQuery]() ) { dict, entry in if let existing = dict[entry.id] { if entry.date > existing.date { dict[entry.id] = entry } } else { dict[entry.id] = entry } } let mergedHistory = Array(mergedHistoryDict.values).sorted { $0.date > $1.date } if let encoded = await Self.encode(mergedHistory) { NSUbiquitousKeyValueStore.default.set(encoded, forKey: "searchHistory") await MainActor.run { self.searchHistoryData = encoded } } case .customProviders: var iCloudProviders: [BooruProviderCustom] = [] if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { iCloudProviders = await Self .decode([BooruProviderCustom].self, from: iCloudData) ?? [] } let localProviders = await Self.decode([BooruProviderCustom].self, from: customProvidersData) ?? [] let mergedProvidersDict = (localProviders + iCloudProviders).reduce( into: [UUID: BooruProviderCustom]() ) { dict, provider in if dict[provider.id] == nil { dict[provider.id] = provider } } let mergedProviders = Array(mergedProvidersDict.values) if let encoded = await Self.encode(mergedProviders) { NSUbiquitousKeyValueStore.default.set(encoded, forKey: "customProviders") await MainActor.run { self.customProvidersData = encoded } } } await MainActor.run { self.loadBookmarksCache() self.loadSearchHistoryCache() self.objectWillChange.send() } } } // MARK: Cache Loaders private func loadCache( from data: Data, sort: @escaping ([T]) -> [T], assign: @escaping ([T]) -> Void ) { let decoded = Self.decode([T].self, from: data) ?? [] let sorted = sort(decoded) assign(sorted) } private func loadBookmarksCache() { loadCache( from: bookmarksData, sort: { $0.sorted { $0.date > $1.date } }, assign: { [weak self] in self?.bookmarksCache = $0 } ) } private func loadSearchHistoryCache() { loadCache( from: searchHistoryData, sort: { $0.sorted { $0.date > $1.date } }, assign: { [weak self] in self?.searchHistoryCache = $0 } ) } private func loadDisplayRatingsCache() { loadCache( from: displayRatingsData, sort: { $0 }, assign: { [weak self] in self?.displayRatingsCache = $0 } ) } private func loadBlurRatingsCache() { loadCache( from: blurRatingsData, sort: { $0 }, assign: { [weak self] in self?.blurRatingsCache = $0 } ) } // MARK: - Public Methods func appendToSearchHistory(_ query: BooruSearchQuery) { var updated = searchHistory updated.append(query) updateSearchHistory(updated) } func resetToDefaults() { detailViewQuality = .original thumbnailQuality = .preview searchSuggestionsMode = .disabled thumbnailGridColumns = 2 preferredBooru = .safebooru enableShareShortcut = false updateDisplayRatings(BooruRating.allCases) updateBlurRatings([.explicit]) displayDetailsInformationBar = true preloadedCarouselImages = 3 #if os(macOS) saveTagsToFile = false #endif } func syncFromCloud() { if self.enableSync { Task.detached { [weak self] in guard let self else { return } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { await MainActor.run { self.bookmarksData = data } } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { await MainActor.run { self.searchHistoryData = data } } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { await MainActor.run { self.customProvidersData = data } } await MainActor.run { self.loadBookmarksCache() self.loadSearchHistoryCache() self.objectWillChange.send() } } } } func triggerSyncIfNeededForAll() { self.triggerSyncIfNeeded(for: .bookmarks) self.triggerSyncIfNeeded(for: .searchHistory) self.triggerSyncIfNeeded(for: .customProviders) } // MARK: Bookmark Management func addBookmark(provider: BooruProvider, tags: [String]) { var updatedBookmarks = bookmarks updatedBookmarks.append( SettingsBookmark(provider: provider, tags: tags.map { $0.lowercased() }) ) if let data = Self.encode(updatedBookmarks), data.count < 1_000_000 { // 1 MB Task { await updateBookmarks(updatedBookmarks) } } else { debugPrint("SettingsManager.addBookmark: iCloud data limit exceeded") } } func removeBookmark(at offsets: IndexSet) { var updated = bookmarks updated.remove(atOffsets: offsets) Task { await updateBookmarks(updated) } } func removeBookmark(withTags tags: [String]) { let updated = bookmarks.filter { !$0.tags.contains(where: tags.contains) } Task { await updateBookmarks(updated) } } func removeBookmark(withID id: UUID) { let updated = bookmarks.filter { $0.id != id } Task { await updateBookmarks(updated) } } func exportBookmarks() throws -> Data { try JSONEncoder().encode(bookmarks) } func importBookmarks(from data: Data) throws { let importedBookmarks = try JSONDecoder().decode([SettingsBookmark].self, from: data) let existingBookmarkIDs = Set(bookmarks.map(\.id)) let newBookmarks = importedBookmarks.filter { !existingBookmarkIDs.contains($0.id) } var updated = bookmarks updated.append(contentsOf: newBookmarks) Task { await updateBookmarks(updated) } } func updateBookmarkFolder(withID id: UUID, folder: UUID?) { guard let index = bookmarks.firstIndex(where: { $0.id == id }) else { return } var updated = bookmarks updated[index].folder = folder Task { await updateBookmarks(updated) } pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } func updateBookmarkLastVisit(withID id: UUID, date: Date = Date()) { guard let index = bookmarks.firstIndex(where: { $0.id == id }) else { return } var updated = bookmarks updated[index].lastVisit = date Task { await updateBookmarks(updated) } pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } func incrementBookmarkVisitCount(withID id: UUID) { guard let index = bookmarks.firstIndex(where: { $0.id == id }) else { return } var updated = bookmarks updated[index].visitedCount += 1 Task { await updateBookmarks(updated) } pendingSyncKeys.insert(.bookmarks) triggerBatchedSync() } func folderName(forID id: UUID) -> String? { folders.first { $0.id == id }?.name } func renameFolder(_ folder: SettingsFolder, to newName: String) { var updated = folders guard let index = updated.firstIndex(where: { $0.id == folder.id }) else { return } updated[index].name = newName syncableData( key: "folders", localData: $foldersData, newValue: updated, ) { $0 } } // MARK: Search History Management func removeSearchHistoryEntry(at offsets: IndexSet) { var updated = searchHistory updated.remove(atOffsets: offsets) updateSearchHistory(updated) pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } func removeSearchHistoryEntry(withID id: UUID) { let updated = searchHistory.filter { $0.id != id } updateSearchHistory(updated) pendingSyncKeys.insert(.searchHistory) triggerBatchedSync() } #if DEBUG // https://stackoverflow.com/a/68926484/14452787 private func randomWord() -> String { var word = "" for _ in 0..<5 { word += String(format: "%c", Int.random(in: 97..<123)) as String } return word } func addDummyBookmarks() { for _ in 0..<10 { let randomTags: [String] = Array(repeating: randomWord(), count: Int.random(in: 1...5)) addBookmark(provider: .safebooru, tags: randomTags) } } func addDummySearchHistory() { for _ in 0..<10 { let randomTags: [String] = Array(repeating: randomWord(), count: Int.random(in: 1...5)) appendToSearchHistory( BooruSearchQuery(provider: .safebooru, tags: randomTags) ) } } #endif // MARK: - Deinitialisation deinit { if let observation = syncObservation { NotificationCenter.default.removeObserver(observation) } } }