From 90721cdc87ab3031d27be240173c864a3f1df415 Mon Sep 17 00:00:00 2001 From: Stijn Date: Sun, 22 Feb 2026 16:54:30 +0100 Subject: [PATCH 1/2] fix: Add macOS platform declaration to resolve SPM 6 strict check MarkdownUI requires macOS 12.0+, but ConversationKit didn't declare a macOS platform, defaulting to 10.13. SPM 6 enforces this strictly when consumed via remote URLs. Co-Authored-By: Claude Opus 4.6 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 6269e9b..a81a4d8 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "ConversationKit", - platforms: [.iOS(.v17), .macCatalyst(.v17)], + platforms: [.iOS(.v17), .macCatalyst(.v17), .macOS(.v14)], products: [ .library( name: "ConversationKit", From d8c6f13b36b4377ba5e0991e9e2c56166a79a736 Mon Sep 17 00:00:00 2001 From: Stijn Date: Mon, 23 Feb 2026 16:41:58 +0100 Subject: [PATCH 2/2] fix: Add macOS platform support via Chameleon typealias pattern Replace all UIKit-specific types with cross-platform abstractions: - PlatformImage typealias (UIImage on iOS, NSImage on macOS) - Platform color extensions (separator, secondary background, gray4) - Cross-platform RoundedCorner shape replacing UIBezierPath - Availability guards for iOS 26/macOS 26 glass effects - Conditional navigationBarTitleDisplayMode for iOS only Co-Authored-By: Claude Opus 4.6 --- .../Model/ImageAttachment.swift | 6 +- .../Platform/PlatformTypes.swift | 74 +++++++++++++++++++ .../Views/AttachmentPreviewCard.swift | 6 +- .../Views/AttachmentPreviewScrollView.swift | 6 +- .../Views/ConversationView.swift | 10 ++- .../Views/MessageComposerView.swift | 12 +-- .../ConversationKit/Views/MessageView.swift | 59 +++++++++++---- 7 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 Sources/ConversationKit/Platform/PlatformTypes.swift diff --git a/Sources/ConversationKit/Model/ImageAttachment.swift b/Sources/ConversationKit/Model/ImageAttachment.swift index 186987c..c728d9d 100644 --- a/Sources/ConversationKit/Model/ImageAttachment.swift +++ b/Sources/ConversationKit/Model/ImageAttachment.swift @@ -20,9 +20,9 @@ import SwiftUI public struct ImageAttachment: Attachment { public let id = UUID() - public let image: UIImage + public let image: PlatformImage - public init(image: UIImage) { + public init(image: PlatformImage) { self.image = image } @@ -37,7 +37,7 @@ public struct ImageAttachment: Attachment { extension ImageAttachment: View { public var body: some View { - Image(uiImage: image) + Image(platformImage: image) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) diff --git a/Sources/ConversationKit/Platform/PlatformTypes.swift b/Sources/ConversationKit/Platform/PlatformTypes.swift new file mode 100644 index 0000000..0923034 --- /dev/null +++ b/Sources/ConversationKit/Platform/PlatformTypes.swift @@ -0,0 +1,74 @@ +// +// PlatformTypes.swift +// ConversationKit +// +// Cross-platform type aliases following the Chameleon pattern: +// centralize platform differences so view code stays clean. + +import SwiftUI + +#if canImport(UIKit) + import UIKit + public typealias PlatformImage = UIImage +#elseif canImport(AppKit) + import AppKit + public typealias PlatformImage = NSImage +#endif + +// MARK: - Platform Image Construction + +extension PlatformImage { + /// Create a platform image from an SF Symbol name. + static func systemSymbol(_ name: String) -> PlatformImage? { + #if canImport(UIKit) + return UIImage(systemName: name) + #elseif canImport(AppKit) + return NSImage(systemSymbolName: name, accessibilityDescription: nil) + #endif + } +} + +// MARK: - Platform Image → SwiftUI Image + +extension Image { + /// Create a SwiftUI Image from a platform-native image. + init(platformImage: PlatformImage) { + #if canImport(UIKit) + self.init(uiImage: platformImage) + #elseif canImport(AppKit) + self.init(nsImage: platformImage) + #endif + } +} + +// MARK: - Platform Colors + +extension Color { + /// Secondary system background — `.secondarySystemBackground` on iOS, + /// `.controlBackgroundColor` on macOS. + static var platformSecondaryBackground: Color { + #if canImport(UIKit) + Color(uiColor: .secondarySystemBackground) + #elseif canImport(AppKit) + Color(nsColor: .controlBackgroundColor) + #endif + } + + /// System gray 4 — `.systemGray4` on iOS, `.systemGray` on macOS. + static var platformGray4: Color { + #if canImport(UIKit) + Color(uiColor: .systemGray4) + #elseif canImport(AppKit) + Color(nsColor: .systemGray) + #endif + } + + /// Separator color — `.separator` on iOS, `.separatorColor` on macOS. + static var platformSeparator: Color { + #if canImport(UIKit) + Color(uiColor: .separator) + #elseif canImport(AppKit) + Color(nsColor: .separatorColor) + #endif + } +} diff --git a/Sources/ConversationKit/Views/AttachmentPreviewCard.swift b/Sources/ConversationKit/Views/AttachmentPreviewCard.swift index 1b82b6a..23554c8 100644 --- a/Sources/ConversationKit/Views/AttachmentPreviewCard.swift +++ b/Sources/ConversationKit/Views/AttachmentPreviewCard.swift @@ -44,7 +44,7 @@ public struct AttachmentPreviewCard: View { public struct ConcentricClipShapeModifier: ViewModifier { public func body(content: Content) -> some View { #if compiler(>=6.2) - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { content .clipShape(.rect(corners: Edge.Corner.Style.concentric(minimum: 12), isUniform: false)) } else { @@ -60,13 +60,13 @@ public struct ConcentricClipShapeModifier: ViewModifier { .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(Color(.separator), lineWidth: 0.5) + .stroke(Color.platformSeparator, lineWidth: 0.5) ) } } #Preview { - AttachmentPreviewCard(attachment: ImageAttachment(image: UIImage(systemName: "photo")!)) { + AttachmentPreviewCard(attachment: ImageAttachment(image: PlatformImage.systemSymbol("photo")!)) { print("Delete action tapped") } } diff --git a/Sources/ConversationKit/Views/AttachmentPreviewScrollView.swift b/Sources/ConversationKit/Views/AttachmentPreviewScrollView.swift index 0f7e447..1c1f7e7 100644 --- a/Sources/ConversationKit/Views/AttachmentPreviewScrollView.swift +++ b/Sources/ConversationKit/Views/AttachmentPreviewScrollView.swift @@ -40,9 +40,9 @@ struct AttachmentPreviewScrollView: View { #Preview { struct PreviewWrapper: View { @State var attachments = [ - ImageAttachment(image: UIImage(systemName: "photo")!), - ImageAttachment(image: UIImage(systemName: "camera")!), - ImageAttachment(image: UIImage(systemName: "mic")!) + ImageAttachment(image: PlatformImage.systemSymbol("photo")!), + ImageAttachment(image: PlatformImage.systemSymbol("camera")!), + ImageAttachment(image: PlatformImage.systemSymbol("mic")!) ] var body: some View { diff --git a/Sources/ConversationKit/Views/ConversationView.swift b/Sources/ConversationKit/Views/ConversationView.swift index b2f0e60..0a2e0db 100644 --- a/Sources/ConversationKit/Views/ConversationView.swift +++ b/Sources/ConversationKit/Views/ConversationView.swift @@ -303,7 +303,9 @@ extension ConversationView where AttachmentType == EmptyAttachment { } } .navigationTitle("Chat") + #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #endif } } @@ -361,9 +363,9 @@ extension ConversationView where AttachmentType == EmptyAttachment { Markdown(messageContent) .padding() .background { - Color(uiColor: message.participant == .other - ? .secondarySystemBackground - : .systemGray4) + message.participant == .other + ? Color.platformSecondaryBackground + : Color.platformGray4 } .roundedCorner(10, corners: .allCorners) if message.participant == .other { @@ -381,6 +383,8 @@ extension ConversationView where AttachmentType == EmptyAttachment { } } .navigationTitle("Chat") + #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #endif } } diff --git a/Sources/ConversationKit/Views/MessageComposerView.swift b/Sources/ConversationKit/Views/MessageComposerView.swift index 244aed4..1dc7b1c 100644 --- a/Sources/ConversationKit/Views/MessageComposerView.swift +++ b/Sources/ConversationKit/Views/MessageComposerView.swift @@ -53,7 +53,7 @@ public struct MessageComposerView: View { public var body: some View { #if compiler(>=6.2) - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { GlassEffectContainer { HStack(alignment: .bottom) { if !disableAttachments, let attachmentActions { @@ -121,7 +121,7 @@ public struct MessageComposerView: View { .clipShape(Circle()) .overlay( Circle() - .stroke(Color(.separator), lineWidth: 0.5) + .stroke(Color.platformSeparator, lineWidth: 0.5) ) .padding(.trailing, 8) } @@ -156,7 +156,7 @@ public struct MessageComposerView: View { .clipShape(RoundedRectangle(cornerRadius: 22)) .overlay( RoundedRectangle(cornerRadius: 22) - .stroke(Color(.separator), lineWidth: 0.5) + .stroke(Color.platformSeparator, lineWidth: 0.5) ) } .padding(.top, 8) @@ -174,9 +174,9 @@ extension MessageComposerView where AttachmentType == EmptyAttachment { #Preview("With Attachments") { @Previewable @State var message = "Hello, world!" @Previewable @State var attachments = [ - ImageAttachment(image: UIImage(systemName: "photo")!), - ImageAttachment(image: UIImage(systemName: "camera")!), - ImageAttachment(image: UIImage(systemName: "mic")!) + ImageAttachment(image: PlatformImage.systemSymbol("photo")!), + ImageAttachment(image: PlatformImage.systemSymbol("camera")!), + ImageAttachment(image: PlatformImage.systemSymbol("mic")!) ] MessageComposerView(message: $message, attachments: $attachments) diff --git a/Sources/ConversationKit/Views/MessageView.swift b/Sources/ConversationKit/Views/MessageView.swift index 7ecd696..2bd8b1c 100644 --- a/Sources/ConversationKit/Views/MessageView.swift +++ b/Sources/ConversationKit/Views/MessageView.swift @@ -20,29 +20,62 @@ import SwiftUI import MarkdownUI +/// Cross-platform corner specification replacing UIRectCorner. +struct RectCorner: OptionSet, Sendable { + let rawValue: Int + static let topLeft = RectCorner(rawValue: 1 << 0) + static let topRight = RectCorner(rawValue: 1 << 1) + static let bottomLeft = RectCorner(rawValue: 1 << 2) + static let bottomRight = RectCorner(rawValue: 1 << 3) + static let allCorners: RectCorner = [.topLeft, .topRight, .bottomLeft, .bottomRight] +} + +/// Cross-platform rounded corner shape using pure SwiftUI Path. struct RoundedCorner: Shape { var radius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners + var corners: RectCorner = .allCorners func path(in rect: CGRect) -> Path { - let path = UIBezierPath( - roundedRect: rect, - byRoundingCorners: corners, - cornerRadii: CGSize(width: radius, height: radius) - ) - return Path(path.cgPath) + let tl = corners.contains(.topLeft) ? radius : 0 + let tr = corners.contains(.topRight) ? radius : 0 + let bl = corners.contains(.bottomLeft) ? radius : 0 + let br = corners.contains(.bottomRight) ? radius : 0 + + var path = Path() + path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY)) + path.addArc( + tangent1End: CGPoint(x: rect.maxX, y: rect.minY), + tangent2End: CGPoint(x: rect.maxX, y: rect.minY + tr), + radius: tr) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br)) + path.addArc( + tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), + tangent2End: CGPoint(x: rect.maxX - br, y: rect.maxY), + radius: br) + path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY)) + path.addArc( + tangent1End: CGPoint(x: rect.minX, y: rect.maxY), + tangent2End: CGPoint(x: rect.minX, y: rect.maxY - bl), + radius: bl) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl)) + path.addArc( + tangent1End: CGPoint(x: rect.minX, y: rect.minY), + tangent2End: CGPoint(x: rect.minX + tl, y: rect.minY), + radius: tl) + return path } } extension View { - func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View { + func roundedCorner(_ radius: CGFloat, corners: RectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } } public struct MessageView: View { @Environment(\.presentErrorAction) var presentErrorAction - + let message: String? let imageURL: String? let fullWidth: Bool = false @@ -97,15 +130,15 @@ public struct MessageView: View { Markdown(message) } } - } + } .padding() .if(fullWidth) { view in view.frame(maxWidth: .infinity, alignment: .leading) } .background { - Color(uiColor: participant == .other - ? .secondarySystemBackground - : .systemGray4) + participant == .other + ? Color.platformSecondaryBackground + : Color.platformGray4 } .roundedCorner(8, corners: participant == .other ? .topLeft : .topRight) .roundedCorner(20, corners: participant == .other ? [.topRight, .bottomLeft, .bottomRight] : [.topLeft, .bottomLeft, .bottomRight])