openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
Glucksberg 96a156ab87 fix(macos): prevent crash when Textual syntax highlighting bundle is missing
Add graceful fallback to SwiftUI's native AttributedString markdown rendering
when the Textual resource bundle is not found. This prevents the app from
crashing with an NSBundle.module assertion failure when code blocks are
rendered without the prism-bundle.js resource being properly embedded.

Fixes #2002
2026-01-30 14:48:31 +00:00

146 lines
4.7 KiB
Swift

import SwiftUI
import Textual
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
case standard
case compact
}
// MARK: - Textual Bundle Availability
/// Checks if the Textual syntax highlighting bundle is available.
/// This must be called BEFORE any Textual types are accessed to avoid a crash
/// from SPM's generated Bundle.module accessor when the bundle is missing.
private let textualBundleAvailable: Bool = {
let bundleNames = ["textual_Textual", "Textual_Textual"]
guard let resourceURL = Bundle.main.resourceURL else { return false }
for name in bundleNames {
let bundleURL = resourceURL.appendingPathComponent("\(name).bundle")
if FileManager.default.fileExists(atPath: bundleURL.path) {
return true
}
}
return false
}()
@MainActor
struct ChatMarkdownRenderer: View {
enum Context {
case user
case assistant
}
let text: String
let context: Context
let variant: ChatMarkdownVariant
let font: Font
let textColor: Color
var body: some View {
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
if textualBundleAvailable {
StructuredText(markdown: processed.cleaned)
.modifier(ChatMarkdownStyle(
variant: self.variant,
context: self.context,
font: self.font,
textColor: self.textColor))
} else {
// Fallback when Textual's resource bundle is missing (avoids crash).
FallbackMarkdownText(
text: processed.cleaned,
font: self.font,
textColor: self.textColor)
}
if !processed.images.isEmpty {
InlineImageList(images: processed.images)
}
}
}
}
private struct ChatMarkdownStyle: ViewModifier {
let variant: ChatMarkdownVariant
let context: ChatMarkdownRenderer.Context
let font: Font
let textColor: Color
func body(content: Content) -> some View {
Group {
if self.variant == .compact {
content.textual.structuredTextStyle(.default)
} else {
content.textual.structuredTextStyle(.gitHub)
}
}
.font(self.font)
.foregroundStyle(self.textColor)
.textual.inlineStyle(self.inlineStyle)
.textual.textSelection(.enabled)
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))
.link(.foregroundColor(linkColor))
}
}
@MainActor
private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View {
ForEach(images, id: \.id) { item in
if let img = item.image {
OpenClawPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - Fallback Markdown Rendering
/// Fallback markdown renderer using SwiftUI's native AttributedString.
/// Used when Textual's resource bundle is missing to avoid crashes.
@MainActor
private struct FallbackMarkdownText: View {
let text: String
let font: Font
let textColor: Color
var body: some View {
if let attributed = try? AttributedString(markdown: self.text, options: Self.markdownOptions) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
.textSelection(.enabled)
} else {
Text(self.text)
.font(self.font)
.foregroundStyle(self.textColor)
.textSelection(.enabled)
}
}
private static let markdownOptions = AttributedString.MarkdownParsingOptions(
allowsExtendedAttributes: true,
interpretedSyntax: .inlineOnlyPreservingWhitespace,
failurePolicy: .returnPartiallyParsedIfPossible)
}