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 = {
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?
context.parent = self.viewContext
// the background context needs to be parented directly to the PSC
// 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
}()

View File

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Reader.xcdatamodel</string>
<string>Reader 2.xcdatamodel</string>
</dict>
</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"?>
<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">
<attribute name="id" attributeType="String"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@ -30,10 +30,4 @@
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<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>

View File

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

View File

@ -104,6 +104,7 @@ class BackgroundManager: NSObject {
do {
try await fervorController.persistentContainer.upsertItems(items)
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)

View File

@ -9,7 +9,7 @@ import Foundation
import CoreData
import Persistence
enum ItemListType: Hashable {
enum ItemListType: Hashable, Equatable {
case unread
case all
case group(Group)
@ -52,7 +52,9 @@ enum ItemListType: Hashable {
case .all:
return nil
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):
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
}

View File

@ -45,7 +45,11 @@ class AppSplitViewController: UISplitViewController {
setViewController(sidebarNav, for: .primary)
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
setViewController(secondaryNav, for: .secondary)

View File

@ -7,9 +7,16 @@
import UIKit
import Fervor
import Persistence
import OSLog
private let signposter = OSSignposter(subsystem: "net.shadowfacts.Reader", category: "HomeCollectionViewCell")
class HomeCollectionViewCell: UICollectionViewListCell {
private var currentItemCountTask: Task<Void, Never>?
private var itemCount: Int?
#if !targetEnvironment(macCatalyst)
override func updateConfiguration(using state: UICellConfigurationState) {
var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
@ -22,4 +29,47 @@ class HomeCollectionViewCell: UICollectionViewListCell {
}
#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
supplementaryView.contentConfiguration = config
}
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { cell, indexPath, item in
var config = UIListContentConfiguration.valueCell()
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
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { [unowned self] cell, indexPath, item in
cell.updateUI(item: item, persistentContainer: self.fervorController.persistentContainer)
cell.accessories = [.disclosureIndicator()]
}

View File

@ -60,6 +60,22 @@ class ItemsViewController: UIViewController {
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
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)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.delegate = self

View File

@ -72,7 +72,10 @@ class ReadViewController: UIViewController {
webView.isOpaque = false
webView.backgroundColor = .clear
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
#endif
}
if let content = itemContentHTML() {
webView.loadHTMLString(content, baseURL: item.url)

View File

@ -11,6 +11,7 @@ import Persistence
import OSLog
private let logger = Logger(subsystem: "net.shadowfacts.Reader", category: "WidgetHelper")
private let signposter = OSSignposter(logger: logger)
struct WidgetHelper {
private init() {}
@ -26,7 +27,9 @@ struct WidgetHelper {
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
req.fetchLimit = 32
req.predicate = NSPredicate(format: "read = NO")
let state = signposter.beginInterval("fetch")
var items = (try? context.fetch(req)) ?? []
signposter.endInterval("fetch", state)
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!)
WidgetCenter.shared.reloadAllTimelines()