From 9c4fa43247e68e39151b5fa0ed418fde50b15153 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:45:58 +1000 Subject: [PATCH 1/2] Preserve user settings on logout and add data management features Logout no longer wipes user preferences (themes, font scale, toggles, etc.) - only session/auth keys (including imgur) are cleared. Settings now survive cookie expiration and manual logout. New Data Management section in Settings: - Clear Cache: now clears all caches (URLCache, Nuke disk/memory, WKWebView data, external stylesheet) and shows accurate size before and after clearing - Reset All Settings: restores preferences to defaults while staying logged in - Export Settings: saves preferences as a dated JSON file via share sheet - Import Settings: restores preferences from a previously exported JSON file, with warnings when importing from an older app build --- App/Main/AppDelegate.swift | 55 ++++- .../PostsViewExternalStylesheetLoader.swift | 10 + App/Settings/SettingsViewController.swift | 167 +++++++++++++-- .../AwfulSettings/SettingsExporter.swift | 193 ++++++++++++++++++ .../AwfulSettings/UserDefaults+Settings.swift | 35 +++- .../AwfulSettingsUI/Localizable.xcstrings | 23 ++- .../AwfulSettingsUI/SettingsView.swift | 44 +++- 7 files changed, 498 insertions(+), 29 deletions(-) create mode 100644 AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift diff --git a/App/Main/AppDelegate.swift b/App/Main/AppDelegate.swift index f0fe31c7f..ddf58f8e4 100644 --- a/App/Main/AppDelegate.swift +++ b/App/Main/AppDelegate.swift @@ -186,13 +186,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } func logOut() { - // Logging out doubles as an "empty cache" button. let cookieJar = HTTPCookieStorage.shared for cookie in cookieJar.cookies ?? [] { cookieJar.deleteCookie(cookie) } - UserDefaults.standard.removeAllObjectsInMainBundleDomain() - emptyCache() + UserDefaults.standard.removeSessionObjects() + Task { await emptyCache() } let loginVC = LoginViewController.newFromStoryboard() loginVC.completionBlock = { [weak self] (login) in @@ -212,9 +211,55 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } } - func emptyCache() { + func emptyCache() async { URLCache.shared.removeAllCachedResponses() - ImageCache.shared.removeAll() + ImagePipeline.shared.cache.removeAll() + + // Clear WKWebView data (cookies, cache, localStorage, etc.) + let dataStore = WKWebsiteDataStore.default() + let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() + await dataStore.removeData(ofTypes: dataTypes, modifiedSince: .distantPast) + + // Clear external stylesheet cache + PostsViewExternalStylesheetLoader.shared.clearCache() + } + + func calculateCacheSize() async -> Int64 { + var totalSize: Int64 = 0 + let fileManager = FileManager.default + + // Caches directory (includes URLCache, Nuke disk cache, external stylesheet, etc.) + if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first { + totalSize += Self.directorySize(at: cachesURL) + } + + // WKWebView data is typically stored in Library/WebKit + if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first { + let webKitDir = libraryURL.appendingPathComponent("WebKit", isDirectory: true) + totalSize += Self.directorySize(at: webKitDir) + } + + return totalSize + } + + static func directorySize(at url: URL) -> Int64 { + let fileManager = FileManager.default + guard let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return 0 } + + var size: Int64 = 0 + for case let fileURL as URL in enumerator { + guard + let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]), + values.isDirectory != true, + let fileSize = values.fileSize + else { continue } + size += Int64(fileSize) + } + return size } func open(route: AwfulRoute) { diff --git a/App/Posts/PostsViewExternalStylesheetLoader.swift b/App/Posts/PostsViewExternalStylesheetLoader.swift index bab455467..4bddfed92 100644 --- a/App/Posts/PostsViewExternalStylesheetLoader.swift +++ b/App/Posts/PostsViewExternalStylesheetLoader.swift @@ -165,6 +165,16 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: } } + func clearCache() { + let fileManager = FileManager.default + if let contents = try? fileManager.contentsOfDirectory(at: cacheFolder, includingPropertiesForKeys: nil) { + for url in contents { + try? fileManager.removeItem(at: url) + } + } + stylesheet = nil + } + private func reloadCachedStylesheet() { do { stylesheet = try String(contentsOf: cachedStylesheetURL) diff --git a/App/Settings/SettingsViewController.swift b/App/Settings/SettingsViewController.swift index 1b9c56bee..66f275fd2 100644 --- a/App/Settings/SettingsViewController.swift +++ b/App/Settings/SettingsViewController.swift @@ -4,14 +4,17 @@ import AwfulCore import AwfulSettings import AwfulSettingsUI import AwfulTheming +import Combine import CoreData import os import SwiftUI +import UniformTypeIdentifiers private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SettingsViewController") final class SettingsViewController: HostingController { let managedObjectContext: NSManagedObjectContext + private var cacheSizeText: CurrentValueSubject = .init("Calculating…") init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext @@ -28,19 +31,25 @@ final class SettingsViewController: HostingController { unowned var contents: SettingsViewController! } let box = UnownedBox() + let cacheSizeText = CurrentValueSubject("Calculating…") super.init(rootView: SettingsContainerView( appIconDataSource: makeAppIconDataSource(), + cacheSizeText: cacheSizeText, currentUser: currentUser, emptyCache: { box.contents.emptyCache() }, + exportSettings: { box.contents.exportSettings() }, goToAwfulThread: { box.contents.goToAwfulThread() }, // Not sure how to tell for real, seems like a decent proxy? hasRegularSizeClassInLandscape: UIDevice.current.userInterfaceIdiom == .pad || UIScreen.main.scale > 2, + importSettings: { box.contents.importSettings() }, isMac: ProcessInfo.processInfo.isMacCatalystApp, isPad: UIDevice.current.userInterfaceIdiom == .pad, logOut: { AppDelegate.instance.logOut() }, - managedObjectContext: managedObjectContext + managedObjectContext: managedObjectContext, + resetSettings: { box.contents.resetSettings() } )) + self.cacheSizeText = cacheSizeText box.contents = self title = String(localized: "Settings", bundle: .module) @@ -49,23 +58,105 @@ final class SettingsViewController: HostingController { themeDidChange() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + refreshCacheSize() + } + + private func refreshCacheSize() { + Task { + let size = await AppDelegate.instance.calculateCacheSize() + let measurement = Measurement(value: Double(size), unit: UnitInformationStorage.bytes) + let formatted: String + if size < 1_000_000 { + formatted = measurement.converted(to: .kilobytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))) + ) + } else { + formatted = measurement.converted(to: .megabytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1))) + ) + } + cacheSizeText.send(formatted) + } + } + func emptyCache() { - let usageBefore = Measurement(value: Double(URLCache.shared.currentDiskUsage), unit: UnitInformationStorage.bytes) - AppDelegate.instance.emptyCache() - let usageAfter = Measurement(value: Double(URLCache.shared.currentDiskUsage), unit: UnitInformationStorage.bytes) - let delta = (usageBefore - usageAfter).converted(to: .megabytes) - let message = "You cleared up \(delta.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1)))))! Great job, go hog wild!!" - let alertController = UIAlertController(title: "Cache Cleared", message: message, preferredStyle: .alert) - let okAction = UIAlertAction(title: "OK", style: .default) { action in - self.dismiss(animated: true) + Task { + let sizeBefore = await AppDelegate.instance.calculateCacheSize() + await AppDelegate.instance.emptyCache() + let sizeAfter = await AppDelegate.instance.calculateCacheSize() + + let delta = sizeBefore - sizeAfter + let measurement = Measurement(value: Double(max(delta, 0)), unit: UnitInformationStorage.bytes) + let formatted: String + if delta < 1_000_000 { + formatted = measurement.converted(to: .kilobytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))) + ) + } else { + formatted = measurement.converted(to: .megabytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1))) + ) + } + let message = "You cleared \(formatted)! Some system-managed files can't be removed, so a small amount of cache usage is normal." + let alertController = UIAlertController(title: "Cache Cleared", message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in + self.dismiss(animated: true) + }) + self.present(alertController, animated: true) + + refreshCacheSize() + } + } + + func resetSettings() { + let alert = UIAlertController( + title: "Reset All Settings?", + message: "This will restore all preferences to their defaults. You will remain logged in.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Reset", style: .destructive) { _ in + UserDefaults.standard.resetPreferences() + }) + present(alert, animated: true) + } + + func exportSettings() { + do { + let data = try SettingsExporter.exportSettings() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let filename = "awful-settings-\(dateFormatter.string(from: Date())).json" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + try data.write(to: tempURL) + + let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + activityVC.popoverPresentationController?.sourceView = view + activityVC.completionWithItemsHandler = { _, _, _, _ in + try? FileManager.default.removeItem(at: tempURL) + } + present(activityVC, animated: true) + } catch { + Log.error("Failed to export settings: \(error)") + let alert = UIAlertController(title: "Export Failed", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) } - alertController.addAction(okAction) - self.present(alertController, animated: true) + } + + func importSettings() { + let types = [UTType.json] + let picker = UIDocumentPickerViewController(forOpeningContentTypes: types) + picker.delegate = self + picker.allowsMultipleSelection = false + present(picker, animated: true) } func goToAwfulThread() { @@ -85,6 +176,43 @@ final class SettingsViewController: HostingController { } } +extension SettingsViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + guard url.startAccessingSecurityScopedResource() else { + let alert = UIAlertController(title: "Import Failed", message: "Could not access the selected file.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + return + } + defer { url.stopAccessingSecurityScopedResource() } + + do { + let data = try Data(contentsOf: url) + let result = try SettingsExporter.importSettings(from: data) + + var message = "Successfully applied \(result.appliedCount) setting\(result.appliedCount == 1 ? "" : "s")." + if result.isOlderBuild, !result.missingKeys.isEmpty { + message += "\n\nThis file was exported from an older version of Awful (build \(result.exportBuildNumber ?? "unknown")). \(result.missingKeys.count) newer setting\(result.missingKeys.count == 1 ? " was" : "s were") not included and will use default values." + } + + let alert = UIAlertController( + title: "Settings Imported", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } catch { + Log.error("Failed to import settings: \(error)") + let alert = UIAlertController(title: "Import Failed", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + } +} + /// See the `README.md` section "Alternate App Icons" for more info. private let appIcons: [AppIconDataSource.AppIcon] = [ .init(accessibilityLabel: String(localized: "Rated five", bundle: .module), imageName: AppIconImageNames.rated_five), @@ -119,29 +247,42 @@ private let appIcons: [AppIconDataSource.AppIcon] = [ /// Wrapper for observing the current `User`. struct SettingsContainerView: View { let appIconDataSource: AppIconDataSource + let cacheSizeText: CurrentValueSubject @ObservedObject var currentUser: User let emptyCache: () -> Void + let exportSettings: () -> Void let goToAwfulThread: () -> Void let hasRegularSizeClassInLandscape: Bool + let importSettings: () -> Void let isMac: Bool let isPad: Bool let logOut: () -> Void let managedObjectContext: NSManagedObjectContext + let resetSettings: () -> Void + + @State private var displayedCacheSize: String = "Calculating…" var body: some View { SettingsView( appIconDataSource: appIconDataSource, avatarURL: currentUser.avatarURL, + cacheSizeText: displayedCacheSize, canOpenURL: UIApplication.shared.canOpenURL(_:), currentUsername: currentUser.username ?? "", emptyCache: emptyCache, + exportSettings: exportSettings, goToAwfulThread: goToAwfulThread, hasRegularSizeClassInLandscape: hasRegularSizeClassInLandscape, + importSettings: importSettings, isMac: isMac, isPad: isPad, - logOut: logOut + logOut: logOut, + resetSettings: resetSettings ) .environment(\.managedObjectContext, managedObjectContext) .themed() + .onReceive(cacheSizeText) { newValue in + displayedCacheSize = newValue + } } } diff --git a/AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift b/AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift new file mode 100644 index 000000000..18219e7e0 --- /dev/null +++ b/AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift @@ -0,0 +1,193 @@ +// SettingsExporter.swift +// +// Copyright 2024 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation + +/// Exports and imports user preferences as JSON for backup/sharing. +public enum SettingsExporter { + + /// Current schema version for exported settings files. + private static let schemaVersion = 1 + + /// All preference keys that should be included in an export. + /// Session/auth keys (userID, username, etc.) are intentionally excluded. + private static let preferenceKeys: [String] = [ + Settings.appIconName.key, + Settings.autoDarkTheme.key, + Settings.automaticTimg.key, + Settings.autoplayGIFs.key, + Settings.bookmarksSortedUnread.key, + Settings.clipboardURLEnabled.key, + Settings.confirmBeforeReplying.key, + Settings.darkMode.key, + Settings.defaultBrowser.key, + Settings.defaultDarkThemeName.key, + Settings.defaultLightThemeName.key, + Settings.embedBlueskyPosts.key, + Settings.embedTweets.key, + Settings.enableCustomTitlePostLayout.key, + Settings.enableHaptics.key, + Settings.fontScale.key, + Settings.forumThreadsSortedUnread.key, + Settings.frogAndGhostEnabled.key, + Settings.handoffEnabled.key, + Settings.hideSidebarInLandscape.key, + Settings.jumpToPostEndOnDoubleTap.key, + Settings.loadImages.key, + Settings.openTwitterLinksInTwitter.key, + Settings.openYouTubeLinksInYouTube.key, + Settings.pullForNext.key, + Settings.showAvatars.key, + Settings.showThreadTags.key, + Settings.showUnreadAnnouncementsBadge.key, + Settings.useNewSmiliePicker.key, + ] + + /// Exports the current user preferences as JSON data. + /// + /// Includes all preference keys and any forum-specific theme overrides + /// (keys matching the pattern `theme-light-*` or `theme-dark-*`). + public static func exportSettings( + defaults: UserDefaults = .standard + ) throws -> Data { + var exported: [String: Any] = [ + "_version": schemaVersion, + "_exportDate": ISO8601DateFormatter().string(from: Date()), + "_buildNumber": Bundle.main.version ?? "unknown", + ] + + // Collect known preference keys + for key in preferenceKeys { + if let value = defaults.object(forKey: key) { + exported[key] = value + } + } + + // Collect forum-specific theme keys (theme-light-*, theme-dark-*) + let allKeys = defaults.dictionaryRepresentation() + for (key, value) in allKeys { + if isForumSpecificThemeKey(key) { + exported[key] = value + } + } + + return try JSONSerialization.data( + withJSONObject: exported, + options: [.prettyPrinted, .sortedKeys] + ) + } + + /// The result of importing settings, including any warnings. + public struct ImportResult { + /// Number of settings that were applied. + public let appliedCount: Int + /// Keys present in the current app but missing from the imported file. + public let missingKeys: [String] + /// The build number the file was exported from, if available. + public let exportBuildNumber: String? + /// Whether the file was exported from an older build than the current app. + public let isOlderBuild: Bool + } + + /// Imports settings from JSON data, applying them to UserDefaults. + /// + /// - Parameters: + /// - data: The JSON data previously exported by `exportSettings`. + /// - validThemeNames: If provided, theme values will be validated against this set. + /// - defaults: The UserDefaults store to write to. + /// - Returns: An `ImportResult` with the count of applied settings and any warnings. + public static func importSettings( + from data: Data, + validThemeNames: Set? = nil, + defaults: UserDefaults = .standard + ) throws -> ImportResult { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ImportError.invalidFormat + } + + guard let version = json["_version"] as? Int else { + throw ImportError.missingVersion + } + guard version <= schemaVersion else { + throw ImportError.unsupportedVersion(version) + } + + let exportBuildNumber = json["_buildNumber"] as? String + let currentBuildNumber = Bundle.main.version + let isOlderBuild: Bool = { + guard let exported = exportBuildNumber, + let current = currentBuildNumber, + let exportedInt = Int(exported), + let currentInt = Int(current) + else { return false } + return exportedInt < currentInt + }() + + let knownKeys = Set(preferenceKeys) + let importedSettingKeys = Set(json.keys.filter { !$0.hasPrefix("_") }) + var appliedCount = 0 + + for (key, value) in json { + // Skip metadata keys + if key.hasPrefix("_") { continue } + + // Only import known preference keys or forum-specific theme keys + guard knownKeys.contains(key) || isForumSpecificThemeKey(key) else { continue } + + // Validate theme values if a validation set is provided + if let validNames = validThemeNames, isThemeValueKey(key) { + if let themeName = value as? String, !validNames.contains(themeName) { + continue + } + } + + defaults.set(value, forKey: key) + appliedCount += 1 + } + + // Find preference keys that exist in the current app but were missing from the file + let missingKeys = preferenceKeys.filter { !importedSettingKeys.contains($0) } + + return ImportResult( + appliedCount: appliedCount, + missingKeys: missingKeys, + exportBuildNumber: exportBuildNumber, + isOlderBuild: isOlderBuild + ) + } + + private static func isForumSpecificThemeKey(_ key: String) -> Bool { + let parts = key.split(separator: "-") + guard + parts.count == 3, + parts[0] == "theme", + parts[1] == "light" || parts[1] == "dark", + Int(parts[2]) != nil + else { return false } + return true + } + + private static func isThemeValueKey(_ key: String) -> Bool { + key == Settings.defaultDarkThemeName.key + || key == Settings.defaultLightThemeName.key + || isForumSpecificThemeKey(key) + } + + public enum ImportError: LocalizedError { + case invalidFormat + case missingVersion + case unsupportedVersion(Int) + + public var errorDescription: String? { + switch self { + case .invalidFormat: + return "The file is not a valid Awful settings file." + case .missingVersion: + return "The settings file is missing a version number." + case .unsupportedVersion(let version): + return "This settings file (version \(version)) was created by a newer version of Awful." + } + } + } +} diff --git a/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift b/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift index 79e9dc844..5d48e1ee4 100644 --- a/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift +++ b/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift @@ -22,8 +22,37 @@ public extension UserDefaults { // MARK: Mass deletion public extension UserDefaults { - func removeAllObjectsInMainBundleDomain() { - guard let bundleID = Bundle.main.bundleIdentifier else { return } - setPersistentDomain([:], forName: bundleID) + /// Removes only session/auth-related keys, preserving user preferences. + func removeSessionObjects() { + let sessionKeys = [ + Settings.userID.key, + Settings.username.key, + Settings.canSendPrivateMessages.key, + Settings.lastOfferedPasteboardURLString.key, + Settings.imgurUploadMode.key, + ] + for key in sessionKeys { + removeObject(forKey: key) + } + } + + /// Removes all preference keys (everything except session/auth keys), + /// restoring them to their defaults. The user stays logged in. + /// + /// Keys are removed individually rather than via `setPersistentDomain` + /// so that KVO fires for each key and `@AppStorage` updates immediately. + func resetPreferences() { + let sessionKeys: Set = [ + Settings.userID.key, + Settings.username.key, + Settings.canSendPrivateMessages.key, + Settings.lastOfferedPasteboardURLString.key, + Settings.imgurUploadMode.key, + ] + for key in dictionaryRepresentation().keys { + if !sessionKeys.contains(key) { + removeObject(forKey: key) + } + } } } diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index ad4d94f76..6d6e23204 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -66,9 +66,18 @@ }, "Checking the clipboard for a forums URL when you open the app allows you to jump straight to a copied URL in Awful." : { + }, + "Clear Cache" : { + + }, + "Clearing the cache removes all downloaded images, web data, and temporary files. Resetting settings restores all preferences to their defaults." : { + }, "Dark Mode" : { + }, + "Data Management" : { + }, "Default Browser" : { @@ -87,15 +96,15 @@ }, "Embed Tweets" : { - }, - "Empty Cache" : { - }, "Enable Custom Title Post Layout" : { }, "Enable Haptics" : { + }, + "Export Settings" : { + }, "Forum-Specific Themes" : { @@ -117,6 +126,9 @@ }, "Imgur Uploads" : { + }, + "Import Settings" : { + }, "Links" : { @@ -127,7 +139,7 @@ "Log Out" : { }, - "Logging out erases all cached forums, threads, and posts." : { + "Logging out erases cached forums, threads, and posts. Your settings and preferences will be preserved." : { }, "New Smilie Picker" : { @@ -153,6 +165,9 @@ }, "Pull for Next Page" : { + }, + "Reset All Settings" : { + }, "Scale Text %@%%" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index ab34cb906..8d083d3ec 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -40,15 +40,19 @@ public struct SettingsView: View { let appIconDataSource: AppIconDataSource let avatarURL: URL? let buildInfo = BuildInfo() + let cacheSizeText: String let canOpenURL: (URL) -> Bool let currentUsername: String @State private var didScrollToSelectedAppIcon = false let emptyCache: () -> Void + let exportSettings: () -> Void let goToAwfulThread: () -> Void let hasRegularSizeClassInLandscape: Bool + let importSettings: () -> Void let isMac: Bool let isPad: Bool let logOut: () -> Void + let resetSettings: () -> Void @Environment(\.managedObjectContext) var managedObjectContext @Environment(\.theme) var theme @@ -80,32 +84,39 @@ public struct SettingsView: View { public init( appIconDataSource: AppIconDataSource, avatarURL: URL?, + cacheSizeText: String, canOpenURL: @escaping (URL) -> Bool, currentUsername: String, emptyCache: @escaping () -> Void, + exportSettings: @escaping () -> Void, goToAwfulThread: @escaping () -> Void, hasRegularSizeClassInLandscape: Bool, + importSettings: @escaping () -> Void, isMac: Bool, isPad: Bool, - logOut: @escaping () -> Void + logOut: @escaping () -> Void, + resetSettings: @escaping () -> Void ) { self.appIconDataSource = appIconDataSource self.avatarURL = avatarURL + self.cacheSizeText = cacheSizeText self.canOpenURL = canOpenURL self.currentUsername = currentUsername self.emptyCache = emptyCache + self.exportSettings = exportSettings self.goToAwfulThread = goToAwfulThread self.hasRegularSizeClassInLandscape = hasRegularSizeClassInLandscape + self.importSettings = importSettings self.isMac = isMac self.isPad = isPad self.logOut = logOut + self.resetSettings = resetSettings } public var body: some View { Form { Section { Button("Log Out", bundle: .module) { logOut() } - Button("Empty Cache", bundle: .module) { emptyCache() } } header: { VStack(alignment: .leading) { Group { @@ -125,7 +136,7 @@ public struct SettingsView: View { } .header() } footer: { - Text("Logging out erases all cached forums, threads, and posts.", bundle: .module) + Text("Logging out erases cached forums, threads, and posts. Your settings and preferences will be preserved.", bundle: .module) .footer() } .section() @@ -297,6 +308,27 @@ public struct SettingsView: View { } .section() + Section { + Button { emptyCache() } label: { + HStack { + Text("Clear Cache", bundle: .module) + Spacer() + Text(cacheSizeText) + .foregroundStyle(theme[color: "listSecondaryText"]!) + } + } + Button("Reset All Settings", bundle: .module) { resetSettings() } + Button("Export Settings", bundle: .module) { exportSettings() } + Button("Import Settings", bundle: .module) { importSettings() } + } header: { + Text("Data Management", bundle: .module) + .header() + } footer: { + Text("Clearing the cache removes all downloaded images, web data, and temporary files. Resetting settings restores all preferences to their defaults.", bundle: .module) + .footer() + } + .section() + Section { NavigationLink("Acknowledgements", bundle: .module) { AcknowledgementsView() @@ -354,14 +386,18 @@ private struct SectionModifier: ViewModifier { SettingsView( appIconDataSource: .preview, avatarURL: nil, + cacheSizeText: "42.3 MB", canOpenURL: { _ in true }, currentUsername: "Random Newbie", emptyCache: { print("emptying cache") }, + exportSettings: { print("exporting settings") }, goToAwfulThread: { print("navigating to Awful's thread") }, hasRegularSizeClassInLandscape: true, + importSettings: { print("importing settings") }, isMac: false, isPad: true, - logOut: { print("logging out") } + logOut: { print("logging out") }, + resetSettings: { print("resetting settings") } ) .navigationTitle(Text(verbatim: "Settings")) .navigationBarTitleDisplayMode(.inline) From 4c63c2d0dade1562f9fb32cbc81c23222bd4b07a Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:33:25 +1000 Subject: [PATCH 2/2] =?UTF-8?q?Logout=20behavior:=20-=20Preserve=20UserDef?= =?UTF-8?q?aults=20preferences=20on=20logout;=20only=20session/auth=20keys?= =?UTF-8?q?=20(userID,=20username,=20canSendPrivateMessages,=20imgur=20upl?= =?UTF-8?q?oad=20mode,=20last=20pasteboard=20URL)=20are=20cleared.=20-=20S?= =?UTF-8?q?top=20wiping=20the=20Core=20Data=20store=20on=20logout.=20Cache?= =?UTF-8?q?d=20forums,=20threads,=20and=20posts=20survive.=20Fixes=20a=20l?= =?UTF-8?q?ong-standing=20crash=20where=20logging=20in=20again=20after=20l?= =?UTF-8?q?ogout=20hit=20"persistent=20store=20is=20not=20reachable"=20?= =?UTF-8?q?=E2=80=94=20ForumsClient's=20background=20MOC=20retained=20obje?= =?UTF-8?q?cts=20tied=20to=20the=20deleted=20store=20and=20the=20next=20sa?= =?UTF-8?q?ve-notification=20merge=20blew=20up.=20-=20Drop=20the=20"Loggin?= =?UTF-8?q?g=20out=20erases..."=20footer;=20logout=20is=20just=20logout=20?= =?UTF-8?q?now.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear Cache (Settings): - New Data Management section with Clear Cache and Reset All Settings. - Clear Cache now wipes URLCache, Nuke disk/memory, WKWebView data, the external stylesheet cache, AND the Core Data store, and reports accurate size before/after. - Store-size estimate includes the Core Data store directory. - Avoids the stale-MOC crash by cycling ForumsClient.shared.managedObjectContext around the store reset. - DataStoreWillReset / DataStoreDidReset promoted to public Notification.Name extensions on AwfulCore. All FRC owners (ThreadListDataSource, ForumListDataSource, MessageListDataSource, ForumSpecificThemesViewController) now observe .dataStoreDidReset and re-fetch so their caches stop pointing at dangling objectIDs. - SettingsViewController rebuilds its currentUser header on reset so re-render doesn't fault the invalidated User. - RootViewControllerStack observes reset too: dismisses any modal (compose sheet, etc.) and pops every tab's nav stack plus the split view's detail nav back to root, so pushed detail VCs (PostsPageViewController, PrivateMessageViewController, profile, etc.) can't crash trying to render stale managed objects. Reset All Settings: - Restores preferences to defaults while staying logged in. Keys are removed individually (not via setPersistentDomain) so KVO fires and @AppStorage observers update immediately. Also: - Fix: AppDelegate.calculateCacheSize() offloaded to a detached utility Task so file enumeration doesn't stall the main thread when Settings appears; directorySize(at:) marked nonisolated. - Expose DataStore.storeDirectoryURL publicly so AppDelegate can size it. --- App/Data Sources/ForumListDataSource.swift | 15 ++ App/Data Sources/MessageListDataSource.swift | 13 ++ App/Data Sources/ThreadListDataSource.swift | 13 ++ App/Main/AppDelegate.swift | 51 +++-- App/Main/RootViewControllerStack.swift | 19 ++ App/Settings/SettingsViewController.swift | 149 ++++---------- .../Sources/AwfulCore/Data/DataStore.swift | 19 +- .../AwfulSettings/SettingsExporter.swift | 193 ------------------ .../AwfulSettings/UserDefaults+Settings.swift | 28 +-- .../ForumSpecificThemesViewController.swift | 6 + .../AwfulSettingsUI/Localizable.xcstrings | 11 +- .../AwfulSettingsUI/SettingsView.swift | 15 +- 12 files changed, 168 insertions(+), 364 deletions(-) delete mode 100644 AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift diff --git a/App/Data Sources/ForumListDataSource.swift b/App/Data Sources/ForumListDataSource.swift index b15a794d7..9a995300d 100644 --- a/App/Data Sources/ForumListDataSource.swift +++ b/App/Data Sources/ForumListDataSource.swift @@ -74,6 +74,21 @@ final class ForumListDataSource: NSObject { announcementsController.delegate = self favoriteForumsController.delegate = self forumsController.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + @objc private func dataStoreDidReset() { + // Old store's objects are no longer reachable from the coordinator. Re-fetch so + // the FRCs' caches stop pointing at dangling objectIDs. + for controller in resultsControllers { + do { + try controller.performFetch() + } catch { + logger.error("Failed to re-fetch after data store reset: \(error)") + } + } + tableView.reloadData() } private var resultsControllers: [NSFetchedResultsController] { diff --git a/App/Data Sources/MessageListDataSource.swift b/App/Data Sources/MessageListDataSource.swift index 6603d8a3a..4ae06a964 100644 --- a/App/Data Sources/MessageListDataSource.swift +++ b/App/Data Sources/MessageListDataSource.swift @@ -36,6 +36,19 @@ final class MessageListDataSource: NSObject { tableView.register(MessageListCell.self, forCellReuseIdentifier: cellReuseIdentifier) resultsController.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + @objc private func dataStoreDidReset() { + // Old store's objects are no longer reachable from the coordinator. Re-fetch so + // the FRC's cache stops pointing at dangling objectIDs. + do { + try resultsController.performFetch() + } catch { + Log.error("Failed to re-fetch after data store reset: \(error)") + } + tableView.reloadData() } func message(at indexPath: IndexPath) -> PrivateMessage { diff --git a/App/Data Sources/ThreadListDataSource.swift b/App/Data Sources/ThreadListDataSource.swift index ba8bf21aa..53c0fb960 100644 --- a/App/Data Sources/ThreadListDataSource.swift +++ b/App/Data Sources/ThreadListDataSource.swift @@ -104,6 +104,19 @@ final class ThreadListDataSource: NSObject { tableView.register(ThreadListCell.self, forCellReuseIdentifier: threadCellIdentifier) resultsController.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + @objc private func dataStoreDidReset() { + // The old store's objects are no longer reachable from the coordinator. Re-fetch + // against the fresh store so the FRC's cache stops pointing at dangling objectIDs. + do { + try resultsController.performFetch() + } catch { + Log.error("Failed to re-fetch after data store reset: \(error)") + } + tableView.reloadData() } func indexPath(of thread: AwfulThread) -> IndexPath? { diff --git a/App/Main/AppDelegate.swift b/App/Main/AppDelegate.swift index 7bff0056f..d4a3dd4df 100644 --- a/App/Main/AppDelegate.swift +++ b/App/Main/AppDelegate.swift @@ -160,8 +160,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { setRootViewController(loginVC.enclosingNavigationController, animated: true) { [weak self] in self?._rootViewControllerStack = nil self?.urlRouter = nil - - self?.dataStore.deleteStoreAndReset() } } @@ -170,33 +168,52 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { ImagePipeline.shared.cache.removeAll() // Clear WKWebView data (cookies, cache, localStorage, etc.) - let dataStore = WKWebsiteDataStore.default() + let webDataStore = WKWebsiteDataStore.default() let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() - await dataStore.removeData(ofTypes: dataTypes, modifiedSince: .distantPast) + await webDataStore.removeData(ofTypes: dataTypes, modifiedSince: .distantPast) // Clear external stylesheet cache PostsViewExternalStylesheetLoader.shared.clearCache() } + /// Clears all caches *and* deletes the Core Data store (cached forums, threads, posts). + /// Used by the Settings "Clear Cache" button; logout intentionally does not call this. + func emptyCacheAndResetStore() async { + await emptyCache() + + // ForumsClient's background MOC retains objects tied to the old persistent store, + // so cycle its MOC around the reset — otherwise a subsequent save-notification + // merge tries to reach the deleted store and crashes. + ForumsClient.shared.managedObjectContext = nil + dataStore.deleteStoreAndReset() + ForumsClient.shared.managedObjectContext = managedObjectContext + } + func calculateCacheSize() async -> Int64 { - var totalSize: Int64 = 0 - let fileManager = FileManager.default + let storeDirectoryURL = dataStore.storeDirectoryURL + return await Task.detached(priority: .utility) { + var totalSize: Int64 = 0 + let fileManager = FileManager.default + + // Caches directory (includes URLCache, Nuke disk cache, external stylesheet, etc.) + if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first { + totalSize += Self.directorySize(at: cachesURL) + } - // Caches directory (includes URLCache, Nuke disk cache, external stylesheet, etc.) - if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first { - totalSize += Self.directorySize(at: cachesURL) - } + // WKWebView data is typically stored in Library/WebKit + if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first { + let webKitDir = libraryURL.appendingPathComponent("WebKit", isDirectory: true) + totalSize += Self.directorySize(at: webKitDir) + } - // WKWebView data is typically stored in Library/WebKit - if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first { - let webKitDir = libraryURL.appendingPathComponent("WebKit", isDirectory: true) - totalSize += Self.directorySize(at: webKitDir) - } + // Core Data store (cached forums, threads, posts) + totalSize += Self.directorySize(at: storeDirectoryURL) - return totalSize + return totalSize + }.value } - static func directorySize(at url: URL) -> Int64 { + nonisolated static func directorySize(at url: URL) -> Int64 { let fileManager = FileManager.default guard let enumerator = fileManager.enumerator( at: url, diff --git a/App/Main/RootViewControllerStack.swift b/App/Main/RootViewControllerStack.swift index a2ff9b4da..d998dc0f0 100644 --- a/App/Main/RootViewControllerStack.swift +++ b/App/Main/RootViewControllerStack.swift @@ -2,6 +2,7 @@ // // Copyright 2014 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app +import AwfulCore import AwfulSettings import AwfulTheming import Combine @@ -70,6 +71,24 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate .store(in: &cancellables) configureSplitViewControllerDisplayMode() + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + /// Pushed detail view controllers (PostsPage, PrivateMessageView, Profile, etc.) and + /// any presented modals hold references to managed objects from the now-deleted store. + /// Unwind the UI to tab roots so rendering/interaction can't fault those dead objects. + @objc private func dataStoreDidReset() { + rootViewController.dismiss(animated: false) + + for tab in tabBarController.viewControllers ?? [] { + (tab as? UINavigationController)?.popToRootViewController(animated: false) + } + + if splitViewController.viewControllers.count > 1, + let detailNav = splitViewController.viewControllers[1] as? UINavigationController { + detailNav.popToRootViewController(animated: false) + } } private func createEmptyDetailNavigationController() -> UINavigationController { diff --git a/App/Settings/SettingsViewController.swift b/App/Settings/SettingsViewController.swift index d68e59258..264c04432 100644 --- a/App/Settings/SettingsViewController.swift +++ b/App/Settings/SettingsViewController.swift @@ -6,15 +6,11 @@ import AwfulSettingsUI import AwfulTheming import Combine import CoreData -import os import SwiftUI -import UniformTypeIdentifiers - -private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SettingsViewController") final class SettingsViewController: HostingController { let managedObjectContext: NSManagedObjectContext - private var cacheSizeText: CurrentValueSubject = .init("Calculating…") + private var cacheSizeText: CurrentValueSubject! init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext @@ -38,11 +34,9 @@ final class SettingsViewController: HostingController { cacheSizeText: cacheSizeText, currentUser: currentUser, emptyCache: { box.contents.emptyCache() }, - exportSettings: { box.contents.exportSettings() }, goToAwfulThread: { box.contents.goToAwfulThread() }, // Not sure how to tell for real, seems like a decent proxy? hasRegularSizeClassInLandscape: UIDevice.current.userInterfaceIdiom == .pad || UIScreen.main.scale > 2, - importSettings: { box.contents.importSettings() }, isMac: ProcessInfo.processInfo.isMacCatalystApp, isPad: UIDevice.current.userInterfaceIdiom == .pad, logOut: { AppDelegate.instance.logOut() }, @@ -57,12 +51,38 @@ final class SettingsViewController: HostingController { tabBarItem.selectedImage = UIImage(named: "cog-filled") themeDidChange() + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func dataStoreDidReset() { + // The current User object is tied to the old store. Re-fetch (findOrCreate) so the + // Settings header doesn't crash when it tries to fault the invalidated object. + let newUser = managedObjectContext.performAndWait { + User.objectForKey(objectKey: UserKey( + userID: UserDefaults.standard.value(for: Settings.userID)!, + username: UserDefaults.standard.value(for: Settings.username) + ), in: managedObjectContext) + } + rootView = SettingsContainerView( + appIconDataSource: rootView.appIconDataSource, + cacheSizeText: rootView.cacheSizeText, + currentUser: newUser, + emptyCache: rootView.emptyCache, + goToAwfulThread: rootView.goToAwfulThread, + hasRegularSizeClassInLandscape: rootView.hasRegularSizeClassInLandscape, + isMac: rootView.isMac, + isPad: rootView.isPad, + logOut: rootView.logOut, + managedObjectContext: rootView.managedObjectContext, + resetSettings: rootView.resetSettings + ) + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) refreshCacheSize() @@ -71,40 +91,18 @@ final class SettingsViewController: HostingController { private func refreshCacheSize() { Task { let size = await AppDelegate.instance.calculateCacheSize() - let measurement = Measurement(value: Double(size), unit: UnitInformationStorage.bytes) - let formatted: String - if size < 1_000_000 { - formatted = measurement.converted(to: .kilobytes).formatted( - .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))) - ) - } else { - formatted = measurement.converted(to: .megabytes).formatted( - .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1))) - ) - } - cacheSizeText.send(formatted) + cacheSizeText.send(Self.formatByteCount(size)) } } func emptyCache() { Task { let sizeBefore = await AppDelegate.instance.calculateCacheSize() - await AppDelegate.instance.emptyCache() + await AppDelegate.instance.emptyCacheAndResetStore() let sizeAfter = await AppDelegate.instance.calculateCacheSize() - let delta = sizeBefore - sizeAfter - let measurement = Measurement(value: Double(max(delta, 0)), unit: UnitInformationStorage.bytes) - let formatted: String - if delta < 1_000_000 { - formatted = measurement.converted(to: .kilobytes).formatted( - .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))) - ) - } else { - formatted = measurement.converted(to: .megabytes).formatted( - .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1))) - ) - } - let message = "You cleared \(formatted)! Some system-managed files can't be removed, so a small amount of cache usage is normal." + let delta = max(sizeBefore - sizeAfter, 0) + let message = "You cleared \(Self.formatByteCount(delta))! Some system-managed files can't be removed, so a small amount of cache usage is normal." let alertController = UIAlertController(title: "Cache Cleared", message: message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in self.dismiss(animated: true) @@ -115,6 +113,19 @@ final class SettingsViewController: HostingController { } } + private static func formatByteCount(_ bytes: Int64) -> String { + let measurement = Measurement(value: Double(bytes), unit: UnitInformationStorage.bytes) + if bytes < 1_000_000 { + return measurement.converted(to: .kilobytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))) + ) + } else { + return measurement.converted(to: .megabytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1))) + ) + } + } + func resetSettings() { let alert = UIAlertController( title: "Reset All Settings?", @@ -128,37 +139,6 @@ final class SettingsViewController: HostingController { present(alert, animated: true) } - func exportSettings() { - do { - let data = try SettingsExporter.exportSettings() - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - let filename = "awful-settings-\(dateFormatter.string(from: Date())).json" - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - try data.write(to: tempURL) - - let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) - activityVC.popoverPresentationController?.sourceView = view - activityVC.completionWithItemsHandler = { _, _, _, _ in - try? FileManager.default.removeItem(at: tempURL) - } - present(activityVC, animated: true) - } catch { - Log.error("Failed to export settings: \(error)") - let alert = UIAlertController(title: "Export Failed", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - } - } - - func importSettings() { - let types = [UTType.json] - let picker = UIDocumentPickerViewController(forOpeningContentTypes: types) - picker.delegate = self - picker.allowsMultipleSelection = false - present(picker, animated: true) - } - func goToAwfulThread() { AppDelegate.instance.open(route: .threadPage(threadID: "3837546", page: .nextUnread, .seen)) } @@ -176,43 +156,6 @@ final class SettingsViewController: HostingController { } } -extension SettingsViewController: UIDocumentPickerDelegate { - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - guard let url = urls.first else { return } - - guard url.startAccessingSecurityScopedResource() else { - let alert = UIAlertController(title: "Import Failed", message: "Could not access the selected file.", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - return - } - defer { url.stopAccessingSecurityScopedResource() } - - do { - let data = try Data(contentsOf: url) - let result = try SettingsExporter.importSettings(from: data) - - var message = "Successfully applied \(result.appliedCount) setting\(result.appliedCount == 1 ? "" : "s")." - if result.isOlderBuild, !result.missingKeys.isEmpty { - message += "\n\nThis file was exported from an older version of Awful (build \(result.exportBuildNumber ?? "unknown")). \(result.missingKeys.count) newer setting\(result.missingKeys.count == 1 ? " was" : "s were") not included and will use default values." - } - - let alert = UIAlertController( - title: "Settings Imported", - message: message, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - } catch { - Log.error("Failed to import settings: \(error)") - let alert = UIAlertController(title: "Import Failed", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - } - } -} - /// See the `README.md` section "Alternate App Icons" for more info. Now ordered in a 3 x 5 grid private let appIcons: [AppIconDataSource.AppIcon] = [ .init(accessibilityLabel: String(localized: "Rated five", bundle: .module), imageName: AppIconImageNames.rated_five), @@ -256,10 +199,8 @@ struct SettingsContainerView: View { let cacheSizeText: CurrentValueSubject @ObservedObject var currentUser: User let emptyCache: () -> Void - let exportSettings: () -> Void let goToAwfulThread: () -> Void let hasRegularSizeClassInLandscape: Bool - let importSettings: () -> Void let isMac: Bool let isPad: Bool let logOut: () -> Void @@ -276,10 +217,8 @@ struct SettingsContainerView: View { canOpenURL: UIApplication.shared.canOpenURL(_:), currentUsername: currentUser.username ?? "", emptyCache: emptyCache, - exportSettings: exportSettings, goToAwfulThread: goToAwfulThread, hasRegularSizeClassInLandscape: hasRegularSizeClassInLandscape, - importSettings: importSettings, isMac: isMac, isPad: isPad, logOut: logOut, diff --git a/AwfulCore/Sources/AwfulCore/Data/DataStore.swift b/AwfulCore/Sources/AwfulCore/Data/DataStore.swift index 137edb93d..12b85e6a3 100644 --- a/AwfulCore/Sources/AwfulCore/Data/DataStore.swift +++ b/AwfulCore/Sources/AwfulCore/Data/DataStore.swift @@ -13,7 +13,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: public final class DataStore: NSObject { /// A directory in which the store is saved. Since stores can span multiple files, a directory is required. - let storeDirectoryURL: URL + public let storeDirectoryURL: URL /// A main-queue-concurrency-type context that is automatically saved when the application enters the background. public let mainManagedObjectContext: NSManagedObjectContext @@ -165,7 +165,7 @@ public final class DataStore: NSObject { invalidatePruneTimer() operationQueue.cancelAllOperations() - NotificationCenter.default.post(name: NSNotification.Name(rawValue: DataStoreWillResetNotification), object: self) + NotificationCenter.default.post(name: .dataStoreWillReset, object: self) mainManagedObjectContext.reset() if let persistentStore = persistentStore { @@ -188,7 +188,7 @@ public final class DataStore: NSObject { loadPersistentStore() - NotificationCenter.default.post(name: NSNotification.Name(rawValue: DataStoreDidResetNotification), object: self) + NotificationCenter.default.post(name: .dataStoreDidReset, object: self) } } @@ -223,8 +223,13 @@ public final class LastModifiedContextObserver: NSObject { } } -/// Posted when a data store (the notification's object) is about to delete its persistent data. Please relinquish any references you may have to managed objects originating from the data store, as they are now invalid. -let DataStoreWillResetNotification = "Data store will be deleted" +public extension Notification.Name { + /// Posted when a data store (the notification's object) is about to delete its persistent + /// data. Observers must relinquish any references to managed objects originating from the + /// store, as they are now invalid. + static let dataStoreWillReset = Notification.Name("DataStoreWillReset") -/// Posted once a data store (the notification's object) has deleted its persistent data. Managed objects can once again be inserted into, fetched from, and updated in the data store. -let DataStoreDidResetNotification = "Data store did reset" + /// Posted once a data store (the notification's object) has deleted its persistent data. + /// Managed objects can once again be inserted into, fetched from, and updated in the store. + static let dataStoreDidReset = Notification.Name("DataStoreDidReset") +} diff --git a/AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift b/AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift deleted file mode 100644 index 18219e7e0..000000000 --- a/AwfulSettings/Sources/AwfulSettings/SettingsExporter.swift +++ /dev/null @@ -1,193 +0,0 @@ -// SettingsExporter.swift -// -// Copyright 2024 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app - -import Foundation - -/// Exports and imports user preferences as JSON for backup/sharing. -public enum SettingsExporter { - - /// Current schema version for exported settings files. - private static let schemaVersion = 1 - - /// All preference keys that should be included in an export. - /// Session/auth keys (userID, username, etc.) are intentionally excluded. - private static let preferenceKeys: [String] = [ - Settings.appIconName.key, - Settings.autoDarkTheme.key, - Settings.automaticTimg.key, - Settings.autoplayGIFs.key, - Settings.bookmarksSortedUnread.key, - Settings.clipboardURLEnabled.key, - Settings.confirmBeforeReplying.key, - Settings.darkMode.key, - Settings.defaultBrowser.key, - Settings.defaultDarkThemeName.key, - Settings.defaultLightThemeName.key, - Settings.embedBlueskyPosts.key, - Settings.embedTweets.key, - Settings.enableCustomTitlePostLayout.key, - Settings.enableHaptics.key, - Settings.fontScale.key, - Settings.forumThreadsSortedUnread.key, - Settings.frogAndGhostEnabled.key, - Settings.handoffEnabled.key, - Settings.hideSidebarInLandscape.key, - Settings.jumpToPostEndOnDoubleTap.key, - Settings.loadImages.key, - Settings.openTwitterLinksInTwitter.key, - Settings.openYouTubeLinksInYouTube.key, - Settings.pullForNext.key, - Settings.showAvatars.key, - Settings.showThreadTags.key, - Settings.showUnreadAnnouncementsBadge.key, - Settings.useNewSmiliePicker.key, - ] - - /// Exports the current user preferences as JSON data. - /// - /// Includes all preference keys and any forum-specific theme overrides - /// (keys matching the pattern `theme-light-*` or `theme-dark-*`). - public static func exportSettings( - defaults: UserDefaults = .standard - ) throws -> Data { - var exported: [String: Any] = [ - "_version": schemaVersion, - "_exportDate": ISO8601DateFormatter().string(from: Date()), - "_buildNumber": Bundle.main.version ?? "unknown", - ] - - // Collect known preference keys - for key in preferenceKeys { - if let value = defaults.object(forKey: key) { - exported[key] = value - } - } - - // Collect forum-specific theme keys (theme-light-*, theme-dark-*) - let allKeys = defaults.dictionaryRepresentation() - for (key, value) in allKeys { - if isForumSpecificThemeKey(key) { - exported[key] = value - } - } - - return try JSONSerialization.data( - withJSONObject: exported, - options: [.prettyPrinted, .sortedKeys] - ) - } - - /// The result of importing settings, including any warnings. - public struct ImportResult { - /// Number of settings that were applied. - public let appliedCount: Int - /// Keys present in the current app but missing from the imported file. - public let missingKeys: [String] - /// The build number the file was exported from, if available. - public let exportBuildNumber: String? - /// Whether the file was exported from an older build than the current app. - public let isOlderBuild: Bool - } - - /// Imports settings from JSON data, applying them to UserDefaults. - /// - /// - Parameters: - /// - data: The JSON data previously exported by `exportSettings`. - /// - validThemeNames: If provided, theme values will be validated against this set. - /// - defaults: The UserDefaults store to write to. - /// - Returns: An `ImportResult` with the count of applied settings and any warnings. - public static func importSettings( - from data: Data, - validThemeNames: Set? = nil, - defaults: UserDefaults = .standard - ) throws -> ImportResult { - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw ImportError.invalidFormat - } - - guard let version = json["_version"] as? Int else { - throw ImportError.missingVersion - } - guard version <= schemaVersion else { - throw ImportError.unsupportedVersion(version) - } - - let exportBuildNumber = json["_buildNumber"] as? String - let currentBuildNumber = Bundle.main.version - let isOlderBuild: Bool = { - guard let exported = exportBuildNumber, - let current = currentBuildNumber, - let exportedInt = Int(exported), - let currentInt = Int(current) - else { return false } - return exportedInt < currentInt - }() - - let knownKeys = Set(preferenceKeys) - let importedSettingKeys = Set(json.keys.filter { !$0.hasPrefix("_") }) - var appliedCount = 0 - - for (key, value) in json { - // Skip metadata keys - if key.hasPrefix("_") { continue } - - // Only import known preference keys or forum-specific theme keys - guard knownKeys.contains(key) || isForumSpecificThemeKey(key) else { continue } - - // Validate theme values if a validation set is provided - if let validNames = validThemeNames, isThemeValueKey(key) { - if let themeName = value as? String, !validNames.contains(themeName) { - continue - } - } - - defaults.set(value, forKey: key) - appliedCount += 1 - } - - // Find preference keys that exist in the current app but were missing from the file - let missingKeys = preferenceKeys.filter { !importedSettingKeys.contains($0) } - - return ImportResult( - appliedCount: appliedCount, - missingKeys: missingKeys, - exportBuildNumber: exportBuildNumber, - isOlderBuild: isOlderBuild - ) - } - - private static func isForumSpecificThemeKey(_ key: String) -> Bool { - let parts = key.split(separator: "-") - guard - parts.count == 3, - parts[0] == "theme", - parts[1] == "light" || parts[1] == "dark", - Int(parts[2]) != nil - else { return false } - return true - } - - private static func isThemeValueKey(_ key: String) -> Bool { - key == Settings.defaultDarkThemeName.key - || key == Settings.defaultLightThemeName.key - || isForumSpecificThemeKey(key) - } - - public enum ImportError: LocalizedError { - case invalidFormat - case missingVersion - case unsupportedVersion(Int) - - public var errorDescription: String? { - switch self { - case .invalidFormat: - return "The file is not a valid Awful settings file." - case .missingVersion: - return "The settings file is missing a version number." - case .unsupportedVersion(let version): - return "This settings file (version \(version)) was created by a newer version of Awful." - } - } - } -} diff --git a/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift b/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift index 5d48e1ee4..a0087cf2e 100644 --- a/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift +++ b/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift @@ -21,16 +21,17 @@ public extension UserDefaults { // MARK: Mass deletion +private let sessionKeys: Set = [ + Settings.userID.key, + Settings.username.key, + Settings.canSendPrivateMessages.key, + Settings.lastOfferedPasteboardURLString.key, + Settings.imgurUploadMode.key, +] + public extension UserDefaults { /// Removes only session/auth-related keys, preserving user preferences. func removeSessionObjects() { - let sessionKeys = [ - Settings.userID.key, - Settings.username.key, - Settings.canSendPrivateMessages.key, - Settings.lastOfferedPasteboardURLString.key, - Settings.imgurUploadMode.key, - ] for key in sessionKeys { removeObject(forKey: key) } @@ -42,17 +43,8 @@ public extension UserDefaults { /// Keys are removed individually rather than via `setPersistentDomain` /// so that KVO fires for each key and `@AppStorage` updates immediately. func resetPreferences() { - let sessionKeys: Set = [ - Settings.userID.key, - Settings.username.key, - Settings.canSendPrivateMessages.key, - Settings.lastOfferedPasteboardURLString.key, - Settings.imgurUploadMode.key, - ] - for key in dictionaryRepresentation().keys { - if !sessionKeys.contains(key) { - removeObject(forKey: key) - } + for key in dictionaryRepresentation().keys where !sessionKeys.contains(key) { + removeObject(forKey: key) } } } diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift index a40c98dfe..7ba1bc60c 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift @@ -42,12 +42,18 @@ final class ForumSpecificThemesViewController: TableViewController { try! resultsController.performFetch() NotificationCenter.default.addObserver(self, selector: #selector(forumSpecificThemeDidChange), name: Theme.themeForForumDidChangeNotification, object: Theme.self) + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) } @objc private func forumSpecificThemeDidChange(_ notification: Notification) { tableView.reloadData() } + @objc private func dataStoreDidReset() { + try? resultsController.performFetch() + tableView.reloadData() + } + // MARK: - UITableViewDataSource and UITableViewDelegate override func numberOfSections(in tableView: UITableView) -> Int { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index 9e1367c47..980113cdc 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -70,7 +70,7 @@ "Clear Cache" : { }, - "Clearing the cache removes all downloaded images, web data, and temporary files. Resetting settings restores all preferences to their defaults." : { + "Clearing the cache removes downloaded images, web data, and cached forums, threads, and posts. Resetting settings restores all preferences to their defaults." : { }, "Dark Mode" : { @@ -105,9 +105,6 @@ }, "Enable Haptics" : { - }, - "Export Settings" : { - }, "Forum-Specific Themes" : { @@ -132,9 +129,6 @@ }, "Immersive Mode" : { - }, - "Import Settings" : { - }, "Links" : { @@ -144,9 +138,6 @@ }, "Log Out" : { - }, - "Logging out erases cached forums, threads, and posts. Your settings and preferences will be preserved." : { - }, "New Smilie Picker" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index 07d8c0b76..193b3e339 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -45,10 +45,8 @@ public struct SettingsView: View { let canOpenURL: (URL) -> Bool let currentUsername: String let emptyCache: () -> Void - let exportSettings: () -> Void let goToAwfulThread: () -> Void let hasRegularSizeClassInLandscape: Bool - let importSettings: () -> Void let isMac: Bool let isPad: Bool let logOut: () -> Void @@ -88,10 +86,8 @@ public struct SettingsView: View { canOpenURL: @escaping (URL) -> Bool, currentUsername: String, emptyCache: @escaping () -> Void, - exportSettings: @escaping () -> Void, goToAwfulThread: @escaping () -> Void, hasRegularSizeClassInLandscape: Bool, - importSettings: @escaping () -> Void, isMac: Bool, isPad: Bool, logOut: @escaping () -> Void, @@ -103,10 +99,8 @@ public struct SettingsView: View { self.canOpenURL = canOpenURL self.currentUsername = currentUsername self.emptyCache = emptyCache - self.exportSettings = exportSettings self.goToAwfulThread = goToAwfulThread self.hasRegularSizeClassInLandscape = hasRegularSizeClassInLandscape - self.importSettings = importSettings self.isMac = isMac self.isPad = isPad self.logOut = logOut @@ -135,9 +129,6 @@ public struct SettingsView: View { Text(currentUsername) } .header() - } footer: { - Text("Logging out erases cached forums, threads, and posts. Your settings and preferences will be preserved.", bundle: .module) - .footer() } .section() @@ -323,13 +314,11 @@ public struct SettingsView: View { } } Button("Reset All Settings", bundle: .module) { resetSettings() } - Button("Export Settings", bundle: .module) { exportSettings() } - Button("Import Settings", bundle: .module) { importSettings() } } header: { Text("Data Management", bundle: .module) .header() } footer: { - Text("Clearing the cache removes all downloaded images, web data, and temporary files. Resetting settings restores all preferences to their defaults.", bundle: .module) + Text("Clearing the cache removes downloaded images, web data, and cached forums, threads, and posts. Resetting settings restores all preferences to their defaults.", bundle: .module) .footer() } .section() @@ -395,10 +384,8 @@ private struct SectionModifier: ViewModifier { canOpenURL: { _ in true }, currentUsername: "Random Newbie", emptyCache: { print("emptying cache") }, - exportSettings: { print("exporting settings") }, goToAwfulThread: { print("navigating to Awful's thread") }, hasRegularSizeClassInLandscape: true, - importSettings: { print("importing settings") }, isMac: false, isPad: true, logOut: { print("logging out") },