Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
# Change Log

## [3.1.0] - 2026-06-04
## [4.0.0] - 2026-06-26

### Added

- Coded Departure Routes are now parsed from the TXT distribution (`CDR.txt`) in addition to CSV; the six fields present only in the CSV file are `nil` when parsed from TXT
- The CSV `TerminalCommFacility` now carries radar, military-operations, and class-airspace data, folded in from the `RDR`, `MIL_OPS`, and `CLS_ARSP` files that the FAA split out of the legacy `TWR` subscriber file — matching the TXT representation

### Changed

- **BREAKING:** `JSONZipEncoder` and `JSONZipDecoder` are now `Sendable` value types (`struct`) instead of `JSONEncoder`/`JSONDecoder` subclasses, eliminating their unsound `@unchecked Sendable` conformances. Set formatting via `JSONZipEncoder(outputFormatting:)` (the `outputFormatting` property remains available); both types can now be shared safely across concurrency domains
- Progress reporting is now updated synchronously and in order. The downloader's GCD `DispatchQueue.main.async` hop and the per-chunk `Task { @MainActor in … }` hops in the archive and directory distributions were replaced with direct, thread-safe `Progress` mutation, removing a hazard where progress could be reported out of order or after a stream finished
- Adopted the Approachable Concurrency upcoming-feature flags (`NonisolatedNonsendingByDefault`, `InferIsolatedConformances`), which changes the execution semantics of `nonisolated` async work
- **BREAKING:** Raised the minimum deployment targets to macOS 15, iOS 18, tvOS 18, watchOS 11, and visionOS 2 to adopt the standard-library `Synchronization` module (`Mutex`/`Atomic`)

### Internal

- Replaced `nonisolated(unsafe)` statics in the test URL-protocol mock with a `Synchronization.Mutex`, and modernized the manual smoke-test harness to top-level `await`

## [3.0.0] - 2026-06-03

### Breaking Changes
Expand Down
10 changes: 9 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

import PackageDescription

let approachableConcurrency: [SwiftSetting] = [
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]

let package = Package(
name: "SwiftNASR",
defaultLocalization: "en",
platforms: [.macOS(.v13), .iOS(.v16), .tvOS(.v16), .watchOS(.v9), .visionOS(.v1)],
platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18), .watchOS(.v11), .visionOS(.v2)],

