Skip to content

Commit 5ad7866

Browse files
committed
Add app metadata to end of main execs for apps to use at runtime
The format I've settled on should be both forwards compatible and easy to make backwards compatible extensions to. For now it's just some magic bytes and a sized JSON string containing appIdentifier and appVersion fields. The goal is for metadata parsers to always be able to at least partially parse metadata produced by a newer Swift Bundler version than they were designed for (the forwards compatibility I mentioned earlier).
1 parent c2fd6a9 commit 5ad7866

File tree

5 files changed

+123
-2
lines changed

5 files changed

+123
-2
lines changed

Diff for: Sources/schema-gen/SchemaGenerator.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import SwiftParser
33
import SwiftSyntax
44

5-
extension FileHandle: TextOutputStream {
5+
extension FileHandle: Swift.TextOutputStream {
66
public func write(_ string: String) {
77
let data = Data(string.utf8)
88
self.write(data)

Diff for: Sources/swift-bundler/Bundler/MetadataInserter.swift

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Foundation
2+
3+
/// Inserts metadata into executable files.
4+
///
5+
/// Swift Bundler inserts metadata at the end of your main executable file
6+
/// after compilation. The format is intended to be simple and portable to
7+
/// ensure that even if someone isn't using the Swift Bundler runtime they
8+
/// can easily parse the metadata at runtime. If the metadata format ever
9+
/// gets extended, it will be extended in such a way that current metadata
10+
/// remains valid, and future metadata is backwards compatible.
11+
enum MetadataInserter {
12+
/// If an executable ends with this string, it probably contains Swift
13+
/// Bundler metadata.
14+
static let magicBytes: [UInt8] = Array("SBUNMETA".utf8)
15+
16+
/// Metadata appended to the end of executable files built with Swift
17+
/// Bundler.
18+
struct Metadata: Codable {
19+
/// The app's identifier.
20+
var appIdentifier: String
21+
/// The app's version.
22+
var appVersion: String
23+
}
24+
25+
/// Generates an app's metadata from its configuration.
26+
static func metadata(for configuration: AppConfiguration) -> Metadata {
27+
Metadata(
28+
appIdentifier: configuration.identifier,
29+
appVersion: configuration.version
30+
)
31+
}
32+
33+
/// Inserts metadata at the end of the given executable file.
34+
static func insert(
35+
_ metadata: Metadata,
36+
into executableFile: URL
37+
) -> Result<(), MetadataInserterError> {
38+
Data.read(from: executableFile)
39+
.mapError { error in
40+
.failedToReadExecutableFile(executableFile, error)
41+
}
42+
.andThen { data in
43+
JSONEncoder().encode(metadata)
44+
.mapError { error in
45+
.failedToEncodeMetadata(error)
46+
}
47+
.map { encodedMetadata in
48+
var data = data
49+
50+
// A tag representing the type of the next metadata entry. For now
51+
// it's just '0' meaning 'end'. This is purely to allow for the
52+
// format to be extended in the future (e.g. to include resources).
53+
// We could of course just keep adding more entries to the JSON,
54+
// but certain types of data just don't work well with JSON (e.g.
55+
// large amounts of binary data).
56+
//
57+
// For forwards compatibility, do NOT require this value to be one
58+
// you support. Simply stop parsing if you don't understand it.
59+
writeBigEndianUInt64(0, to: &data)
60+
61+
// The default JSON metadata entry. For now this is the only type
62+
// of metadata entry. It would be suffixed with a tag type, however
63+
// this is guaranteed to always be the first entry (for backwards
64+
// compatibility), and I want to bake that into the format.
65+
data.append(contentsOf: encodedMetadata)
66+
// The data's length in bytes.
67+
writeBigEndianUInt64(UInt64(encodedMetadata.count), to: &data)
68+
69+
// Magic bytes so that apps (and external tools) can know if they
70+
// contain any Swift Bundler metadata. Since it's technically
71+
// possible for false positives to occur, apps and tools should
72+
// always fail safely if the metadata is malformed.
73+
data.append(contentsOf: magicBytes)
74+
75+
return data
76+
}
77+
}
78+
.andThen { (modifiedData: Data) in
79+
modifiedData.write(to: executableFile)
80+
.mapError { error in
81+
.failedToWriteExecutableFile(executableFile, error)
82+
}
83+
}
84+
}
85+
86+
/// Writes a single UInt64 value to the end of a data buffer (in big endian
87+
/// order).
88+
private static func writeBigEndianUInt64(_ value: UInt64, to data: inout Data) {
89+
let count = MemoryLayout<UInt64>.size
90+
withUnsafePointer(to: value.bigEndian) { pointer in
91+
pointer.withMemoryRebound(to: UInt8.self, capacity: count) { pointer in
92+
data.append(pointer, count: count)
93+
}
94+
}
95+
}
96+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Foundation
2+
3+
/// An error returned by ``MetadataInserter``.
4+
enum MetadataInserterError: LocalizedError {
5+
case failedToReadExecutableFile(URL, any Error)
6+
case failedToEncodeMetadata(any Error)
7+
case failedToWriteExecutableFile(URL, any Error)
8+
}

Diff for: Sources/swift-bundler/Commands/BundleCommand.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,6 @@ struct BundleCommand: AsyncCommand {
246246
try arguments.productsDirectory
247247
?? SwiftPackageManager.getProductsDirectory(buildContext).unwrap()
248248

249-
// Create bundle job
250249
let bundlerContext = BundlerContext(
251250
appName: appName,
252251
packageName: manifest.displayName,
@@ -282,6 +281,14 @@ struct BundleCommand: AsyncCommand {
282281
try build().unwrap()
283282
}
284283

284+
// TODO: Insert when moving to the bundle directory, perhaps via a method
285+
// on some sort of BuildProducts struct. Otherwise we end up adding
286+
// metadata multiple times if the user uses `--skip-build`, which is
287+
// harmless, but janky.
288+
let executable = productsDirectory.appendingPathComponent("\(appName)")
289+
let metadata = MetadataInserter.metadata(for: appConfiguration)
290+
try MetadataInserter.insert(metadata, into: executable).unwrap()
291+
285292
try Self.removeExistingOutputs(outputDirectory: outputDirectory).unwrap()
286293
return try Self.bundle(
287294
with: arguments.bundler.bundler,

Diff for: Sources/swift-bundler/Extensions/JSONEncoder.swift

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Foundation
2+
3+
extension JSONEncoder {
4+
/// Attempts to encode the given value as JSON, returning a result.
5+
func encode<T: Encodable>(_ value: T) -> Result<Data, any Error> {
6+
Result {
7+
try encode(value)
8+
}
9+
}
10+
}

0 commit comments

Comments
 (0)