Compare commits

..

8 Commits

Author SHA1 Message Date
Shadowfacts e264e8842c Don't use shared background context for fetching item counts
A significant fraction of the time was spent waiting for the background
context to be available, before the count could even be started. Since
the counts don't need to use the shared background context, let them
each use their own context to avoid contention.
2022-09-10 14:14:31 -04:00
Shadowfacts c78ed42a5e Fix show sidebar button not appearing on iPad 2022-09-10 13:32:18 -04:00
Shadowfacts 6fa71bee38 Index all the things 2022-09-10 00:19:11 -04:00
Shadowfacts 6bb292ba13 Move item count fetching to background task 2022-09-10 00:09:46 -04:00
Shadowfacts c12b9ae879 Parent background context to PSC to prevent hangs when updating widget 2022-09-09 23:51:41 -04:00
Shadowfacts 2380eeee4a Add Mark as (Un)read swipe action 2022-09-07 23:00:23 -04:00
Shadowfacts f4196a2c26 Fix building with Xcode 14 RC 2022-09-07 22:58:39 -04:00
Shadowfacts ff9286ca48 More logging 2022-09-07 22:57:48 -04:00
13 changed files with 141 additions and 22 deletions

View File

@ -19,8 +19,9 @@ public class PersistentContainer: NSPersistentContainer, @unchecked Sendable {
public private(set) lazy var backgroundContext: NSManagedObjectContext = { public private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
// todo: should the background context really be parented to the view context, or should they both be direct children of the PSC? // the background context needs to be parented directly to the PSC
context.parent = self.viewContext // if it's parented to the viewContext, it blocks the viewContext (and potentially the main thread) when it needs to look things up
context.persistentStoreCoordinator = self.persistentStoreCoordinator
return context return context
}() }()

View File

@ -3,6 +3,6 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>_XCCurrentVersionName</key> <key>_XCCurrentVersionName</key>
<string>Reader.xcdatamodel</string> <string>Reader 2.xcdatamodel</string>
</dict> </dict>
</plist> </plist>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21279" systemVersion="22A5331f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Feed" representedClassName="Feed" syncable="YES">
<attribute name="id" attributeType="String"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="title" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<relationship name="groups" toMany="YES" deletionRule="Nullify" destinationEntity="Group" inverseName="feeds" inverseEntity="Group"/>
<relationship name="items" toMany="YES" deletionRule="Cascade" destinationEntity="Item" inverseName="feed" inverseEntity="Item"/>
</entity>
<entity name="Group" representedClassName="Group" syncable="YES">
<attribute name="id" attributeType="String"/>
<attribute name="title" attributeType="String"/>
<relationship name="feeds" toMany="YES" deletionRule="Nullify" destinationEntity="Feed" inverseName="groups" inverseEntity="Feed"/>
</entity>
<entity name="Item" representedClassName="Item" versionHashModifier="5" syncable="YES">
<attribute name="author" optional="YES" attributeType="String"/>
<attribute name="content" optional="YES" attributeType="String"/>
<attribute name="excerpt" optional="YES" attributeType="String"/>
<attribute name="generatedExcerpt" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="needsReadStateSync" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="read" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="title" optional="YES" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<relationship name="feed" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed"/>
<fetchIndex name="byID">
<fetchIndexElement property="id" type="Binary" order="ascending"/>
</fetchIndex>
<fetchIndex name="byRead">
<fetchIndexElement property="read" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
<entity name="SyncState" representedClassName="SyncState" syncable="YES" codeGenerationType="class">
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<fetchRequest name="FetchRequest" entity="Item" predicateString="TRUEPREDICATE"/>
</model>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21223.11" systemVersion="22A5266r" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21279" systemVersion="22A5331f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Feed" representedClassName="Feed" syncable="YES"> <entity name="Feed" representedClassName="Feed" syncable="YES">
<attribute name="id" attributeType="String"/> <attribute name="id" attributeType="String"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@ -30,10 +30,4 @@
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
</entity> </entity>
<fetchRequest name="FetchRequest" entity="Item" predicateString="TRUEPREDICATE"/> <fetchRequest name="FetchRequest" entity="Item" predicateString="TRUEPREDICATE"/>
<elements>
<element name="Feed" positionX="-54" positionY="9" width="128" height="119"/>
<element name="Group" positionX="-63" positionY="-18" width="128" height="74"/>
<element name="Item" positionX="-45" positionY="63" width="128" height="194"/>
<element name="SyncState" positionX="-63" positionY="90" width="128" height="44"/>
</elements>
</model> </model>

