diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 51985e00d..858e1875f 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -43,6 +43,7 @@ final class AppDefaults: Sendable { static let defaultBrowserID = "defaultBrowserID" static let currentThemeName = "currentThemeName" static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled" + static let cacheImagesForOffline = "cacheImagesForOffline" // Hidden prefs static let showDebugMenu = "ShowDebugMenu" @@ -319,6 +320,15 @@ final class AppDefaults: Sendable { } } + var cacheImagesForOffline: Bool { + get { + UserDefaults.standard.bool(forKey: Key.cacheImagesForOffline) + } + set { + UserDefaults.standard.set(newValue, forKey: Key.cacheImagesForOffline) + } + } + init() { // Migrate every-10-minute refresh interval to 30 minutes. let rawValue = UserDefaults.standard.integer(forKey: Key.refreshInterval) @@ -344,7 +354,8 @@ final class AppDefaults: Sendable { Key.refreshInterval: RefreshInterval.every2Hours.rawValue, Key.showDebugMenu: showDebugMenu, Key.currentThemeName: Self.defaultThemeName, - Key.articleContentJavascriptEnabled: true + Key.articleContentJavascriptEnabled: true, + Key.cacheImagesForOffline: false ] UserDefaults.standard.register(defaults: defaults) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index a54c50a06..74e9895b8 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -147,6 +147,7 @@ let appName = "NetNewsWire" installAppleEventHandlers() CacheCleaner.purgeIfNecessary() + ArticleImagePrefetcher.shared.start() // Try to establish a cache in the Caches folder, but if it fails for some reason fall back to a temporary dir let cacheFolder: String diff --git a/Mac/Base.lproj/Preferences.storyboard b/Mac/Base.lproj/Preferences.storyboard index 4ce945c4e..806e46468 100644 --- a/Mac/Base.lproj/Preferences.storyboard +++ b/Mac/Base.lproj/Preferences.storyboard @@ -31,11 +31,11 @@ - + - + @@ -146,8 +146,37 @@ + + + + + + + + + - + @@ -282,7 +311,13 @@ - + + + + + + + @@ -291,7 +326,8 @@ - + + @@ -309,7 +345,7 @@ - + @@ -325,7 +361,7 @@ - + @@ -351,6 +387,7 @@ + diff --git a/Mac/Preferences/General/GeneralPrefencesViewController.swift b/Mac/Preferences/General/GeneralPrefencesViewController.swift index d86805515..65b827978 100644 --- a/Mac/Preferences/General/GeneralPrefencesViewController.swift +++ b/Mac/Preferences/General/GeneralPrefencesViewController.swift @@ -7,8 +7,10 @@ // import AppKit +import Account import RSCore import RSWeb +import Images import UserNotifications import UniformTypeIdentifiers @@ -17,6 +19,7 @@ final class GeneralPreferencesViewController: NSViewController { @IBOutlet var articleTextSizePopup: NSPopUpButton! @IBOutlet var articleThemePopup: NSPopUpButton! @IBOutlet var defaultBrowserPopup: NSPopUpButton! + @IBOutlet var cacheImagesSizeLabel: NSTextField! public override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) @@ -70,6 +73,20 @@ final class GeneralPreferencesViewController: NSViewController { updateBrowserPopup() } + /// The checkbox is bound to the default; this action (also wired to it) updates the + /// cache-size line and, when the setting is switched on, offers to cache existing + /// articles' images right away. + @IBAction func cacheImagesForOfflineChanged(_ sender: Any) { + updateOfflineImagesUI() + if AppDefaults.shared.cacheImagesForOffline { + promptToCacheAllImages() + } + } + + @objc func cacheAllImagesProgressDidChange(_ note: Notification) { + updateOfflineImagesUI() + } + } // MARK: - Private @@ -110,11 +127,67 @@ private extension GeneralPreferencesViewController { func commonInit() { NotificationCenter.default.addObserver(self, selector: #selector(applicationWillBecomeActive(_:)), name: NSApplication.willBecomeActiveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(articleThemeNamesDidChangeNotification(_:)), name: .ArticleThemeNamesDidChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(cacheAllImagesProgressDidChange(_:)), name: .ArticleImageCacheAllProgressDidChange, object: nil) } func updateUI() { updateArticleThemePopup() updateBrowserPopup() + updateOfflineImagesUI() + } + + /// Show live progress during a "cache all" run, otherwise the current on-disk cache size, + /// in the line beneath the checkbox. The size is only recomputed when a run isn't in + /// progress, to avoid enumerating the cache folder on every progress tick. + func updateOfflineImagesUI() { + let prefetcher = ArticleImagePrefetcher.shared + guard prefetcher.isCachingAll else { + refreshCacheImagesSize() + return + } + if prefetcher.cacheAllTotal > 0 { + let format = NSLocalizedString("Caching Images… %d of %d", comment: "Cache-all progress") + cacheImagesSizeLabel.stringValue = String(format: format, prefetcher.cacheAllCompleted, prefetcher.cacheAllTotal) + } else { + cacheImagesSizeLabel.stringValue = NSLocalizedString("Caching Images…", comment: "Cache-all starting") + } + } + + /// When offline caching is switched on, offer to backfill images for existing unread + /// articles now — prefetch otherwise only catches images from future refreshes, and the + /// moment someone enables this is usually right before they go offline. + func promptToCacheAllImages() { + guard !ArticleImagePrefetcher.shared.isCachingAll else { + return + } + let unreadCount = AccountManager.shared.unreadCount + guard unreadCount > 0 else { + return + } + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = NSLocalizedString("Cache Images Now?", comment: "Cache images now alert title") + let format = NSLocalizedString("Download images for your %d unread articles now, so they can be read offline?", comment: "Cache images now alert message") + alert.informativeText = String(format: format, unreadCount) + alert.addButton(withTitle: NSLocalizedString("Cache Images", comment: "Cache Images now button")) + alert.addButton(withTitle: NSLocalizedString("Not Now", comment: "Not Now")) + if alert.runModal() == .alertFirstButtonReturn { + ArticleImagePrefetcher.shared.cacheAllArticleImagesNow() + updateOfflineImagesUI() + } + } + + func refreshCacheImagesSize() { + Task { @MainActor in + let stats = await ArticleImageDownloader.shared.cacheStats() + if stats.byteCount > 0 { + let size = stats.byteCount.formatted(.byteCount(style: .file)) + let format = NSLocalizedString("%d images · %@ used", comment: "Cached image count and size") + cacheImagesSizeLabel.stringValue = String(format: format, stats.fileCount, size) + } else { + cacheImagesSizeLabel.stringValue = "" + } + } } func updateArticleThemePopup() { diff --git a/Mac/Preferences/PreferencesWindowController.swift b/Mac/Preferences/PreferencesWindowController.swift index 5dcb8f361..fe4e3e3aa 100644 --- a/Mac/Preferences/PreferencesWindowController.swift +++ b/Mac/Preferences/PreferencesWindowController.swift @@ -167,7 +167,9 @@ private extension PreferencesWindowController { let windowFrame = window!.frame let contentViewFrame = window!.contentView!.frame - let deltaHeight = contentViewFrame.height - viewFrame.height + let heightForView = fittingHeight(of: view) + + let deltaHeight = contentViewFrame.height - heightForView let heightForWindow = windowFrame.height - deltaHeight let windowOriginY = windowFrame.minY + deltaHeight @@ -179,6 +181,7 @@ private extension PreferencesWindowController { var updatedViewFrame = viewFrame updatedViewFrame.origin = NSPoint.zero updatedViewFrame.size.width = windowWidth + updatedViewFrame.size.height = heightForView if viewFrame != updatedViewFrame { view.frame = updatedViewFrame } @@ -189,4 +192,25 @@ private extension PreferencesWindowController { window!.contentView?.alphaValue = 1.0 } } + + /// Measure the pane's natural Auto Layout height at the fixed window width. This is + /// called before the view is added to the window (see `switchToView`), so the view's + /// height is free and `fittingSize` reflects its content — letting the window grow to + /// fit taller panes instead of squishing/clipping their contents. Falls back to the + /// storyboard frame height if the measurement looks implausible. + func fittingHeight(of view: NSView) -> CGFloat { + let hadTranslates = view.translatesAutoresizingMaskIntoConstraints + view.translatesAutoresizingMaskIntoConstraints = false + let widthConstraint = view.widthAnchor.constraint(equalToConstant: windowWidth) + widthConstraint.isActive = true + view.layoutSubtreeIfNeeded() + let measured = view.fittingSize.height + widthConstraint.isActive = false + view.translatesAutoresizingMaskIntoConstraints = hadTranslates + + guard measured > 0, measured < 2000 else { + return view.frame.height + } + return measured + } } diff --git a/Modules/Images/Sources/Images/ArticleImageDownloader.swift b/Modules/Images/Sources/Images/ArticleImageDownloader.swift new file mode 100644 index 000000000..83358f1e3 --- /dev/null +++ b/Modules/Images/Sources/Images/ArticleImageDownloader.swift @@ -0,0 +1,365 @@ +// +// ArticleImageDownloader.swift +// Images +// +// Caches images embedded in article HTML so articles can be viewed offline. +// +// Unlike ImageDownloader (which downscales to icon size), this keeps the +// original image bytes, since these are displayed full-size in the article view. +// + +import Foundation +import os +import RSCore +import RSWeb + +@MainActor public final class ArticleImageDownloader { + + public static let shared = ArticleImageDownloader() + + nonisolated static private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ArticleImageDownloader") + + nonisolated private let diskCache: BinaryDiskCache + nonisolated private let queue: DispatchQueue + + /// On-disk footprint of the article-image cache. + public struct CacheStats: Sendable { + public let fileCount: Int + public let byteCount: Int64 + } + + // Full-size article images are large, so the in-memory cache is bounded by total byte + // cost (NSCache auto-evicts under memory pressure on both platforms — unlike a plain + // dictionary, which only the notifications below would ever clear). + private let imageCache: NSCache = { + let cache = NSCache() + cache.totalCostLimit = 50_000_000 // ~50 MB + return cache + }() + private var inFlight = [String: Task]() // coalesces concurrent downloads of the same url + + // Prefetch is fire-and-forget over a whole refresh's worth of images, so cap how many + // download at once instead of enqueueing an unbounded burst of background fetches. + private static let maxConcurrentPrefetches = 4 + private var activePrefetchCount = 0 + private var pendingPrefetchURLs = [String]() + private var queuedPrefetchURLs = Set() // dedupes the pending queue + + /// Re-checked before draining the prefetch queue so that turning the offline-caching + /// setting off mid-refresh stops queued downloads. Injected by the app layer, which owns + /// the setting (this module can't see AppDefaults). Defaults to always-enabled. + public var isPrefetchingEnabled: @MainActor () -> Bool = { true } + + // Article images come from arbitrary feed-supplied URLs, so bound what we're willing to + // write to disk: skip implausibly large responses and anything that isn't actually an image. + private static let maxImageByteCount = 20_000_000 // 20 MB + + // Total on-disk cap for the offline image cache. Since the ArticleImages folder is kept out + // of the routine cache flush while offline caching is on, it needs its own bound; oldest + // images are evicted once the total exceeds this. + nonisolated private static let maxDiskCacheByteCount: Int64 = 500_000_000 // 500 MB + // Run eviction once per this many bytes written rather than on every file, so the coalesced + // check covers all write paths (render-time and prefetch) without enumerating on each save. + nonisolated private static let evictionCheckByteInterval: Int64 = 50_000_000 // 50 MB + // Only ever mutated on `queue` (serial), so unsynchronized access is safe. + nonisolated(unsafe) private var bytesWrittenSinceEviction: Int64 = 0 + + init() { + let folder = AppConfig.cacheSubfolder(named: "ArticleImages") + self.diskCache = BinaryDiskCache(folder: folder.path) + self.queue = DispatchQueue(label: "ArticleImageDownloader serial queue - \(folder.path)") + + NotificationCenter.default.addObserver(self, selector: #selector(handleAppDidGoToBackground(_:)), name: .appDidGoToBackground, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleLowMemory(_:)), name: .lowMemory, object: nil) + + enforceDiskCacheLimit() // reclaim anything over the cap left by a previous session + } + + @objc func handleAppDidGoToBackground(_ notification: Notification) { + imageCache.removeAllObjects() + } + + @objc func handleLowMemory(_ notification: Notification) { + imageCache.removeAllObjects() + } + + /// Ensure the image at `url` is cached on disk. Fire-and-forget; used at article-download time. + /// Downloads are throttled to `maxConcurrentPrefetches` at a time. + public func prefetch(_ url: String) { + guard isHTTP(url), cachedData(url) == nil, !queuedPrefetchURLs.contains(url) else { + return + } + queuedPrefetchURLs.insert(url) + pendingPrefetchURLs.append(url) + startPendingPrefetchesIfPossible() + } + + /// Total on-disk footprint of the article-image cache (file count and bytes). + /// Enumerates the cache folder off the main thread on the disk queue. + nonisolated public func cacheStats() async -> CacheStats { + await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: Self.computeCacheStats(folderPath: self.diskCache.folder)) + } + } + } + + /// Evict the oldest cached images (by file date) until the on-disk cache is back under its + /// size cap. Runs on the disk queue. Call after a batch of caching (prefetch drain / cache-all). + nonisolated public func enforceDiskCacheLimit() { + queue.async { + Self.evictIfOverLimit(folderPath: self.diskCache.folder, maxBytes: Self.maxDiskCacheByteCount) + } + } + + /// Return image bytes from memory, then disk, then (if allowed) the network. + /// Returns nil if the image isn't cached and can't be fetched. + public func data(for url: String, allowNetwork: Bool) async -> Data? { + guard isHTTP(url) else { + return nil + } + if let data = cachedData(url) { + return data + } + if let data = await readFromDisk(url) { + setCachedData(data, url) + return data + } + guard allowNetwork else { + return nil + } + return await download(url) + } +} + +private extension ArticleImageDownloader { + + func isHTTP(_ url: String) -> Bool { + let lowercased = url.lowercased() + return lowercased.hasPrefix("http://") || lowercased.hasPrefix("https://") + } + + /// Start pending prefetch downloads up to the concurrency limit, freeing a slot (and + /// pulling the next URL) as each finishes. + func startPendingPrefetchesIfPossible() { + guard isPrefetchingEnabled() else { + pendingPrefetchURLs.removeAll() + queuedPrefetchURLs.removeAll() + return + } + while activePrefetchCount < Self.maxConcurrentPrefetches, !pendingPrefetchURLs.isEmpty { + let url = pendingPrefetchURLs.removeFirst() + queuedPrefetchURLs.remove(url) + activePrefetchCount += 1 + Task { @MainActor in + // Re-check in case the setting was turned off after this slot was dequeued but + // before it started (in-flight downloads already past this point still finish). + if isPrefetchingEnabled(), await readFromDisk(url) == nil { + _ = await download(url) + } + activePrefetchCount -= 1 + if activePrefetchCount == 0, pendingPrefetchURLs.isEmpty { + enforceDiskCacheLimit() // keep the cache under its cap once a burst finishes + } + startPendingPrefetchesIfPossible() + } + } + } + + /// Download, save to disk, cache in memory, and return the bytes. Returns nil on failure. + /// Concurrent calls for the same url share a single download (so a background prefetch + /// and a foreground display request don't race or duplicate work). + func download(_ url: String) async -> Data? { + if let existing = inFlight[url] { + return await existing.value + } + if ImageMetadataDatabase.shared.recentlyFailed(url: url) { + Self.logger.debug("Skipping recently-failed URL: \(url)") + return nil + } + let task = Task { @MainActor in + await performDownload(url) + } + inFlight[url] = task + let result = await task.value + inFlight[url] = nil + return result + } + + func performDownload(_ url: String) async -> Data? { + guard let imageURL = URL(string: url) else { + ImageMetadataDatabase.shared.recordFailure(url: url, statusCode: nil) + return nil + } + + let downloadResponse: DownloadResponse + do { + downloadResponse = try await Downloader.shared.download(imageURL) + } catch { + Self.logger.error("Error downloading article image at \(url): \(error.localizedDescription)") + return nil // transient — don't record a failure + } + let data = downloadResponse.data + let response = downloadResponse.response + + if let response, response.statusIsOK { + // Only cache a plausible image response. An empty body (a transient CDN hiccup), an + // oversized body, or a non-image body is treated as transient: return nil without + // blacklisting, so a later view can retry. Don't permanently fail on OK statuses. + guard let data, !data.isEmpty, data.count <= Self.maxImageByteCount, isProbablyImage(data, response: response) else { + return nil + } + saveToDisk(url, data) + setCachedData(data, url) + ImageMetadataDatabase.shared.clearFailure(url: url) + return data + } + + let statusCode = (response as? HTTPURLResponse)?.statusCode + let isTransient = statusCode.map { (500...599).contains($0) } ?? true + if !isTransient { + ImageMetadataDatabase.shared.recordFailure(url: url, statusCode: statusCode) + } + return nil + } + + /// Whether a response looks like an image worth caching: an image/* Content-Type, or + /// failing that, recognizable image magic bytes. Keeps non-image bodies (error pages, + /// redirects to HTML, etc.) fetched from feed-supplied URLs out of the disk cache. + func isProbablyImage(_ data: Data, response: URLResponse) -> Bool { + if let mimeType = response.mimeType?.lowercased(), mimeType.hasPrefix("image/") { + return true + } + return Self.hasImageMagicBytes(data) + } + + nonisolated static func hasImageMagicBytes(_ data: Data) -> Bool { + let bytes = [UInt8](data.prefix(12)) + guard bytes.count >= 3 else { + return false + } + if bytes[0] == 0xFF, bytes[1] == 0xD8, bytes[2] == 0xFF { + return true // JPEG + } + if bytes.count >= 8, bytes[0] == 0x89, bytes[1] == 0x50, bytes[2] == 0x4E, bytes[3] == 0x47 { + return true // PNG + } + if bytes[0] == 0x47, bytes[1] == 0x49, bytes[2] == 0x46 { + return true // GIF + } + if bytes[0] == 0x42, bytes[1] == 0x4D { + return true // BMP + } + if bytes[0] == 0x49, bytes[1] == 0x49, bytes[2] == 0x2A { + return true // TIFF (little-endian) + } + if bytes[0] == 0x4D, bytes[1] == 0x4D, bytes[2] == 0x00 { + return true // TIFF (big-endian) + } + if bytes.count >= 12, bytes[0] == 0x52, bytes[1] == 0x49, bytes[2] == 0x46, bytes[3] == 0x46, + bytes[8] == 0x57, bytes[9] == 0x45, bytes[10] == 0x42, bytes[11] == 0x50 { + return true // WebP (RIFF....WEBP) + } + if bytes.count >= 12, bytes[4] == 0x66, bytes[5] == 0x74, bytes[6] == 0x79, bytes[7] == 0x70 { + let brand = String(bytes: bytes[8..<12], encoding: .ascii) ?? "" + // ISO base media (ftyp) — accept only image brands, not video (mp4 etc.) + if brand.hasPrefix("avif") || brand.hasPrefix("avis") || brand.hasPrefix("heic") || brand.hasPrefix("heix") || brand.hasPrefix("mif1") || brand.hasPrefix("msf1") { + return true // AVIF / HEIC + } + } + if bytes.count >= 4, bytes[0] == 0x00, bytes[1] == 0x00, bytes[2] == 0x01, bytes[3] == 0x00 { + return true // ICO + } + return false + } + + func cachedData(_ url: String) -> Data? { + imageCache.object(forKey: url as NSString) as Data? + } + + func setCachedData(_ data: Data, _ url: String) { + imageCache.setObject(data as NSData, forKey: url as NSString, cost: data.count) + } + + func saveToDisk(_ url: String, _ data: Data) { + queue.async { + self.diskCache[self.diskKey(url)] = data + // Every cached image — prefetch or render-time (the scheme handler's data(for:) path) — + // is written here, so enforce the size cap from the write path too, coalesced to once + // per evictionCheckByteInterval to avoid enumerating the folder on every save. + self.bytesWrittenSinceEviction += Int64(data.count) + if self.bytesWrittenSinceEviction >= Self.evictionCheckByteInterval { + self.bytesWrittenSinceEviction = 0 + Self.evictIfOverLimit(folderPath: self.diskCache.folder, maxBytes: Self.maxDiskCacheByteCount) + } + } + } + + func readFromDisk(_ url: String) async -> Data? { + await withCheckedContinuation { continuation in + queue.async { + let data = self.diskCache[self.diskKey(url)] + DispatchQueue.main.async { + continuation.resume(returning: (data?.isEmpty == false) ? data : nil) + } + } + } + } + + nonisolated func diskKey(_ url: String) -> String { + url.md5String + } + + nonisolated static func evictIfOverLimit(folderPath: String, maxBytes: Int64) { + let folderURL = URL(fileURLWithPath: folderPath) + let keys: [URLResourceKey] = [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] + let fileManager = FileManager.default + guard let enumerator = fileManager.enumerator(at: folderURL, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles]) else { + return + } + var files = [(url: URL, size: Int64, date: Date)]() + var total: Int64 = 0 + for case let fileURL as URL in enumerator { + guard let values = try? fileURL.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { + continue + } + let size = Int64(values.fileSize ?? 0) + files.append((fileURL, size, values.contentModificationDate ?? .distantPast)) + total += size + } + guard total > maxBytes else { + return + } + // Evict oldest-cached first until back under the cap. + files.sort { $0.date < $1.date } + for file in files { + if total <= maxBytes { + break + } + do { + try fileManager.removeItem(at: file.url) + total -= file.size + } catch { + logger.error("ArticleImageDownloader: could not evict \(file.url.lastPathComponent): \(error.localizedDescription)") + } + } + } + + nonisolated static func computeCacheStats(folderPath: String) -> CacheStats { + let folderURL = URL(fileURLWithPath: folderPath) + let keys: [URLResourceKey] = [.isRegularFileKey, .fileSizeKey] + guard let enumerator = FileManager.default.enumerator(at: folderURL, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles]) else { + return CacheStats(fileCount: 0, byteCount: 0) + } + var fileCount = 0 + var byteCount: Int64 = 0 + for case let fileURL as URL in enumerator { + guard let values = try? fileURL.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { + continue + } + fileCount += 1 + byteCount += Int64(values.fileSize ?? 0) + } + return CacheStats(fileCount: fileCount, byteCount: byteCount) + } +} diff --git a/Modules/RSWeb/Sources/RSWeb/Downloader.swift b/Modules/RSWeb/Sources/RSWeb/Downloader.swift index dac4fad84..7d6d391cc 100755 --- a/Modules/RSWeb/Sources/RSWeb/Downloader.swift +++ b/Modules/RSWeb/Sources/RSWeb/Downloader.swift @@ -20,6 +20,16 @@ public typealias DownloadCallback = @MainActor (DownloadResponse, Error?) -> Swi private let urlSession: URLSession private var callbacks = [URL: [(callback: DownloadCallback, fromCache: Bool)]]() private let cache = DownloadCache.shared + private let redirectBlocker = RedirectBlocker() + + /// Optional policy consulted before following an HTTP redirect: return `false` for a + /// destination URL that should not be followed (e.g. a tracker/ad domain), and the redirect + /// is not followed — the request completes with the redirect response instead. `nil` (the + /// default) follows all redirects. Applies to every download this shared instance performs. + public var redirectValidator: (@Sendable (URL) -> Bool)? { + get { redirectBlocker.validator } + set { redirectBlocker.validator = newValue } + } nonisolated private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Downloader") @@ -35,7 +45,7 @@ public typealias DownloadCallback = @MainActor (DownloadResponse, Error?) -> Swi sessionConfiguration.httpAdditionalHeaders = userAgentHeaders } - urlSession = URLSession(configuration: sessionConfiguration) + urlSession = URLSession(configuration: sessionConfiguration, delegate: redirectBlocker, delegateQueue: nil) } deinit { @@ -115,6 +125,37 @@ public typealias DownloadCallback = @MainActor (DownloadResponse, Error?) -> Swi } } +/// URLSession delegate that can veto redirects via an injected policy. Thread-safe: the session +/// calls its delegate on a background queue, while the policy is set from the main actor. +private final class RedirectBlocker: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + + private let lock = NSLock() + private var _validator: (@Sendable (URL) -> Bool)? + + var validator: (@Sendable (URL) -> Bool)? { + get { + lock.lock() + defer { lock.unlock() } + return _validator + } + set { + lock.lock() + defer { lock.unlock() } + _validator = newValue + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + if let url = request.url, let validator = validator, !validator(url) { + // Don't follow the redirect: the task completes with the redirect response, so nothing + // is fetched from (or cached for) the disallowed destination. + completionHandler(nil) + return + } + completionHandler(request) + } +} + private extension Downloader { func callAndReleaseCallbacks(_ url: URL, _ data: Data? = nil, _ response: URLResponse? = nil, _ error: Error? = nil) { diff --git a/Shared/Article Rendering/ArticleImageContentBlocker.swift b/Shared/Article Rendering/ArticleImageContentBlocker.swift new file mode 100644 index 000000000..a4f943257 --- /dev/null +++ b/Shared/Article Rendering/ArticleImageContentBlocker.swift @@ -0,0 +1,84 @@ +// +// ArticleImageContentBlocker.swift +// NetNewsWire +// +// Applies the same block list the article web view uses (ContentRules.json) to +// app-side image fetches. Routing article images through the offline cache uses +// URLSession, which doesn't consult WebKit's WKContentRuleList — so without this, +// a tracker/ad image WebKit would block could still be fetched (and prefetched) +// when offline caching is on. This closes that gap by reusing the same rules. +// + +import Foundation +import os + +@MainActor final class ArticleImageContentBlocker { + + static let shared = ArticleImageContentBlocker() + + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ArticleImageContentBlocker") + + // nonisolated + immutable so it can be matched from any thread (e.g. the Downloader's + // redirect delegate, which runs off the main actor). NSRegularExpression is Sendable and + // thread-safe for matching. + nonisolated private let blockRegex: NSRegularExpression? + + init() { + blockRegex = Self.compileBlockRegex() + } + + /// True if `urlString` matches one of the web view's content-blocking rules + /// (ad/tracker domains), and so should not be fetched or cached. + nonisolated func isBlocked(_ urlString: String) -> Bool { + guard let blockRegex else { + return false + } + let range = NSRange(urlString.startIndex..., in: urlString) + return blockRegex.firstMatch(in: urlString, range: range) != nil + } +} + +private extension ArticleImageContentBlocker { + + /// Load ContentRules.json and combine every `block` rule's `url-filter` into one + /// alternation regex, so a single match tells us whether a URL is blocked. + static func compileBlockRegex() -> NSRegularExpression? { + guard let url = Bundle.main.url(forResource: "ContentRules", withExtension: "json") else { + logger.warning("ArticleImageContentBlocker: ContentRules.json not found") + return nil + } + do { + let data = try Data(contentsOf: url) + let rules = try JSONDecoder().decode([ContentRule].self, from: data) + let patterns = rules.compactMap { rule -> String? in + guard rule.action.type == "block", let filter = rule.trigger.urlFilter, !filter.isEmpty else { + return nil + } + return "(?:\(filter))" + } + guard !patterns.isEmpty else { + return nil + } + return try NSRegularExpression(pattern: patterns.joined(separator: "|"), options: [.caseInsensitive]) + } catch { + logger.error("ArticleImageContentBlocker: failed to load rules: \(error.localizedDescription)") + return nil + } + } + + struct ContentRule: Decodable { + let trigger: Trigger + let action: Action + + struct Trigger: Decodable { + let urlFilter: String? + enum CodingKeys: String, CodingKey { + case urlFilter = "url-filter" + } + } + + struct Action: Decodable { + let type: String + } + } +} diff --git a/Shared/Article Rendering/ArticleImageSchemeHandler.swift b/Shared/Article Rendering/ArticleImageSchemeHandler.swift new file mode 100644 index 000000000..525b017fa --- /dev/null +++ b/Shared/Article Rendering/ArticleImageSchemeHandler.swift @@ -0,0 +1,141 @@ +// +// ArticleImageSchemeHandler.swift +// NetNewsWire +// +// Serves article-body images to the web view from the offline cache. +// +// main.js rewrites each to nnwimage://cache/?u= +// (only when offline caching is enabled). This handler decodes the original URL, +// serves the bytes from ArticleImageDownloader (memory → disk → network), and +// returns them to WebKit. When offline and uncached, the request fails and the +// web view shows a broken image — the rest of the article still renders. +// + +import Foundation +import WebKit +import Images + +@MainActor final class ArticleImageSchemeHandler: NSObject, WKURLSchemeHandler { + + static let scheme = "nnwimage" + static let shared = ArticleImageSchemeHandler() + + /// Tasks currently in flight. A task is "ours to complete" only while its ID is in this set. + /// We must never call back into a task WebKit has stopped (it throws an Objective-C exception). + /// Membership is positive (insert on start, remove on stop or completion), so a stale entry + /// can't survive to collide with a later task that reuses the same object address. + private var activeTasks = Set() + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + + let taskID = ObjectIdentifier(urlSchemeTask) + + // The handler is registered unconditionally (see WebViewConfiguration), but only + // main.js's rewrite and the prefetcher are gated on the setting — this scheme is + // otherwise reachable directly from article HTML (e.g. a feed embedding + // "nnwimage://cache/?u=..." itself). Refuse to fetch or cache anything unless the + // user has actually opted in, so the setting's off-state is a real guarantee. + guard AppDefaults.shared.cacheImagesForOffline else { + urlSchemeTask.didFailWithError(URLError(.resourceUnavailable)) + return + } + + guard let requestURL = urlSchemeTask.request.url, + let originalURL = Self.originalURLString(from: requestURL) else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + + // The web view's own content blocker doesn't apply to this URLSession-backed fetch, + // so apply the same block list here — otherwise routing images through the cache would + // bypass tracker/ad blocking. + guard !ArticleImageContentBlocker.shared.isBlocked(originalURL) else { + urlSchemeTask.didFailWithError(URLError(.resourceUnavailable)) + return + } + + activeTasks.insert(taskID) + + Task { @MainActor in + let data = await ArticleImageDownloader.shared.data(for: originalURL, allowNetwork: true) + + // If the task was stopped while we were fetching, it's no longer in activeTasks. + // Calling into a stopped task throws an Objective-C exception, so bail out cleanly. + guard activeTasks.remove(taskID) != nil else { + return + } + + guard let data else { + urlSchemeTask.didFailWithError(URLError(.resourceUnavailable)) + return + } + + let mimeType = Self.mimeType(for: data) + let response = URLResponse(url: requestURL, mimeType: mimeType, expectedContentLength: data.count, textEncodingName: nil) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + activeTasks.remove(ObjectIdentifier(urlSchemeTask)) + } +} + +private extension ArticleImageSchemeHandler { + + /// Pull the percent-encoded original URL out of nnwimage://cache/?u=. + static func originalURLString(from url: URL) -> String? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.host == "cache", // only nnwimage://cache/?u=... is a cache request + let value = components.queryItems?.first(where: { $0.name == "u" })?.value, + !value.isEmpty else { + return nil + } + return value + } + + /// Sniff the image type from the leading bytes so WebKit gets a correct Content-Type. + /// A wrong type can keep an otherwise-valid cached image from rendering offline. + static func mimeType(for data: Data) -> String { + let bytes = [UInt8](data.prefix(12)) + guard let first = bytes.first else { + return "application/octet-stream" + } + + // ISO base media (ftyp) container — AVIF and HEIC share it; distinguish by brand. + if bytes.count >= 12, bytes[4] == 0x66, bytes[5] == 0x74, bytes[6] == 0x79, bytes[7] == 0x70 { + let brand = String(bytes: bytes[8..<12], encoding: .ascii) ?? "" + if brand.hasPrefix("avif") || brand.hasPrefix("avis") { + return "image/avif" + } + if brand.hasPrefix("heic") || brand.hasPrefix("heix") || brand.hasPrefix("mif1") || brand.hasPrefix("msf1") { + return "image/heic" + } + } + // ICO + if bytes.count >= 4, bytes[0] == 0x00, bytes[1] == 0x00, bytes[2] == 0x01, bytes[3] == 0x00 { + return "image/x-icon" + } + + switch first { + case 0xFF: + return "image/jpeg" + case 0x89: + return "image/png" + case 0x47: + return "image/gif" + case 0x42: + return "image/bmp" + case 0x49, 0x4D: + return "image/tiff" + case 0x52: // "RIFF" → WebP + return "image/webp" + case 0x3C: // "<" → SVG/XML + return "image/svg+xml" + default: + return "application/octet-stream" + } + } +} diff --git a/Shared/Article Rendering/ArticleRenderer.swift b/Shared/Article Rendering/ArticleRenderer.swift index d9f57708d..a195f48a4 100644 --- a/Shared/Article Rendering/ArticleRenderer.swift +++ b/Shared/Article Rendering/ArticleRenderer.swift @@ -219,7 +219,16 @@ private extension ArticleRenderer { d["external_link"] = "" } - d["body"] = body + if AppDefaults.shared.cacheImagesForOffline { + // Tell main.js to route article images through the offline cache (see + // rewriteImagesForOfflineCache). This is a hidden marker element rather than an inline + //