From 58049091370f1bf5cc678f0d9de1915c138eb92c Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 25 Jun 2026 15:58:13 -0400 Subject: [PATCH 1/2] fix(broadcast): make ConsoleLogger Apple-only for non-Apple builds ConsoleLogger imported OSLog unconditionally, breaking non-Apple builds (`no such module 'OSLog'`). Wrap the whole type in `#if canImport(OSLog)` so it stays OSLog-backed on Apple platforms and is simply unavailable where OSLog can't be imported (e.g. Linux). Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Broadcast/Loggers/ConsoleLogger.swift | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/Sources/Broadcast/Loggers/ConsoleLogger.swift b/Sources/Broadcast/Loggers/ConsoleLogger.swift index 0c2ed86..71dcb08 100644 --- a/Sources/Broadcast/Loggers/ConsoleLogger.swift +++ b/Sources/Broadcast/Loggers/ConsoleLogger.swift @@ -1,39 +1,43 @@ -import OSLog +#if canImport(OSLog) + import OSLog -/// A destination that writes Broadcast logs to Apple's unified logging system. -/// -/// Use ``ConsoleLogger`` when you want logs to appear in Console.app, Xcode, or OSLog -/// collection tools. Apps should usually initialize it with their own subsystem and -/// category so logs can be inspected separately from Broadcast internals. Broadcast -/// writes rendered log values with public privacy, so do not send secrets, tokens, or -/// sensitive user data to this destination. -public struct ConsoleLogger: LoggingDestination { - private let logger: Logger - - /// Creates an OSLog-backed destination. + /// A destination that writes Broadcast logs to Apple's unified logging system. /// - /// Use your app or framework bundle identifier as the subsystem and a stable area - /// name as the category. - public init(subsystem: String, category: String) { - self.logger = Logger(subsystem: subsystem, category: category) - } + /// Use ``ConsoleLogger`` when you want logs to appear in Console.app, Xcode, or OSLog + /// collection tools. Apps should usually initialize it with their own subsystem and + /// category so logs can be inspected separately from Broadcast internals. Broadcast + /// writes rendered log values with public privacy, so do not send secrets, tokens, or + /// sensitive user data to this destination. + /// + /// This destination is only available on Apple platforms where OSLog can be imported. + public struct ConsoleLogger: LoggingDestination { + private let logger: Logger - public func log(_ record: Log.Record) { - let text = self.recordFormatter.format(record) - self.logger.log(level: record.level.osLogType, "\(text, privacy: .public)") + /// Creates an OSLog-backed destination. + /// + /// Use your app or framework bundle identifier as the subsystem and a stable area + /// name as the category. + public init(subsystem: String, category: String) { + self.logger = Logger(subsystem: subsystem, category: category) + } + + public func log(_ record: Log.Record) { + let text = self.recordFormatter.format(record) + self.logger.log(level: record.level.osLogType, "\(text, privacy: .public)") + } } -} -// MARK: Log.Level + // MARK: Log.Level -private extension Log.Level { - var osLogType: OSLogType { - switch self { - case .debug: .debug - case .info: .info - case .warn: .default - case .error: .error - case .fault: .fault + private extension Log.Level { + var osLogType: OSLogType { + switch self { + case .debug: .debug + case .info: .info + case .warn: .default + case .error: .error + case .fault: .fault + } } } -} +#endif From 012a3a6cf191adb642ed395830b0a115dee8710b Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 26 Jun 2026 13:25:50 -0400 Subject: [PATCH 2/2] fix(broadcast): build on non-Apple platforms; require Swift 6.0 Boutique (Bodega/SQLite/swift-collections) imports CryptoKit, which is unavailable off Apple platforms. Gate its target dependency to Apple via `.when(platforms:)` so SwiftPM never compiles it on Linux, and wrap `MultiSessionLogger` + its test in `#if canImport(Boutique)`. `Log.default` drops the OSLog-only `ConsoleLogger` where OSLog is absent. Bump swift-tools to 6.0 (with swiftLanguageModes [.v5]): the library uses `Synchronization.Mutex` and `Foundation.FormatStyle`, both of which only exist in the Swift 6.0 toolchain on Linux. The iOS 18 / macOS 15 floor already requires Swift 6 on Apple, so 5.10 toolchains now get a clean tools-version error instead of failing deep in the FormatStyle layer. Verified: `swift build` is green on Linux (swift:6.3) and macOS (6.3.2); 5.10 fails fast with a clear "requires Swift tools 6.0" message. Co-Authored-By: Claude Opus 4.8 --- Package.swift | 30 +- Sources/Broadcast/Log/Log.swift | 16 +- .../Loggers/MultiSessionLogger.swift | 3 + .../BroadcastTests/Log+Structured.Tests.swift | 19 +- .../MultiSessionLogger.Tests.swift | 302 ++++++++++-------- 5 files changed, 235 insertions(+), 135 deletions(-) diff --git a/Package.swift b/Package.swift index 85e0f26..0427f6c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,19 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +// Broadcast requires Swift 6.0+: it uses `Synchronization.Mutex` and, on non-Apple platforms, +// `Foundation.FormatStyle` — both of which only exist in the Swift 6.0 toolchain on Linux. +// +// Boutique (and its Bodega/SQLite/swift-collections chain) imports `CryptoKit`, which is +// unavailable on non-Apple platforms, so its *target* dependency is gated to Apple platforms +// with `.when(platforms:)`. On Linux SwiftPM still resolves Boutique but never compiles it, +// and `MultiSessionLogger` (guarded by `#if canImport(Boutique)`) compiles to nothing. +// `ConsoleLogger` is likewise gated on `#if canImport(OSLog)`. +// +// `swiftLanguageModes: [.v5]` keeps Swift 5 language mode so existing `static var` globals do +// not become hard concurrency errors under Swift 6 mode. let package = Package( name: "Broadcast", platforms: [ @@ -23,15 +34,24 @@ let package = Package( .target( name: "Broadcast", dependencies: [ - .product(name: "Boutique", package: "Boutique") + .product( + name: "Boutique", + package: "Boutique", + condition: .when(platforms: [.iOS, .macOS, .tvOS, .watchOS, .visionOS]) + ) ] ), .testTarget( name: "BroadcastTests", dependencies: [ - .product(name: "Boutique", package: "Boutique"), - "Broadcast" + "Broadcast", + .product( + name: "Boutique", + package: "Boutique", + condition: .when(platforms: [.iOS, .macOS, .tvOS, .watchOS, .visionOS]) + ) ] ) - ] + ], + swiftLanguageModes: [.v5] ) diff --git a/Sources/Broadcast/Log/Log.swift b/Sources/Broadcast/Log/Log.swift index 12a5d13..55d66e6 100644 --- a/Sources/Broadcast/Log/Log.swift +++ b/Sources/Broadcast/Log/Log.swift @@ -88,22 +88,30 @@ public extension Log { /// lifetime control, deterministic tests, or multiple independently exported buffers. static let sessionLogger = SessionLogger() + #if canImport(OSLog) /// Broadcast's shared console destination. /// /// Prefer creating your own ``ConsoleLogger`` with your app's subsystem and category /// for production integrations. static let consoleLogger = ConsoleLogger(subsystem: "com.mergesort.broadcast", category: "logs") + #endif /// A convenience log that writes to Broadcast's default console and session destinations. /// /// This is useful for quick integration or examples. Apps that need support-log /// export, privacy-specific routing, or dependency injection should construct /// their own ``Log``. + /// + /// ``ConsoleLogger`` is only included on Apple platforms where OSLog is available; + /// elsewhere the default log writes to the ``SessionLogger`` alone. static let `default` = Log( - destinations: [ - Log.consoleLogger, - Log.sessionLogger - ] + destinations: { + #if canImport(OSLog) + return [Log.consoleLogger, Log.sessionLogger] + #else + return [Log.sessionLogger] + #endif + }() ) } diff --git a/Sources/Broadcast/Loggers/MultiSessionLogger.swift b/Sources/Broadcast/Loggers/MultiSessionLogger.swift index 9ae2c97..768bc79 100644 --- a/Sources/Broadcast/Loggers/MultiSessionLogger.swift +++ b/Sources/Broadcast/Loggers/MultiSessionLogger.swift @@ -1,3 +1,4 @@ +#if canImport(Boutique) import Boutique import Foundation import Synchronization @@ -137,3 +138,5 @@ private final class MultiSessionLogStorage { try? await self.$records.removeAll() } } + +#endif diff --git a/Tests/BroadcastTests/Log+Structured.Tests.swift b/Tests/BroadcastTests/Log+Structured.Tests.swift index 27832e1..8d39bba 100644 --- a/Tests/BroadcastTests/Log+Structured.Tests.swift +++ b/Tests/BroadcastTests/Log+Structured.Tests.swift @@ -2,6 +2,19 @@ import Foundation import Testing +extension Trait where Self == ConditionTrait { + /// Disables the annotated test when OSLog is unavailable (non-Apple platforms), because + /// `ConsoleLogger` is not compiled into that build. The test is reported as disabled + /// rather than silently passing. + static var requiresConsoleLogger: Self { + #if canImport(OSLog) + .enabled(if: true) + #else + .disabled("Requires OSLog, which is only available on Apple platforms") + #endif + } +} + struct StructuredLogTests { @Test func formatsRecordWithDefaultBroadcastFormatStyle() { @@ -338,8 +351,9 @@ struct StructuredLogTests { #expect(record.formatted(style) == "[42] canonical-log-line level=info signal=Metric category=Sync message=\"Measured reminder sync\" duration=1.25s") } - @Test + @Test(.requiresConsoleLogger) func consoleLoggerUsesDefaultRecordFormatter() { + #if canImport(OSLog) let logger = ConsoleLogger(subsystem: "com.mergesort.BroadcastTests", category: "logs") let record = Log.Record( timestamp: Log.Timestamp(Date(timeIntervalSince1970: 0)), @@ -351,6 +365,9 @@ struct StructuredLogTests { ) #expect(logger.recordFormatter.format(record) == "[Warn | Event | Notifications] @ 1970-01-01T00:00:00Z | Reached retry threshold | payload=[attempts=3]") + #else + Issue.record("OSLog is unavailable on this platform; this test should not run.") + #endif } @Test diff --git a/Tests/BroadcastTests/MultiSessionLogger.Tests.swift b/Tests/BroadcastTests/MultiSessionLogger.Tests.swift index 528f295..438d4ad 100644 --- a/Tests/BroadcastTests/MultiSessionLogger.Tests.swift +++ b/Tests/BroadcastTests/MultiSessionLogger.Tests.swift @@ -1,175 +1,226 @@ +#if canImport(Boutique) import Boutique +#endif @testable import Broadcast import Foundation import Synchronization import Testing +extension Trait where Self == ConditionTrait { + /// Disables the annotated suite or test when Boutique is unavailable (non-Apple + /// platforms), because `MultiSessionLogger` and its Boutique backing are not compiled + /// into that build. The tests are reported as disabled rather than silently passing. + static var requiresMultiSessionLogging: Self { + #if canImport(Boutique) + .enabled(if: true) + #else + .disabled("Requires Boutique, which is only available on Apple platforms") + #endif + } +} + @MainActor +@Suite(.requiresMultiSessionLogging) struct MultiSessionLoggerTests { @Test func persistsLogsWithDefaultTimestampFormatStyle() async throws { - let logger = try await Self.makeLogger( - dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } - ) - - logger.info("Created reminder") - await logger.flush() - - #expect(logger.logs() == "[Info] @ 1970-01-01T00:00:00Z | Created reminder") + #if canImport(Boutique) + let logger = try await Self.makeLogger( + dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } + ) + + logger.info("Created reminder") + await logger.flush() + + #expect(logger.logs() == "[Info] @ 1970-01-01T00:00:00Z | Created reminder") + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } @Test func persistsLogsWithConfiguredTimestamp() async throws { - let logger = try await Self.makeLogger( - dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) }, - timestampFormatStyle: .timestamp - ) - - logger.info("Created reminder") - await logger.flush() - - #expect(logger.logs() == "[Info] @ 0 | Created reminder") + #if canImport(Boutique) + let logger = try await Self.makeLogger( + dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) }, + timestampFormatStyle: .timestamp + ) + + logger.info("Created reminder") + await logger.flush() + + #expect(logger.logs() == "[Info] @ 0 | Created reminder") + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } @Test func persistsMultipleValues() async throws { - let logger = try await Self.makeLogger( - dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } - ) - - logger.info("Created", "Reminder", 3) - await logger.flush() - - #expect(logger.logs() == "[Info] @ 1970-01-01T00:00:00Z | Created, Reminder, 3") + #if canImport(Boutique) + let logger = try await Self.makeLogger( + dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } + ) + + logger.info("Created", "Reminder", 3) + await logger.flush() + + #expect(logger.logs() == "[Info] @ 1970-01-01T00:00:00Z | Created, Reminder, 3") + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } @Test func exposesBufferedRecords() async throws { - let logger = try await Self.makeLogger( - dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } - ) - - logger.info(.action, "Created reminder", category: "Reminders", payload: [.init(key: "priority", value: "High")]) - await logger.flush() - - let record = try #require(logger.records().first) - - #expect(logger.records().count == 1) - #expect(record.timestamp == Log.Timestamp(Date(timeIntervalSince1970: 0))) - #expect(record.level == .info) - #expect(record.signal == .action) - #expect(record.message == "Created reminder") - #expect(record.category == "Reminders") - #expect(record.payload == [.init(key: "priority", value: "High")]) + #if canImport(Boutique) + let logger = try await Self.makeLogger( + dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } + ) + + logger.info(.action, "Created reminder", category: "Reminders", payload: [.init(key: "priority", value: "High")]) + await logger.flush() + + let record = try #require(logger.records().first) + + #expect(logger.records().count == 1) + #expect(record.timestamp == Log.Timestamp(Date(timeIntervalSince1970: 0))) + #expect(record.level == .info) + #expect(record.signal == .action) + #expect(record.message == "Created reminder") + #expect(record.category == "Reminders") + #expect(record.payload == [.init(key: "priority", value: "High")]) + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } @Test func persistsEveryLevel() async throws { - let logger = try await Self.makeLogger( - dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } - ) - - logger.debug("Read reminders", 1) - logger.info("Created reminder", 2) - logger.warn("Skipped reminder notification", 3) - logger.error("Failed reminder sync", 4) - logger.fault("Detected reminder store corruption", 5) - await logger.flush() - - #expect(logger.logs() == """ - [Debug] @ 1970-01-01T00:00:00Z | Read reminders, 1 - [Info] @ 1970-01-01T00:00:00Z | Created reminder, 2 - [Warn] @ 1970-01-01T00:00:00Z | Skipped reminder notification, 3 - [Error] @ 1970-01-01T00:00:00Z | Failed reminder sync, 4 - [Fault] @ 1970-01-01T00:00:00Z | Detected reminder store corruption, 5 - """) + #if canImport(Boutique) + let logger = try await Self.makeLogger( + dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) } + ) + + logger.debug("Read reminders", 1) + logger.info("Created reminder", 2) + logger.warn("Skipped reminder notification", 3) + logger.error("Failed reminder sync", 4) + logger.fault("Detected reminder store corruption", 5) + await logger.flush() + + #expect(logger.logs() == """ + [Debug] @ 1970-01-01T00:00:00Z | Read reminders, 1 + [Info] @ 1970-01-01T00:00:00Z | Created reminder, 2 + [Warn] @ 1970-01-01T00:00:00Z | Skipped reminder notification, 3 + [Error] @ 1970-01-01T00:00:00Z | Failed reminder sync, 4 + [Fault] @ 1970-01-01T00:00:00Z | Detected reminder store corruption, 5 + """) + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } @Test func waitsForQueuedWritesBeforeReadingLogs() async throws { - let dateProvider = SequentialDateProvider( - dates: [ - Date(timeIntervalSince1970: 1), - Date(timeIntervalSince1970: 2), - Date(timeIntervalSince1970: 3) - ] - ) - - let logger = try await Self.makeLogger( - dateProvider: Log.DateProvider { dateProvider.nextDate() }, - timestampFormatStyle: .timestamp - ) - - logger.info("First") - logger.info("Second") - logger.info("Third") - await logger.flush() - - #expect(logger.logs() == "[Info] @ 1 | First\n[Info] @ 2 | Second\n[Info] @ 3 | Third") + #if canImport(Boutique) + let dateProvider = SequentialDateProvider( + dates: [ + Date(timeIntervalSince1970: 1), + Date(timeIntervalSince1970: 2), + Date(timeIntervalSince1970: 3) + ] + ) + + let logger = try await Self.makeLogger( + dateProvider: Log.DateProvider { dateProvider.nextDate() }, + timestampFormatStyle: .timestamp + ) + + logger.info("First") + logger.info("Second") + logger.info("Third") + await logger.flush() + + #expect(logger.logs() == "[Info] @ 1 | First\n[Info] @ 2 | Second\n[Info] @ 3 | Third") + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } @Test func returnsNewestLogsWhenCountIsProvided() async throws { - let dateProvider = SequentialDateProvider( - dates: [ - Date(timeIntervalSince1970: 1), - Date(timeIntervalSince1970: 2), - Date(timeIntervalSince1970: 3) - ] - ) - - let logger = try await Self.makeLogger( - dateProvider: Log.DateProvider { dateProvider.nextDate() }, - timestampFormatStyle: .timestamp - ) - - logger.info("First") - logger.info("Second") - logger.info("Third") - - await logger.flush() - - #expect(logger.logs(count: 2) == "[Info] @ 3 | Third\n[Info] @ 2 | Second") + #if canImport(Boutique) + let dateProvider = SequentialDateProvider( + dates: [ + Date(timeIntervalSince1970: 1), + Date(timeIntervalSince1970: 2), + Date(timeIntervalSince1970: 3) + ] + ) + + let logger = try await Self.makeLogger( + dateProvider: Log.DateProvider { dateProvider.nextDate() }, + timestampFormatStyle: .timestamp + ) + + logger.info("First") + logger.info("Second") + logger.info("Third") + + await logger.flush() + + #expect(logger.logs(count: 2) == "[Info] @ 3 | Third\n[Info] @ 2 | Second") + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } @Test func removesAllBufferedAndPersistedLogs() async throws { - let store = try await Store( - storage: SQLiteStorageEngine(directory: .temporary(appendingPath: "BroadcastTests_\(UUID().uuidString)"))! - ) - let logger = MultiSessionLogger(store: store) - - logger.info("First") - logger.info("Second") - await logger.removeAll() - - let restoredLogger = MultiSessionLogger(store: store) - await restoredLogger.flush() - - #expect(logger.records().isEmpty) - #expect(logger.logs().isEmpty) - #expect(restoredLogger.records().isEmpty) + #if canImport(Boutique) + let store = try await Store( + storage: SQLiteStorageEngine(directory: .temporary(appendingPath: "BroadcastTests_\(UUID().uuidString)"))! + ) + let logger = MultiSessionLogger(store: store) + + logger.info("First") + logger.info("Second") + await logger.removeAll() + + let restoredLogger = MultiSessionLogger(store: store) + await restoredLogger.flush() + + #expect(logger.records().isEmpty) + #expect(logger.logs().isEmpty) + #expect(restoredLogger.records().isEmpty) + #else + Issue.record("Boutique is unavailable on this platform; this test should not run.") + #endif } } -// MARK: Private +#if canImport(Boutique) + // MARK: Private -private extension MultiSessionLoggerTests { - static func makeLogger(dateProvider: Log.DateProvider = .default, timestampFormatStyle: Log.Timestamp.FormatStyle = .default) async throws -> MultiSessionLogger { - let store = try await Store( - storage: SQLiteStorageEngine(directory: .temporary(appendingPath: "BroadcastTests_\(UUID().uuidString)"))! - ) + private extension MultiSessionLoggerTests { + static func makeLogger(dateProvider: Log.DateProvider = .default, timestampFormatStyle: Log.Timestamp.FormatStyle = .default) async throws -> MultiSessionLogger { + let store = try await Store( + storage: SQLiteStorageEngine(directory: .temporary(appendingPath: "BroadcastTests_\(UUID().uuidString)"))! + ) - return MultiSessionLogger( - store: store, - dateProvider: dateProvider, - timestampFormatStyle: timestampFormatStyle - ) + return MultiSessionLogger( + store: store, + dateProvider: dateProvider, + timestampFormatStyle: timestampFormatStyle + ) + } } -} +#endif // MARK: SequentialDateProvider +#if canImport(Boutique) private final class SequentialDateProvider: Sendable { private let dates: Mutex<[Date]> @@ -183,3 +234,4 @@ private final class SequentialDateProvider: Sendable { } } } +#endif