From 526ca2d94a4405673fcf08253c014e43db4fb04e Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 6 Jul 2026 19:50:05 -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), replace the `\(x, format: .number)` interpolation sugar with `.formatted(.number)` in CIFPError.swift, and import FoundationNetworking where URLSession is used. Port the E2E tool's progress bar off NSKeyValueObservation (Darwin only) to polling, which lets it build and run on Linux too. Add Ubuntu to the CI matrix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 15 +- Package.swift | 24 +- Sources/SwiftCIFP/LinuxLocalization.swift | 20 ++ Sources/SwiftCIFP/Parser/CIFPError.swift | 319 ++++++--------------- Sources/SwiftCIFP_E2E/CIFPDataLoader.swift | 3 + Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift | 18 +- 6 files changed, 132 insertions(+), 267 deletions(-) create mode 100644 Sources/SwiftCIFP/LinuxLocalization.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c28924..36a652c 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 e4cee33..ae2fe8f 100644 --- a/Package.swift +++ b/Package.swift @@ -39,17 +39,15 @@ let package = Package( swiftLanguageModes: [.v5, .v6] ) -#if os(macOS) - package.targets.append( - .executableTarget( - name: "SwiftCIFP_E2E", - dependencies: [ - "SwiftCIFP", - .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: "SwiftCIFP_E2E", + dependencies: [ + "SwiftCIFP", + .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/SwiftCIFP/LinuxLocalization.swift b/Sources/SwiftCIFP/LinuxLocalization.swift new file mode 100644 index 0000000..dfcd3bd --- /dev/null +++ b/Sources/SwiftCIFP/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/SwiftCIFP/Parser/CIFPError.swift b/Sources/SwiftCIFP/Parser/CIFPError.swift index d572369..7cfadc6 100644 --- a/Sources/SwiftCIFP/Parser/CIFPError.swift +++ b/Sources/SwiftCIFP/Parser/CIFPError.swift @@ -89,295 +89,142 @@ public enum CIFPError: 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: "CIFP data format was invalid.", bundle: .module) - #else - "CIFP data format was invalid." - #endif + String(localized: "CIFP data format was invalid.", bundle: .module) case .parseError: - #if canImport(Darwin) - String(localized: "CIFP data could not be parsed.", bundle: .module) - #else - "CIFP data could not be parsed." - #endif + String(localized: "CIFP data could not be parsed.", bundle: .module) case .fileNotFound: - #if canImport(Darwin) - String(localized: "CIFP file was not found.", bundle: .module) - #else - "CIFP file was not found." - #endif + String(localized: "CIFP 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) case .unknownSectionCode: - #if canImport(Darwin) - String(localized: "Unknown section code.", bundle: .module) - #else - "Unknown section code." - #endif + String(localized: "Unknown section code.", bundle: .module) case .unknownRecordType: - #if canImport(Darwin) - String(localized: "Unknown record type.", bundle: .module) - #else - "Unknown record type." - #endif + String(localized: "Unknown record type.", bundle: .module) case .missingRequiredField: - #if canImport(Darwin) - String(localized: "Required field was missing.", bundle: .module) - #else - "Required field was missing." - #endif + String(localized: "Required field was missing.", bundle: .module) case .unknownSubsectionCode: - #if canImport(Darwin) - String(localized: "Unknown subsection code.", bundle: .module) - #else - "Unknown subsection code." - #endif + String(localized: "Unknown subsection code.", bundle: .module) case .aggregationError: - #if canImport(Darwin) - String(localized: "Record aggregation failed.", bundle: .module) - #else - "Record aggregation failed." - #endif + String(localized: "Record aggregation failed.", bundle: .module) } } public var failureReason: String? { switch self { case .invalidEncoding: - #if canImport(Darwin) - return String(localized: "The file is not valid ASCII encoding.", bundle: .module) - #else - return "The file is not valid ASCII encoding." - #endif + return String(localized: "The file is not valid ASCII encoding.", bundle: .module) case .invalidFormat(let error): return switch error { case .missingHeader: - #if canImport(Darwin) - String( - localized: "The CIFP file does not contain valid header records.", - bundle: .module - ) - #else - "The CIFP file does not contain valid header records." - #endif + String( + localized: "The CIFP file does not contain valid header records.", + bundle: .module + ) case .invalidCycleDate: - #if canImport(Darwin) - String( - localized: "The cycle date in the header could not be parsed.", - bundle: .module - ) - #else - "The cycle date in the header could not be parsed." - #endif + String( + localized: "The cycle date in the header could not be parsed.", + bundle: .module + ) case .invalidCoordinate(let value): - #if canImport(Darwin) - String( - localized: "Coordinate value “\(value)” is not in valid HDDDMMSSSS format.", - bundle: .module - ) - #else - "Coordinate value “\(value)” is not in valid HDDDMMSSSS format." - #endif + String( + localized: "Coordinate value “\(value)” is not in valid HDDDMMSSSS format.", + bundle: .module + ) case .invalidAltitude(let value): - #if canImport(Darwin) - String(localized: "Altitude value “\(value)” could not be parsed.", bundle: .module) - #else - "Altitude value “\(value)” could not be parsed." - #endif + String(localized: "Altitude value “\(value)” could not be parsed.", 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) - return String( - localized: "Failed to parse \(field) “\(value)” at line \(line, format: .number).", - bundle: .module - ) - #else - return "Failed to parse \(field) “\(value)” at line \(line)." - #endif + return String( + localized: "Failed to parse \(field) “\(value)” at line \(line.formatted(.number)).", + bundle: .module + ) case .fileNotFound(let url): - #if canImport(Darwin) - return String(localized: "The file at “\(url.path)” could not be found.", bundle: .module) - #else - return "The file at “\(url.path)” could not be found." - #endif + return String(localized: "The file at “\(url.path)” could not be found.", bundle: .module) case .streamError(let error): - #if canImport(Darwin) - return String( - localized: "An error occurred while reading: \(error.localizedDescription)", - bundle: .module - ) - #else - return "An error occurred while reading: \(error.localizedDescription)" - #endif + return String( + localized: "An error occurred while reading: \(error.localizedDescription)", + bundle: .module + ) case let .lineTooShort(expected, actual, line): - #if canImport(Darwin) - return String( - localized: - "Line \(line, format: .number) has \(actual, format: .number) characters but \(expected, format: .number) are required.", - bundle: .module - ) - #else - return "Line \(line) has \(actual) characters but \(expected) are required." - #endif + return String( + localized: + "Line \(line.formatted(.number)) has \(actual.formatted(.number)) characters but \(expected.formatted(.number)) are required.", + bundle: .module + ) case let .unknownSectionCode(code, line): - #if canImport(Darwin) - return String( - localized: "Unknown section code “\(code)” at line \(line, format: .number).", - bundle: .module - ) - #else - return "Unknown section code “\(code)” at line \(line)." - #endif + return String( + localized: "Unknown section code “\(code)” at line \(line.formatted(.number)).", + bundle: .module + ) case let .unknownRecordType(type, line): - #if canImport(Darwin) - return String( - localized: "Unknown record type “\(String(type))” at line \(line, format: .number).", - bundle: .module - ) - #else - return "Unknown record type “\(String(type))” at line \(line)." - #endif + return String( + localized: "Unknown record type “\(String(type))” at line \(line.formatted(.number)).", + bundle: .module + ) case let .missingRequiredField(field, recordType, line): - #if canImport(Darwin) - return String( - localized: - "Field “\(field)” is required for \(recordType) at line \(line, format: .number).", - bundle: .module - ) - #else - return "Field “\(field)” is required for \(recordType) at line \(line)." - #endif + return String( + localized: + "Field “\(field)” is required for \(recordType) at line \(line.formatted(.number)).", + bundle: .module + ) case let .unknownSubsectionCode(section, subsection, line): - #if canImport(Darwin) - return String( - localized: - "Unknown subsection “\(String(subsection))” in section “\(section)” at line \(line, format: .number).", - bundle: .module - ) - #else - return - "Unknown subsection “\(String(subsection))” in section “\(section)” at line \(line)." - #endif + return String( + localized: + "Unknown subsection “\(String(subsection))” in section “\(section)” at line \(line.formatted(.number)).", + bundle: .module + ) case let .aggregationError(recordType, identifier, reason): let reasonString: String = switch reason { case .missingAirwayMetadata: - #if canImport(Darwin) - String(localized: "missing route type or level", bundle: .module) - #else - "missing route type or level" - #endif + String(localized: "missing route type or level", bundle: .module) case .missingMSARadius: - #if canImport(Darwin) - String(localized: "missing radius", bundle: .module) - #else - "missing radius" - #endif + String(localized: "missing radius", bundle: .module) case .invalidRouteType(let char): - #if canImport(Darwin) - String(localized: "invalid route type “\(String(char))”", bundle: .module) - #else - "invalid route type “\(String(char))”" - #endif + String(localized: "invalid route type “\(String(char))”", bundle: .module) case .missingRouteType: - #if canImport(Darwin) - String(localized: "missing route type", bundle: .module) - #else - "missing route type" - #endif + String(localized: "missing route type", bundle: .module) case .invalidApproachType(let char): - #if canImport(Darwin) - String(localized: "invalid approach type “\(String(char))”", bundle: .module) - #else - "invalid approach type “\(String(char))”" - #endif + String(localized: "invalid approach type “\(String(char))”", bundle: .module) case .missingApproachType: - #if canImport(Darwin) - String(localized: "missing approach type", bundle: .module) - #else - "missing approach type" - #endif + String(localized: "missing approach type", bundle: .module) case .noBoundaryRecords: - #if canImport(Darwin) - String(localized: "no boundary records", bundle: .module) - #else - "no boundary records" - #endif + String(localized: "no boundary records", bundle: .module) case .missingRestrictiveType: - #if canImport(Darwin) - String(localized: "missing restrictive type", bundle: .module) - #else - "missing restrictive type" - #endif + String(localized: "missing restrictive type", bundle: .module) case .noLegRecords: - #if canImport(Darwin) - String(localized: "no leg records", bundle: .module) - #else - "no leg records" - #endif + String(localized: "no leg records", bundle: .module) } - #if canImport(Darwin) - return String( - localized: "\(recordType) “\(identifier)” could not be built: \(reasonString).", - bundle: .module - ) - #else - return "\(recordType) “\(identifier)” could not be built: \(reasonString)." - #endif + return String( + localized: "\(recordType) “\(identifier)” could not be built: \(reasonString).", + bundle: .module + ) } } public var recoverySuggestion: String? { switch self { case .invalidEncoding, .invalidFormat: - #if canImport(Darwin) - String(localized: "Verify the file is a valid FAA CIFP file.", bundle: .module) - #else - "Verify the file is a valid FAA CIFP file." - #endif + String(localized: "Verify the file is a valid FAA CIFP 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, .unknownSectionCode, .unknownRecordType, .missingRequiredField, .unknownSubsectionCode, .aggregationError: nil diff --git a/Sources/SwiftCIFP_E2E/CIFPDataLoader.swift b/Sources/SwiftCIFP_E2E/CIFPDataLoader.swift index c5130e4..0fe2bc5 100644 --- a/Sources/SwiftCIFP_E2E/CIFPDataLoader.swift +++ b/Sources/SwiftCIFP_E2E/CIFPDataLoader.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import SwiftCIFP /// Protocol for loading CIFP data from various sources. diff --git a/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift b/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift index c6a1628..97ed624 100644 --- a/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift +++ b/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift @@ -143,10 +143,13 @@ struct SwiftCIFP_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 lastPercent = 0 - private var observation: NSKeyValueObservation? + private var pollTask: Task? init() { bar = ProgressBar( @@ -160,9 +163,12 @@ private actor ProgressTracker { } func track(_ progress: Foundation.Progress) { - observation = progress.observe(\.fractionCompleted, options: [.new]) { prog, _ in - let newPercent = Int(prog.fractionCompleted * 100) - Task { await self.update(to: newPercent) } + pollTask = Task { + while !Task.isCancelled, !progress.isFinished { + update(to: Int(progress.fractionCompleted * 100)) + try? await Task.sleep(for: .milliseconds(50)) + } + update(to: Int(progress.fractionCompleted * 100)) } } @@ -174,7 +180,7 @@ private actor ProgressTracker { } func stop() { - observation?.invalidate() - observation = nil + pollTask?.cancel() + pollTask = nil } } From 9c198d838d03493a49b468ffb14ede47760810d1 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 6 Jul 2026 20:40:57 -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 36a652c..804ff2d 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 705b635..83229d1 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -17,7 +17,7 @@ concurrency: jobs: build: name: Build Documentation - runs-on: macos-26 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: SwiftyLab/setup-swift@latest