From 3cc073c3e0a6d310e5a062ca70cc0e3cf74408a6 Mon Sep 17 00:00:00 2001 From: NuPlay <73557895+NuPlay@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:13:12 +0900 Subject: [PATCH] fix: report the web view and CSS failures that were never surfaced `RichTextError.webViewConfigurationFailed` and `.cssGenerationFailed` are public API, documented in the README, and nothing in the package ever constructed them. Give them the real failure modes they were missing. `webViewConfigurationFailed` now fires on macOS when `drawsBackground` cannot be set. That call reaches WKWebView through KVC on a key that is not public API, and `setValue(_:forKey:)` raises NSUnknownKeyException if the key ever goes away - an Objective-C exception Swift cannot catch, so it would take the host app down. The setter is probed first and the failure is reported instead. `cssGenerationFailed` now fires when `fontColor` or `linkColor` holds an invalid hex value. Nothing rejected those: they reached the stylesheet as `color: #whatever`, the browser dropped the declaration, and the colour silently fell back to the default with no way to trace it. That second hook also puts `ColorSet.isValid` to work, which until now was public API referenced only by its own tests. Its length rule was wrong while it was unused: `#RGB` and `#RGBA` are valid CSS but were reported invalid, and it disagreed with `BackgroundColor.isHexColorLiteral`, which already accepted all four lengths. Both now accept 3, 4, 6 and 8, and the check no longer builds an NSPredicate per call. --- README.md | 5 ++-- Sources/RichText/Models/ColorSet.swift | 21 ++++++++++++++--- Sources/RichText/Models/RichTextEnums.swift | 6 +++++ Sources/RichText/Views/Webview.swift | 23 +++++++++++++++++-- .../RichTextSwiftTestingTests.swift | 23 +++++++++++++++++++ 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 46f3a46..732534b 100644 --- a/README.md +++ b/README.md @@ -316,10 +316,13 @@ RichText(html: String, configuration: Configuration, placeholder: AnyView?) .onError { error in switch error { case .htmlLoadingFailed(let html): + // The web view failed to load the generated document print("Failed to load HTML: \(html)") case .webViewConfigurationFailed: + // The web view could not be configured as requested (macOS transparency) print("WebView configuration failed") case .cssGenerationFailed: + // A colour was given as an invalid hex value, so the browser drops the declaration print("CSS generation failed") case .mediaHandlingFailed(let media): print("Media handling failed: \(media)") @@ -361,7 +364,6 @@ let config = Configuration( baseURL: Bundle.main.bundleURL, mediaClickHandler: { media in /* handle clicks */ }, errorHandler: { error in /* handle errors */ }, - isColorsImportant: .onlyLinks, transition: .easeInOut(duration: 0.3) ) @@ -556,7 +558,6 @@ RichText(html: largeHtmlContent) img { max-width: 100%; height: auto; - loading: lazy; /* Native lazy loading */ } """) .loadingTransition(.none) // Disable transitions for faster rendering diff --git a/Sources/RichText/Models/ColorSet.swift b/Sources/RichText/Models/ColorSet.swift index 6ce3387..ccc0ece 100644 --- a/Sources/RichText/Models/ColorSet.swift +++ b/Sources/RichText/Models/ColorSet.swift @@ -86,14 +86,29 @@ public struct ColorSet: Equatable { } /// Validates if the hex color strings are valid + /// + /// An invalid value is not rejected anywhere - it is interpolated into the stylesheet as + /// `color: #whatever`, which the browser silently drops along with the rest of the + /// declaration. ``RichText`` reports this through ``RichTextError/cssGenerationFailed``. + /// /// - Returns: true if both light and dark hex values are valid public var isValid: Bool { return isValidHexColor(light) && isValidHexColor(dark) } private func isValidHexColor(_ hex: String) -> Bool { - let hexRegex = "^[0-9A-Fa-f]{6}$|^[0-9A-Fa-f]{8}$" // 6 or 8 characters - let predicate = NSPredicate(format: "SELF MATCHES %@", hexRegex) - return predicate.evaluate(with: hex) + // `#RGB`, `#RGBA`, `#RRGGBB` and `#RRGGBBAA` are all valid CSS colours. The previous + // 6-or-8 rule rejected the two shorthand forms as invalid even though they render + // fine, and disagreed with `BackgroundColor.isHexColorLiteral`, which already + // accepted all four lengths. + let validLengths = [3, 4, 6, 8] + + guard validLengths.contains(hex.count) else { + return false + } + + return hex.allSatisfy { character in + character.isHexDigit + } } } diff --git a/Sources/RichText/Models/RichTextEnums.swift b/Sources/RichText/Models/RichTextEnums.swift index 9c951f0..f0cf93a 100644 --- a/Sources/RichText/Models/RichTextEnums.swift +++ b/Sources/RichText/Models/RichTextEnums.swift @@ -88,9 +88,15 @@ public typealias MediaClickHandler = (MediaClickType) -> Void /// RichText error types public enum RichTextError: LocalizedError { + /// The web view failed to load the generated document. case htmlLoadingFailed(String) + /// The web view could not be configured as requested, so it will not look or behave as + /// the configuration asked for. case webViewConfigurationFailed + /// The generated CSS will not be applied as intended, for example because a colour was + /// given as an invalid hex value and the browser will drop the declaration. case cssGenerationFailed + /// A media click message from the page could not be handled. case mediaHandlingFailed(String) public var errorDescription: String? { diff --git a/Sources/RichText/Views/Webview.swift b/Sources/RichText/Views/Webview.swift index 281f72c..bfb5045 100644 --- a/Sources/RichText/Views/Webview.swift +++ b/Sources/RichText/Views/Webview.swift @@ -104,8 +104,18 @@ extension WebView: NSViewRepresentable { // Set delegate webview.navigationDelegate = context.coordinator - // Configure appearance - webview.setValue(false, forKey: "drawsBackground") + // Configure appearance. + // + // `drawsBackground` is not part of WKWebView's public API, it is reached through KVC. + // `setValue(_:forKey:)` raises NSUnknownKeyException if the key ever goes away, and + // that is an Objective-C exception Swift cannot catch, so it would take the host app + // down. Probe for the setter and report a configuration failure instead. + if webview.responds(to: NSSelectorFromString("setDrawsBackground:")) { + webview.setValue(false, forKey: "drawsBackground") + } else { + webViewLogger.error("WKWebView no longer exposes drawsBackground; background will not be transparent") + conf.errorHandler?(.webViewConfigurationFailed) + } // Load HTML content loadHTMLIfNeeded(in: webview, coordinator: context.coordinator) @@ -311,6 +321,15 @@ extension WebView { return } + // A malformed hex value is not rejected anywhere: it reaches the stylesheet as + // `color: #whatever`, and the browser drops that declaration along with the rest of + // the rule. The text or link colour then silently falls back to the default, which is + // very hard to trace from the outside, so surface it. + if !conf.fontColor.isValid || !conf.linkColor.isValid { + webViewLogger.error("Invalid hex colour in fontColor or linkColor; the declaration will be dropped by the browser") + conf.errorHandler?(.cssGenerationFailed) + } + coordinator.loadedHTML = htmlString coordinator.loadedBaseURL = baseURL diff --git a/Tests/RichTextTests/RichTextSwiftTestingTests.swift b/Tests/RichTextTests/RichTextSwiftTestingTests.swift index 9386179..a273537 100644 --- a/Tests/RichTextTests/RichTextSwiftTestingTests.swift +++ b/Tests/RichTextTests/RichTextSwiftTestingTests.swift @@ -846,6 +846,29 @@ struct RichTextAllTests { let invalidColorSet = ColorSet(light: "INVALID", dark: "00FF00") #expect(!invalidColorSet.isValid) + + // `#RGB` and `#RGBA` are valid CSS and were previously reported as invalid. + #expect(ColorSet(light: "F00", dark: "0F0").isValid) + #expect(ColorSet(light: "F00A", dark: "0F0A").isValid) + + // Wrong length, right length but not hex, and empty. + #expect(!ColorSet(light: "FF000", dark: "00FF00").isValid) + #expect(!ColorSet(light: "GGGGGG", dark: "00FF00").isValid) + #expect(!ColorSet(light: "", dark: "00FF00").isValid) + } + + @Test("Every RichTextError case describes itself") + func errorCasesAreDescribed() { + let errors: [RichTextError] = [ + .htmlLoadingFailed("

x

"), + .webViewConfigurationFailed, + .cssGenerationFailed, + .mediaHandlingFailed("image") + ] + + for error in errors { + #expect(error.errorDescription?.isEmpty == false) + } } @Test("ColorSet raw values work correctly")