products: [
.library(
Expand All @@ -27,6 +32,7 @@ let package = Package(
name: "SwiftNASR",
dependencies: ["ZIPFoundation", "StreamingCSV"],
resources: [.process("Resources")],
swiftSettings: approachableConcurrency,
linkerSettings: [.linkedLibrary("swift_Concurrency")]
),
.testTarget(
Expand All @@ -36,6 +42,7 @@ let package = Package(
.copy("Resources/MockDistribution"),
.copy("Resources/FailingMockDistribution")
],
swiftSettings: approachableConcurrency,
linkerSettings: [.linkedLibrary("swift_Concurrency")]
),
.executableTarget(
Expand All @@ -45,6 +52,7 @@ let package = Package(
.product(name: "ArgumentParser", package: "swift-argument-parser")
],
path: "Tests/SwiftNASR_E2E",
swiftSettings: approachableConcurrency,
linkerSettings: [.linkedLibrary("swift_Concurrency")]
)
],
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public final class ArchiveDataDistribution: Distribution {

_ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: false, progress: nil) { data in
buffer.append(data)
Task { @MainActor in progress.completedUnitCount += Int64(data.count) }
progress.completedUnitCount += Int64(data.count)
// Handle both \r\n and \n line endings
while true {
let crlfRange = buffer.range(of: crlfDelimiter)
Expand Down Expand Up @@ -133,7 +133,7 @@ public final class ArchiveDataDistribution: Distribution {

_ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: false, progress: nil) {
data in
Task { @MainActor in progress.completedUnitCount += Int64(data.count) }
progress.completedUnitCount += Int64(data.count)
// Force a copy to avoid ZIPFoundation buffer reuse issues
continuation.yield(Data(data))
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public final class ArchiveFileDistribution: Distribution {
totalBytesProcessed += UInt64(data.count)
buffer.append(data)

Task { @MainActor in progress.completedUnitCount += Int64(data.count) }
progress.completedUnitCount += Int64(data.count)

// Process lines from buffer - handle both \r\n and \n line endings
while true {
Expand Down Expand Up @@ -154,7 +154,7 @@ public final class ArchiveFileDistribution: Distribution {

_ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: true, progress: nil) {
data in
Task { @MainActor in progress.completedUnitCount += Int64(data.count) }
progress.completedUnitCount += Int64(data.count)
// Force a copy to avoid ZIPFoundation buffer reuse issues
continuation.yield(Data(data))
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/SwiftNASR/Distribution/DirectoryDistribution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,15 @@ public final class DirectoryDistribution: Distribution {
if subdata.last == carriageReturn {
subdata.removeLast()
}
Task { @MainActor in progress.completedUnitCount += Int64(byteCount) }
progress.completedUnitCount += Int64(byteCount)

eachLine(subdata)
lines += 1

buffer.removeSubrange(subrange)
} else {
let data = handle.readData(ofLength: chunkSize)
Task { @MainActor in progress.completedUnitCount += Int64(data.count) }
progress.completedUnitCount += Int64(data.count)
guard !data.isEmpty else {
if !buffer.isEmpty {
// Strip trailing \r for Windows-style line endings
Expand Down Expand Up @@ -145,7 +145,7 @@ public final class DirectoryDistribution: Distribution {
while true {
let data = handle.readData(ofLength: chunkSize)
guard !data.isEmpty else { break }
Task { @MainActor in progress.completedUnitCount += Int64(data.count) }
progress.completedUnitCount += Int64(data.count)
continuation.yield(data)
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftNASR/Documentation.docc/Getting Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ print(sanCarlos.runways[0].length)

To avoid parsing a large dataset each time your application loads, I recommend
encoding the ``NASRData`` object. Choose the encoder you wish to use; for
example, `JSONEncoder` uses a straightforward and portable format. This class
example, `JSONEncoder` uses a straightforward and portable format. SwiftNASR
also provides ``JSONZipEncoder`` to cut down on space when needed.

You can encode the whole object, containing all the data you've loaded:
Expand Down
6 changes: 2 additions & 4 deletions Sources/SwiftNASR/Downloaders/Downloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@ final class DownloadDelegate: NSObject, URLSessionDownloadDelegate, Sendable {
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
DispatchQueue.main.async { [weak self] in
self?.progress.completedUnitCount = totalBytesWritten
self?.progress.totalUnitCount = totalBytesExpectedToWrite
}
progress.completedUnitCount = totalBytesWritten
progress.totalUnitCount = totalBytesExpectedToWrite
}
}

Expand Down
70 changes: 62 additions & 8 deletions Sources/SwiftNASR/Support/JSONZipCoder.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
import Foundation
import ZIPFoundation

public class JSONZipEncoder: JSONEncoder, @unchecked Sendable {
override public func encode<T>(_ value: T) throws -> Data where T: Encodable {
/**
A JSON encoder that compresses its output into a ZIP archive.

``JSONZipEncoder`` behaves like `JSONEncoder`, but writes the encoded JSON into
a ZIP archive (as a single `distribution.json` entry) to reduce the size of
serialized ``NASRData``. Decode the result with ``JSONZipDecoder``.
*/
public struct JSONZipEncoder: Sendable {

/// The output formatting applied to the encoded JSON, mirroring
/// `JSONEncoder/outputFormatting`.
public var outputFormatting: JSONEncoder.OutputFormatting

/**
Creates a new encoder.

- Parameter outputFormatting: The output formatting applied to the encoded
JSON.
*/

public init(outputFormatting: JSONEncoder.OutputFormatting = []) {
self.outputFormatting = outputFormatting
}

/**
Encodes a value as ZIP-compressed JSON.

- Parameter value: The value to encode.
- Returns: The compressed archive data.
- Throws: ``JSONZipError`` if the archive could not be created, or an encoding
error from the underlying `JSONEncoder`.
*/

public func encode(_ value: some Encodable) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = outputFormatting
do {
let data = try super.encode(value)
let data = try encoder.encode(value)
let archive = try Archive(accessMode: .create)
_ = try archive.addEntry(
with: "distribution.json",
with: distributionEntryName,
type: .file,
uncompressedSize: Int64(data.count)
) { (position: Int64, size: Int) -> Data in
Expand All @@ -23,24 +57,44 @@ public class JSONZipEncoder: JSONEncoder, @unchecked Sendable {
}
}

public class JSONZipDecoder: JSONDecoder, @unchecked Sendable {
override public func decode<T>(_ type: T.Type, from data: Data) throws -> T where T: Decodable {
/**
A JSON decoder that reads ZIP-compressed JSON produced by ``JSONZipEncoder``.
*/
public struct JSONZipDecoder: Sendable {

/// Creates a new decoder.
public init() {}

/**
Decodes a value from ZIP-compressed JSON.

- Parameter type: The type to decode.
- Parameter data: The compressed archive data.
- Returns: The decoded value.
- Throws: ``JSONZipError`` if the archive could not be read, or a decoding
error from the underlying `JSONDecoder`.
*/

public func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
do {
let archive = try Archive(data: data, accessMode: .read, pathEncoding: .ascii)
guard let entry = archive["distribution.json"] else {
guard let entry = archive[distributionEntryName] else {
throw JSONZipError.noDistributionFile
}

var json = Data(capacity: Int(entry.uncompressedSize))
_ = try archive.extract(entry) { json.append($0) }

return try super.decode(type, from: json)
return try JSONDecoder().decode(type, from: json)
} catch _ as Archive.ArchiveError {
throw JSONZipError.couldntReadArchive
}
}
}

// The name of the single archive entry that holds the encoded JSON.
private let distributionEntryName = "distribution.json"

enum JSONZipError: Swift.Error {
case couldntReadArchive
case emptyArchive
Expand Down
6 changes: 1 addition & 5 deletions Tests/SwiftNASRTests/Support/JSONZipCoderSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import ZIPFoundation
final class JSONZipCoderSpec: QuickSpec {
override static func spec() {
let object = ["foo": 1, "bar": 2]
var encoder: JSONZipEncoder {
let coder = JSONZipEncoder()
coder.outputFormatting = .sortedKeys
return coder
}
let encoder = JSONZipEncoder(outputFormatting: .sortedKeys)
let decoder = JSONZipDecoder()

describe("JSONZipEncoder") {
Expand Down
24 changes: 21 additions & 3 deletions Tests/SwiftNASRTests/Support/Mocks/MockURLProtocol.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Synchronization

struct MockResponse {
var data: Data?
Expand All @@ -7,9 +8,21 @@ struct MockResponse {
}

class MockURLProtocol: URLProtocol {
// Quick tests do not run in parallel so access should always be synchronous
nonisolated(unsafe) static var nextResponse: MockResponse?
nonisolated(unsafe) static var lastURL: URL?
// `URLProtocol`'s `init`/`startLoading` are synchronous callbacks driven by
// `URLSession`, so an actor doesn't fit; a Mutex provides real mutual exclusion
// for this shared test fixture without requiring the protected state to be
// statically `Sendable`.
private static let state = Mutex(State())

static var nextResponse: MockResponse? {
get { state.withLock { $0.nextResponse } }
set { state.withLock { $0.nextResponse = newValue } }
}

static var lastURL: URL? {
get { state.withLock { $0.lastURL } }
set { state.withLock { $0.lastURL = newValue } }
}

override init(
request: URLRequest,
Expand Down Expand Up @@ -54,4 +67,9 @@ class MockURLProtocol: URLProtocol {
override func stopLoading() {
// Required, but not used in this mock
}

private struct State {
var nextResponse: MockResponse?
var lastURL: URL?
}
}
17 changes: 6 additions & 11 deletions Tests/SwiftNASR_Simple/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,14 @@ if FileManager.default.fileExists(atPath: txtPath.path) {
print("NASR created!")

print("About to call nasr.load()...")
Task {
do {
try await nasr.load { progress in
print("Load progress: \(progress.fractionCompleted)")
}
print("Load completed successfully!")
} catch {
print("Load failed: \(error)")
do {
try await nasr.load { progress in
print("Load progress: \(progress.fractionCompleted)")
}
exit(0)
print("Load completed successfully!")
} catch {
print("Load failed: \(error)")
}

RunLoop.main.run()
} else {
print("TXT archive not found")
}
Loading