Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ed1039b
adopt swift-log
kaascevich Dec 29, 2025
439952b
undo a test change I made
kaascevich Dec 29, 2025
378ce3a
documentation and formatting fixups
kaascevich Dec 29, 2025
f5bf14b
cache logs by `extractSwiftBundlerMetadata()` and output them once th…
kaascevich Dec 29, 2025
1c9cee8
add a `logHandler(label:metadata:)` requirement to `App` and use it
kaascevich Dec 29, 2025
cca959a
use swift-log in the backends
kaascevich Dec 29, 2025
ca62da6
use `InMemoryLogHandler` for `extractSwiftBundlerMetadata()`
kaascevich Dec 30, 2025
ccfba39
refactor `extractSwiftBundlerMetadata()` to be `throws`
kaascevich Dec 30, 2025
be915af
documentation fixups
kaascevich Dec 30, 2025
5d0bdfa
fix comment casing
kaascevich Dec 30, 2025
150a6cd
fix swift-bundler metadata error log not using `localizedDescription`
kaascevich Dec 30, 2025
41b3a37
Merge branch 'main' into swift-log
kaascevich Dec 31, 2025
87582a2
convert remaining `print` calls into `logger` calls
kaascevich Dec 31, 2025
8e6200e
remove `logger.notice` call only in place for debugging
kaascevich Jan 2, 2026
d494666
change the minimum swift-log version to 1.7.0 (for Swift 5.10 compat)
kaascevich Jan 2, 2026
9837f5e
lower swift-log version again
kaascevich Jan 2, 2026
5d28af8
Merge branch 'stackotter:main' into swift-log
kaascevich Jan 2, 2026
dc3c131
add missing `return` to UIKitBackend init
kaascevich Jan 2, 2026
cf457ff
please just work this time
kaascevich Jan 2, 2026
7424008
commit Package.resolved properly
kaascevich Jan 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Examples/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ let package = Package(
url: "https://github.com/stackotter/swift-benchmark",
.upToNextMinor(from: "0.2.0")
),
.package(
url: "https://github.com/apple/swift-log.git",
from: "1.8.0"
),
// .package(
// url: "https://github.com/stackotter/TermKit",
// revision: "163afa64f1257a0c026cc83ed8bc47a5f8fc9704"
Expand All @@ -145,6 +149,7 @@ let package = Package(
dependencies: [
"HotReloadingMacrosPlugin",
.product(name: "ImageFormats", package: "swift-image-formats"),
.product(name: "Logging", package: "swift-log"),
],
exclude: [
"Builders/ViewBuilder.swift.gyb",
Expand Down
36 changes: 28 additions & 8 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import AppKit
import Logging
import SwiftCrossUI
import WebKit

/// The storage behind `logger`.
///
/// `nil` if the logger hasn't been set yet.
///
/// > Safety: This is only set once, before it is ever read.
nonisolated(unsafe) private var _logger: Logger?

/// The global logger for this backend.
var logger: Logger {
guard let _logger else { fatalError("logger not yet initialized") }
return _logger
}

extension App {
public typealias Backend = AppKitBackend

public var backend: AppKitBackend {
AppKitBackend()
_logger = Logger(
label: "AppKitBackend",
factory: Self.logHandler(label:metadataProvider:)
)
return AppKitBackend()
}
}

Expand Down Expand Up @@ -419,8 +437,7 @@ public final class AppKitBackend: AppBackend {
public func setPosition(ofChildAt index: Int, in container: Widget, to position: SIMD2<Int>) {
let container = container as! NSContainerView
guard container.children.indices.contains(index) else {
// TODO: Create proper logging system.
print("warning: Attempted to set position of non-existent container child")
logger.warning("attempted to set position of non-existent container child")
return
}

Expand Down Expand Up @@ -1259,7 +1276,7 @@ public final class AppKitBackend: AppBackend {
}

guard response != .abort, response != .cancel else {
print("warning: Got abort or cancel modal response, unexpected and unhandled")
logger.warning("got abort or cancel modal response, unexpected and unhandled")
return
}

Expand Down Expand Up @@ -1956,11 +1973,11 @@ class NSCustomTableViewDelegate: NSObject, NSTableViewDelegate, NSTableViewDataS
row: Int
) -> NSView? {
guard let tableColumn else {
print("warning: No column provided")
logger.warning("no column provided")
return nil
}
guard let columnIndex = columnIndices[ObjectIdentifier(tableColumn)] else {
print("warning: NSTableView asked for value of non-existent column")
logger.warning("NSTableView asked for value of non-existent column")
return nil
}
return widgets[row * columnCount + columnIndex]
Expand Down Expand Up @@ -2000,7 +2017,10 @@ extension ColorScheme {
extension Color {
init(_ nsColor: NSColor) {
guard let resolvedNSColor = nsColor.usingColorSpace(.deviceRGB) else {
print("error: Failed to convert NSColor to RGB")
logger.error(
"failed to convert NSColor to RGB",
metadata: ["NSColor": "\(nsColor)"]
)
self = .black
return
}
Expand Down Expand Up @@ -2291,7 +2311,7 @@ final class CustomWKNavigationDelegate: NSObject, WKNavigationDelegate {

func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
guard let url = webView.url else {
print("warning: Web view has no URL")
logger.warning("web view has no URL")
return
}

Expand Down
147 changes: 107 additions & 40 deletions Sources/SwiftCrossUI/App.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import Foundation
import Logging

/// Backing storage for `logger`.
nonisolated(unsafe) private var _logger: Logger?

/// The global logger.
var logger: Logger {
guard let _logger else { fatalError("logger not yet initialized") }
return _logger
}

/// An application.
@MainActor
Expand All @@ -8,9 +18,11 @@ public protocol App {
/// The type of scene representing the content of the app.
associatedtype Body: Scene

/// Metadata loaded at app start up. By default SwiftCrossUI attempts
/// to load metadata inserted by Swift Bundler if present. Used by backends'
/// default ``App/backend`` implementations if not `nil`.
/// Metadata loaded at app start up.
///
/// By default SwiftCrossUI attempts to load metadata inserted by Swift
/// Bundler if present. Used by backends' default ``App/backend``
/// implementations if not `nil`.
static var metadata: AppMetadata? { get }

/// The application's backend.
Expand All @@ -20,46 +32,87 @@ public protocol App {
@SceneBuilder var body: Body { get }

/// Creates an instance of the app.
///
/// This initializer is run before anything else, so you can perform early
/// setup tasks in here, such as opening a database or preparing a
/// dependency injection library.
init()

/// Returns the log handler to use for log messages emitted by SwiftCrossUI
/// and its backends.
///
/// By default, SwiftCrossUI outputs log messages to standard error, but you
/// can use any log handler you want by implementing this requirement.
///
/// # See Also
/// - <doc:Logging>
static func logHandler(
label: String,
metadataProvider: Logger.MetadataProvider?
) -> any LogHandler
}

/// Force refresh the entire scene graph. Used by hot reloading. If you need to do
/// this in your own code then something has gone very wrong...
@MainActor
public var _forceRefresh: () -> Void = {}

/// Metadata embedded by Swift Bundler if present. Loaded at app start up.
/// Metadata embedded by Swift Bundler, if present. Loaded at app start up.
///
/// This will contain the app's metadata, if present, by the time ``App/init()``
/// gets called.
@MainActor
private var swiftBundlerAppMetadata: AppMetadata?

/// An error encountered when parsing Swift Bundler metadata.
private enum SwiftBundlerMetadataError: LocalizedError {
case noExecutableURL
case failedToReadExecutable
case jsonNotDictionary(String)
case missingAppIdentifier
case missingAppVersion

var errorDescription: String? {
switch self {
case .noExecutableURL:
"no executable URL"
case .failedToReadExecutable:
"executable failed to read itself (to extract metadata)"
case .jsonNotDictionary:
"Root metadata JSON value wasn't an object"
"root metadata JSON value wasn't an object"
case .missingAppIdentifier:
"Missing 'appIdentifier' (of type String)"
"missing 'appIdentifier' (of type String)"
case .missingAppVersion:
"Missing 'appVersion' (of type String)"
"missing 'appVersion' (of type String)"
}
}
}

extension App {
/// Metadata loaded at app start up.
///
/// This will contain the app's metadata, if present, by the time
/// ``App/init()`` gets called.
public static var metadata: AppMetadata? {
swiftBundlerAppMetadata
}

/// The default log handler for apps which don't specify a custom one.
///
/// This simply outputs logs to standard error.
///
/// # See Also
/// - <doc:Logging>
public static func logHandler(
label: String,
metadataProvider: Logger.MetadataProvider?
) -> any LogHandler {
StreamLogHandler.standardError(label: label)
}

/// Runs the application.
public static func main() {
swiftBundlerAppMetadata = extractSwiftBundlerMetadata()

extractMetadataAndInitializeLogging()
let app = Self()
let _app = _App(app)
_forceRefresh = {
Expand All @@ -70,15 +123,34 @@ extension App {
_app.run()
}

private static func extractSwiftBundlerMetadata() -> AppMetadata? {
private static func extractMetadataAndInitializeLogging() {
// Extract metadata _before_ initializing the logger, so users can use
// said metadata when declaring a custom logger.
let result = Result {
swiftBundlerAppMetadata = try extractSwiftBundlerMetadata()
}

_logger = Logger(
label: "SwiftCrossUI",
factory: logHandler(label:metadataProvider:)
)

// Check for an error once the logger is ready.
if case .failure(let error) = result {
logger.error(
"failed to extract swift-bundler metadata",
metadata: ["error": "\(error.localizedDescription)"]
)
}
}

private static func extractSwiftBundlerMetadata() throws -> AppMetadata? {
guard let executable = Bundle.main.executableURL else {
print("warning: No executable url")
return nil
throw SwiftBundlerMetadataError.noExecutableURL
}

guard let data = try? Data(contentsOf: executable) else {
print("warning: Executable failed to read self (to extract metadata)")
return nil
throw SwiftBundlerMetadataError.failedToReadExecutable
}

// Check if executable has Swift Bundler metadata magic bytes.
Expand All @@ -92,33 +164,28 @@ extension App {
let jsonStart = lengthStart - Int(jsonLength)
let jsonData = Data(bytes[jsonStart..<lengthStart])

do {
// Manually parsed due to the `additionalMetadata` field (which would
// require a lot of boilerplate code to parse with Codable).
let jsonValue = try JSONSerialization.jsonObject(with: jsonData)
guard let json = jsonValue as? [String: Any] else {
throw SwiftBundlerMetadataError.jsonNotDictionary(String(describing: jsonValue))
}
guard let identifier = json["appIdentifier"] as? String else {
throw SwiftBundlerMetadataError.missingAppIdentifier
}
guard let version = json["appVersion"] as? String else {
throw SwiftBundlerMetadataError.missingAppVersion
}
let additionalMetadata =
json["additionalMetadata"].map { value in
value as? [String: Any] ?? [:]
} ?? [:]
return AppMetadata(
identifier: identifier,
version: version,
additionalMetadata: additionalMetadata
)
} catch {
print("warning: Swift Bundler metadata present but couldn't be parsed")
print(" -> \(error)")
return nil
// Manually parsed due to the `additionalMetadata` field (which would
// require a lot of boilerplate code to parse with Codable).
let jsonValue = try JSONSerialization.jsonObject(with: jsonData)
guard let json = jsonValue as? [String: Any] else {
throw SwiftBundlerMetadataError.jsonNotDictionary(String(describing: jsonValue))
}
guard let identifier = json["appIdentifier"] as? String else {
throw SwiftBundlerMetadataError.missingAppIdentifier
}
guard let version = json["appVersion"] as? String else {
throw SwiftBundlerMetadataError.missingAppVersion
}
let additionalMetadata =
json["additionalMetadata"].map { value in
value as? [String: Any] ?? [:]
} ?? [:]

return AppMetadata(
identifier: identifier,
version: version,
additionalMetadata: additionalMetadata
)
}

private static func parseBigEndianUInt64(
Expand Down
6 changes: 3 additions & 3 deletions Sources/SwiftCrossUI/Backend/AppBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -809,14 +809,14 @@ extension AppBackend {
extension AppBackend {
/// Used by placeholder implementations of backend methods.
private func todo(_ function: String = #function) -> Never {
print("\(type(of: self)): \(function) not implemented")
logger.critical("\(type(of: self)): \(function) not implemented")
Foundation.exit(1)
}

private func ignored(_ function: String = #function) {
#if DEBUG
print(
"\(type(of: self)): \(function) is being ignored\nConsult at the documentation for further information."
logger.warning(
"\(type(of: self)): \(function) is being ignored; consult the documentation for further information"
)
#endif
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ private struct DismissActionKey: EnvironmentKey {
static var defaultValue: DismissAction {
DismissAction(action: {
#if DEBUG
print("warning: dismiss() called but no presentation context is available")
logger.warning("dismiss() called but no presentation context is available")
#endif
})
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/SwiftCrossUI/Environment/Actions/OpenURLAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ public struct OpenURLAction {
do {
try backend.openExternalURL(url)
} catch {
print("warning: Failed to open external url: \(error)")
logger.warning("failed to open external url", metadata: [
"url": "\(url)",
"error": "\(error)",
])
}
}
}
Expand Down
Loading