Another round of strict concurrency fixes

This commit is contained in:
Shadowfacts 2024-01-28 14:03:14 -05:00
parent 27d44340e8
commit b235f0e826
27 changed files with 104 additions and 84 deletions

View File

@ -18,19 +18,23 @@ class ActionViewController: UIViewController {
super.viewDidLoad()
findURLFromWebPage { (components) in
if let components = components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components = components {
self.searchForURLInApp(components)
DispatchQueue.main.async {
if let components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components {
DispatchQueue.main.async {
self.searchForURLInApp(components)
}
}
}
}
}
}
}
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
@ -54,7 +58,7 @@ class ActionViewController: UIViewController {
completion(nil)
}
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {

View File

@ -10,7 +10,7 @@ import Foundation
import Combine
import Pachyderm
public class InstanceFeatures: ObservableObject {
public final class InstanceFeatures: ObservableObject {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive)
private let _featuresUpdated = PassthroughSubject<Void, Never>()

View File

@ -8,7 +8,7 @@
import Foundation
import CryptoKit
public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
public let id: String
public let instanceURL: URL
public let clientID: String

View File

@ -12,7 +12,7 @@ import UserAccounts
import InstanceFeatures
import Combine
class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Sendable {
let accountInfo: UserAccountInfo?
let client: Client
let instanceFeatures: InstanceFeatures
@ -20,7 +20,9 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
@MainActor
private var customEmojis: [Emoji]?
@Published var ownAccount: Account?
@MainActor
@Published
private(set) var ownAccount: Account?
init(accountInfo: UserAccountInfo) {
self.accountInfo = accountInfo

View File

@ -20,7 +20,7 @@ import OSLog
private let oauthScopes = [Scope.read, .write, .follow]
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
class MastodonController: ObservableObject {
final class MastodonController: ObservableObject, Sendable {
@MainActor
static private(set) var all = [UserAccountInfo: MastodonController]()
@ -283,7 +283,8 @@ class MastodonController: ObservableObject {
let account = try await run(Client.getSelfAccount()).0
let context = persistentContainer.viewContext
return await context.perform {
// this closure is declared separately so we can tell the compiler it's Sendable
let performBlock: @MainActor @Sendable () -> MainThreadBox<AccountMO> = {
let accountMO: AccountMO
if let existing = self.persistentContainer.account(for: account.id, in: context) {
accountMO = existing
@ -296,6 +297,8 @@ class MastodonController: ObservableObject {
self.account = accountMO
return MainThreadBox(value: accountMO)
}
// it's safe to remove the MainActor annotation, because this is the view context
return await context.perform(unsafeBitCast(performBlock, to: (@Sendable () -> MainThreadBox<AccountMO>).self))
}
fetchOwnAccountTask = task
return try await task.value.value
@ -342,7 +345,7 @@ class MastodonController: ObservableObject {
}
}
private func retrying<T: Sendable>(_ label: StaticString, action: () async throws -> T) async throws -> T {
private func retrying<T: Sendable>(_ label: StaticString, action: @Sendable () async throws -> T) async throws -> T {
for attempt in 0..<4 {
do {
return try await action()
@ -582,6 +585,7 @@ class MastodonController: ObservableObject {
)
}
@MainActor
func createDraft(editing status: StatusMO, source: StatusSource) -> Draft {
precondition(status.id == source.id)
let draft = DraftsPersistentContainer.shared.createEditDraft(

View File

@ -27,6 +27,18 @@ struct ImageGrayscalifier {
}
}
static func convertIfNecessary(url: URL?, image: UIImage) async -> UIImage? {
let grayscale = await MainActor.run {
Preferences.shared.grayscaleImages
}
if grayscale,
let source = image.cgImage {
return await convert(url: url, cgImage: source)
} else {
return image
}
}
static func convert(url: URL?, image: UIImage) -> UIImage? {
if let url,
let cached = cache.object(forKey: url as NSURL) {

View File

@ -14,7 +14,7 @@ import WebURLFoundationExtras
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

View File

@ -11,7 +11,7 @@ import Pachyderm
class ProfileDirectoryViewController: UIViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

View File

@ -11,7 +11,7 @@ import Pachyderm
class SuggestedProfilesViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
var collectionView: UICollectionView!
private var layout: MultiColumnCollectionViewLayout!

View File

@ -13,7 +13,7 @@ import Combine
class TrendingHashtagsViewController: UIViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

View File

@ -14,7 +14,7 @@ import Combine
class TrendingLinksViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

View File

@ -11,7 +11,7 @@ import Pachyderm
class TrendingStatusesViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
let filterer: Filterer
var collectionView: UICollectionView! {

View File

@ -144,7 +144,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
contentViewTopConstraint,
])
contentViewSizeObservation = (contentView as UIView).observe(\.bounds, changeHandler: { [unowned self] _, _ in
self.centerImage()
MainActor.runUnsafely {
self.centerImage()
}
})
}

View File

@ -11,7 +11,7 @@ import Combine
class MainSplitViewController: UISplitViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
private var sidebar: MainSidebarViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController?

View File

@ -11,7 +11,7 @@ import ComposeUI
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
private var composePlaceholder: UIViewController!

View File

@ -15,7 +15,7 @@ import Sentry
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
weak var mastodonController: MastodonController!
private let mastodonController: MastodonController
private let filterer: Filterer
private let allowedTypes: [Pachyderm.Notification.Kind]

View File

@ -27,7 +27,7 @@ class InstanceSelectorTableViewController: UITableViewController {
weak var delegate: InstanceSelectorTableViewControllerDelegate?
var dataSource: DataSource!
var dataSource: UITableViewDiffableDataSource<Section, Item>!
var searchController: UISearchController!
var recommendedInstances: [InstanceSelector.Instance] = []
@ -73,7 +73,7 @@ class InstanceSelectorTableViewController: UITableViewController {
tableView.estimatedRowHeight = 120
createActivityIndicatorHeader()
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item {
case let .selected(_, instance):
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
@ -338,9 +338,6 @@ extension InstanceSelectorTableViewController {
}
}
}
class DataSource: UITableViewDiffableDataSource<Section, Item> {
}
}
extension InstanceSelectorTableViewController: UISearchResultsUpdating {

View File

@ -12,7 +12,7 @@ import Combine
class ProfileViewController: UIViewController, StateRestorableViewController {
weak var mastodonController: MastodonController!
let mastodonController: MastodonController
// This property is optional because MyProfileViewController may not have the user's account ID
// when first constructed. It should never be set to nil.

View File

@ -8,6 +8,7 @@
import SwiftUI
@MainActor
private var converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
@ -15,6 +16,7 @@ private var converter = HTMLConverter(
paragraphStyle: .default
)
@MainActor
struct ReportStatusView: View {
let status: StatusMO
let mastodonController: MastodonController

View File

@ -30,7 +30,7 @@ extension SearchResultsViewControllerDelegate {
class SearchResultsViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
let mastodonController: MastodonController
weak var exploreNavigationController: UINavigationController?
weak var delegate: SearchResultsViewControllerDelegate?

View File

@ -32,7 +32,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
weak var delegate: TimelineViewControllerDelegate?
let timeline: Timeline
weak var mastodonController: MastodonController!
let mastodonController: MastodonController
private let filterer: Filterer
var persistsState = false
@ -587,7 +587,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
snapshot.appendSections([.statuses])
let items = allStatuses.map { Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items)
await dataSource.apply(snapshot, animatingDifferences: false)
await apply(snapshot, animatingDifferences: false)
collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top)
stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)")

View File

@ -73,8 +73,8 @@ class LargeAccountDetailView: UIView {
if let avatar = account.avatar {
avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
guard let self = self, let image = image else { return }
self.avatarRequest = nil
DispatchQueue.main.async {
self.avatarRequest = nil
self.avatarImageView.image = image
}
}

View File

@ -90,8 +90,7 @@ class CachedImageView: UIImageView {
return
}
try Task.checkCancellation()
// TODO: check that this isn't on the main thread
guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
guard let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
return
}
try Task.checkCancellation()

View File

@ -19,8 +19,11 @@ class InstanceTableViewCell: UITableViewCell {
var instance: InstanceV1?
var selectorInstance: InstanceSelector.Instance?
var thumbnailURL: URL?
var thumbnailRequest: ImageCache.Request?
private var thumbnailTask: Task<Void, Never>?
deinit {
thumbnailTask?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
@ -68,20 +71,19 @@ class InstanceTableViewCell: UITableViewCell {
private func updateThumbnail(url: URL) {
thumbnailImageView.image = nil
thumbnailURL = url
thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (_, image) in
guard let self = self, self.thumbnailURL == url, let image = image else { return }
self.thumbnailRequest = nil
DispatchQueue.main.async {
self.thumbnailImageView.image = image
thumbnailTask = Task {
guard let image = await ImageCache.attachments.get(url).1,
!Task.isCancelled else {
return
}
thumbnailImageView.image = image
}
}
override func prepareForReuse() {
super.prepareForReuse()
thumbnailRequest?.cancel()
thumbnailTask?.cancel()
instance = nil
selectorInstance = nil
}

View File

@ -24,7 +24,7 @@ class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
var attributedStrings = pairs.map { NSAttributedString(string: $0.0) }
let recombine = { [weak self] in
let recombine: @MainActor @Sendable () -> Void = { [weak self] in
if let self,
let combiner = self.combiner {
self.attributedText = combiner(attributedStrings)

View File

@ -53,7 +53,9 @@ class ProfileFieldsView: UIView {
private func commonInit() {
boundsObservation = observe(\.bounds, changeHandler: { [unowned self] _, _ in
self.setNeedsUpdateConstraints()
MainActor.runUnsafely {
self.setNeedsUpdateConstraints()
}
})
#if os(visionOS)

View File

@ -42,8 +42,7 @@ class ProfileHeaderView: UIView {
var accountID: String!
private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
private var imagesTask: Task<Void, Never>?
private var isGrayscale = false
private var followButtonMode = FollowButtonMode.follow {
@ -56,8 +55,7 @@ class ProfileHeaderView: UIView {
}
deinit {
avatarRequest?.cancel()
headerRequest?.cancel()
imagesTask?.cancel()
}
override func awakeFromNib() {
@ -134,7 +132,12 @@ class ProfileHeaderView: UIView {
usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked
updateImages(account: account)
imagesTask?.cancel()
let avatar = account.avatar
let header = account.header
imagesTask = Task {
await updateImages(avatar: avatar, header: header)
}
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
@ -286,50 +289,41 @@ class ProfileHeaderView: UIView {
displayNameLabel.updateForAccountDisplayName(account: account)
if isGrayscale != Preferences.shared.grayscaleImages {
updateImages(account: account)
isGrayscale = Preferences.shared.grayscaleImages
imagesTask?.cancel()
let avatar = account.avatar
let header = account.header
imagesTask = Task {
await updateImages(avatar: avatar, header: header)
}
}
}
private func updateImages(account: AccountMO) {
isGrayscale = Preferences.shared.grayscaleImages
let accountID = account.id
if let avatarURL = account.avatar {
// always load original for avatars, because ImageCache.avatars stores them scaled-down in memory
avatarRequest = ImageCache.avatars.get(avatarURL, loadOriginal: true) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self?.avatarRequest = nil
}
private nonisolated func updateImages(avatar: URL?, header: URL?) async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
guard let avatar,
let image = await ImageCache.avatars.get(avatar, loadOriginal: true).1,
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: avatar, image: image),
!Task.isCancelled else {
return
}
DispatchQueue.main.async {
self.avatarRequest = nil
await MainActor.run {
self.avatarImageView.image = transformedImage
}
}
}
if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: header, image: image) else {
DispatchQueue.main.async {
self?.headerRequest = nil
}
group.addTask {
guard let header,
let image = await ImageCache.avatars.get(header, loadOriginal: true).1,
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: header, image: image),
!Task.isCancelled else {
return
}
DispatchQueue.main.async {
self.headerRequest = nil
await MainActor.run {
self.headerImageView.image = transformedImage
}
}
await group.waitForAll()
}
}