Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions App/Data Sources/ForumListDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSFetchRequestResult>] {
Expand Down
13 changes: 13 additions & 0 deletions App/Data Sources/MessageListDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions App/Data Sources/ThreadListDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
76 changes: 69 additions & 7 deletions App/Main/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,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
Expand All @@ -161,14 +160,77 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
setRootViewController(loginVC.enclosingNavigationController, animated: true) { [weak self] in
self?._rootViewControllerStack = nil
self?.urlRouter = nil

self?.dataStore.deleteStoreAndReset()
}
}

func emptyCache() {
func emptyCache() async {
URLCache.shared.removeAllCachedResponses()
ImageCache.shared.removeAll()
ImagePipeline.shared.cache.removeAll()

// Clear WKWebView data (cookies, cache, localStorage, etc.)
let webDataStore = WKWebsiteDataStore.default()
let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
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 {
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)
}

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

nonisolated 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) {
Expand Down
19 changes: 19 additions & 0 deletions App/Main/RootViewControllerStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions App/Posts/PostsViewExternalStylesheetLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 96 additions & 16 deletions App/Settings/SettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import AwfulCore
import AwfulSettings
import AwfulSettingsUI
import AwfulTheming
import Combine
import CoreData
import os
import SwiftUI

private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SettingsViewController")

final class SettingsViewController: HostingController<SettingsContainerView> {
let managedObjectContext: NSManagedObjectContext
private var cacheSizeText: CurrentValueSubject<String, Never>!

init(managedObjectContext: NSManagedObjectContext) {
self.managedObjectContext = managedObjectContext
Expand All @@ -28,9 +27,11 @@ final class SettingsViewController: HostingController<SettingsContainerView> {
unowned var contents: SettingsViewController!
}
let box = UnownedBox()
let cacheSizeText = CurrentValueSubject<String, Never>("Calculating…")

super.init(rootView: SettingsContainerView(
appIconDataSource: makeAppIconDataSource(),
cacheSizeText: cacheSizeText,
currentUser: currentUser,
emptyCache: { box.contents.emptyCache() },
goToAwfulThread: { box.contents.goToAwfulThread() },
Expand All @@ -39,33 +40,103 @@ final class SettingsViewController: HostingController<SettingsContainerView> {
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)
tabBarItem.image = UIImage(named: "cog")
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()
}

private func refreshCacheSize() {
Task {
let size = await AppDelegate.instance.calculateCacheSize()
cacheSizeText.send(Self.formatByteCount(size))
}
}

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.emptyCacheAndResetStore()
let sizeAfter = await AppDelegate.instance.calculateCacheSize()

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)
})
self.present(alertController, animated: true)

refreshCacheSize()
}
alertController.addAction(okAction)
self.present(alertController, animated: true)
}

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?",
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 goToAwfulThread() {
Expand Down Expand Up @@ -125,6 +196,7 @@ private let appIcons: [AppIconDataSource.AppIcon] = [
/// Wrapper for observing the current `User`.
struct SettingsContainerView: View {
let appIconDataSource: AppIconDataSource
let cacheSizeText: CurrentValueSubject<String, Never>
@ObservedObject var currentUser: User
let emptyCache: () -> Void
let goToAwfulThread: () -> Void
Expand All @@ -133,22 +205,30 @@ struct SettingsContainerView: View {
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,
goToAwfulThread: goToAwfulThread,
hasRegularSizeClassInLandscape: hasRegularSizeClassInLandscape,
isMac: isMac,
isPad: isPad,
logOut: logOut
logOut: logOut,
resetSettings: resetSettings
)
.environment(\.managedObjectContext, managedObjectContext)
.themed()
.onReceive(cacheSizeText) { newValue in
displayedCacheSize = newValue
}
}
}

Expand Down
Loading
Loading