Fix numerous concurrency warnings by marking things as Sendable or as MainActor.

This commit is contained in:
Brent Simmons
2024-04-02 22:07:19 -07:00
parent 40abf257a6
commit 3fea0f0758
21 changed files with 156 additions and 139 deletions

View File

@@ -15,60 +15,65 @@ class FeedFinder {
static func find(url: URL) async throws -> Set<FeedSpecifier> {
try await withCheckedThrowingContinuation { continuation in
self.find(url: url) { result in
switch result {
case .success(let feedSpecifiers):
continuation.resume(returning: feedSpecifiers)
case .failure(let error):
continuation.resume(throwing: error)
Task { @MainActor in
self.find(url: url) { result in
switch result {
case .success(let feedSpecifiers):
continuation.resume(returning: feedSpecifiers)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
static func find(url: URL, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
@MainActor static func find(url: URL, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
downloadAddingToCache(url) { (data, response, error) in
if response?.forcedStatusCode == 404 {
if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), urlComponents.host == "micro.blog" {
urlComponents.path = "\(urlComponents.path).json"
if let newURLString = urlComponents.url?.absoluteString {
let microblogFeedSpecifier = FeedSpecifier(title: nil, urlString: newURLString, source: .HTMLLink, orderFound: 1)
completion(.success(Set([microblogFeedSpecifier])))
MainActor.assumeIsolated {
if response?.forcedStatusCode == 404 {
if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), urlComponents.host == "micro.blog" {
urlComponents.path = "\(urlComponents.path).json"
if let newURLString = urlComponents.url?.absoluteString {
let microblogFeedSpecifier = FeedSpecifier(title: nil, urlString: newURLString, source: .HTMLLink, orderFound: 1)
completion(.success(Set([microblogFeedSpecifier])))
}
} else {
completion(.failure(AccountError.createErrorNotFound))
}
} else {
completion(.failure(AccountError.createErrorNotFound))
return
}
return
if let error = error {
completion(.failure(error))
return
}
guard let data = data, let response = response else {
completion(.failure(AccountError.createErrorNotFound))
return
}
if !response.statusIsOK || data.isEmpty {
completion(.failure(AccountError.createErrorNotFound))
return
}
if FeedFinder.isFeed(data, url.absoluteString) {
let feedSpecifier = FeedSpecifier(title: nil, urlString: url.absoluteString, source: .UserEntered, orderFound: 1)
completion(.success(Set([feedSpecifier])))
return
}
if !FeedFinder.isHTML(data) {
completion(.failure(AccountError.createErrorNotFound))
return
}
FeedFinder.findFeedsInHTMLPage(htmlData: data, urlString: url.absoluteString, completion: completion)
}
if let error = error {
completion(.failure(error))
return
}
guard let data = data, let response = response else {
completion(.failure(AccountError.createErrorNotFound))
return
}
if !response.statusIsOK || data.isEmpty {
completion(.failure(AccountError.createErrorNotFound))
return
}
if FeedFinder.isFeed(data, url.absoluteString) {
let feedSpecifier = FeedSpecifier(title: nil, urlString: url.absoluteString, source: .UserEntered, orderFound: 1)
completion(.success(Set([feedSpecifier])))
return
}
if !FeedFinder.isHTML(data) {
completion(.failure(AccountError.createErrorNotFound))
return
}
FeedFinder.findFeedsInHTMLPage(htmlData: data, urlString: url.absoluteString, completion: completion)
}
}
}
@@ -87,7 +92,7 @@ private extension FeedFinder {
}
}
static func findFeedsInHTMLPage(htmlData: Data, urlString: String, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
@MainActor static func findFeedsInHTMLPage(htmlData: Data, urlString: String, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
// Feeds in the <head> section we automatically assume are feeds.
// If there are none from the <head> section,
// then possible feeds in <body> section are downloaded individually
@@ -149,7 +154,7 @@ private extension FeedFinder {
return data.isProbablyHTML
}
static func downloadFeedSpecifiers(_ downloadFeedSpecifiers: Set<FeedSpecifier>, feedSpecifiers: [String: FeedSpecifier], completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
@MainActor static func downloadFeedSpecifiers(_ downloadFeedSpecifiers: Set<FeedSpecifier>, feedSpecifiers: [String: FeedSpecifier], completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
var resultFeedSpecifiers = feedSpecifiers
let group = DispatchGroup()
@@ -160,15 +165,19 @@ private extension FeedFinder {
}
group.enter()
downloadUsingCache(url) { (data, response, error) in
if let data = data, let response = response, response.statusIsOK, error == nil {
if self.isFeed(data, downloadFeedSpecifier.urlString) {
addFeedSpecifier(downloadFeedSpecifier, feedSpecifiers: &resultFeedSpecifiers)
Task { @MainActor in
downloadUsingCache(url) { (data, response, error) in
MainActor.assumeIsolated {
if let data = data, let response = response, response.statusIsOK, error == nil {
if self.isFeed(data, downloadFeedSpecifier.urlString) {
addFeedSpecifier(downloadFeedSpecifier, feedSpecifiers: &resultFeedSpecifiers)
}
}
group.leave()
}
}
group.leave()
}
}
group.notify(queue: DispatchQueue.main) {

View File

@@ -8,7 +8,7 @@
import Foundation
struct FeedSpecifier: Hashable {
struct FeedSpecifier: Hashable, Sendable {
enum Source: Int {
case UserEntered = 0, HTMLHead, HTMLLink

View File

@@ -10,7 +10,7 @@ import Foundation
struct FeedbinDate {
public static var formatter: DateFormatter = {
public static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
formatter.locale = Locale(identifier: "en_US")

View File

@@ -606,7 +606,7 @@ extension FeedlyAPICaller: FeedlyGetCollectionsService {
extension FeedlyAPICaller: FeedlyGetStreamContentsService {
func getStreamContents(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ()) {
@MainActor func getStreamContents(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ()) {
guard !isSuspended else {
return DispatchQueue.main.async {
completion(.failure(TransportError.suspended))
@@ -674,7 +674,7 @@ extension FeedlyAPICaller: FeedlyGetStreamContentsService {
extension FeedlyAPICaller: FeedlyGetStreamIdsService {
func getStreamIds(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIds, Error>) -> ()) {
@MainActor func getStreamIds(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIds, Error>) -> ()) {
guard !isSuspended else {
return DispatchQueue.main.async {
completion(.failure(TransportError.suspended))

View File

@@ -9,7 +9,7 @@
import Foundation
protocol FeedlyResourceProviding {
var resource: FeedlyResourceId { get }
@MainActor var resource: FeedlyResourceId { get }
}
extension FeedlyFeedResourceId: FeedlyResourceProviding {

View File

@@ -9,7 +9,7 @@
import Foundation
protocol FeedlyEntryIdentifierProviding: AnyObject {
var entryIds: Set<String> { get }
@MainActor var entryIds: Set<String> { get }
}
final class FeedlyEntryIdentifierProvider: FeedlyEntryIdentifierProviding {
@@ -19,11 +19,11 @@ final class FeedlyEntryIdentifierProvider: FeedlyEntryIdentifierProviding {
self.entryIds = entryIds
}
func addEntryIds(from provider: FeedlyEntryIdentifierProviding) {
@MainActor func addEntryIds(from provider: FeedlyEntryIdentifierProviding) {
entryIds.formUnion(provider.entryIds)
}
func addEntryIds(in articleIds: [String]) {
@MainActor func addEntryIds(in articleIds: [String]) {
entryIds.formUnion(articleIds)
}
}

View File

@@ -12,7 +12,7 @@ import Foundation
protocol FeedlyResourceId {
/// The resource Id from Feedly.
var id: String { get }
@MainActor var id: String { get }
}
/// The Feed Resource is documented here: https://developer.feedly.com/cloud/

View File

@@ -9,7 +9,7 @@
import Foundation
protocol FeedlyCheckpointOperationDelegate: AnyObject {
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation)
@MainActor func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation)
}
/// Let the delegate know an instance is executing. The semantics are up to the delegate.

View File

@@ -11,12 +11,12 @@ import Parser
import os.log
protocol FeedlyEntryProviding {
var entries: [FeedlyEntry] { get }
@MainActor var entries: [FeedlyEntry] { get }
}
protocol FeedlyParsedItemProviding {
var parsedItemProviderName: String { get }
var parsedEntries: Set<ParsedItem> { get }
@MainActor var parsedItemProviderName: String { get }
@MainActor var parsedEntries: Set<ParsedItem> { get }
}
protocol FeedlyGetStreamContentsOperationDelegate: AnyObject {
@@ -26,7 +26,7 @@ protocol FeedlyGetStreamContentsOperationDelegate: AnyObject {
/// Get the stream content of a Collection from Feedly.
final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding {
struct ResourceProvider: FeedlyResourceProviding {
@MainActor struct ResourceProvider: FeedlyResourceProviding {
var resource: FeedlyResourceId
}

View File

@@ -10,7 +10,7 @@ import Foundation
import os.log
protocol FeedlyFeedsAndFoldersProviding {
var feedsAndFolders: [([FeedlyFeed], Folder)] { get }
@MainActor var feedsAndFolders: [([FeedlyFeed], Folder)] { get }
}
/// Reflect Collections from Feedly as Folders.

View File

@@ -11,7 +11,7 @@ import Web
import Core
protocol FeedlyOperationDelegate: AnyObject {
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error)
@MainActor func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error)
}
/// Abstract base class for Feedly sync operations.

View File

@@ -13,7 +13,7 @@ protocol FeedlySearchService: AnyObject {
}
protocol FeedlySearchOperationDelegate: AnyObject {
func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse)
@MainActor func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse)
}
/// Find one and only one feed for a given query (usually, a URL).

View File

@@ -14,14 +14,14 @@ struct InitialFeedDownloader {
static func download(_ url: URL) async -> ParsedFeed? {
await withCheckedContinuation { continuation in
await withCheckedContinuation { @MainActor continuation in
self.download(url) { parsedFeed in
continuation.resume(returning: parsedFeed)
}
}
}
static func download(_ url: URL,_ completion: @escaping (_ parsedFeed: ParsedFeed?) -> Void) {
@MainActor static func download(_ url: URL,_ completion: @escaping (_ parsedFeed: ParsedFeed?) -> Void) {
downloadUsingCache(url) { (data, response, error) in
guard let data = data else {

View File

@@ -133,14 +133,16 @@ final class FaviconDownloader {
return favicon(with: faviconURL, homePageURL: url)
}
findFaviconURLs(with: url) { (faviconURLs) in
if let faviconURLs = faviconURLs {
// If the site explicitly specifies favicon.ico, it will appear twice.
self.currentHomePageHasOnlyFaviconICO = faviconURLs.count == 1
Task { @MainActor in
findFaviconURLs(with: url) { (faviconURLs) in
if let faviconURLs = faviconURLs {
// If the site explicitly specifies favicon.ico, it will appear twice.
self.currentHomePageHasOnlyFaviconICO = faviconURLs.count == 1
if let firstIconURL = faviconURLs.first {
let _ = self.favicon(with: firstIconURL, homePageURL: url)
self.remainingFaviconURLs[url] = faviconURLs.dropFirst()
if let firstIconURL = faviconURLs.first {
let _ = self.favicon(with: firstIconURL, homePageURL: url)
self.remainingFaviconURLs[url] = faviconURLs.dropFirst()
}
}
}
}
@@ -197,7 +199,7 @@ private extension FaviconDownloader {
static let localeForLowercasing = Locale(identifier: "en_US")
func findFaviconURLs(with homePageURL: String, _ completion: @escaping ([String]?) -> Void) {
@MainActor func findFaviconURLs(with homePageURL: String, _ completion: @escaping ([String]?) -> Void) {
guard let url = URL(unicodeString: homePageURL) else {
completion(nil)

View File

@@ -16,14 +16,14 @@ import UniformTypeIdentifiers
struct FaviconURLFinder {
/// Uniform types to ignore when finding favicon URLs.
static var ignoredTypes = [UTType.svg]
static let ignoredTypes = [UTType.svg]
/// Finds favicon URLs in a web page.
/// - Parameters:
/// - homePageURL: The page to search.
/// - completion: A closure called when the links have been found.
/// - urls: An array of favicon URLs as strings.
static func findFaviconURLs(with homePageURL: String, _ completion: @escaping (_ urls: [String]?) -> Void) {
@MainActor static func findFaviconURLs(with homePageURL: String, _ completion: @escaping (_ urls: [String]?) -> Void) {
guard let _ = URL(unicodeString: homePageURL) else {
completion(nil)

View File

@@ -78,23 +78,25 @@ private extension SingleFaviconDownloader {
readFromDisk { (image) in
if let image = image {
self.diskStatus = .onDisk
self.iconImage = IconImage(image)
self.postDidLoadFaviconNotification()
return
}
self.diskStatus = .notOnDisk
self.downloadFavicon { (image) in
MainActor.assumeIsolated {
if let image = image {
self.diskStatus = .onDisk
self.iconImage = IconImage(image)
self.postDidLoadFaviconNotification()
return
}
self.postDidLoadFaviconNotification()
self.diskStatus = .notOnDisk
self.downloadFavicon { (image) in
if let image = image {
self.iconImage = IconImage(image)
}
self.postDidLoadFaviconNotification()
}
}
}
}
@@ -133,7 +135,7 @@ private extension SingleFaviconDownloader {
}
}
func downloadFavicon(_ completion: @escaping (RSImage?) -> Void) {
@MainActor func downloadFavicon(_ completion: @escaping (RSImage?) -> Void) {
guard let url = URL(string: faviconURL) else {
completion(nil)

View File

@@ -14,7 +14,7 @@ struct HTMLMetadataDownloader {
static let serialDispatchQueue = DispatchQueue(label: "HTMLMetadataDownloader")
static func downloadMetadata(for url: String, _ completion: @escaping (RSHTMLMetadata?) -> Void) {
@MainActor static func downloadMetadata(for url: String, _ completion: @escaping (RSHTMLMetadata?) -> Void) {
guard let actualURL = URL(unicodeString: url) else {
completion(nil)
return

View File

@@ -104,22 +104,24 @@ private extension ImageDownloader {
return
}
downloadUsingCache(imageURL) { (data, response, error) in
if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil {
self.saveToDisk(url, data)
completion(data)
return
Task { @MainActor in
downloadUsingCache(imageURL) { (data, response, error) in
if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil {
self.saveToDisk(url, data)
completion(data)
return
}
if let response = response as? HTTPURLResponse, response.statusCode >= HTTPResponseCode.badRequest && response.statusCode <= HTTPResponseCode.notAcceptable {
self.badURLs.insert(url)
}
if let error = error {
os_log(.info, log: self.log, "Error downloading image at %@: %@.", url, error.localizedDescription)
}
completion(nil)
}
if let response = response as? HTTPURLResponse, response.statusCode >= HTTPResponseCode.badRequest && response.statusCode <= HTTPResponseCode.notAcceptable {
self.badURLs.insert(url)
}
if let error = error {
os_log(.info, log: self.log, "Error downloading image at %@: %@.", url, error.localizedDescription)
}
completion(nil)
}
}

View File

@@ -10,9 +10,9 @@ import Foundation
// Main thread only.
public typealias OneShotDownloadCallback = (Data?, URLResponse?, Error?) -> Swift.Void
public typealias OneShotDownloadCallback = @Sendable (Data?, URLResponse?, Error?) -> Swift.Void
private final class OneShotDownloadManager {
@MainActor private final class OneShotDownloadManager {
private let urlSession: URLSession
fileprivate static let shared = OneShotDownloadManager()
@@ -61,12 +61,12 @@ private final class OneShotDownloadManager {
// Call one of these. Its easier than referring to OneShotDownloadManager.
// callback is called on the main queue.
public func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
@MainActor public func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
precondition(Thread.isMainThread)
OneShotDownloadManager.shared.download(url, completion)
}
public func download(_ urlRequest: URLRequest, _ completion: @escaping OneShotDownloadCallback) {
@MainActor public func download(_ urlRequest: URLRequest, _ completion: @escaping OneShotDownloadCallback) {
precondition(Thread.isMainThread)
OneShotDownloadManager.shared.download(urlRequest, completion)
}
@@ -136,7 +136,7 @@ private final class DownloadWithCacheManager {
private var pendingCallbacks = [CallbackRecord]()
private var urlsInProgress = Set<URL>()
func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback, forceRedownload: Bool = false) {
@MainActor func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback, forceRedownload: Bool = false) {
if lastCleanupDate.timeIntervalSinceNow < -DownloadWithCacheManager.cleanupInterval {
lastCleanupDate = Date()
@@ -160,33 +160,35 @@ private final class DownloadWithCacheManager {
OneShotDownloadManager.shared.download(url) { (data, response, error) in
self.urlsInProgress.remove(url)
MainActor.assumeIsolated {
self.urlsInProgress.remove(url)
if let data = data, let response = response, response.statusIsOK, error == nil {
let cacheRecord = WebCacheRecord(url: url, dateDownloaded: Date(), data: data, response: response)
self.cache[url] = cacheRecord
}
var callbackCount = 0
self.pendingCallbacks.forEach{ (callbackRecord) in
if url == callbackRecord.url {
callbackRecord.completion(data, response, error)
callbackCount += 1
if let data = data, let response = response, response.statusIsOK, error == nil {
let cacheRecord = WebCacheRecord(url: url, dateDownloaded: Date(), data: data, response: response)
self.cache[url] = cacheRecord
}
var callbackCount = 0
self.pendingCallbacks.forEach{ (callbackRecord) in
if url == callbackRecord.url {
callbackRecord.completion(data, response, error)
callbackCount += 1
}
}
self.pendingCallbacks.removeAll(where: { (callbackRecord) -> Bool in
return callbackRecord.url == url
})
}
self.pendingCallbacks.removeAll(where: { (callbackRecord) -> Bool in
return callbackRecord.url == url
})
}
}
}
public func downloadUsingCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
@MainActor public func downloadUsingCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
precondition(Thread.isMainThread)
DownloadWithCacheManager.shared.download(url, completion)
}
public func downloadAddingToCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
@MainActor public func downloadAddingToCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
precondition(Thread.isMainThread)
DownloadWithCacheManager.shared.download(url, completion, forceRedownload: true)
}

View File

@@ -84,7 +84,7 @@ struct AppAssets {
static let markAboveAsReadImage = UIImage(systemName: "arrowtriangle.up.circle")!
static let folderImage = IconImage(UIImage(systemName: "folder.fill")!, isSymbol: true, isBackgroundSupressed: true, preferredColor: AppAssets.secondaryAccentColor.cgColor)
@MainActor static let folderImage = IconImage(UIImage(systemName: "folder.fill")!, isSymbol: true, isBackgroundSupressed: true, preferredColor: AppAssets.secondaryAccentColor.cgColor)
static let folderImageNonIcon = UIImage(systemName: "folder.fill")!.withRenderingMode(.alwaysOriginal).withTintColor(.secondaryLabel)
@@ -104,7 +104,7 @@ struct AppAssets {
static let safariImage = UIImage(systemName: "safari")!
static let searchFeedImage = IconImage(UIImage(systemName: "magnifyingglass")!, isSymbol: true)
@MainActor static let searchFeedImage = IconImage(UIImage(systemName: "magnifyingglass")!, isSymbol: true)
static let secondaryAccentColor = UIColor(named: "secondaryAccentColor")!
@@ -120,7 +120,7 @@ struct AppAssets {
static let starOpenImage = UIImage(systemName: "star")!
static let starredFeedImage: IconImage = {
@MainActor static let starredFeedImage: IconImage = {
let image = UIImage(systemName: "star.fill")!
return IconImage(image, isSymbol: true, isBackgroundSupressed: true, preferredColor: AppAssets.starColor.cgColor)
}()
@@ -132,14 +132,14 @@ struct AppAssets {
return image.withTintColor(AppAssets.starColor, renderingMode: .alwaysOriginal)
}()
static let todayFeedImage: IconImage = {
@MainActor static let todayFeedImage: IconImage = {
let image = UIImage(systemName: "sun.max.fill")!
return IconImage(image, isSymbol: true, isBackgroundSupressed: true, preferredColor: UIColor.systemOrange.cgColor)
}()
static let trashImage = UIImage(systemName: "trash")!
static let unreadFeedImage: IconImage = {
@MainActor static let unreadFeedImage: IconImage = {
let image = UIImage(systemName: "largecircle.fill.circle")!
return IconImage(image, isSymbol: true, isBackgroundSupressed: true, preferredColor: AppAssets.secondaryAccentColor.cgColor)
}()
@@ -148,7 +148,7 @@ struct AppAssets {
static let controlBackgroundColor = UIColor(named: "controlBackgroundColor")!
static func image(for accountType: AccountType) -> UIImage? {
@MainActor static func image(for accountType: AccountType) -> UIImage? {
switch accountType {
case .onMyMac:
if UIDevice.current.userInterfaceIdiom == .pad {

View File

@@ -15,7 +15,7 @@ import Secrets
import WidgetKit
import Core
var appDelegate: AppDelegate!
@MainActor var appDelegate: AppDelegate!
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, UnreadCountProvider {