From ba60f92223a855b917efd1f0df96d6617deeeb9f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 27 Jan 2024 11:40:03 -0500 Subject: [PATCH] Compiles with strict concurrency checking --- Tusker/Extensions/MainActor+Unsafe.swift | 13 +- Tusker/ImageGrayscalifier.swift | 32 ++- .../Screens/Preferences/About/AboutView.swift | 1 + Tusker/Views/Attachments/AttachmentView.swift | 187 ++++++++---------- 4 files changed, 120 insertions(+), 113 deletions(-) diff --git a/Tusker/Extensions/MainActor+Unsafe.swift b/Tusker/Extensions/MainActor+Unsafe.swift index 2920e9cf..ee3639b6 100644 --- a/Tusker/Extensions/MainActor+Unsafe.swift +++ b/Tusker/Extensions/MainActor+Unsafe.swift @@ -51,9 +51,18 @@ public extension MainActor { if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { return try MainActor.assumeIsolated(body) } - + dispatchPrecondition(condition: .onQueue(.main)) return try withoutActuallyEscaping(body) { fn in try unsafeBitCast(fn, to: (() throws -> T).self)() } - }} + } + + @_unavailableFromAsync + @available(*, deprecated, message: "Tool of last resort, do not use this.") + static func runUnsafelyMaybeIntroducingDataRace(_ body: @MainActor () throws -> T) rethrows -> T { + return try withoutActuallyEscaping(body) { fn in + try unsafeBitCast(fn, to: (() throws -> T).self)() + } + } +} diff --git a/Tusker/ImageGrayscalifier.swift b/Tusker/ImageGrayscalifier.swift index 548d3395..4119c6b8 100644 --- a/Tusker/ImageGrayscalifier.swift +++ b/Tusker/ImageGrayscalifier.swift @@ -15,7 +15,10 @@ struct ImageGrayscalifier { private static let cache = NSCache() static func convertIfNecessary(url: URL?, image: UIImage) -> UIImage? { - if Preferences.shared.grayscaleImages, + let grayscale = MainActor.runUnsafelyMaybeIntroducingDataRace { + Preferences.shared.grayscaleImages + } + if grayscale, let source = image.cgImage { // todo: should this return the original image if conversion fails? return convert(url: url, cgImage: source) @@ -35,6 +38,21 @@ struct ImageGrayscalifier { return doConvert(CIImage(cgImage: cgImage), url: url) } + static func convert(url: URL?, image: UIImage) async -> UIImage? { + if let url, + let cached = cache.object(forKey: url as NSURL) { + return cached + } + guard let cgImage = image.cgImage else { + return nil + } + return await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: doConvert(CIImage(cgImage: cgImage), url: url)) + } + } + } + static func convert(url: URL?, data: Data) -> UIImage? { if let url = url, let cached = cache.object(forKey: url as NSURL) { @@ -56,6 +74,18 @@ struct ImageGrayscalifier { return doConvert(CIImage(cgImage: cgImage), url: url) } + static func convert(url: URL?, cgImage: CGImage) async -> UIImage? { + if let url = url, + let cached = cache.object(forKey: url as NSURL) { + return cached + } + return await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: doConvert(CIImage(cgImage: cgImage), url: url)) + } + } + } + private static func doConvert(_ source: CIImage, url: URL?) -> UIImage? { guard let filter = CIFilter(name: "CIColorMonochrome") else { return nil diff --git a/Tusker/Screens/Preferences/About/AboutView.swift b/Tusker/Screens/Preferences/About/AboutView.swift index d9c8172b..c45ca511 100644 --- a/Tusker/Screens/Preferences/About/AboutView.swift +++ b/Tusker/Screens/Preferences/About/AboutView.swift @@ -9,6 +9,7 @@ import SwiftUI import MessageUI +@MainActor struct AboutView: View { @State private var logData: Data? @State private var isGettingLogData = false diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index 6548b43d..c424d3b3 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -30,7 +30,7 @@ class AttachmentView: GIFImageView { var attachment: Attachment! var index: Int! - private var attachmentRequest: ImageCache.Request? + private var loadAttachmentTask: Task? private var source: Source? private var autoplayGifs: Bool { @@ -45,11 +45,13 @@ class AttachmentView: GIFImageView { self.attachment = attachment self.index = index - loadAttachment() + self.loadAttachmentTask = Task { + await self.loadAttachment() + } } deinit { - attachmentRequest?.cancel() + loadAttachmentTask?.cancel() } required init?(coder aDecoder: NSCoder) { @@ -76,7 +78,9 @@ class AttachmentView: GIFImageView { gifPlaybackModeChanged() if isGrayscale != Preferences.shared.grayscaleImages { - self.displayImage() + Task { + await displayImage() + } } if getBadges().isEmpty != Preferences.shared.showAttachmentBadges { @@ -107,34 +111,34 @@ class AttachmentView: GIFImageView { } } - func loadAttachment() { + private func loadAttachment() async { + let blurHashTask: Task? if let hash = attachment.blurHash { - AttachmentView.queue.async { [weak self] in - guard let self = self else { return } - + blurHashTask = Task { guard var preview = UIImage(blurHash: hash, size: self.blurHashSize()) else { return } + try Task.checkCancellation() if Preferences.shared.grayscaleImages, let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: preview.cgImage!) { preview = grayscale } + try Task.checkCancellation() - DispatchQueue.main.async { [weak self] in - guard let self = self, self.image == nil else { return } - self.image = preview - } + self.image = preview } + } else { + blurHashTask = nil } createBadgesView(getBadges()) switch attachment.kind { case .image: - loadImage() + await loadImage() case .video: - loadVideo() + await loadVideo() case .audio: loadAudio() case .gifv: @@ -142,6 +146,8 @@ class AttachmentView: GIFImageView { case .unknown: createUnknownLabel() } + + blurHashTask?.cancel() } private func getBadges() -> Badges { @@ -182,66 +188,27 @@ class AttachmentView: GIFImageView { } } - func loadImage() { - let attachmentURL = attachment.url - attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, image) in - guard let self = self, - self.attachment.url == attachmentURL else { - return - } - - DispatchQueue.main.async { - self.attachmentRequest = nil - - if attachmentURL.pathExtension == "gif", - let data { - self.source = .gifData(attachmentURL, data, image) - if self.autoplayGifs { - let controller = GIFController(gifData: data) - controller.attach(to: self) - controller.startAnimating() - } else { - self.displayImage() - } - } else if let image { - self.source = .image(attachmentURL, image) - self.displayImage() - } + private func loadImage() async { + let (data, image) = await ImageCache.attachments.get(attachment.url) + guard !Task.isCancelled else { return } + + if attachment.url.pathExtension == "gif", + let data { + source = .gifData(attachment.url, data, image) + if autoplayGifs { + let controller = GIFController(gifData: data) + controller.attach(to: self) + controller.startAnimating() + } else { + await displayImage() } + } else if let image { + source = .image(attachment.url, image) + await displayImage() } } - func loadVideo() { - if let previewURL = self.attachment.previewURL { - attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (_, image) in - guard let self, let image else { return } - DispatchQueue.main.async { - self.attachmentRequest = nil - self.source = .image(previewURL, image) - self.displayImage() - } - }) - } else { - let attachmentURL = self.attachment.url - AttachmentView.queue.async { [weak self] in - let asset = AVURLAsset(url: attachmentURL) - let generator = AVAssetImageGenerator(asset: asset) - generator.appliesPreferredTrackTransform = true - #if os(visionOS) - #warning("Use async AVAssetImageGenerator.image(at:)") - #else - guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } - UIImage(cgImage: image).prepareForDisplay { [weak self] image in - DispatchQueue.main.async { [weak self] in - guard let self, let image else { return } - self.source = .image(attachmentURL, image) - self.displayImage() - } - } - #endif - } - } - + private func loadVideo() async { let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) playImageView.translatesAutoresizingMaskIntoConstraints = false addSubview(playImageView) @@ -251,9 +218,40 @@ class AttachmentView: GIFImageView { playImageView.centerXAnchor.constraint(equalTo: centerXAnchor), playImageView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) + + if let previewURL = attachment.previewURL { + guard let image = await ImageCache.attachments.get(previewURL).1, + !Task.isCancelled else { + return + } + + source = .image(previewURL, image) + await displayImage() + } else { + let asset = AVURLAsset(url: attachment.url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + let image: CGImage? + #if os(visionOS) + image = try? await generator.image(at: .zero).image + #else + if #available(iOS 16.0, *) { + image = try? await generator.image(at: .zero).image + } else { + image = try? generator.copyCGImage(at: .zero, actualTime: nil) + } + #endif + guard let image, + let prepared = await UIImage(cgImage: image).byPreparingForDisplay(), + !Task.isCancelled else { + return + } + source = .image(attachment.url, prepared) + await displayImage() + } } - func loadAudio() { + private func loadAudio() { let label = UILabel() label.text = "Audio Only" let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) @@ -274,23 +272,8 @@ class AttachmentView: GIFImageView { ]) } - func loadGifv() { - let attachmentURL = self.attachment.url - let asset = AVURLAsset(url: attachmentURL) - AttachmentView.queue.async { - let generator = AVAssetImageGenerator(asset: asset) - generator.appliesPreferredTrackTransform = true - #if os(visionOS) - #warning("Use async AVAssetImageGenerator.image(at:)") - #else - guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } - DispatchQueue.main.async { - self.source = .cgImage(attachmentURL, image) - self.displayImage() - } - #endif - } - + private func loadGifv() { + let asset = AVURLAsset(url: attachment.url) let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill) self.gifvView = gifvView gifvView.translatesAutoresizingMaskIntoConstraints = false @@ -306,7 +289,7 @@ class AttachmentView: GIFImageView { ]) } - func createUnknownLabel() { + private func createUnknownLabel() { backgroundColor = .appSecondaryBackground let label = UILabel() label.text = "Unknown Attachment Type" @@ -325,8 +308,7 @@ class AttachmentView: GIFImageView { ]) } - @MainActor - private func displayImage() { + private func displayImage() async { isGrayscale = Preferences.shared.grayscaleImages switch source { @@ -335,12 +317,7 @@ class AttachmentView: GIFImageView { case let .image(url, sourceImage): if isGrayscale { - ImageGrayscalifier.queue.async { [weak self] in - let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage) - DispatchQueue.main.async { [weak self] in - self?.image = grayscale - } - } + self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage) } else { self.image = sourceImage } @@ -348,26 +325,16 @@ class AttachmentView: GIFImageView { case let .gifData(url, _, sourceImage): if isGrayscale, let sourceImage { - ImageGrayscalifier.queue.async { [weak self] in - let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage) - DispatchQueue.main.async { [weak self] in - self?.image = grayscale - } - } + self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage) } else { self.image = sourceImage } case let .cgImage(url, cgImage): if isGrayscale { - ImageGrayscalifier.queue.async { [weak self] in - let grayscale = ImageGrayscalifier.convert(url: url, cgImage: cgImage) - DispatchQueue.main.async { [weak self] in - self?.image = grayscale - } - } + self.image = await ImageGrayscalifier.convert(url: url, cgImage: cgImage) } else { - image = UIImage(cgImage: cgImage) + self.image = UIImage(cgImage: cgImage) } } }