diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c28924..804ff2d 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 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 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 } }