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() super.viewDidLoad()
findURLFromWebPage { (components) in findURLFromWebPage { (components) in
if let components = components { DispatchQueue.main.async {
self.searchForURLInApp(components) if let components {
} else { self.searchForURLInApp(components)
self.findURLItem { (components) in } else {
if let components = components { self.findURLItem { (components) in
self.searchForURLInApp(components) 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 item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! { for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else { guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
@ -54,7 +58,7 @@ class ActionViewController: UIViewController {
completion(nil) 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 item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! { for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else { guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ import OSLog
private let oauthScopes = [Scope.read, .write, .follow] private let oauthScopes = [Scope.read, .write, .follow]
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
class MastodonController: ObservableObject { final class MastodonController: ObservableObject, Sendable {
@MainActor @MainActor
static private(set) var all = [UserAccountInfo: MastodonController]() static private(set) var all = [UserAccountInfo: MastodonController]()
@ -283,7 +283,8 @@ class MastodonController: ObservableObject {
let account = try await run(Client.getSelfAccount()).0 let account = try await run(Client.getSelfAccount()).0
let context = persistentContainer.viewContext 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 let accountMO: AccountMO
if let existing = self.persistentContainer.account(for: account.id, in: context) { if let existing = self.persistentContainer.account(for: account.id, in: context) {
accountMO = existing accountMO = existing
@ -296,6 +297,8 @@ class MastodonController: ObservableObject {
self.account = accountMO self.account = accountMO
return MainThreadBox(value: 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 fetchOwnAccountTask = task
return try await task.value.value 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 { for attempt in 0..<4 {
do { do {
return try await action() return try await action()
@ -582,6 +585,7 @@ class MastodonController: ObservableObject {
) )
} }
@MainActor
func createDraft(editing status: StatusMO, source: StatusSource) -> Draft { func createDraft(editing status: StatusMO, source: StatusSource) -> Draft {
precondition(status.id == source.id) precondition(status.id == source.id)
let draft = DraftsPersistentContainer.shared.createEditDraft( 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? { static func convert(url: URL?, image: UIImage) -> UIImage? {
if let url, if let url,
let cached = cache.object(forKey: url as NSURL) { let cached = cache.object(forKey: url as NSURL) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -144,7 +144,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
contentViewTopConstraint, contentViewTopConstraint,
]) ])
contentViewSizeObservation = (contentView as UIView).observe(\.bounds, changeHandler: { [unowned self] _, _ in 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 { class MainSplitViewController: UISplitViewController {
weak var mastodonController: MastodonController! private let mastodonController: MastodonController
private var sidebar: MainSidebarViewController! private var sidebar: MainSidebarViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController? private var fastAccountSwitcher: FastAccountSwitcherViewController?

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import Combine
class ProfileViewController: UIViewController, StateRestorableViewController { 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 // This property is optional because MyProfileViewController may not have the user's account ID
// when first constructed. It should never be set to nil. // when first constructed. It should never be set to nil.

View File

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

View File

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

View File

@ -32,7 +32,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
weak var delegate: TimelineViewControllerDelegate? weak var delegate: TimelineViewControllerDelegate?
let timeline: Timeline let timeline: Timeline
weak var mastodonController: MastodonController! let mastodonController: MastodonController
private let filterer: Filterer private let filterer: Filterer
var persistsState = false var persistsState = false
@ -587,7 +587,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let items = allStatuses.map { Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown) } let items = allStatuses.map { Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items) snapshot.appendItems(items)
await dataSource.apply(snapshot, animatingDifferences: false) await apply(snapshot, animatingDifferences: false)
collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top) collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top)
stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)") 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 { if let avatar = account.avatar {
avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
guard let self = self, let image = image else { return } guard let self = self, let image = image else { return }
self.avatarRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarRequest = nil
self.avatarImageView.image = image self.avatarImageView.image = image
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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