From 305bead96cad3a8440bf3a093528daca0acc1423 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 6 Jul 2026 19:55:45 -0700 Subject: [PATCH 1/2] Add Linux support Guard String(localized:) behind a Linux shim that resolves keys to themselves (the API doesn't exist on swift-corelibs-foundation), and import FoundationNetworking where URLSession/HTTPURLResponse are used. URLSession.bytes(from:) has no Linux equivalent, so URLStreamLoader falls back to buffering the whole response with data(from:) there (FAA DOF files are modest-sized). Port the E2E tool's progress bar off NSKeyValueObservation (Darwin only) to polling. Build the E2E executable target on all platforms instead of macOS only, and add Ubuntu to the CI matrix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 15 +-- Package.swift | 24 ++-- Sources/SwiftDOF/LinuxLocalization.swift | 20 +++ Sources/SwiftDOF/Parser/DOFError.swift | 162 ++++++----------------- Sources/SwiftDOF_E2E/DOFDataLoader.swift | 56 +++++--- Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift | 18 ++- 6 files changed, 127 insertions(+), 168 deletions(-) create mode 100644 Sources/SwiftDOF/LinuxLocalization.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c28924..32a8a11 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,17 +41,6 @@ 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 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) } From 4bb668befa0008620252cbb9882a73743774d8f1 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 6 Jul 2026 20:40:59 -0700 Subject: [PATCH 2/2] ci: move SwiftLint and docs build to Ubuntu runners Keep the Build-and-Test matrix on both macOS and Ubuntu. Move the SwiftLint job to ubuntu-latest via the ghcr.io/realm/swiftlint container (SwiftLint runs cleanly on Linux), and move the documentation build to ubuntu-latest. Periphery stays on macOS (needs an index-store build with no clean Linux install path). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 ++--- .github/workflows/doc.yml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32a8a11..cc2fac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,11 +43,10 @@ jobs: 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