diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c28924..cc2fac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ # Category A (tools-version 6.2 + macOS 26 min): macos-26 + Swift 6.2 # Category B (tools-version 6.2 + older macOS): macos-15 + 6.2, macos-26 + 6.2 # Category C (tools-version 6.0): macos-15 + 6.0, macos-15 + 6.2, macos-26 + 6.2 -# Linux (if viable): ubuntu + Swift 6.0, ubuntu + Swift 6.2 +# Linux: ubuntu + Swift 6.3 # # When Swift 6.3 ships: bump 6.0→6.1 and 6.2→6.3 in Category C # When bumping tools-version to 6.2: drop 6.0/6.1, move to Category A or B @@ -29,6 +29,8 @@ jobs: include: - os: macos-26 swift: "6.2" + - os: ubuntu-latest + swift: "6.3" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 @@ -39,24 +41,12 @@ jobs: run: swift build -v - name: Test run: swift test -v - test-linux: - name: Test on Linux - runs-on: ubuntu-latest - container: - image: swift:6.2 - steps: - - uses: actions/checkout@v6 - - name: Build - run: swift build -v - - name: Test - run: swift test -v lint: name: Run SwiftLint - runs-on: macos-latest + runs-on: ubuntu-latest + container: ghcr.io/realm/swiftlint:latest steps: - uses: actions/checkout@v6 - - name: Install SwiftLint - run: brew install swiftlint - name: Run SwiftLint run: swiftlint --strict swift-format: diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 720570e..c2b6730 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -17,7 +17,7 @@ concurrency: jobs: build: name: Generate Documentation - runs-on: macos-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: SwiftyLab/setup-swift@latest diff --git a/Package.swift b/Package.swift index f5c825b..2847707 100644 --- a/Package.swift +++ b/Package.swift @@ -42,17 +42,15 @@ let package = Package( swiftLanguageModes: [.v5, .v6] ) -#if os(macOS) - package.targets.append( - .executableTarget( - name: "SwiftDOF_E2E", - dependencies: [ - "SwiftDOF", - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "ZIPFoundation", package: "ZIPFoundation"), - .product(name: "Progress", package: "Progress.swift") - ], - swiftSettings: approachableConcurrency - ) +package.targets.append( + .executableTarget( + name: "SwiftDOF_E2E", + dependencies: [ + "SwiftDOF", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "ZIPFoundation", package: "ZIPFoundation"), + .product(name: "Progress", package: "Progress.swift") + ], + swiftSettings: approachableConcurrency ) -#endif +) diff --git a/Sources/SwiftDOF/LinuxLocalization.swift b/Sources/SwiftDOF/LinuxLocalization.swift new file mode 100644 index 0000000..dfcd3bd --- /dev/null +++ b/Sources/SwiftDOF/LinuxLocalization.swift @@ -0,0 +1,20 @@ +// `String(localized:)` comes from FoundationInternationalization on Apple platforms but +// is unavailable on Linux. This package uses its default ("en") localization text as the +// lookup key, so on Linux we resolve each key to itself. Excluded on Apple, where the +// real Foundation API is used. + +#if !canImport(Darwin) + import Foundation + + extension String { + init( + localized key: String, + table _: String? = nil, + bundle _: Bundle? = nil, + locale _: Locale? = nil, + comment _: StaticString? = nil + ) { + self = key + } + } +#endif diff --git a/Sources/SwiftDOF/Parser/DOFError.swift b/Sources/SwiftDOF/Parser/DOFError.swift index 7b6766a..43ad06e 100644 --- a/Sources/SwiftDOF/Parser/DOFError.swift +++ b/Sources/SwiftDOF/Parser/DOFError.swift @@ -44,156 +44,80 @@ public enum DOFError: Error, LocalizedError, Sendable { public var errorDescription: String? { switch self { case .invalidEncoding: - #if canImport(Darwin) - String(localized: "File encoding was invalid.", bundle: .module) - #else - "File encoding was invalid." - #endif + String(localized: "File encoding was invalid.", bundle: .module) case .invalidFormat: - #if canImport(Darwin) - String(localized: "DOF data format was invalid.", bundle: .module) - #else - "DOF data format was invalid." - #endif + String(localized: "DOF data format was invalid.", bundle: .module) case .parseError: - #if canImport(Darwin) - String(localized: "DOF data could not be parsed.", bundle: .module) - #else - "DOF data could not be parsed." - #endif + String(localized: "DOF data could not be parsed.", bundle: .module) case .fileNotFound: - #if canImport(Darwin) - String(localized: "DOF file was not found.", bundle: .module) - #else - "DOF file was not found." - #endif + String(localized: "DOF file was not found.", bundle: .module) case .streamError: - #if canImport(Darwin) - String(localized: "A stream error occurred.", bundle: .module) - #else - "A stream error occurred." - #endif + String(localized: "A stream error occurred.", bundle: .module) case .lineTooShort: - #if canImport(Darwin) - String(localized: "Line was too short.", bundle: .module) - #else - "Line was too short." - #endif + String(localized: "Line was too short.", bundle: .module) } } public var failureReason: String? { switch self { case .invalidEncoding: - #if canImport(Darwin) - String(localized: "The file is not valid Latin-1 encoding.", bundle: .module) - #else - "The file is not valid Latin-1 encoding." - #endif + String(localized: "The file is not valid Latin-1 encoding.", bundle: .module) case .invalidFormat(let error): switch error { case .missingCurrencyDate: - #if canImport(Darwin) - String(localized: "The DOF header does not contain a currency date.", bundle: .module) - #else - "The DOF header does not contain a currency date." - #endif + String(localized: "The DOF header does not contain a currency date.", bundle: .module) case .currencyDateHeaderNotFound: - #if canImport(Darwin) - String( - localized: "The “CURRENCY DATE = ” pattern was not found in the header.", - bundle: .module - ) - #else - "The “CURRENCY DATE = ” pattern was not found in the header." - #endif + String( + localized: "The “CURRENCY DATE = ” pattern was not found in the header.", + bundle: .module + ) case .invalidCurrencyDateFormat: - #if canImport(Darwin) - String(localized: "The currency date is not in MM/DD/YY format.", bundle: .module) - #else - "The currency date is not in MM/DD/YY format." - #endif + String(localized: "The currency date is not in MM/DD/YY format.", bundle: .module) case .invalidCurrencyDateComponents: - #if canImport(Darwin) - String( - localized: "The currency date contains non-numeric components.", - bundle: .module - ) - #else - "The currency date contains non-numeric components." - #endif + String( + localized: "The currency date contains non-numeric components.", + bundle: .module + ) case .invalidLatitudeDirection(let char): - #if canImport(Darwin) - String( - localized: "Latitude direction “\(String(char))” is invalid. Expected “N” or “S”.", - bundle: .module - ) - #else - "Latitude direction “\(String(char))” is invalid. Expected “N” or “S”." - #endif + String( + localized: "Latitude direction “\(String(char))” is invalid. Expected “N” or “S”.", + bundle: .module + ) case .invalidLongitudeDirection(let char): - #if canImport(Darwin) - String( - localized: "Longitude direction “\(String(char))” is invalid. Expected “E” or “W”.", - bundle: .module - ) - #else - "Longitude direction “\(String(char))” is invalid. Expected “E” or “W”." - #endif + String( + localized: "Longitude direction “\(String(char))” is invalid. Expected “E” or “W”.", + bundle: .module + ) } case let .parseError(field, value, line): - #if canImport(Darwin) - String(localized: "Failed to parse \(field) “\(value)” at line \(line).", bundle: .module) - #else - "Failed to parse \(field) “\(value)” at line \(line)." - #endif + String(localized: "Failed to parse \(field) “\(value)” at line \(line).", bundle: .module) case .fileNotFound(let url): - #if canImport(Darwin) - String(localized: "The file at “\(url.path)” could not be found.", bundle: .module) - #else - "The file at “\(url.path)” could not be found." - #endif + String(localized: "The file at “\(url.path)” could not be found.", bundle: .module) case .streamError(let error): - #if canImport(Darwin) - String( - localized: "An error occurred while reading: \(error.localizedDescription)", - bundle: .module - ) - #else - "An error occurred while reading: \(error.localizedDescription)" - #endif + String( + localized: "An error occurred while reading: \(error.localizedDescription)", + bundle: .module + ) case let .lineTooShort(expected, actual, line): - #if canImport(Darwin) - String( - localized: "Line \(line) has \(actual) characters but \(expected) are required.", - bundle: .module - ) - #else - "Line \(line) has \(actual) characters but \(expected) are required." - #endif + String( + localized: "Line \(line) has \(actual) characters but \(expected) are required.", + bundle: .module + ) } } public var recoverySuggestion: String? { switch self { case .invalidEncoding, .invalidFormat: - #if canImport(Darwin) - String( - localized: "Verify the file is a valid FAA Digital Obstacle File.", - bundle: .module - ) - #else - "Verify the file is a valid FAA Digital Obstacle File." - #endif + String( + localized: "Verify the file is a valid FAA Digital Obstacle File.", + bundle: .module + ) case .fileNotFound: - #if canImport(Darwin) - String( - localized: "Verify that the file exists and has not been moved or deleted.", - bundle: .module - ) - #else - "Verify that the file exists and has not been moved or deleted." - #endif + String( + localized: "Verify that the file exists and has not been moved or deleted.", + bundle: .module + ) case .parseError, .streamError, .lineTooShort: nil } diff --git a/Sources/SwiftDOF_E2E/DOFDataLoader.swift b/Sources/SwiftDOF_E2E/DOFDataLoader.swift index cd2044a..2ddf162 100644 --- a/Sources/SwiftDOF_E2E/DOFDataLoader.swift +++ b/Sources/SwiftDOF_E2E/DOFDataLoader.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import SwiftDOF /// Protocol for loading DOF data from various sources. @@ -63,26 +66,43 @@ struct URLZipLoader: DOFDataLoader { struct URLStreamLoader: DOFDataLoader { let url: URL - func load( - progressHandler: @Sendable (Progress) -> Void, - errorCallback: @escaping (Error, Int) -> Void - ) async throws -> DOF { - let (bytes, response) = try await URLSession.shared.bytes(from: url) + // `URLSession.bytes(from:)` isn’t available in FoundationNetworking, so Linux falls back to + // buffering the whole response and parsing it synchronously instead of streaming. + #if canImport(FoundationNetworking) + func load( + progressHandler: @Sendable (Progress) -> Void, + errorCallback: @escaping (Error, Int) -> Void + ) async throws -> DOF { + let (data, response) = try await URLSession.shared.data(from: url) - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { - throw DOFDataLoaderError.downloadFailed(statusCode: httpResponse.statusCode) - } - - // Get content length for progress tracking - let contentLength = (response as? HTTPURLResponse)?.expectedContentLength ?? -1 + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { + throw DOFDataLoaderError.downloadFailed(statusCode: httpResponse.statusCode) + } - return try await DOF( - bytes: bytes, - totalBytes: contentLength > 0 ? contentLength : nil, - progressHandler: progressHandler, - errorCallback: errorCallback - ) - } + return try DOF(data: data, progressHandler: progressHandler, errorCallback: errorCallback) + } + #else + func load( + progressHandler: @Sendable (Progress) -> Void, + errorCallback: @escaping (Error, Int) -> Void + ) async throws -> DOF { + let (bytes, response) = try await URLSession.shared.bytes(from: url) + + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { + throw DOFDataLoaderError.downloadFailed(statusCode: httpResponse.statusCode) + } + + // Get content length for progress tracking + let contentLength = (response as? HTTPURLResponse)?.expectedContentLength ?? -1 + + return try await DOF( + bytes: bytes, + totalBytes: contentLength > 0 ? contentLength : nil, + progressHandler: progressHandler, + errorCallback: errorCallback + ) + } + #endif } // MARK: - Errors diff --git a/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift b/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift index 13a23e2..614ede8 100644 --- a/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift +++ b/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift @@ -121,9 +121,12 @@ struct SwiftDOF_E2E: AsyncParsableCommand { // MARK: - ProgressTracker /// Actor to safely track progress using Progress.swift library. +/// +/// Polls `fractionCompleted` on a timer rather than observing it via KVO, since +/// `NSKeyValueObservation` requires the Objective-C runtime and isn't available on Linux. private actor ProgressTracker { private var bar: ProgressBar? - private var observation: NSKeyValueObservation? + private var pollTask: Task? private var lastStep = 0 func track(_ progress: Foundation.Progress) { @@ -136,9 +139,12 @@ private actor ProgressTracker { ] ) - observation = progress.observe(\.fractionCompleted, options: [.new]) { [self] progress, _ in - let currentStep = Int(progress.fractionCompleted * 100) - Task { await self.advanceTo(currentStep) } + pollTask = Task { + while !Task.isCancelled, !progress.isFinished { + advanceTo(Int(progress.fractionCompleted * 100)) + try? await Task.sleep(for: .milliseconds(50)) + } + advanceTo(Int(progress.fractionCompleted * 100)) } } @@ -150,8 +156,8 @@ private actor ProgressTracker { } func stop() { - observation?.invalidate() - observation = nil + pollTask?.cancel() + pollTask = nil // Ensure bar reaches 100% advanceTo(100) }