View File

@ -75,7 +75,18 @@
argument = "-com.apple.CoreData.ConcurrencyDebug 1" argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES"> isEnabled = "YES">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLDebug 1"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments> </CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "SQLITE_AUTO_TRACE"
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View File

@ -104,6 +104,7 @@ class BackgroundManager: NSObject {
do { do {
try await fervorController.persistentContainer.upsertItems(items) try await fervorController.persistentContainer.upsertItems(items)
logger.info("Upserted \(items.count) items during background refresh") logger.info("Upserted \(items.count) items during background refresh")
logger.info("Most recent unread item: \(items.first(where: { $0.read == false })!.title!, privacy: .public)")
await WidgetHelper.updateWidgetData(fervorController: fervorController) await WidgetHelper.updateWidgetData(fervorController: fervorController)

View File

@ -9,7 +9,7 @@ import Foundation
import CoreData import CoreData
import Persistence import Persistence
enum ItemListType: Hashable { enum ItemListType: Hashable, Equatable {
case unread case unread
case all case all
case group(Group) case group(Group)
@ -52,7 +52,9 @@ enum ItemListType: Hashable {
case .all: case .all:
return nil return nil
case .group(let group): case .group(let group):
req.predicate = NSPredicate(format: "read = NO AND feed in %@", group.feeds!) // use the feed ids, because passing the NSSet of feeds into the predicate results in a multithreading violation if the request is used on a different context
let feedIDs = group.feeds!.map { ($0 as! Feed).objectID }
req.predicate = NSPredicate(format: "read = NO AND feed in %@", feedIDs)
case .feed(let feed): case .feed(let feed):
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed) req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
} }

View File

@ -45,7 +45,11 @@ class AppSplitViewController: UISplitViewController {
setViewController(sidebarNav, for: .primary) setViewController(sidebarNav, for: .primary)
secondaryNav = UINavigationController() secondaryNav = UINavigationController()
secondaryNav.isNavigationBarHidden = true // the toggle sidebar button only appears if there's a navigation bar
// so we just make always transparent, rather than disabling it
let secondaryNavBarAppearance = UINavigationBarAppearance()
secondaryNavBarAppearance.configureWithTransparentBackground()
secondaryNav.navigationBar.standardAppearance = secondaryNavBarAppearance
secondaryNav.view.backgroundColor = .appBackground secondaryNav.view.backgroundColor = .appBackground
setViewController(secondaryNav, for: .secondary) setViewController(secondaryNav, for: .secondary)

View File

@ -7,9 +7,16 @@
import UIKit import UIKit
import Fervor import Fervor
import Persistence
import OSLog
private let signposter = OSSignposter(subsystem: "net.shadowfacts.Reader", category: "HomeCollectionViewCell")
class HomeCollectionViewCell: UICollectionViewListCell { class HomeCollectionViewCell: UICollectionViewListCell {
private var currentItemCountTask: Task<Void, Never>?
private var itemCount: Int?
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
override func updateConfiguration(using state: UICellConfigurationState) { override func updateConfiguration(using state: UICellConfigurationState) {
var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state) var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
@ -22,4 +29,47 @@ class HomeCollectionViewCell: UICollectionViewListCell {
} }
#endif #endif
override func prepareForReuse() {
super.prepareForReuse()
currentItemCountTask?.cancel()
itemCount = nil
}
func updateUI(item: ItemListType, persistentContainer: PersistentContainer) {
var config = UIListContentConfiguration.valueCell()
config.text = item.title
if let itemCount {
config.secondaryText = itemCount.formatted(.number)
}
config.secondaryTextProperties.color = .tintColor
self.contentConfiguration = config
currentItemCountTask = Task(priority: .userInitiated) {
let state = signposter.beginInterval("fetch count", id: signposter.makeSignpostID(), "\(String(item.hashValue, radix: 16), privacy: .public)")
if let count = await fetchCount(item: item, in: persistentContainer),
!Task.isCancelled {
self.itemCount = count
config.secondaryText = count.formatted(.number)
self.contentConfiguration = config
}
signposter.endInterval("fetch count", state)
}
}
private func fetchCount(item: ItemListType, in persistentContainer: PersistentContainer) async -> Int? {
guard let request = item.countFetchRequest else {
return nil
}
return await withCheckedContinuation({ continuation in
let state = signposter.beginInterval("waiting to perform", id: signposter.makeSignpostID(), "\(String(item.hashValue, radix: 16), privacy: .public)")
persistentContainer.performBackgroundTask { context in
signposter.endInterval("waiting to perform", state)
let state = signposter.beginInterval("count", id: signposter.makeSignpostID(), "\(String(item.hashValue, radix: 16), privacy: .public)")
let count = try? context.count(for: request)
signposter.endInterval("count", state)
continuation.resume(returning: count)
}
})
}
} }

View File

@ -126,15 +126,8 @@ class HomeViewController: UIViewController {
config.text = section.title config.text = section.title
supplementaryView.contentConfiguration = config supplementaryView.contentConfiguration = config
} }
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { cell, indexPath, item in let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { [unowned self] cell, indexPath, item in
var config = UIListContentConfiguration.valueCell() cell.updateUI(item: item, persistentContainer: self.fervorController.persistentContainer)
config.text = item.title
if let req = item.countFetchRequest,
let count = try? self.fervorController.persistentContainer.viewContext.count(for: req) {
config.secondaryText = "\(count)"
config.secondaryTextProperties.color = .tintColor
}
cell.contentConfiguration = config
cell.accessories = [.disclosureIndicator()] cell.accessories = [.disclosureIndicator()]
} }

View File

@ -60,6 +60,22 @@ class ItemsViewController: UIViewController {
var configuration = UICollectionLayoutListConfiguration(appearance: .plain) var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
configuration.backgroundColor = .clear configuration.backgroundColor = .clear
configuration.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
guard let itemID = self.dataSource.itemIdentifier(for: indexPath) else {
return nil
}
let item = fervorController.persistentContainer.viewContext.object(with: itemID) as! Item
let action = UIContextualAction(style: .normal, title: item.read ? "Mark as Unread" : "Mark as Read") { _, _, completion in
Task {
await self.fervorController.markItem(item, read: !item.read)
completion(true)
}
}
action.image = UIImage(systemName: item.read ? "checkmark.circle" : "checkmark.circle.fill")
return UISwipeActionsConfiguration(actions: [
action
])
}
let layout = UICollectionViewCompositionalLayout.list(using: configuration) let layout = UICollectionViewCompositionalLayout.list(using: configuration)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.delegate = self collectionView.delegate = self

View File

@ -72,7 +72,10 @@ class ReadViewController: UIViewController {
webView.isOpaque = false webView.isOpaque = false
webView.backgroundColor = .clear webView.backgroundColor = .clear
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
// TODO: Xcode 14 RC doesn't have the macOS 13 SDK, so we can't use this
#if !targetEnvironment(macCatalyst)
webView.isFindInteractionEnabled = true webView.isFindInteractionEnabled = true
#endif
} }
if let content = itemContentHTML() { if let content = itemContentHTML() {
webView.loadHTMLString(content, baseURL: item.url) webView.loadHTMLString(content, baseURL: item.url)

View File

@ -11,6 +11,7 @@ import Persistence
import OSLog import OSLog
private let logger = Logger(subsystem: "net.shadowfacts.Reader", category: "WidgetHelper") private let logger = Logger(subsystem: "net.shadowfacts.Reader", category: "WidgetHelper")
private let signposter = OSSignposter(logger: logger)
struct WidgetHelper { struct WidgetHelper {
private init() {} private init() {}
@ -26,7 +27,9 @@ struct WidgetHelper {
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
req.fetchLimit = 32 req.fetchLimit = 32
req.predicate = NSPredicate(format: "read = NO") req.predicate = NSPredicate(format: "read = NO")
let state = signposter.beginInterval("fetch")
var items = (try? context.fetch(req)) ?? [] var items = (try? context.fetch(req)) ?? []
signposter.endInterval("fetch", state)
var prioritizedItems: [Item] = [] var prioritizedItems: [Item] = []
@ -51,6 +54,8 @@ struct WidgetHelper {
} }
} }
logger.info("Saving widget data with first item: '\(prioritizedItems.first!.title ?? "<untitled>", privacy: .public)'")
WidgetData(recentItems: prioritizedItems).save(account: fervorController.account!) WidgetData(recentItems: prioritizedItems).save(account: fervorController.account!)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()