diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f42b0fd0..8d3014d19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: branches: [ v11, 'feat/**' ] paths-ignore: - '**.md' + workflow_dispatch: jobs: lint: @@ -131,6 +132,20 @@ jobs: - uses: ./.github/actions/test-swiftpm + test-swiftpm-windows: + name: Windows - Swift 6.3 - SPM + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - name: Install Swift + uses: compnerd/gha-setup-swift@main + with: + swift-version: swift-6.3-release + swift-build: 6.3-RELEASE + - name: Run tests + run: swift test + contract-tests: runs-on: macos-15 diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index ebf09e7b6..7b7ed98e8 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -377,6 +377,12 @@ C443A41023186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; C443A41123186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; C443A41223186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; + BC0000000000000000000101 /* WindowsEnvironmentReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0000000000000000000100 /* WindowsEnvironmentReporter.swift */; }; + BC0000000000000000000102 /* WindowsEnvironmentReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0000000000000000000100 /* WindowsEnvironmentReporter.swift */; }; + BC0000000000000000000103 /* WindowsEnvironmentReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0000000000000000000100 /* WindowsEnvironmentReporter.swift */; }; + BC0000000000000000000104 /* WindowsEnvironmentReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0000000000000000000100 /* WindowsEnvironmentReporter.swift */; }; + BC0000000000000000000201 /* WindowsEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0000000000000000000200 /* WindowsEnvironmentReporterSpec.swift */; }; + BC0000000000000000000302 /* Match.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0000000000000000000301 /* Match.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -582,6 +588,9 @@ C43C37E0236BA050003C1624 /* LDEvaluationDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDEvaluationDetail.swift; sourceTree = ""; }; C443A4092315AA4D00145710 /* NetworkReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkReporter.swift; sourceTree = ""; }; C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionModeChangeObserver.swift; sourceTree = ""; }; + BC0000000000000000000100 /* WindowsEnvironmentReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowsEnvironmentReporter.swift; sourceTree = ""; }; + BC0000000000000000000200 /* WindowsEnvironmentReporterSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowsEnvironmentReporterSpec.swift; sourceTree = ""; }; + BC0000000000000000000301 /* Match.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Match.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -782,6 +791,7 @@ B4265EB024E7390C001CFD2C /* TestUtil.swift */, A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */, A3BA7D032BD2BD620000DB28 /* TestContext.swift */, + BC0000000000000000000300 /* Matcher */, ); name = LaunchDarklyTests; path = LaunchDarkly/LaunchDarklyTests; @@ -949,6 +959,7 @@ isa = PBXGroup; children = ( A35038842F4F96CA0032BA9F /* WatchOSEnvironmentReporterSpec.swift */, + BC0000000000000000000200 /* WindowsEnvironmentReporterSpec.swift */, A3047D622A606B6000F568E0 /* ApplicationInfoEnvironmentReporterSpec.swift */, A3047D5F2A606B6000F568E0 /* EnvironmentReporterChainBaseSpec.swift */, A3047D5E2A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift */, @@ -990,6 +1001,7 @@ A358D6E12A4DE98300270C60 /* MacOSEnvironmentReporter.swift */, A358D6E62A4DE99B00270C60 /* WatchOSEnvironmentReporter.swift */, A358D6EB2A4DE9A600270C60 /* TVOSEnvironmentReporter.swift */, + BC0000000000000000000100 /* WindowsEnvironmentReporter.swift */, A358D6F12A4DEB4C00270C60 /* EnvironmentReporterBuilder.swift */, A358D6F62A4DF1D500270C60 /* SDKEnvironmentReporter.swift */, A35AD45F2A619E45005A8DCB /* SystemCapabilities.swift */, @@ -1016,6 +1028,14 @@ name = Frameworks; sourceTree = ""; }; + BC0000000000000000000300 /* Matcher */ = { + isa = PBXGroup; + children = ( + BC0000000000000000000301 /* Match.swift */, + ); + path = Matcher; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1440,6 +1460,7 @@ A3BA7CEC2BD056920000DB28 /* Hook.swift in Sources */, 832D68A5224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4632A619E45005A8DCB /* SystemCapabilities.swift in Sources */, + BC0000000000000000000101 /* WindowsEnvironmentReporter.swift in Sources */, A358D6FA2A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, 831188432113ADBE00D77CB5 /* LDCommon.swift in Sources */, A358D6D42A4DD48600270C60 /* EnvironmentReporterChainBase.swift in Sources */, @@ -1480,6 +1501,7 @@ 3D24061B2E0D90E000F91253 /* SdkMetadata.swift in Sources */, A310881D2837DC0400184942 /* Kind.swift in Sources */, A35AD4622A619E45005A8DCB /* SystemCapabilities.swift in Sources */, + BC0000000000000000000102 /* WindowsEnvironmentReporter.swift in Sources */, C443A40423145FBE00145710 /* ConnectionInformation.swift in Sources */, 832D68A4224A38FC005F052A /* CacheConverter.swift in Sources */, 831EF34C20655E730001C643 /* FlagChangeObserver.swift in Sources */, @@ -1603,6 +1625,7 @@ A3BA7CE92BD056920000DB28 /* Hook.swift in Sources */, 832D68A2224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4602A619E45005A8DCB /* SystemCapabilities.swift in Sources */, + BC0000000000000000000103 /* WindowsEnvironmentReporter.swift in Sources */, A358D6F72A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, 835E1D401F63450A00184DB4 /* ObjcLDConfig.swift in Sources */, A358D6D12A4DD48600270C60 /* EnvironmentReporterChainBase.swift in Sources */, @@ -1629,11 +1652,13 @@ 83411A5F1FABDA8700E5CF39 /* mocks.generated.swift in Sources */, 83DDBF001FA2589900E428B6 /* FlagStoreSpec.swift in Sources */, A35038852F4F96CA0032BA9F /* WatchOSEnvironmentReporterSpec.swift in Sources */, + BC0000000000000000000201 /* WindowsEnvironmentReporterSpec.swift in Sources */, B4F689142497B2FC004D3CE0 /* DiagnosticEventSpec.swift in Sources */, 83396BC91F7C3711000E256E /* DarklyServiceSpec.swift in Sources */, 3D9A12582A73236800698B8D /* UtilSpec.swift in Sources */, 83EF67931F9945E800403126 /* EventSpec.swift in Sources */, A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */, + BC0000000000000000000302 /* Match.swift in Sources */, 83B6E3F1222EFA3800FF2A6A /* ThreadSpec.swift in Sources */, 831AAE3020A9E75D00B46DBA /* ThrottlerSpec.swift in Sources */, 832D68AC224B3321005F052A /* CacheConverterSpec.swift in Sources */, @@ -1743,6 +1768,7 @@ A3BA7CEA2BD056920000DB28 /* Hook.swift in Sources */, 83D9EC952062DEAB004D7FA6 /* Date.swift in Sources */, A35AD4612A619E45005A8DCB /* SystemCapabilities.swift in Sources */, + BC0000000000000000000104 /* WindowsEnvironmentReporter.swift in Sources */, A358D6F82A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, 832D68A3224A38FC005F052A /* CacheConverter.swift in Sources */, A358D6D22A4DD48600270C60 /* EnvironmentReporterChainBase.swift in Sources */, diff --git a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift index 7ab1080ad..34a7cfc0b 100644 --- a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift +++ b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift @@ -26,7 +26,11 @@ /// import Foundation +#if !os(Windows) import Compression +#else +import WinSDK +#endif public extension Data { /// Compresses the data using the gzip algorithm. @@ -43,20 +47,30 @@ internal extension Data { /// - parameter withAlgorithm: Compression algorithm to use. See the `CompressionAlgorithm` type /// - returns: compressed data func compress(withAlgorithm algo: CompressionAlgorithm) -> Data? { + #if os(Windows) + guard algo == .zlib else { return nil } + return windowsDeflate() + #else return self.withUnsafeBytes { (sourcePtr: UnsafePointer) -> Data? in let config = (operation: COMPRESSION_STREAM_ENCODE, algorithm: algo.lowLevelType) return perform(config, source: sourcePtr, sourceSize: count) } + #endif } /// Decompresses the data. /// - parameter withAlgorithm: Compression algorithm to use. See the `CompressionAlgorithm` type /// - returns: decompressed data func decompress(withAlgorithm algo: CompressionAlgorithm) -> Data? { + #if os(Windows) + guard algo == .zlib else { return nil } + return windowsInflate() + #else return self.withUnsafeBytes { (sourcePtr: UnsafePointer) -> Data? in let config = (operation: COMPRESSION_STREAM_DECODE, algorithm: algo.lowLevelType) return perform(config, source: sourcePtr, sourceSize: count) } + #endif } /// Please consider the [libcompression documentation](https://developer.apple.com/reference/compression/1665429-data_compression) @@ -76,20 +90,28 @@ internal extension Data { /// - returns: raw deflated data according to [RFC-1951](https://tools.ietf.org/html/rfc1951). /// - note: Fixed at compression level 5 (best trade off between speed and time) func deflate() -> Data? { + #if os(Windows) + return windowsDeflate() + #else return self.withUnsafeBytes { (sourcePtr: UnsafePointer) -> Data? in let config = (operation: COMPRESSION_STREAM_ENCODE, algorithm: COMPRESSION_ZLIB) return perform(config, source: sourcePtr, sourceSize: count) } + #endif } /// Decompresses the data using the zlib deflate algorithm. Self is expected to be a raw deflate /// stream according to [RFC-1951](https://tools.ietf.org/html/rfc1951). /// - returns: uncompressed data func inflate() -> Data? { + #if os(Windows) + return windowsInflate() + #else return self.withUnsafeBytes { (sourcePtr: UnsafePointer) -> Data? in let config = (operation: COMPRESSION_STREAM_DECODE, algorithm: COMPRESSION_ZLIB) return perform(config, source: sourcePtr, sourceSize: count) } + #endif } /// Compresses the data using the deflate algorithm and makes it comply to the zlib format. @@ -98,12 +120,17 @@ internal extension Data { func zip() -> Data? { let header = Data([0x78, 0x5e]) + #if os(Windows) + guard var result = windowsDeflate() else { return nil } + result.insert(contentsOf: header, at: 0) + #else let deflated = self.withUnsafeBytes { (sourcePtr: UnsafePointer) -> Data? in let config = (operation: COMPRESSION_STREAM_ENCODE, algorithm: COMPRESSION_ZLIB) return perform(config, source: sourcePtr, sourceSize: count, preload: header) } guard var result = deflated else { return nil } + #endif var adler = self.adler32().checksum.bigEndian result.append(Data(bytes: &adler, count: MemoryLayout.size)) @@ -128,11 +155,16 @@ internal extension Data { // check the header checksum guard header % 31 == 0 else { return nil } + #if os(Windows) + let deflated = Data(self[2..<(count - 4)]) + let cresult = deflated.windowsInflate() + #else let cresult: Data? = withUnsafeBytes { (ptr: UnsafePointer) -> Data? in let source = ptr.advanced(by: 2) let config = (operation: COMPRESSION_STREAM_DECODE, algorithm: COMPRESSION_ZLIB) return perform(config, source: source, sourceSize: count - overhead) } + #endif guard let inflated = cresult else { return nil } @@ -149,6 +181,9 @@ internal extension Data { /// - returns: deflated data in gzip format [RFC-1952](https://tools.ietf.org/html/rfc1952) /// - note: Fixed at compression level 5 (best trade off between speed and time) func gzip() -> Data? { + #if os(Windows) + return windowsGzip() + #else var header = Data([0x1f, 0x8b, 0x08, 0x00]) // magic, magic, deflate, noflags var unixtime = UInt32(Date().timeIntervalSince1970).littleEndian @@ -172,12 +207,16 @@ internal extension Data { result.append(Data(bytes: &isize, count: MemoryLayout.size)) return result + #endif } /// Decompresses the data using the gzip deflate algorithm. Self is expected to be a gzip deflate /// stream according to [RFC-1952](https://tools.ietf.org/html/rfc1952). /// - returns: uncompressed data func gunzip() -> Data? { + #if os(Windows) + return windowsGunzip() + #else // 10 byte header + data + 8 byte footer. See https://tools.ietf.org/html/rfc1952#section-2 let overhead = 10 + 8 guard count >= overhead else { return nil } @@ -237,6 +276,7 @@ internal extension Data { guard ftr.isize == UInt32(truncatingIfNeeded: inflated.count) else { return nil } guard ftr.crc32 == inflated.crc32().checksum else { return nil } return inflated + #endif } /// Calculate the Adler32 checksum of the data. @@ -292,9 +332,13 @@ struct Crc32: CustomStringConvertible { /// Load `crc32()` from '/usr/lib/libz.dylib' if libz is installed. /// - returns: A function pointer to crc32() of zlib or nil if zlib can't be found private static func loadCrc32fromZLib() -> ZLibCrc32FuncPtr? { + #if os(Windows) + return nil + #else guard let libz = dlopen("/usr/lib/libz.dylib", RTLD_NOW) else { return nil } guard let fptr = dlsym(libz, "crc32") else { return nil } return unsafeBitCast(fptr, to: ZLibCrc32FuncPtr.self) + #endif } /// Rudimentary fallback implementation of the crc32 checksum. This is only a backup used @@ -381,9 +425,13 @@ struct Adler32: CustomStringConvertible { /// Load `adler32()` from '/usr/lib/libz.dylib' if libz is installed. /// - returns: A function pointer to adler32() of zlib or nil if zlib can't be found private static func loadAdler32fromZLib() -> ZLibAdler32FuncPtr? { + #if os(Windows) + return nil + #else guard let libz = dlopen("/usr/lib/libz.dylib", RTLD_NOW) else { return nil } guard let fptr = dlsym(libz, "adler32") else { return nil } return unsafeBitCast(fptr, to: ZLibAdler32FuncPtr.self) + #endif } /// Rudimentary fallback implementation of the adler32 checksum. This is only a backup used @@ -412,6 +460,334 @@ fileprivate extension Data { } } +#if os(Windows) +private extension Data { + func windowsDeflate() -> Data? { + windowsMSZIPDeflate(self) + } + + func windowsInflate() -> Data? { + guard !isEmpty else { return nil } + return windowsMSZIPInflate(self) + } + + func windowsGzip() -> Data? { + var result = WindowsCompression.gzipHeader + + var unixtime = UInt32(Date().timeIntervalSince1970).littleEndian + result.append(Data(bytes: &unixtime, count: MemoryLayout.size)) + result.append(contentsOf: WindowsCompression.gzipTrailerHeader) + + guard let deflated = windowsDeflate() else { return nil } + result.append(deflated) + + var crc32 = self.crc32().checksum.littleEndian + result.append(Data(bytes: &crc32, count: MemoryLayout.size)) + + var isize = UInt32(truncatingIfNeeded: count).littleEndian + result.append(Data(bytes: &isize, count: MemoryLayout.size)) + + return result + } + + func windowsGunzip() -> Data? { + gunzipMember() + } + + private func gunzipMember() -> Data? { + let minimumMemberSize = WindowsCompression.gzipHeaderSize + WindowsCompression.gzipFooterSize + guard count >= minimumMemberSize else { return nil } + guard self[0] == WindowsCompression.gzipID1, + self[1] == WindowsCompression.gzipID2, + self[2] == WindowsCompression.gzipCompressionMethodDeflate + else { return nil } + + let flags = self[3] + var position = WindowsCompression.gzipHeaderSize + let footerStart = count - WindowsCompression.gzipFooterSize + + if flags & WindowsCompression.gzipExtraFlag != 0 { + guard footerStart - position >= 2 else { return nil } + let extraLength = Int(littleEndianUInt16(at: position)) + position += 2 + guard extraLength <= footerStart - position else { return nil } + position += extraLength + } + + if flags & WindowsCompression.gzipNameFlag != 0 { + guard let nextPosition = indexAfterNullTerminatedField(startingAt: position, limit: footerStart) else { + return nil + } + position = nextPosition + } + + if flags & WindowsCompression.gzipCommentFlag != 0 { + guard let nextPosition = indexAfterNullTerminatedField(startingAt: position, limit: footerStart) else { + return nil + } + position = nextPosition + } + + if flags & WindowsCompression.gzipHeaderCRCFlag != 0 { + guard footerStart - position >= 2 else { return nil } + position += 2 + } + + guard position < footerStart else { return nil } + let compressedStart = position + let compressedEnd = footerStart + + let expectedCrc32 = littleEndianUInt32(at: footerStart) + let expectedSize = littleEndianUInt32(at: footerStart + 4) + let deflated = Data(self[compressedStart.. Int? { + var offset = offset + while offset < limit, self[offset] != 0x00 { + offset += 1 + } + guard offset < limit else { return nil } + return offset + 1 + } + + private func littleEndianUInt16(at offset: Int) -> UInt16 { + UInt16(self[offset]) | UInt16(self[offset + 1]) << 8 + } + + private func littleEndianUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + } +} + +private enum WindowsCompression { + static let maxMSZIPBlockSize = 32 * 1024 + static let maxStoredBlockSize = Int(UInt16.max) + static let mszipHeader = Data([0x43, 0x4b]) + static let emptyStoredBlock: [UInt8] = [0x01, 0x00, 0x00, 0xff, 0xff] + + static let gzipID1: UInt8 = 0x1f + static let gzipID2: UInt8 = 0x8b + static let gzipCompressionMethodDeflate: UInt8 = 0x08 + static let gzipHeader = Data([gzipID1, gzipID2, gzipCompressionMethodDeflate, 0x00]) + static let gzipTrailerHeader: [UInt8] = [0x00, 0x03] + static let gzipHeaderSize = 10 + static let gzipFooterSize = 8 + + static let gzipHeaderCRCFlag: UInt8 = 0b00010 + static let gzipExtraFlag: UInt8 = 0b00100 + static let gzipNameFlag: UInt8 = 0b01000 + static let gzipCommentFlag: UInt8 = 0b10000 +} + +// Windows does not provide Apple's Compression module, and SwiftPM does not ship a zlib +// module we can import here. The Windows Compression API's MSZIP raw mode exposes +// deflate coding with a two-byte "CK" MSZIP marker. We strip that marker when writing +// RFC1951 payloads and add it back before asking Windows to decode a payload. MSZIP +// raw mode is limited to 32 KB blocks when writing; larger inputs use stored RFC1951 +// blocks so the gzip/zlib wrappers stay standards-compliant without bit-level block +// surgery. The stored-block decoder is also kept as a narrow fallback for payloads that +// Windows rejects but that are valid uncompressed DEFLATE streams. +private func windowsMSZIPDeflate(_ input: Data) -> Data? { + guard !input.isEmpty else { + return Data(WindowsCompression.emptyStoredBlock) + } + + guard input.count <= WindowsCompression.maxMSZIPBlockSize else { + return windowsStoredDeflate(input) + } + + return withWindowsCompressor { compressor in + var compressedSize: SIZE_T = 0 + _ = input.withUnsafeBytes { source in + Compress(compressor, source.baseAddress, SIZE_T(input.count), nil, 0, &compressedSize) + } + + guard compressedSize >= SIZE_T(WindowsCompression.mszipHeader.count), + compressedSize <= SIZE_T(Int.max) + else { return nil } + + var output = Data(count: Int(compressedSize)) + let outputCapacity = output.count + let compressed = input.withUnsafeBytes { source in + output.withUnsafeMutableBytes { destination in + Compress( + compressor, + source.baseAddress, + SIZE_T(input.count), + destination.baseAddress, + SIZE_T(outputCapacity), + &compressedSize) + } + } + + guard compressed else { return nil } + guard compressedSize <= SIZE_T(outputCapacity) else { return nil } + + output.removeSubrange(Int(compressedSize).. Data { + var result = Data() + var offset = 0 + + while offset < input.count { + let remaining = input.count - offset + let blockSize = Swift.min(remaining, WindowsCompression.maxStoredBlockSize) + let blockEnd = offset + blockSize + let isFinalBlock = blockEnd == input.count + + // Stored DEFLATE blocks are byte-aligned: one header byte for BFINAL/BTYPE, + // followed by LEN, one's-complement NLEN, then the uncompressed payload. + result.append(isFinalBlock ? 0x01 : 0x00) + let blockLength = UInt16(blockSize) + var length = blockLength.littleEndian + var invertedLength = (~blockLength).littleEndian + result.append(Data(bytes: &length, count: MemoryLayout.size)) + result.append(Data(bytes: &invertedLength, count: MemoryLayout.size)) + result.append(input[offset.. Data? { + if input.elementsEqual(WindowsCompression.emptyStoredBlock) { + guard expectedSize == nil || expectedSize == 0 else { return nil } + return Data() + } + + var mszipInput = WindowsCompression.mszipHeader + mszipInput.append(input) + + guard input.count <= Int.max / 2 else { return nil } + let initialOutputCapacity = Swift.max(input.count * 2, 64) + + if let expectedSize = expectedSize { + guard expectedSize > 0 else { return nil } + var outputCapacity = Swift.min(initialOutputCapacity, expectedSize) + + while outputCapacity > 0 { + guard let output = windowsMSZIPInflate(mszipInput, outputCapacity: outputCapacity) else { + return windowsStoredInflate(input) + } + + if output.count < outputCapacity || outputCapacity == expectedSize { + guard output.count == expectedSize else { return nil } + return output + } + + guard outputCapacity <= Int.max / 2 else { return nil } + outputCapacity = Swift.min(outputCapacity * 2, expectedSize) + } + + return nil + } + + var outputCapacity = initialOutputCapacity + while outputCapacity > 0 { + guard let output = windowsMSZIPInflate(mszipInput, outputCapacity: outputCapacity) else { + return windowsStoredInflate(input) + } + + if output.count < outputCapacity { + return output + } + + guard outputCapacity <= Int.max / 2 else { return nil } + outputCapacity *= 2 + } + + return nil +} + +private func windowsStoredInflate(_ input: Data) -> Data? { + var result = Data() + var offset = 0 + + while offset < input.count { + let header = input[offset] + let isFinalBlock = header & 0x01 != 0 + let blockType = (header >> 1) & 0x03 + + guard blockType == 0 else { return nil } + offset += 1 + + guard input.count - offset >= 4 else { return nil } + let length = UInt16(input[offset]) | UInt16(input[offset + 1]) << 8 + let invertedLength = UInt16(input[offset + 2]) | UInt16(input[offset + 3]) << 8 + guard length == ~invertedLength else { return nil } + offset += 4 + + let blockLength = Int(length) + guard blockLength <= input.count - offset else { return nil } + result.append(input[offset..<(offset + blockLength)]) + offset += blockLength + + if isFinalBlock { + return offset == input.count ? result : nil + } + } + + return nil +} + +private func windowsMSZIPInflate(_ input: Data, outputCapacity: Int) -> Data? { + withWindowsDecompressor { decompressor in + var output = Data(count: outputCapacity) + let destinationCapacity = output.count + var decompressedSize: SIZE_T = 0 + let decompressed = input.withUnsafeBytes { source in + output.withUnsafeMutableBytes { destination in + Decompress( + decompressor, + source.baseAddress, + SIZE_T(input.count), + destination.baseAddress, + SIZE_T(destinationCapacity), + &decompressedSize) + } + } + + guard decompressed else { return nil } + guard decompressedSize <= SIZE_T(destinationCapacity) else { return nil } + + output.removeSubrange(Int(decompressedSize)..(_ body: (COMPRESSOR_HANDLE?) -> Result?) -> Result? { + var compressor: COMPRESSOR_HANDLE? + let algorithm = DWORD(COMPRESS_ALGORITHM_MSZIP | COMPRESS_RAW) + guard CreateCompressor(algorithm, nil, &compressor) else { return nil } + defer { CloseCompressor(compressor) } + + return body(compressor) +} + +private func withWindowsDecompressor(_ body: (DECOMPRESSOR_HANDLE?) -> Result?) -> Result? { + var decompressor: DECOMPRESSOR_HANDLE? + let algorithm = DWORD(COMPRESS_ALGORITHM_MSZIP | COMPRESS_RAW) + guard CreateDecompressor(algorithm, nil, &decompressor) else { return nil } + defer { CloseDecompressor(decompressor) } + + return body(decompressor) +} +#else fileprivate extension Data.CompressionAlgorithm { var lowLevelType: compression_algorithm { switch self { @@ -478,3 +854,4 @@ private func perform(_ config: Config, source: UnsafePointer, sourceSize: } } } +#endif diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 137faa549..70f4490f0 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -261,14 +261,14 @@ public class LDClient { os_log("%s stopped", log: config.logger, type: .debug, typeName(and: #function)) } - @objc private func didEnterBackground() { + private func didEnterBackground() { os_log("%s", log: config.logger, type: .debug, typeName(and: #function)) Thread.performOnMain { runMode = .background } } - @objc private func willEnterForeground() { + private func willEnterForeground() { os_log("%s", log: config.logger, type: .debug, typeName(and: #function)) Thread.performOnMain { runMode = .foreground @@ -674,7 +674,11 @@ public class LDClient { private func updateCacheAndReportChanges(context: LDContext, oldStoredItems: StoredItems, etag: String?) { - flagCache.saveCachedData(flagStore.storedItems, cacheKey: context.fullyQualifiedHashedKey(), contextHash: context.contextHash(), lastUpdated: Date(), etag: etag) + flagCache.saveCachedData(flagStore.storedItems, + cacheKey: context.fullyQualifiedHashedKey(), + contextHash: context.contextHash(), + lastUpdated: Date(), + etag: etag ?? service.flagRequestEtag) flagChangeNotifier.notifyObservers(oldFlags: oldStoredItems.featureFlags, newFlags: flagStore.storedItems.featureFlags) } @@ -686,7 +690,11 @@ public class LDClient { In other words, if we get confirmation our cache is still fresh, then we shouldn't poll again for another seconds. If we didn't update this, we would poll immediately on restart. */ private func updateCacheFreshness(context: LDContext) { - flagCache.saveCachedData(flagStore.storedItems, cacheKey: context.fullyQualifiedHashedKey(), contextHash: context.contextHash(), lastUpdated: Date(), etag: nil) + flagCache.saveCachedData(flagStore.storedItems, + cacheKey: context.fullyQualifiedHashedKey(), + contextHash: context.contextHash(), + lastUpdated: Date(), + etag: service.flagRequestEtag) } // MARK: Events @@ -764,7 +772,7 @@ public class LDClient { } } - @objc private func didCloseEventSource() { + private func didCloseEventSource() { os_log("%s", log: config.logger, type: .debug, typeName(and: #function)) self.connectionInformation = ConnectionInformation.lastSuccessfulConnectionCheck(connectionInformation: self.connectionInformation) } @@ -853,11 +861,7 @@ public class LDClient { // now register the client with all the plugins for plugin in config.plugins { - do { - plugin.register(client: instance, metadata: environmentMetadata) - } catch { - os_log("Exception thrown registering plugin %@.", log: config.logger, type: .error, plugin.getMetadata().getName()) - } + plugin.register(client: instance, metadata: environmentMetadata) } } @@ -949,26 +953,12 @@ public class LDClient { private var initializedQueue = DispatchQueue(label: "com.launchdarkly.LDClient.initializedQueue") private var identifyQueue = SheddingQueue() + private var notificationTokens = [NSObjectProtocol]() + private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) { self.serviceFactory = serviceFactory - self.hooks = Array(configuration.hooks) environmentReporter = self.serviceFactory.makeEnvironmentReporter(config: configuration) - - // Collect plugin hooks before calling beforeIdentify, so plugin hooks participate in the init identify lifecycle. - let initSdkMetadata = SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion) - let initEnvironmentMetadata = EnvironmentMetadata( - applicationInfo: environmentReporter.applicationInfo, - sdkMetadata: initSdkMetadata, - credential: configuration.mobileKey - ) - for plugin in configuration.plugins { - do { - let pluginHooks = try plugin.getHooks(metadata: initEnvironmentMetadata) - self.hooks.append(contentsOf: pluginHooks) - } catch { - os_log("Exception thrown getting hooks for plugin %@. Unable to get hooks, plugin will not be registered.", log: configuration.logger, type: .error, plugin.getMetadata().getName()) - } - } + self.hooks = LDClient.collectHooks(configuration: configuration, environmentReporter: environmentReporter) flagCache = self.serviceFactory.makeFeatureFlagCache(mobileKey: configuration.mobileKey, maxCachedContexts: configuration.maxCachedContexts) flagStore = self.serviceFactory.makeFlagStore() @@ -1002,14 +992,7 @@ public class LDClient { lastUpdated: cachedData.lastUpdated, service: service) - if let backgroundNotification = SystemCapabilities.backgroundNotification { - NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: backgroundNotification, object: nil) - } - if let foregroundNotification = SystemCapabilities.foregroundNotification { - NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: foregroundNotification, object: nil) - } - - NotificationCenter.default.addObserver(self, selector: #selector(didCloseEventSource), name: Notification.Name(FlagSynchronizer.Constants.didCloseEventSourceName), object: nil) + addLifecycleObservers() eventReporter = self.serviceFactory.makeEventReporter(config: configuration, service: service, onSyncComplete: onEventSyncComplete) service.resetFlagResponseCache(etag: cachedData.etag) @@ -1035,6 +1018,43 @@ public class LDClient { } } } + + private static func collectHooks(configuration: LDConfig, environmentReporter: EnvironmentReporting) -> [Hook] { + var hooks = Array(configuration.hooks) + let sdkMetadata = SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion) + let environmentMetadata = EnvironmentMetadata( + applicationInfo: environmentReporter.applicationInfo, + sdkMetadata: sdkMetadata, + credential: configuration.mobileKey + ) + + // Collect plugin hooks before calling beforeIdentify, so plugin hooks participate in the init identify lifecycle. + for plugin in configuration.plugins { + hooks.append(contentsOf: plugin.getHooks(metadata: environmentMetadata)) + } + + return hooks + } + + private func addLifecycleObservers() { + if let backgroundNotification = SystemCapabilities.backgroundNotification { + let background = NotificationCenter.default.addObserver(forName: backgroundNotification, object: nil, queue: OperationQueue.current, using: { [weak self] _ in + self?.didEnterBackground() + }) + notificationTokens.append(background) + } + if let foregroundNotification = SystemCapabilities.foregroundNotification { + let foreground = NotificationCenter.default.addObserver(forName: foregroundNotification, object: nil, queue: OperationQueue.current, using: { [weak self] _ in + self?.willEnterForeground() + }) + notificationTokens.append(foreground) + } + + let didClose = NotificationCenter.default.addObserver(forName: Notification.Name(FlagSynchronizer.Constants.didCloseEventSourceName), object: nil, queue: OperationQueue.current, using: { [weak self] _ in + self?.didCloseEventSource() + }) + notificationTokens.append(didClose) + } } extension LDClient: TypeIdentifying { } diff --git a/LaunchDarkly/LaunchDarkly/LDCommon.swift b/LaunchDarkly/LaunchDarkly/LDCommon.swift index 0c5248489..0a05666cf 100644 --- a/LaunchDarkly/LaunchDarkly/LDCommon.swift +++ b/LaunchDarkly/LaunchDarkly/LDCommon.swift @@ -20,6 +20,16 @@ extension LDFlagKey { } /// An error thrown from APIs when an invalid argument is provided. +#if os(Linux) || os(Windows) +public class LDInvalidArgumentError: Error { + /// A description of the error. + public let localizedDescription: String + + init(_ description: String) { + self.localizedDescription = description + } +} +#else @objc public class LDInvalidArgumentError: NSObject, Error { /// A description of the error. public let localizedDescription: String @@ -28,6 +38,7 @@ extension LDFlagKey { self.localizedDescription = description } } +#endif struct DynamicKey: CodingKey { let intValue: Int? = nil diff --git a/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift b/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift index 0627d8064..cedddc11d 100644 --- a/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift +++ b/LaunchDarkly/LaunchDarkly/Models/ConnectionInformation.swift @@ -1,6 +1,10 @@ import Foundation import OSLog +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + public struct ConnectionInformation: Codable, CustomStringConvertible { public enum ConnectionMode: String, Codable { case streaming, offline, establishingStreamingConnection, polling diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index f4edfa3bf..84be45a39 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -1,6 +1,10 @@ import Foundation import OSLog +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + /// Defines the connection modes the SDK may be configured to use to retrieve feature flag data from LaunchDarkly. public enum LDStreamingMode { /** @@ -32,7 +36,10 @@ public enum LDStreamingMode { you can use targeting rules to enable "dark mode" for all customers who are using version 15 or greater, and ensure that customers on previous versions don't use the earlier, unfinished version of the feature. */ -@objc public enum AutoEnvAttributes: Int { +#if !os(Linux) && !os(Windows) +@objc +#endif +public enum AutoEnvAttributes: Int { /// Enables the Auto EnvironmentAttributes functionality. case enabled /// Disables the Auto EnvironmentAttributes functionality. @@ -439,8 +446,10 @@ public struct LDConfig { /// LaunchDarkly defined minima for selected configurable items public let minima: Minima + #if !os(Linux) && !os(Windows) /// An NSObject wrapper for the Swift LDConfig struct. Intended for use in mixed apps when Swift code needs to pass a config into an Objective-C method. public var objcLdConfig: ObjcLDConfig { ObjcLDConfig(self) } + #endif /// Initial set of hooks for the client. /// diff --git a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift index 7d980c859..6c90eb072 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -2,6 +2,15 @@ import Foundation import LDSwiftEventSource import OSLog +#if os(Linux) || os(Windows) +import class FoundationNetworking.URLResponse +import class FoundationNetworking.HTTPURLResponse +import struct FoundationNetworking.URLRequest + +import class FoundationNetworking.URLSessionConfiguration +import AnyURLSession +#endif + // swiftlint:disable:next large_tuple typealias ServiceResponse = (data: Data?, urlResponse: URLResponse?, error: Error?, etag: String?) typealias ServiceCompletionHandler = (ServiceResponse) -> Void @@ -18,6 +27,7 @@ protocol DarklyServiceProvider: AnyObject { var config: LDConfig { get } var context: LDContext { get set } var diagnosticCache: DiagnosticCaching? { get } + var flagRequestEtag: String? { get } func getFeatureFlags(useReport: Bool, completion: ServiceCompletionHandler?) func resetFlagResponseCache(etag: String?) @@ -55,7 +65,7 @@ final class DarklyService: DarklyServiceProvider { private var session: URLSession var flagRequestEtag: String? - init(config: LDConfig, context: LDContext, envReporter: EnvironmentReporting, serviceFactory: ClientServiceCreating) { + init(config: LDConfig, context: LDContext, envReporter: EnvironmentReporting, serviceFactory: ClientServiceCreating) { self.config = config self.context = context self.serviceFactory = serviceFactory @@ -70,11 +80,13 @@ final class DarklyService: DarklyServiceProvider { // URLSessionConfiguration is a class, but `.default` creates a new instance. This does not effect other session configuration. let sessionConfig = URLSessionConfiguration.default + #if !os(Linux) && !os(Windows) if #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) { sessionConfig.tlsMinimumSupportedProtocolVersion = .TLSv12 } else { sessionConfig.tlsMinimumSupportedProtocol = .tlsProtocol12 } + #endif // We always revalidate the cache which we handle manually sessionConfig.requestCachePolicy = .reloadIgnoringLocalCacheData @@ -114,9 +126,15 @@ final class DarklyService: DarklyServiceProvider { } self.session.dataTask(with: request) { [weak self] data, response, error in - DispatchQueue.main.async { - self?.processEtag(from: (data: data, urlResponse: response, error: error, etag: self?.flagRequestEtag)) - completion?((data: data, urlResponse: response, error: error, etag: self?.flagRequestEtag)) + DispatchQueue.main.async { [weak self] in + self?.processEtag(from: (data: data, + urlResponse: response, + error: error, + etag: self?.flagRequestEtag)) + completion?((data: data, + urlResponse: response, + error: error, + etag: self?.flagRequestEtag)) } }.resume() } diff --git a/LaunchDarkly/LaunchDarkly/Networking/HTTPURLRequest.swift b/LaunchDarkly/LaunchDarkly/Networking/HTTPURLRequest.swift index 4ece8e2b9..0d77c0b68 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/HTTPURLRequest.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/HTTPURLRequest.swift @@ -1,5 +1,9 @@ import Foundation +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + extension URLRequest { struct HTTPMethods { static let get = "GET" diff --git a/LaunchDarkly/LaunchDarkly/Networking/HTTPURLResponse.swift b/LaunchDarkly/LaunchDarkly/Networking/HTTPURLResponse.swift index d51d76af0..232ffc513 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/HTTPURLResponse.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/HTTPURLResponse.swift @@ -1,5 +1,9 @@ import Foundation +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + extension HTTPURLResponse { struct HeaderKeys { diff --git a/LaunchDarkly/LaunchDarkly/Networking/URLResponse.swift b/LaunchDarkly/LaunchDarkly/Networking/URLResponse.swift index 6469bd36e..1f00aa28f 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/URLResponse.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/URLResponse.swift @@ -1,5 +1,9 @@ import Foundation +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + extension URLResponse { var httpStatusCode: Int? { (self as? HTTPURLResponse)?.statusCode } var httpHeaderEtag: String? { (self as? HTTPURLResponse)?.headerEtag } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift index 79b162f5e..fcef52ff1 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift @@ -89,7 +89,6 @@ final class ClientServiceFactory: ClientServiceCreating { config.headerTransform = { delegate?(url, $0) ?? $0 } config.headers = httpHeaders config.method = connectMethod - config.logger = self.logger if let errorHandler = errorHandler { config.connectionErrorHandler = errorHandler } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/CwlSysctl.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/CwlSysctl.swift index 457a66604..51e7f2f1f 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/CwlSysctl.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/CwlSysctl.swift @@ -1,3 +1,4 @@ +#if canImport(Darwin) // // CwlSysctl.swift // CwlUtils @@ -78,3 +79,4 @@ struct Sysctl { return try! Sysctl.stringForKeys([CTL_HW, HW_MODEL]) } } +#endif // canImport(Darwin) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/DiagnosticReporter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/DiagnosticReporter.swift index 8970d1098..b13d0e96d 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/DiagnosticReporter.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/DiagnosticReporter.swift @@ -1,6 +1,10 @@ import Foundation import OSLog +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + // sourcery: autoMockable protocol DiagnosticReporting { func setMode(_ runMode: LDClientRunMode, online: Bool) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporter.swift index a72a79d0d..55d7d8d87 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporter.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporter.swift @@ -11,10 +11,10 @@ import UIKit #endif enum OperatingSystem: String { - case iOS, watchOS, macOS, tvOS, unknown + case iOS, watchOS, macOS, tvOS, windows, linux, unknown static var allOperatingSystems: [OperatingSystem] { - [.iOS, .watchOS, .macOS, .tvOS] + [.iOS, .watchOS, .macOS, .tvOS, .windows, .linux] } var isBackgroundEnabled: Bool { @@ -28,7 +28,7 @@ enum OperatingSystem: String { OperatingSystem.streamingEnabledOperatingSystems.contains(self) } static var streamingEnabledOperatingSystems: [OperatingSystem] { - [.iOS, .macOS, .tvOS] + [.iOS, .macOS, .tvOS, .windows] } } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/EnvironmentReporterBuilder.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/EnvironmentReporterBuilder.swift index 583ebb445..d28f65a1c 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/EnvironmentReporterBuilder.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/EnvironmentReporterBuilder.swift @@ -36,6 +36,8 @@ class EnvironmentReporterBuilder { reporters.append(MacOSEnvironmentReporter()) #elseif os(tvOS) reporters.append(TVOSEnvironmentReporter()) + #elseif os(Windows) + reporters.append(WindowsEnvironmentReporter()) #endif } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/SystemCapabilities.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/SystemCapabilities.swift index 61a4e97c5..8165aac13 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/SystemCapabilities.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/SystemCapabilities.swift @@ -29,5 +29,15 @@ class SystemCapabilities { static var foregroundNotification: Notification.Name? { UIApplication.willEnterForegroundNotification } static var systemName: String { UIDevice.current.systemName } static var operatingSystem: OperatingSystem { .tvOS } + #elseif os(Windows) + static var backgroundNotification: Notification.Name? { nil } + static var foregroundNotification: Notification.Name? { nil } + static var systemName: String { "Windows" } + static var operatingSystem: OperatingSystem { .windows } + #elseif os(Linux) + static var backgroundNotification: Notification.Name? { UIApplication.didEnterBackgroundNotification } + static var foregroundNotification: Notification.Name? { UIApplication.willEnterForegroundNotification } + static var systemName: String { "Linux" } + static var operatingSystem: OperatingSystem { .linux } #endif } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporter.swift new file mode 100644 index 000000000..7af78b432 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporter.swift @@ -0,0 +1,55 @@ +#if os(Windows) +import Foundation +import WinSDK + +class WindowsEnvironmentReporter: EnvironmentReporterChainBase { + override var applicationInfo: ApplicationInfo { + var info = ApplicationInfo() + info.applicationIdentifier(Bundle.main.object(forInfoDictionaryKey: "CFBundleIdentifier") as? String) + info.applicationVersion(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String) + info.applicationName(Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String) + info.applicationVersionName(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + + if info.applicationId == nil { + info = super.applicationInfo + } + return info + } + + override var manufacturer: String { + "unknown" + } + + override var systemVersion: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + override var osFamily: String { + "Windows" + } + + override var deviceModel: String { + var status: SYSTEM_POWER_STATUS = .init() + if !GetSystemPowerStatus(&status) { + return "unknown" + } + + switch status.ACLineStatus { + // Not using AC power, probably a laptop on battery + case 0: + return "laptop" + // Using AC power, probably a laptop on AC power + case 1: + return "laptop" + // AC power status unknown, likely a desktop + case 255: + return "desktop" + // An unknown value, return unknown since we don't know + default: + return "unknown" + } + } +} +#endif diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift index e8d295928..60775124b 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift @@ -1,6 +1,10 @@ import Foundation import OSLog +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + typealias EventSyncCompleteClosure = ((SynchronizingError?) -> Void) // sourcery: autoMockable protocol EventReporting { diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index e25dcb1da..2ec4346ac 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -3,6 +3,10 @@ import Dispatch import LDSwiftEventSource import OSLog +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + // sourcery: autoMockable protocol LDFlagSynchronizing { // sourcery: defaultMockValue = false @@ -163,7 +167,9 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { // signal completion immediately syncQueue.async { [self] in reportSyncComplete(.upToDate) } } - flagRequestTimer = LDTimer(withTimeInterval: pollingInterval, fireQueue: syncQueue, fireAt: fireAt, execute: processTimer) + flagRequestTimer = LDTimer(withTimeInterval: pollingInterval, fireQueue: syncQueue, fireAt: fireAt, execute: { [weak self] in + self?.processTimer() + }) os_log("%s", log: service.config.logger, type: .debug, typeName(and: #function)) } @@ -179,7 +185,7 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { flagRequestTimer = nil } - @objc private func processTimer() { + private func processTimer() { makeFlagRequest(isOnline: isOnline) } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift index 11e180bee..a38c28dae 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift @@ -22,9 +22,13 @@ final class LDTimer: TimeResponding { // the run loop retains the timer, so the property is weak to avoid a retain cycle. Setting the timer to a strong reference is important so that the timer doesn't get nil'd before it's added to the run loop. let timer: Timer if let at = fireAt { - timer = Timer(fireAt: at, interval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true) + timer = Timer(fire: at, interval: timeInterval, repeats: true, block: { [weak self] _ in + self?.timerFired() + }) } else { - timer = Timer(timeInterval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true) + timer = Timer(timeInterval: timeInterval, repeats: true, block: { [weak self] _ in + self?.timerFired() + }) } self.timer = timer RunLoop.main.add(timer, forMode: RunLoop.Mode.default) @@ -34,7 +38,7 @@ final class LDTimer: TimeResponding { timer?.invalidate() } - @objc private func timerFired() { + private func timerFired() { fireQueue.async { [weak self] in guard (self?.isCancelled ?? true) == false else { return } diff --git a/LaunchDarkly/LaunchDarkly/Util.swift b/LaunchDarkly/LaunchDarkly/Util.swift index aa7deeeea..5711e4204 100644 --- a/LaunchDarkly/LaunchDarkly/Util.swift +++ b/LaunchDarkly/LaunchDarkly/Util.swift @@ -1,4 +1,6 @@ +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) import CommonCrypto +#endif import Foundation class Util { @@ -11,11 +13,18 @@ class Util { class func sha256(_ str: String) -> Data { let data = Data(str.utf8) + + #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) data.withUnsafeBytes { _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &digest) } return Data(digest) + #elseif os(Windows) + return data.sha256Digest + #else + fatalError("\(#function) is unimplemented!") + #endif } } @@ -28,3 +37,42 @@ extension String { return true } } + +#if os(Windows) +import WinSDK + +extension Data { + var sha256Digest: Data { + func handleError(_ status: WinSDK.NTSTATUS) { + // Failfast to mimic CryptoKit/Crypto semantics + if status < 0 { fatalError("Failed to create SHA256 digest using BCrypt APIs (NTSTATUS: \(status))") } + } + + let bcryptSHA256Algorithm = "SHA256" + + var algorithm: WinSDK.BCRYPT_ALG_HANDLE? + bcryptSHA256Algorithm.withCString(encodedAs: UTF16.self) { + handleError(WinSDK.BCryptOpenAlgorithmProvider(&algorithm, $0, nil, 0)) + } + defer { handleError(WinSDK.BCryptCloseAlgorithmProvider(algorithm, 0)) } + + var hash: BCRYPT_HASH_HANDLE? + handleError(WinSDK.BCryptCreateHash(algorithm, &hash, nil, 0, nil, 0, 0)) + defer { handleError(WinSDK.BCryptDestroyHash(hash)) } + + withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in + let input = UnsafeMutablePointer(mutating: buffer.baseAddress?.bindMemory(to: UInt8.self, capacity: buffer.count)) + handleError(WinSDK.BCryptHashData(hash, input, ULONG(buffer.count), 0)) + } + + var result = Data(count: 32) // Size of SHA256 hash + result.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in + let output = buffer.baseAddress?.bindMemory(to: UInt8.self, capacity: buffer.count) + handleError(WinSDK.BCryptFinishHash(hash, output, ULONG(buffer.count), 0)) + } + + return result + } +} + +#endif diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift index f93a6276a..c641ccd18 100644 --- a/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift @@ -2,6 +2,12 @@ import XCTest @testable import LaunchDarkly +#if os(Windows) +private func arc4random() -> UInt32 { + UInt32.random(in: UInt32.min...UInt32.max) +} +#endif + extension Data { func gzip_gunzip() -> Data? { return c_gzip()?.c_gunzip() } @@ -67,6 +73,14 @@ class CompressionTest: XCTestCase { XCTAssertEqual(CompressionTest.blob16mb, CompressionTest.blob16mb.gzip_gunzip()) } + func testSmallCompressibleDataGzipIsSmallerThanInput() { + let data = Data(String(repeating: "a", count: 500).utf8) + let gzipped = data.gzip() + + XCTAssertNotNil(gzipped) + XCTAssertLessThan(gzipped?.count ?? Int.max, data.count) + } + func testGzipCrcFail() { let b = 1024 * 16 let ints = [UInt32](repeating: 0xcafeabee, count: b / 4) @@ -90,4 +104,35 @@ class CompressionTest: XCTestCase { XCTAssertNil(zipped_blob.gunzip()) } + + func testGzipHeaderCRCInFooterFails() { + let gzipWithHeaderCRCInFooter = Data([ + 0x1f, 0x8b, 0x08, 0b00010, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + ]) + + XCTAssertNil(gzipWithHeaderCRCInFooter.gunzip()) + } + + func testGzipExtraFieldInFooterFails() { + let gzipWithExtraFieldInFooter = Data([ + 0x1f, 0x8b, 0x08, 0b00100, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00 + ]) + + XCTAssertNil(gzipWithExtraFieldInFooter.gunzip()) + } + + func testGzipFileNameInFooterFails() { + let gzipWithFileNameInFooter = Data([ + 0x1f, 0x8b, 0x08, 0b01000, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x61, 0x62, 0x63, 0x64, + 0x65, 0x66, 0x67, 0x68 + ]) + + XCTAssertNil(gzipWithFileNameInFooter.gunzip()) + } } diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/OSLogTest.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/OSLogTest.swift new file mode 100644 index 000000000..f3ce57d88 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/OSLogTest.swift @@ -0,0 +1,31 @@ +#if os(Windows) +import XCTest + +@testable import OSLog + +final class OSLogTest: XCTestCase { + func testFormatMessageIncludesArguments() { + let formatted = formatOSLogMessage( + "%s %d %f %@", + arguments: ["alpha", 7, 1.25, "omega"]) + + XCTAssertEqual(formatted, "alpha 7 1.25 omega") + } + + func testFormatMessageHandlesEscapedPercentAndPrivacyAnnotations() { + let formatted = formatOSLogMessage( + "ready %% %{bool}d", + arguments: [true]) + + XCTAssertEqual(formatted, "ready % true") + } + + func testFormatMessageLeavesMissingPlaceholdersVisible() { + let formatted = formatOSLogMessage( + "%s %d", + arguments: ["alpha"]) + + XCTAssertEqual(formatted, "alpha %d") + } +} +#endif diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift index 37c67ad18..80d62964d 100644 --- a/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift @@ -4,11 +4,21 @@ import Nimble @testable import LaunchDarkly final class ThreadSpec: QuickSpec { + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { performOnMainSpec() } - private func performOnMainSpec() { + private class func performOnMainSpec() { var runCount = 0 var ranOnMainThread = false describe("performOnMain") { diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index 7590ae2e4..811ca7aa9 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -5,6 +5,10 @@ import Nimble import LDSwiftEventSource @testable import LaunchDarkly +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + final class LDClientSpec: QuickSpec { struct Constants { fileprivate static let alternateMockUrl = URL(string: "https://dummy.alternate.com")! @@ -13,7 +17,17 @@ final class LDClientSpec: QuickSpec { fileprivate static let updateThreshold: TimeInterval = 0.05 } + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { startSpec() moveToBackgroundSpec() identifySpec() @@ -32,7 +46,7 @@ final class LDClientSpec: QuickSpec { isInitializedSpec() } - private func startSpec() { + private class func startSpec() { describe("start") { startSpec(withTimeout: false) } @@ -62,7 +76,7 @@ final class LDClientSpec: QuickSpec { } } - private func startSpec(withTimeout: Bool) { + private class func startSpec(withTimeout: Bool) { var testContext: TestContext! context("when configured to start online") { @@ -252,7 +266,7 @@ final class LDClientSpec: QuickSpec { } } - func startCompletionSpec() { + class func startCompletionSpec() { var testContext: TestContext! var completed = false var didTimeOut: Bool? = nil @@ -329,21 +343,21 @@ final class LDClientSpec: QuickSpec { it("does complete without timeout") { testContext.start(completion: startCompletion) testContext.onSyncComplete?(.flagCollection((FeatureFlagCollection([:]), nil))) - expect(completed).toEventually(beTrue(), timeout: DispatchTimeInterval.seconds(2)) + expect(completed).toEventually(beTrue(), timeout: .seconds(2)) } it("does complete with timeout") { waitUntil(timeout: .seconds(3)) { done in testContext.start(timeOut: 5.0, timeOutCompletion: startTimeoutCompletion(done)) testContext.onSyncComplete?(.flagCollection((FeatureFlagCollection([:]), nil))) } - expect(completed).toEventually(beTrue(), timeout: DispatchTimeInterval.seconds(2)) + expect(completed).toEventually(beTrue(), timeout: .seconds(2)) expect(didTimeOut) == false } } } } - func moveToBackgroundSpec() { + class func moveToBackgroundSpec() { describe("moveToBackground") { var testContext: TestContext! context("when configured to allow background updates") { @@ -434,7 +448,7 @@ final class LDClientSpec: QuickSpec { } } - private func identifySpec() { + private class func identifySpec() { describe("identify") { it("when the client is online") { let testContext = TestContext(startOnline: true) @@ -578,7 +592,7 @@ final class LDClientSpec: QuickSpec { } } - private func setOnlineSpec() { + private class func setOnlineSpec() { describe("setOnline") { it("set online when the client is offline") { let testContext = TestContext() @@ -653,7 +667,7 @@ final class LDClientSpec: QuickSpec { } } - private func closeSpec() { + private class func closeSpec() { describe("stop") { it("when started and online") { let testContext = TestContext(startOnline: true) @@ -683,7 +697,7 @@ final class LDClientSpec: QuickSpec { } } - private func trackEventSpec() { + private class func trackEventSpec() { describe("track event") { it("records a custom event") { let testContext = TestContext() @@ -695,16 +709,15 @@ final class LDClientSpec: QuickSpec { expect(receivedEvent?.data) == "abc" expect(receivedEvent?.metricValue) == 5.0 } - context("does not record when events are turned off") { + it("does not record when events are turned off") { var config = LDConfig.stub(mobileKey: LDConfig.Constants.mockMobileKey, autoEnvAttributes: .disabled, isDebugBuild: false) config.sendEvents = false let testContext = TestContext(newConfig: config) testContext.start() - let priorRecordedEvents = testContext.eventReporterMock.recordCallCount + expect(testContext.subject.eventReporter is NullEventReporter) == true testContext.subject.track(key: "customEvent", data: "abc", metricValue: 5.0) - expect(testContext.eventReporterMock.recordCallCount) == priorRecordedEvents } - context("does not record when client was stopped") { + it("does not record when client was stopped") { let testContext = TestContext() testContext.start() testContext.subject.close() @@ -715,7 +728,7 @@ final class LDClientSpec: QuickSpec { } } - private func variationSpec() { + private class func variationSpec() { describe("variation") { var testContext: TestContext! beforeEach { @@ -802,10 +815,12 @@ final class LDClientSpec: QuickSpec { } } - private func observeSpec() { + private class func observeSpec() { + final class TestObserver {} var testContext: TestContext! var mockNotifier: FlagChangeNotifyingMock! var callCount: Int = 0 + var testObserver: TestObserver! describe("observe") { beforeEach { testContext = TestContext() @@ -813,68 +828,69 @@ final class LDClientSpec: QuickSpec { mockNotifier = FlagChangeNotifyingMock() testContext.subject.flagChangeNotifier = mockNotifier callCount = 0 + testObserver = TestObserver() } it("observe") { - testContext.subject.observe(key: "test-key", owner: self) { _ in callCount += 1 } + testContext.subject.observe(key: "test-key", owner: testObserver) { _ in callCount += 1 } let receivedObserver = mockNotifier.addFlagChangeObserverReceivedObserver expect(mockNotifier.addFlagChangeObserverCallCount) == 1 expect(receivedObserver?.flagKeys) == ["test-key"] - expect(receivedObserver?.owner) === self + expect(receivedObserver?.owner) === testObserver receivedObserver?.flagChangeHandler?(LDChangedFlag(key: "", oldValue: nil, newValue: nil)) expect(callCount) == 1 } it("observeKeys") { - testContext.subject.observe(keys: ["test-key"], owner: self) { _ in callCount += 1 } + testContext.subject.observe(keys: ["test-key"], owner: testObserver) { _ in callCount += 1 } let receivedObserver = mockNotifier.addFlagChangeObserverReceivedObserver expect(mockNotifier.addFlagChangeObserverCallCount) == 1 expect(receivedObserver?.flagKeys) == ["test-key"] - expect(receivedObserver?.owner) === self + expect(receivedObserver?.owner) === testObserver let changedFlags = ["test-key": LDChangedFlag(key: "", oldValue: nil, newValue: nil)] receivedObserver?.flagCollectionChangeHandler?(changedFlags) expect(callCount) == 1 } it("observeAll") { - testContext.subject.observeAll(owner: self) { _ in callCount += 1 } + testContext.subject.observeAll(owner: testObserver) { _ in callCount += 1 } let receivedObserver = mockNotifier.addFlagChangeObserverReceivedObserver expect(mockNotifier.addFlagChangeObserverCallCount) == 1 expect(receivedObserver?.flagKeys) == LDFlagKey.anyKey - expect(receivedObserver?.owner) === self + expect(receivedObserver?.owner) === testObserver let changedFlags = ["test-key": LDChangedFlag(key: "", oldValue: nil, newValue: nil)] receivedObserver?.flagCollectionChangeHandler?(changedFlags) expect(callCount) == 1 } it("observeFlagsUnchanged") { - testContext.subject.observeFlagsUnchanged(owner: self) { callCount += 1 } + testContext.subject.observeFlagsUnchanged(owner: testObserver) { callCount += 1 } let receivedObserver = mockNotifier.addFlagsUnchangedObserverReceivedObserver expect(mockNotifier.addFlagsUnchangedObserverCallCount) == 1 - expect(receivedObserver?.owner) === self + expect(receivedObserver?.owner) === testObserver receivedObserver?.flagsUnchangedHandler() expect(callCount) == 1 } it("observeConnectionModeChanged") { - testContext.subject.observeCurrentConnectionMode(owner: self) { _ in callCount += 1 } + testContext.subject.observeCurrentConnectionMode(owner: testObserver) { _ in callCount += 1 } let receivedObserver = mockNotifier.addConnectionModeChangedObserverReceivedObserver expect(mockNotifier.addConnectionModeChangedObserverCallCount) == 1 - expect(receivedObserver?.owner) === self + expect(receivedObserver?.owner) === testObserver receivedObserver?.connectionModeChangedHandler(ConnectionInformation.ConnectionMode.offline) expect(callCount) == 1 } it("stopObserving") { - testContext.subject.stopObserving(owner: self) + testContext.subject.stopObserving(owner: testObserver) expect(mockNotifier.removeObserverCallCount) == 1 - expect(mockNotifier.removeObserverReceivedOwner) === self + expect(mockNotifier.removeObserverReceivedOwner) === testObserver } } } - private func onSyncCompleteSpec() { + private class func onSyncCompleteSpec() { describe("on sync complete") { onSyncCompleteSuccessSpec() onSyncCompleteErrorSpec() } } - private func onSyncCompleteSuccessSpec() { + private class func onSyncCompleteSuccessSpec() { it("flag collection") { self.onSyncCompleteSuccessReplacingFlagsSpec() } @@ -886,7 +902,7 @@ final class LDClientSpec: QuickSpec { } } - private func onSyncCompleteSuccessReplacingFlagsSpec() { + private class func onSyncCompleteSuccessReplacingFlagsSpec() { let testContext = TestContext(startOnline: true) testContext.start() testContext.subject.flagChangeNotifier = ClientServiceMockFactory(config: testContext.config).makeFlagChangeNotifier() @@ -912,7 +928,7 @@ final class LDClientSpec: QuickSpec { expect(testContext.changeNotifierMock.notifyObserversReceivedArguments?.oldFlags) == [:] } - func onSyncCompleteStreamingPatchSpec() { + class func onSyncCompleteStreamingPatchSpec() { let stubFlags = FlagMaintainingMock.stubStoredItems() let testContext = TestContext(startOnline: true).withCached(flags: stubFlags.featureFlags) testContext.start() @@ -939,7 +955,7 @@ final class LDClientSpec: QuickSpec { expect(testContext.changeNotifierMock.notifyObserversReceivedArguments?.oldFlags == stubFlags.featureFlags).to(beTrue()) } - func onSyncCompleteDeleteFlagSpec() { + class func onSyncCompleteDeleteFlagSpec() { let stubFlags = FlagMaintainingMock.stubStoredItems() let testContext = TestContext(startOnline: true).withCached(flags: stubFlags.featureFlags) testContext.start() @@ -966,7 +982,7 @@ final class LDClientSpec: QuickSpec { expect(testContext.changeNotifierMock.notifyObserversReceivedArguments?.oldFlags == stubFlags.featureFlags).to(beTrue()) } - func onSyncCompleteErrorSpec() { + class func onSyncCompleteErrorSpec() { func runTest(_ ctx: String, _ err: SynchronizingError, testError: @escaping ((ConnectionInformation.LastConnectionFailureReason) -> Void)) { @@ -1010,7 +1026,7 @@ final class LDClientSpec: QuickSpec { runTest("there was a non-NSError error", .streamError(DummyError())) { _ in } } - private func runModeSpec() { + private class func runModeSpec() { describe("didEnterBackground notification") { context("after starting client") { context("when online") { @@ -1269,14 +1285,14 @@ final class LDClientSpec: QuickSpec { #endif } - private func streamingModeSpec() { + private class func streamingModeSpec() { var testContext: TestContext! describe("flag synchronizer streaming mode") { OperatingSystem.allOperatingSystems.forEach { os in it("on \(os) sets the flag synchronizer streaming mode") { // TODO(os-tests): We need to expand this to the other OSs - if os == .watchOS { + if os == .watchOS || os == .linux { return } @@ -1288,7 +1304,7 @@ final class LDClientSpec: QuickSpec { } } - private func flushSpec() { + private class func flushSpec() { describe("flush") { it("tells the event reporter to report events") { let testContext = TestContext() @@ -1299,7 +1315,7 @@ final class LDClientSpec: QuickSpec { } } - private func allFlagsSpec() { + private class func allFlagsSpec() { let stubFlags = FlagMaintainingMock.stubStoredItems() describe("allFlags") { it("returns all non-null flag values from store") { @@ -1316,7 +1332,7 @@ final class LDClientSpec: QuickSpec { } } - private func connectionInformationSpec() { + private class func connectionInformationSpec() { describe("ConnectionInformation") { it("when client was started in foreground") { let testContext = TestContext(startOnline: true) @@ -1338,7 +1354,7 @@ final class LDClientSpec: QuickSpec { } } - private func variationDetailSpec() { + private class func variationDetailSpec() { describe("variationDetail") { it("when flag doesn't exist") { let testContext = TestContext() @@ -1351,7 +1367,7 @@ final class LDClientSpec: QuickSpec { } } - private func isInitializedSpec() { + private class func isInitializedSpec() { describe("isInitialized") { it("when client was started but no flag update") { let testContext = TestContext(startOnline: true) @@ -1376,7 +1392,7 @@ final class LDClientSpec: QuickSpec { testContext.start() testContext.onSyncComplete?(.flagCollection((FeatureFlagCollection([:]), nil))) - expect(testContext.subject.isInitialized).toEventually(beTrue(), timeout: DispatchTimeInterval.seconds(2)) + expect(testContext.subject.isInitialized).toEventually(beTrue(), timeout: .seconds(2)) testContext.subject.close() expect(testContext.subject.isInitialized) == false diff --git a/LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift b/LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift new file mode 100644 index 000000000..cb00083fe --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift @@ -0,0 +1,40 @@ +import Foundation +import Nimble + +/** + Used by the `toMatch` matcher. + + This is the return type for the closure. + */ +public enum ToMatchResult { + case matched + case failed(reason: String) +} + +/** + A Nimble matcher that takes in a closure for validation. + + Return `.matched` when the validation succeeds. + Return `.failed` with a failure reason when the validation fails. + */ +public func match() -> Nimble.Predicate<() -> ToMatchResult> { + Nimble.Predicate.define { actualExpression in + let optActual = try actualExpression.evaluate() + guard let actual = optActual else { + return Nimble.PredicateResult(status: .fail, message: .fail("expected a closure, got ")) + } + + switch actual() { + case .matched: + return Nimble.PredicateResult( + bool: true, + message: .expectedCustomValueTo("match", actual: "") + ) + case .failed(let reason): + return Nimble.PredicateResult( + bool: false, + message: .expectedCustomValueTo("match", actual: " because <\(reason)>") + ) + } + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift index d954e4fd9..65ce5008a 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift @@ -76,7 +76,11 @@ final class ClientServiceMockFactory: ClientServiceCreating { makeEventReporterReceivedService = service onEventSyncComplete = onSyncComplete - return EventReportingMock() + if config.sendEvents { + return EventReportingMock() + } else { + return NullEventReporter() + } } func makeEventReporter(config: LDConfig, service: DarklyServiceProvider) -> EventReporting { diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift index 88f4a14bd..62be1ae81 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift @@ -1,11 +1,17 @@ import Foundation import Quick import Nimble +#if !os(Linux) && !os(Windows) import OHHTTPStubs import OHHTTPStubsSwift +#endif import LDSwiftEventSource @testable import LaunchDarkly +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + final class DarklyServiceMock: DarklyServiceProvider { struct FlagKeys { static let bool = "bool-flag" @@ -46,7 +52,7 @@ final class DarklyServiceMock: DarklyServiceProvider { } struct Constants { - static let error = NSError(domain: NSURLErrorDomain, code: Int(CFNetworkErrors.cfurlErrorResourceUnavailable.rawValue), userInfo: nil) + static let error = NSError(domain: NSURLErrorDomain, code: Int(URLError.resourceUnavailable.rawValue), userInfo: nil) static let jsonErrorString = "Bad json data" static let errorData = jsonErrorString.data(using: .utf8)! @@ -96,8 +102,11 @@ final class DarklyServiceMock: DarklyServiceProvider { var config: LDConfig var context: LDContext var diagnosticCache: DiagnosticCaching? = nil + var flagRequestEtag: String? + #if !os(Linux) && !os(Windows) var activationBlocks = [(testBlock: HTTPStubsTestBlock, callback: ((URLRequest, HTTPStubsDescriptor, HTTPStubsResponse) -> Void))]() + #endif init(config: LDConfig = LDConfig.stub, context: LDContext = LDContext.stub()) { self.config = config @@ -116,6 +125,7 @@ final class DarklyServiceMock: DarklyServiceProvider { var clearFlagResponseCacheCallCount = 0 func resetFlagResponseCache(etag: String?) { clearFlagResponseCacheCallCount += 1 + flagRequestEtag = etag } var createdEventSource: DarklyStreamingProviderMock? @@ -161,6 +171,8 @@ extension DarklyServiceMock { var flagHost: String? { config.baseUrl.host } + + #if !os(Linux) && !os(Windows) var flagRequestStubTest: HTTPStubsTestBlock { isScheme(Constants.schemeHttps) && isHost(flagHost!) } @@ -214,6 +226,7 @@ extension DarklyServiceMock { stubbedFlagResponse = (nil, response, Constants.error, nil) } } + #endif func flagStubName(statusCode: Int, useReport: Bool) -> String { "Flag request stub using method \(useReport ? URLRequest.HTTPMethods.report : URLRequest.HTTPMethods.get) with response status code \(statusCode)" @@ -224,6 +237,8 @@ extension DarklyServiceMock { var eventHost: String? { config.eventsUrl.host } + + #if !os(Windows) && !os(Linux) var eventRequestStubTest: HTTPStubsTestBlock { isScheme(Constants.schemeHttps) && isHost(eventHost!) && isMethodPOST() } @@ -239,6 +254,7 @@ extension DarklyServiceMock { activate?(request) } } + #endif /// Use when testing requires the mock service to provide a service response to the event request callback func stubEventResponse(success: Bool, responseOnly: Bool = false, errorOnly: Bool = false, responseDate: Date? = nil) { @@ -265,6 +281,7 @@ extension DarklyServiceMock { // MARK: Publish Diagnostic + #if !os(Linux) && !os(Windows) /// Use when testing requires the mock service to actually make an diagnostic request func stubDiagnosticRequest(success: Bool, onActivation activate: ((URLRequest, HTTPStubsDescriptor, HTTPStubsResponse) -> Void)? = nil) { let stubResponse: HTTPStubsResponseBlock = success ? { _ in @@ -297,8 +314,10 @@ extension DarklyServiceMock { return } } + #endif } +#if !os(Linux) && !os(Windows) /** * Matcher testing that the `NSURLRequest` is using the **REPORT** `HTTPMethod` * @@ -306,6 +325,7 @@ extension DarklyServiceMock { * is using the REPORT method */ public func isMethodREPORT() -> HTTPStubsTestBlock { { $0.httpMethod == URLRequest.HTTPMethods.report } } +#endif extension HTTPURLResponse { static func dateHeader(from date: Date?) -> [String: String]? { diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/LDEventSourceMock.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/LDEventSourceMock.swift index 0f08112ab..b9c57780c 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/LDEventSourceMock.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/LDEventSourceMock.swift @@ -1,5 +1,10 @@ import Foundation import LDSwiftEventSource + +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + @testable import LaunchDarkly extension EventHandler { diff --git a/LaunchDarkly/LaunchDarklyTests/Models/Context/LDContextCodableSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/Context/LDContextCodableSpec.swift index 7e74bb7d3..6afeb8ea4 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/Context/LDContextCodableSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/Context/LDContextCodableSpec.swift @@ -50,7 +50,11 @@ final class LDContextCodableSpec: XCTestCase { let output = try jsonEncoder.encode(context) let outputJson = String(data: output, encoding: .utf8) - XCTAssertEqual(json, outputJson) + // ordering of the values in the string is not guaranteed, we should instead serialize the string + // back into a context and ensure that it matches the input context. + let contextFromOutput = try JSONDecoder().decode(LDContext.self, from: Data(try XCTUnwrap(outputJson).utf8)) + + XCTAssertEqual(context, contextFromOutput) } } diff --git a/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift index b76e4186e..70cea6293 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift @@ -5,7 +5,17 @@ import Nimble final class DiagnosticEventSpec: QuickSpec { + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { diagnosticIdSpec() diagnosticSdkSpec() diagnosticPlatformSpec() @@ -16,7 +26,7 @@ final class DiagnosticEventSpec: QuickSpec { diagnosticStatsSpec() } - private func diagnosticIdSpec() { + private class func diagnosticIdSpec() { context("DiagnosticId init") { context("with empty mobile key") { it("inits with correct values") { @@ -62,7 +72,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticSdkSpec() { + private class func diagnosticSdkSpec() { context("DiagnosticSdk") { context("without wrapper configured") { it("has correct values and encoding") { @@ -101,7 +111,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticPlatformSpec() { + private class func diagnosticPlatformSpec() { var environmentReporter: EnvironmentReportingMock! var diagnosticPlatform: DiagnosticPlatform! context("DiagnosticPlatform") { @@ -139,7 +149,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticStreamInitSpec() { + private class func diagnosticStreamInitSpec() { context("DiagnosticStreamInit") { it("inits with given values") { let streamInit = DiagnosticStreamInit(timestamp: 1000, durationMillis: 100, failed: true) @@ -169,7 +179,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func customizedConfig() -> LDConfig { + private class func customizedConfig() -> LDConfig { var customConfig = LDConfig(mobileKey: "foobar", autoEnvAttributes: .disabled, isDebugBuild: true) customConfig.baseUrl = URL(string: "https://clientstream.launchdarkly.com")! customConfig.eventsUrl = URL(string: "https://app.launchdarkly.com")! @@ -192,7 +202,7 @@ final class DiagnosticEventSpec: QuickSpec { return customConfig } - private func diagnosticConfigSpec() { + private class func diagnosticConfigSpec() { let defaultConfig = LDConfig(mobileKey: "foobar", autoEnvAttributes: .disabled, isDebugBuild: true) let customConfig = customizedConfig() context("DiagnosticConfig") { @@ -311,7 +321,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticKindSpec() { + private class func diagnosticKindSpec() { context("DiagnosticKind") { // JSONEncoder will encode raw primitives on newer platforms, but not all supported platforms. For these // tests we wrap the kind in an object to allow us to test the encoding. @@ -334,7 +344,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticInitSpec() { + private class func diagnosticInitSpec() { let customConfig = customizedConfig() var now: Int64! var diagnosticId: DiagnosticId! @@ -376,7 +386,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticStatsSpec() { + private class func diagnosticStatsSpec() { var now: Int64! var diagnosticId: DiagnosticId! var diagnosticStats: DiagnosticStats! @@ -426,10 +436,10 @@ final class DiagnosticEventSpec: QuickSpec { } } - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() - private func loadAndRestore(_ subject: T?) -> T? { + private class func loadAndRestore(_ subject: T?) -> T? { let encoded = try? encoder.encode(subject) return try? decoder.decode(T.self, from: encoded!) } diff --git a/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift index f5457044d..13d51d379 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift @@ -1,6 +1,10 @@ import Foundation import XCTest +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + @testable import LaunchDarkly final class LDConfigSpec: XCTestCase { diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift index 19a917e48..2aa1d87d8 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift @@ -1,3 +1,4 @@ +#if !os(Linux) && !os(Windows) import Foundation import Quick import Nimble @@ -48,7 +49,17 @@ final class DarklyServiceSpec: QuickSpec { } } + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { getFeatureFlagsSpec() flagRequestEtagSpec() clearFlagRequestCacheSpec() @@ -62,7 +73,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func getFeatureFlagsSpec() { + private class func getFeatureFlagsSpec() { var testContext: TestContext! var requestEtag: String! @@ -410,7 +421,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func flagRequestEtagSpec() { + private class func flagRequestEtagSpec() { var originalFlagRequestEtag: String! var testContext: TestContext! describe("flagRequestEtag") { @@ -512,7 +523,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func clearFlagRequestCacheSpec() { + private class func clearFlagRequestCacheSpec() { describe("clearFlagResponseCache") { it("clears cached etag") { let testContext = TestContext() @@ -523,7 +534,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func createEventSourceSpec() { + private class func createEventSourceSpec() { var testContext: TestContext! describe("createEventSource") { @@ -543,7 +554,7 @@ final class DarklyServiceSpec: QuickSpec { let expectedContext = encodeToLDValue(testContext.context, userInfo: [LDContext.UserInfoKeys.includePrivateAttributes: true, LDContext.UserInfoKeys.redactAttributes: false]) expect(receivedArguments!.url.lastPathComponent.jsonValue) == expectedContext expect(receivedArguments!.httpHeaders).toNot(beEmpty()) - expect(receivedArguments!.connectMethod).to(be("GET")) + expect(receivedArguments!.connectMethod) == "GET" expect(receivedArguments!.connectBody).to(beNil()) } } @@ -568,7 +579,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func publishEventDataSpec() { + private class func publishEventDataSpec() { let testData = Data("abc".utf8) var testContext: TestContext! @@ -638,7 +649,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func diagnosticCacheSpec() { + private class func diagnosticCacheSpec() { describe("diagnosticCache") { it("does not create cache with empty mobile key") { let testContext = TestContext(mobileKey: "") @@ -659,11 +670,11 @@ final class DarklyServiceSpec: QuickSpec { } } - private func stubDiagnostic() -> DiagnosticStats { + private class func stubDiagnostic() -> DiagnosticStats { DiagnosticStats(id: DiagnosticId(diagnosticId: "test-id", sdkKey: LDConfig.Constants.mockMobileKey), creationDate: 1000, dataSinceDate: 100, droppedEvents: 0, eventsInLastBatch: 0, streamInits: []) } - private func publishDiagnosticSpec() { + private class func publishDiagnosticSpec() { var testContext: TestContext! describe("publishDiagnostic") { @@ -755,3 +766,4 @@ private extension String { return try? JSONDecoder().decode(LDValue.self, from: data) } } +#endif // !os(Linux) && !os(Windows) diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/HTTPURLResponse.swift b/LaunchDarkly/LaunchDarklyTests/Networking/HTTPURLResponse.swift index ffb5910f2..b5c0ca0b0 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/HTTPURLResponse.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/HTTPURLResponse.swift @@ -1,6 +1,10 @@ import Foundation @testable import LaunchDarkly +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + extension HTTPURLResponse.StatusCodes { static let all = [ok, accepted, badRequest, unauthorized, methodNotAllowed, internalServerError, notImplemented] static let retry = LDConfig.reportRetryStatusCodes diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/URLRequestSpec.swift b/LaunchDarkly/LaunchDarklyTests/Networking/URLRequestSpec.swift index 64471ad67..8c9cd8fd2 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/URLRequestSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/URLRequestSpec.swift @@ -1,6 +1,10 @@ import Foundation import XCTest +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + @testable import LaunchDarkly final class URLRequestSpec: XCTestCase { diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift index 20e36eb13..004766cf5 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift @@ -4,7 +4,17 @@ import Nimble @testable import LaunchDarkly final class DiagnosticCacheSpec: QuickSpec { + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { context("DiagnosticCache") { getCurrentStatsAndResetSpec() incrementDroppedEventCountSpec() @@ -13,7 +23,7 @@ final class DiagnosticCacheSpec: QuickSpec { } } - private func getCurrentStatsAndResetSpec() { + private class func getCurrentStatsAndResetSpec() { context("getCurrentStatsAndReset") { it("has expected initial values") { let diagnosticCache = DiagnosticCache(sdkKey: "this_is_a_fake_key") @@ -55,7 +65,7 @@ final class DiagnosticCacheSpec: QuickSpec { } } - private func incrementDroppedEventCountSpec() { + private class func incrementDroppedEventCountSpec() { context("incrementDroppedEventCount") { it("increments dropped event count") { let diagnosticCache = DiagnosticCache(sdkKey: "this_is_a_fake_key") @@ -94,7 +104,7 @@ final class DiagnosticCacheSpec: QuickSpec { } } - private func recordEventsInLastBatchSpec() { + private class func recordEventsInLastBatchSpec() { context("recordEventsInLastBatch") { it("sets events in last batch") { let diagnosticCache = DiagnosticCache(sdkKey: "this_is_a_fake_key") @@ -116,7 +126,7 @@ final class DiagnosticCacheSpec: QuickSpec { } } - private func addStreamInitSpec() { + private class func addStreamInitSpec() { context("addStreamInit") { it("adds a stream init") { let diagnosticCache = DiagnosticCache(sdkKey: "this_is_a_fake_key") @@ -179,4 +189,5 @@ final class DiagnosticCacheSpec: QuickSpec { } } } + } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift index 7987e267c..166deedab 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift @@ -18,9 +18,13 @@ final class FeatureFlagCacheSpec: XCTestCase { let flagCache = FeatureFlagCache(serviceFactory: serviceFactory, mobileKey: "abc", maxCachedContexts: 2) XCTAssertEqual(flagCache.maxCachedContexts, 2) XCTAssertEqual(serviceFactory.makeKeyedValueCacheCallCount, 1) - let bundleHashed = Util.sha256base64(Bundle.main.bundleIdentifier!) let keyHashed = Util.sha256base64("abc") + #if !os(Linux) && !os(Windows) + let bundleHashed = Util.sha256base64(Bundle.main.bundleIdentifier!) let expectedCacheKey = "com.launchdarkly.client.\(bundleHashed).\(keyHashed)" + #else + let expectedCacheKey = "com.launchdarkly.client.\(keyHashed)" + #endif XCTAssertEqual(serviceFactory.makeKeyedValueCacheReceivedCacheKey, expectedCacheKey) XCTAssertTrue(flagCache.keyedValueCache as? KeyedValueCachingMock === mockValueCache) } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/DiagnosticReporterSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/DiagnosticReporterSpec.swift index 38e4b9d9d..fa34703f5 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/DiagnosticReporterSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/DiagnosticReporterSpec.swift @@ -1,6 +1,11 @@ +#if !os(Linux) && !os(Windows) import Foundation import XCTest +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + @testable import LaunchDarkly final class DiagnosticReporterSpec: XCTestCase { @@ -118,3 +123,4 @@ final class DiagnosticReporterSpec: XCTestCase { tst.stop() } } +#endif diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporterSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporterSpec.swift new file mode 100644 index 000000000..5caf52530 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporterSpec.swift @@ -0,0 +1,44 @@ +#if os(Windows) +import Foundation +import WinSDK +import XCTest + +@testable import LaunchDarkly + +final class WindowsEnvironmentReporterSpec: XCTestCase { + func testDefaultReporterBehavior() { + let chain = EnvironmentReporterChainBase() + chain.setNext(WindowsEnvironmentReporter()) + + XCTAssertEqual(chain.osFamily, "Windows") + XCTAssertEqual(chain.deviceModel, expectedDeviceModel()) + XCTAssertEqual(chain.manufacturer, "unknown") + + let version = chain.systemVersion + let split = version.split(separator: ".") + XCTAssertEqual(split.count, 3) + } + + private func expectedDeviceModel() -> String { + var status: SYSTEM_POWER_STATUS = .init() + if !GetSystemPowerStatus(&status) { + return "unknown" + } + + switch status.ACLineStatus { + // Not using AC power, probably a laptop on battery + case 0: + return "laptop" + // Using AC power, probably a laptop on AC power + case 1: + return "laptop" + // AC power status unknown, likely a desktop + case 255: + return "desktop" + // An unknown value, return unknown since we don't know + default: + return "unknown" + } + } +} +#endif diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift index 588418717..ab192ae05 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift @@ -3,6 +3,10 @@ import Quick import Nimble @testable import LaunchDarkly +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + final class EventReporterSpec: QuickSpec { struct Constants { static let eventFlushInterval: TimeInterval = 10.0 @@ -62,7 +66,17 @@ final class EventReporterSpec: QuickSpec { } } + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { initSpec() isOnlineSpec() recordEventSpec() @@ -71,7 +85,7 @@ final class EventReporterSpec: QuickSpec { reportTimerSpec() } - private func initSpec() { + private class func initSpec() { describe("init") { var testContext: TestContext! beforeEach { @@ -87,7 +101,7 @@ final class EventReporterSpec: QuickSpec { } } - private func isOnlineSpec() { + private class func isOnlineSpec() { describe("isOnline") { var testContext: TestContext! beforeEach { @@ -163,7 +177,7 @@ final class EventReporterSpec: QuickSpec { } } - private func recordEventSpec() { + private class func recordEventSpec() { describe("recordEvent") { var testContext: TestContext! context("event store empty") { @@ -202,7 +216,7 @@ final class EventReporterSpec: QuickSpec { } } - private func reportEventsSpec() { + private class func reportEventsSpec() { describe("reportEvents") { var testContext: TestContext! var eventStubResponseDate: Date! @@ -474,7 +488,7 @@ final class EventReporterSpec: QuickSpec { } } - func testRecordFlagEvaluationEvents() { + class func testRecordFlagEvaluationEvents() { let context = LDContext.stub() let serviceMock = DarklyServiceMock() describe("recordFlagEvaluationEvents") { @@ -610,7 +624,7 @@ final class EventReporterSpec: QuickSpec { } } - private func reportTimerSpec() { + private class func reportTimerSpec() { describe("report timer fires") { var testContext: TestContext! afterEach { diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift index 5c4bbe7b0..45fa444a3 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift @@ -134,7 +134,17 @@ final class FlagChangeNotifierSpec: QuickSpec { } } + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { describe("init") { it("no initial observers") { let notifier = FlagChangeNotifier(logger: OSLog(subsystem: "com.launchdarkly", category: "tests")) @@ -149,7 +159,7 @@ final class FlagChangeNotifierSpec: QuickSpec { notifyConnectionSpec() } - private func removeObserverSpec() { + private class func removeObserverSpec() { describe("removeObserver") { var testContext: TestContext! var removedOwner: FlagChangeHandlerOwnerMock! @@ -197,7 +207,7 @@ final class FlagChangeNotifierSpec: QuickSpec { } } - private func notifyObserverSpec() { + private class func notifyObserverSpec() { describe("notifyObservers") { var testContext: TestContext! beforeEach { @@ -395,7 +405,7 @@ final class FlagChangeNotifierSpec: QuickSpec { } } - private func notifyConnectionSpec() { + private class func notifyConnectionSpec() { describe("notifyConnectionModeChangedObservers") { it("removes and does not notify expired observers") { let testContext = TestContext() diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift index 1421827d5..5b9b13225 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift @@ -4,6 +4,10 @@ import Nimble import LDSwiftEventSource @testable import LaunchDarkly +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + final class FlagSynchronizerSpec: QuickSpec { struct Constants { fileprivate static let pollingInterval: TimeInterval = 1 @@ -34,7 +38,17 @@ final class FlagSynchronizerSpec: QuickSpec { } } + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { initSpec() changeIsOnlineSpec() streamingEventSpec() @@ -42,7 +56,7 @@ final class FlagSynchronizerSpec: QuickSpec { flagRequestSpec() } - func initSpec() { + class func initSpec() { describe("init") { it("starts up streaming offline using get flag requests") { let testContext = TestContext(streamingMode: .streaming, useReport: false) @@ -91,7 +105,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func changeIsOnlineSpec() { + class func changeIsOnlineSpec() { describe("change isOnline") { var testContext: TestContext! @@ -242,7 +256,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingEventSpec() { + class func streamingEventSpec() { describe("streaming events") { streamingPingEventSpec() streamingPutEventSpec() @@ -253,7 +267,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingPingEventSpec() { + class func streamingPingEventSpec() { var testContext: TestContext! context("ping") { context("success") { @@ -264,7 +278,9 @@ final class FlagSynchronizerSpec: QuickSpec { syncResult = result done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: HTTPURLResponse.StatusCodes.ok) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() } @@ -291,7 +307,9 @@ final class FlagSynchronizerSpec: QuickSpec { } done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: HTTPURLResponse.StatusCodes.ok, badData: true) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -320,7 +338,9 @@ final class FlagSynchronizerSpec: QuickSpec { } done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: HTTPURLResponse.StatusCodes.internalServerError, responseOnly: true) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -349,7 +369,9 @@ final class FlagSynchronizerSpec: QuickSpec { } done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: HTTPURLResponse.StatusCodes.internalServerError, errorOnly: true) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -372,7 +394,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingPutEventSpec() { + class func streamingPutEventSpec() { var testContext: TestContext! var syncResult: FlagSyncResult? context("put") { @@ -428,7 +450,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingPatchEventSpec() { + class func streamingPatchEventSpec() { var testContext: TestContext! var syncResult: FlagSyncResult? context("patch") { @@ -486,7 +508,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingDeleteEventSpec() { + class func streamingDeleteEventSpec() { var testContext: TestContext! beforeEach { @@ -550,7 +572,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingOtherEventSpec() { + class func streamingOtherEventSpec() { var syncError: SynchronizingError? context("other events") { @@ -795,7 +817,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - private func streamingProcessingSpec() { + private class func streamingProcessingSpec() { var testContext: TestContext! var syncError: SynchronizingError? @@ -880,7 +902,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func pollingTimerFiresSpec() { + class func pollingTimerFiresSpec() { describe("polling timer fires") { var testContext: TestContext! @@ -927,7 +949,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func flagRequestSpec() { + class func flagRequestSpec() { describe("flag request") { var testContext: TestContext! context("using get method") { @@ -935,7 +957,9 @@ final class FlagSynchronizerSpec: QuickSpec { it("requests flags using a get request exactly one time") { waitUntil { done in testContext = TestContext(streamingMode: .streaming, useReport: false) { _ in done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: HTTPURLResponse.StatusCodes.ok) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -951,7 +975,9 @@ final class FlagSynchronizerSpec: QuickSpec { for statusCode in HTTPURLResponse.StatusCodes.nonRetry { waitUntil { done in testContext = TestContext(streamingMode: .streaming, useReport: false) { _ in done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: statusCode) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -967,7 +993,9 @@ final class FlagSynchronizerSpec: QuickSpec { for statusCode in HTTPURLResponse.StatusCodes.retry { waitUntil { done in testContext = TestContext(streamingMode: .streaming, useReport: false) { _ in done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: statusCode) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -985,7 +1013,9 @@ final class FlagSynchronizerSpec: QuickSpec { it("requests flags using a get request exactly one time") { waitUntil { done in testContext = TestContext(streamingMode: .streaming, useReport: true) { _ in done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: HTTPURLResponse.StatusCodes.ok) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -1001,7 +1031,9 @@ final class FlagSynchronizerSpec: QuickSpec { for statusCode in HTTPURLResponse.StatusCodes.nonRetry { waitUntil { done in testContext = TestContext(streamingMode: .streaming, useReport: true) { _ in done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: statusCode) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -1017,7 +1049,9 @@ final class FlagSynchronizerSpec: QuickSpec { for statusCode in HTTPURLResponse.StatusCodes.retry { waitUntil { done in testContext = TestContext(streamingMode: .streaming, useReport: true) { _ in done() } + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: statusCode) + #endif testContext.flagSynchronizer.isOnline = true testContext.providedEventHandler!.sendPing() @@ -1045,7 +1079,10 @@ final class FlagSynchronizerSpec: QuickSpec { } done() } + + #if !os(Linux) && !os(Windows) testContext.serviceMock.stubFlagResponse(statusCode: HTTPURLResponse.StatusCodes.ok) + #endif testContext.flagSynchronizer.testMakeFlagRequest() } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift index 88baefc9d..bdaaa178c 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift @@ -18,13 +18,23 @@ final class LDTimerSpec: QuickSpec { } } + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { initSpec() timerFiredSpec() cancelSpec() } - private func initSpec() { + private class func initSpec() { describe("init") { it("creates a repeating timer") { let testContext = TestContext(execute: { }) @@ -38,7 +48,7 @@ final class LDTimerSpec: QuickSpec { } } - private func timerFiredSpec() { + private class func timerFiredSpec() { describe("timerFired") { it("calls execute on the fireQueue multiple times") { var fireCount = 0 @@ -66,7 +76,7 @@ final class LDTimerSpec: QuickSpec { } } - private func cancelSpec() { + private class func cancelSpec() { describe("cancel") { it("cancels the timer") { let testContext = TestContext(execute: { }) diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/SynchronizingErrorSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/SynchronizingErrorSpec.swift index 94dae68da..72eb27909 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/SynchronizingErrorSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/SynchronizingErrorSpec.swift @@ -1,6 +1,10 @@ import Foundation import XCTest +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + import LDSwiftEventSource @testable import LaunchDarkly diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift index f3781457d..e7b2fd6da 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift @@ -10,22 +10,32 @@ final class ThrottlerSpec: QuickSpec { static let maxDelay: TimeInterval = 10.0 } - let dispatchQueue = DispatchQueue(label: "ThrottlerSpecQueue") + static let dispatchQueue = DispatchQueue(label: "ThrottlerSpecQueue") - func testThrottler(throttlingDisabled: Bool = false) -> Throttler { + class func testThrottler(throttlingDisabled: Bool = false) -> Throttler { return Throttler(logger: OSLog(subsystem: "com.launchdarkly", category: "tests"), maxDelay: Constants.maxDelay, isDebugBuild: throttlingDisabled, dispatcher: { self.dispatchQueue.sync(execute: $0) }) } + #if SWIFT_PACKAGE + override class func spec() { + specContents() + } + #else override func spec() { + Self.specContents() + } + #endif + + private class func specContents() { initSpec() runSpec() cancelSpec() } - func initSpec() { + class func initSpec() { describe("init") { it("with a maxDelay parameter") { let throttler = Throttler(logger: OSLog(subsystem: "com.launchdarkly", category: "tests"), maxDelay: Constants.maxDelay) @@ -46,7 +56,7 @@ final class ThrottlerSpec: QuickSpec { } } - func runSpec() { + class func runSpec() { describe("runThrottled") { context("throttling enabled") { firstRunsSpec() @@ -60,7 +70,7 @@ final class ThrottlerSpec: QuickSpec { } } - func firstRunsSpec() { + class func firstRunsSpec() { it("first runs immediate") { var hasRun = false let throttler = self.testThrottler() @@ -80,7 +90,7 @@ final class ThrottlerSpec: QuickSpec { } } - func immediateAfterDelaySpec() { + class func immediateAfterDelaySpec() { it("delay resets throttling") { let throttler = self.testThrottler() // First two run immediate @@ -99,7 +109,7 @@ final class ThrottlerSpec: QuickSpec { } } - func throttledRunSpec() { + class func throttledRunSpec() { it("sequential calls are throttled") { let throttler = self.testThrottler() // First two run immediate @@ -119,7 +129,7 @@ final class ThrottlerSpec: QuickSpec { } } - func maxDelaySpec() { + class func maxDelaySpec() { it("limits delay to maxDelay") { let throttler = Throttler(logger: OSLog(subsystem: "com.launchdarkly", category: "tests"), maxDelay: 1.0, isDebugBuild: false) (0..<10).forEach { _ in throttler.runThrottled { } } @@ -138,7 +148,7 @@ final class ThrottlerSpec: QuickSpec { } } - func throttlingDisabledRunSpec() { + class func throttlingDisabledRunSpec() { it("never throttles") { let throttler = self.testThrottler(throttlingDisabled: true) for _ in 0..<5 { @@ -152,7 +162,7 @@ final class ThrottlerSpec: QuickSpec { } } - func cancelSpec() { + class func cancelSpec() { it("can be cancelled") { let throttler = self.testThrottler() // Two immediate runs diff --git a/LaunchDarkly/OSLog/OSLog.swift b/LaunchDarkly/OSLog/OSLog.swift new file mode 100644 index 000000000..23e298307 --- /dev/null +++ b/LaunchDarkly/OSLog/OSLog.swift @@ -0,0 +1,116 @@ +#if os(Windows) +import Foundation +import WinSDK + +public let osLogStringSectionName = ".rdata$oslogstring" + +public struct OSLog: Sendable { + public static let `default` = OSLog(subsystem: "default", category: "default") + public static let disabled = OSLog(subsystem: "disabled", category: "disabled", isEnabled: false) + + public let subsystem: String + public let category: String + fileprivate let isEnabled: Bool + + public init(subsystem: String, category: String) { + self.subsystem = subsystem + self.category = category + self.isEnabled = true + } + + private init(subsystem: String, category: String, isEnabled: Bool) { + self.subsystem = subsystem + self.category = category + self.isEnabled = isEnabled + } +} + +public struct OSLogType: Sendable { + public static let `default` = OSLogType(name: "default") + public static let debug = OSLogType(name: "debug") + public static let info = OSLogType(name: "info") + public static let error = OSLogType(name: "error") + + public let name: String +} + +public func os_log(_ message: StaticString, log: OSLog = .default, type: OSLogType = .default, _ args: CVarArg...) { + guard log.isEnabled else { return } + + let formattedMessage = formatOSLogMessage(message, arguments: args) + let output = "[\(log.subsystem):\(log.category)] \(type.name): \(formattedMessage)\n" + output.withCString(encodedAs: UTF16.self) { + OutputDebugStringW($0) + } +} + +func formatOSLogMessage(_ message: StaticString, arguments: [CVarArg]) -> String { + let format = message.description + guard !arguments.isEmpty else { return format } + + var result = "" + var argumentIndex = 0 + var index = format.startIndex + + while index < format.endIndex { + guard format[index] == "%" else { + result.append(format[index]) + index = format.index(after: index) + continue + } + + let placeholderStart = index + index = format.index(after: index) + guard index < format.endIndex else { + result.append("%") + break + } + + if format[index] == "%" { + result.append("%") + index = format.index(after: index) + continue + } + + var placeholderEnd = index + if format[placeholderEnd] == "{" { + guard let closingBrace = format[placeholderEnd...].firstIndex(of: "}") else { + result.append("%") + continue + } + placeholderEnd = format.index(after: closingBrace) + } + + while placeholderEnd < format.endIndex { + let character = format[placeholderEnd] + placeholderEnd = format.index(after: placeholderEnd) + + guard OSLogFormat.conversionSpecifiers.contains(character) else { + continue + } + + if argumentIndex < arguments.count { + result.append(String(describing: arguments[argumentIndex])) + argumentIndex += 1 + } else { + result.append(contentsOf: format[placeholderStart.. = [ + "@", "d", "i", "u", "o", "x", "X", "f", "F", "e", "E", "g", "G", "a", "A", "c", "C", "s", "S", "p" + ] +} +#endif diff --git a/Package.swift b/Package.swift index 87d81d6a8..6f32ea3b3 100644 --- a/Package.swift +++ b/Package.swift @@ -2,6 +2,15 @@ import PackageDescription +// Temporarily needed to keep the Windows SPM build under the symbol count. +let linkType: Product.Library.LibraryType = { + #if os(Windows) + .dynamic + #else + .static + #endif +}() + let package = Package( name: "LaunchDarkly", platforms: [ @@ -13,37 +22,91 @@ let package = Package( products: [ .library( name: "LaunchDarkly", + type: linkType, targets: ["LaunchDarkly"]), ], dependencies: [ .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", .exact("9.1.0")), - .package(url: "https://github.com/Quick/Quick.git", .exact("4.0.0")), - .package(url: "https://github.com/Quick/Nimble.git", .exact("9.2.1")), + .package(url: "https://github.com/Quick/Quick.git", .exact("7.3.0")), + .package(url: "https://github.com/Quick/Nimble.git", .exact("13.0.0")), .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting", .exact("2.1.2")), - .package(name: "LDSwiftEventSource", url: "https://github.com/LaunchDarkly/swift-eventsource.git", .exact("3.3.0")), + .package(name: "LDSwiftEventSource", url: "https://github.com/thebrowsercompany/swift-eventsource.git", .branchItem("main-bcny")), ], - targets: [ + targets: packageTargets(), + swiftLanguageVersions: [.v5]) + +func packageTargets() -> [Target] { + var targets: [Target] = [ .target( name: "LaunchDarkly", - dependencies: [ - .product(name: "LDSwiftEventSource", package: "LDSwiftEventSource"), - ], + dependencies: launchDarklyDependencies(), path: "LaunchDarkly/LaunchDarkly", - exclude: ["Support"], + exclude: osSpecificExcludes(), resources: [ .process("PrivacyInfo.xcprivacy") + ], + linkerSettings: [ + .linkedLibrary("Cabinet", .when(platforms: [.windows])) ]), .testTarget( name: "LaunchDarklyTests", - dependencies: [ - "LaunchDarkly", - .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), - .product(name: "Quick", package: "Quick"), - .product(name: "CwlPreconditionTesting", package: "CwlPreconditionTesting"), - .product(name: "Nimble", package: "Nimble") - ], + dependencies: osSpecificLDTestsDependencies(), path: "LaunchDarkly", exclude: ["LaunchDarklyTests/Info.plist", "LaunchDarklyTests/.swiftlint.yml"], sources: ["GeneratedCode", "LaunchDarklyTests"]), - ], - swiftLanguageVersions: [.v5]) + ] + + #if os(Windows) + targets.append(.target( + name: "OSLog", + path: "LaunchDarkly/OSLog")) + #endif + + return targets +} + +func launchDarklyDependencies() -> [Target.Dependency] { + var dependencies: [Target.Dependency] = [ + .product(name: "LDSwiftEventSource", package: "LDSwiftEventSource") + ] + + #if os(Windows) + dependencies.append(.target(name: "OSLog")) + #endif + + return dependencies +} + +func osSpecificLDTestsDependencies() -> [Target.Dependency] { + #if os(Windows) + [ + "LaunchDarkly", + .target(name: "OSLog"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble") + ] + #elseif os(Linux) + [ + "LaunchDarkly", + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble") + ] + #else + [ + "LaunchDarkly", + .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), + .product(name: "Quick", package: "Quick"), + .product(name: "CwlPreconditionTesting", package: "CwlPreconditionTesting"), + .product(name: "Nimble", package: "Nimble") + ] + #endif +} + +func osSpecificExcludes() -> [String] { + var exclusions = ["Support"] + #if os(Linux) || os(Windows) + exclusions.append("ObjectiveC") + #endif + + return exclusions +}