From d08eb3971aa0885686c0c7f9a97d242ac39d2098 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Fri, 12 Jan 2024 16:55:40 -0500 Subject: [PATCH 1/9] Add Windows Support To Version 9 (#2) * Windows Compiles * Start pulling things around to build tests * Add Windows Build action * We should allow streaming on Windows * Update package file * Fix tests * Add newlines * add macos testing * Fix tests on Darwin * Go to event source revision with windows support * Fix casing * start adding environment reporting support * Remove swift-crypto and use windows-native hasher * Update linking type * Respond to PR feedback * PR feedback (cherry picked from commit e624a384645317ec4223b5367302dc1608991874) --- .github/workflows/ci.yml | 15 ++++ LaunchDarkly.xcodeproj/project.pbxproj | 26 ++++++ LaunchDarkly/LaunchDarkly/LDClient.swift | 23 +++-- LaunchDarkly/LaunchDarkly/LDCommon.swift | 11 +++ .../Models/ConnectionInformation.swift | 4 + .../LaunchDarkly/Models/LDConfig.swift | 11 ++- .../Networking/DarklyService.swift | 6 ++ .../Networking/HTTPURLRequest.swift | 4 + .../Networking/HTTPURLResponse.swift | 4 + .../LaunchDarkly/Networking/URLResponse.swift | 4 + .../ServiceObjects/CwlSysctl.swift | 2 + .../ServiceObjects/DiagnosticReporter.swift | 4 + .../ServiceObjects/EnvironmentReporter.swift | 6 +- .../EnvironmentReporterBuilder.swift | 2 + .../SystemCapabilities.swift | 10 +++ .../WindowsEnvironmentReporter.swift | 55 ++++++++++++ .../ServiceObjects/EventReporter.swift | 4 + .../ServiceObjects/FlagSynchronizer.swift | 10 ++- .../LaunchDarkly/ServiceObjects/LDTimer.swift | 10 ++- LaunchDarkly/LaunchDarkly/Util.swift | 48 ++++++++++ .../Extensions/ThreadSpec.swift | 4 +- .../LaunchDarklyTests/LDClientSpec.swift | 87 ++++++++++--------- .../LaunchDarklyTests/Matcher/Match.swift | 40 +++++++++ .../Mocks/DarklyServiceMock.swift | 20 ++++- .../Mocks/LDEventSourceMock.swift | 5 ++ .../Models/Context/LDContextCodableSpec.swift | 6 +- .../Models/DiagnosticEventSpec.swift | 26 +++--- .../Models/LDConfigSpec.swift | 4 + .../Networking/DarklyServiceSpec.swift | 22 ++--- .../Networking/HTTPURLResponse.swift | 4 + .../Networking/URLRequestSpec.swift | 4 + .../Cache/DiagnosticCacheSpec.swift | 11 +-- .../Cache/FeatureFlagCacheSpec.swift | 6 +- .../DiagnosticReporterSpec.swift | 6 ++ .../WindowsEnvironmentReporterSpec.swift | 44 ++++++++++ .../ServiceObjects/EventReporterSpec.swift | 18 ++-- .../FlagChangeNotifierSpec.swift | 8 +- .../ServiceObjects/FlagSynchronizerSpec.swift | 51 ++++++++--- .../ServiceObjects/LDTimerSpec.swift | 8 +- .../SynchronizingErrorSpec.swift | 4 + .../ServiceObjects/ThrottlerSpec.swift | 22 ++--- Package.swift | 51 ++++++++--- 42 files changed, 574 insertions(+), 136 deletions(-) create mode 100644 LaunchDarkly/LaunchDarkly/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporter.swift create mode 100644 LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift create mode 100644 LaunchDarkly/LaunchDarklyTests/ServiceObjects/EnvironmentReporting/WindowsEnvironmentReporterSpec.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f42b0fd0..023692c43 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 5.9 - SPM + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - name: Install Swift + uses: compnerd/gha-setup-swift@main + with: + branch: swift-5.9-release + tag: 5.9-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/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 137faa549..925461edb 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 @@ -764,7 +764,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) } @@ -949,6 +949,8 @@ 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) @@ -1003,13 +1005,22 @@ public class LDClient { service: service) if let backgroundNotification = SystemCapabilities.backgroundNotification { - NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: backgroundNotification, object: nil) + let background = NotificationCenter.default.addObserver(forName: backgroundNotification, object:nil, queue: OperationQueue.current, using: { [weak self] notification in + self?.didEnterBackground() + }) + notificationTokens.append(background) } if let foregroundNotification = SystemCapabilities.foregroundNotification { - NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: foregroundNotification, object: nil) + let foreground = NotificationCenter.default.addObserver(forName: foregroundNotification, object: nil, queue: OperationQueue.current, using: { [weak self] notification in + self?.willEnterForeground() + }) + notificationTokens.append(foreground) } - NotificationCenter.default.addObserver(self, selector: #selector(didCloseEventSource), name: Notification.Name(FlagSynchronizer.Constants.didCloseEventSourceName), object: nil) + 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) eventReporter = self.serviceFactory.makeEventReporter(config: configuration, service: service, onSyncComplete: onEventSyncComplete) service.resetFlagResponseCache(etag: cachedData.etag) 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..2d6158659 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -2,6 +2,10 @@ import Foundation import LDSwiftEventSource import OSLog +#if os(Linux) || os(Windows) +import FoundationNetworking +#endif + // swiftlint:disable:next large_tuple typealias ServiceResponse = (data: Data?, urlResponse: URLResponse?, error: Error?, etag: String?) typealias ServiceCompletionHandler = (ServiceResponse) -> Void @@ -70,11 +74,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 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/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..0cfbe022a 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 BCRYPT_SHA256_ALGORITHM = "SHA256" + + var algorithm: WinSDK.BCRYPT_ALG_HANDLE? + BCRYPT_SHA256_ALGORITHM.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/ThreadSpec.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift index 37c67ad18..7193fd9b0 100644 --- a/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift @@ -4,11 +4,11 @@ import Nimble @testable import LaunchDarkly final class ThreadSpec: QuickSpec { - override func spec() { + override class func spec() { 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..78d983cc8 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,7 @@ final class LDClientSpec: QuickSpec { fileprivate static let updateThreshold: TimeInterval = 0.05 } - override func spec() { + override class func spec() { startSpec() moveToBackgroundSpec() identifySpec() @@ -32,7 +36,7 @@ final class LDClientSpec: QuickSpec { isInitializedSpec() } - private func startSpec() { + private class func startSpec() { describe("start") { startSpec(withTimeout: false) } @@ -62,7 +66,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 +256,7 @@ final class LDClientSpec: QuickSpec { } } - func startCompletionSpec() { + class func startCompletionSpec() { var testContext: TestContext! var completed = false var didTimeOut: Bool? = nil @@ -329,21 +333,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 +438,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 +582,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 +657,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 +687,7 @@ final class LDClientSpec: QuickSpec { } } - private func trackEventSpec() { + private class func trackEventSpec() { describe("track event") { it("records a custom event") { let testContext = TestContext() @@ -715,7 +719,7 @@ final class LDClientSpec: QuickSpec { } } - private func variationSpec() { + private class func variationSpec() { describe("variation") { var testContext: TestContext! beforeEach { @@ -802,10 +806,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 +819,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 +893,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 +919,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 +946,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 +973,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 +1017,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 +1276,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 +1295,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 +1306,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 +1323,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 +1345,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 +1358,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 +1383,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..5e6fd14ee --- /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() -> Matcher<() -> ToMatchResult> { + Matcher.define { actualExpression in + let optActual = try actualExpression.evaluate() + guard let actual = optActual else { + return MatcherResult(status: .fail, message: .fail("expected a closure, got ")) + } + + switch actual() { + case .matched: + return MatcherResult( + bool: true, + message: .expectedCustomValueTo("match", actual: "") + ) + case .failed(let reason): + return MatcherResult( + bool: false, + message: .expectedCustomValueTo("match", actual: " because <\(reason)>") + ) + } + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift index 88f4a14bd..5fb61b9a7 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)! @@ -97,7 +103,9 @@ final class DarklyServiceMock: DarklyServiceProvider { var context: LDContext var diagnosticCache: DiagnosticCaching? = nil + #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 @@ -161,6 +169,8 @@ extension DarklyServiceMock { var flagHost: String? { config.baseUrl.host } + + #if !os(Linux) && !os(Windows) var flagRequestStubTest: HTTPStubsTestBlock { isScheme(Constants.schemeHttps) && isHost(flagHost!) } @@ -214,6 +224,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 +235,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 +252,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 +279,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 +312,10 @@ extension DarklyServiceMock { return } } + #endif } +#if !os(Linux) && !os(Windows) /** * Matcher testing that the `NSURLRequest` is using the **REPORT** `HTTPMethod` * @@ -306,6 +323,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..835559fb8 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift @@ -5,7 +5,7 @@ import Nimble final class DiagnosticEventSpec: QuickSpec { - override func spec() { + override class func spec() { diagnosticIdSpec() diagnosticSdkSpec() diagnosticPlatformSpec() @@ -16,7 +16,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 +62,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 +101,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticPlatformSpec() { + private class func diagnosticPlatformSpec() { var environmentReporter: EnvironmentReportingMock! var diagnosticPlatform: DiagnosticPlatform! context("DiagnosticPlatform") { @@ -139,7 +139,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 +169,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 +192,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 +311,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 +334,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticInitSpec() { + private class func diagnosticInitSpec() { let customConfig = customizedConfig() var now: Int64! var diagnosticId: DiagnosticId! @@ -376,7 +376,7 @@ final class DiagnosticEventSpec: QuickSpec { } } - private func diagnosticStatsSpec() { + private class func diagnosticStatsSpec() { var now: Int64! var diagnosticId: DiagnosticId! var diagnosticStats: DiagnosticStats! @@ -426,10 +426,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..affff3b87 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,7 @@ final class DarklyServiceSpec: QuickSpec { } } - override func spec() { + override class func spec() { getFeatureFlagsSpec() flagRequestEtagSpec() clearFlagRequestCacheSpec() @@ -62,7 +63,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func getFeatureFlagsSpec() { + private class func getFeatureFlagsSpec() { var testContext: TestContext! var requestEtag: String! @@ -410,7 +411,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func flagRequestEtagSpec() { + private class func flagRequestEtagSpec() { var originalFlagRequestEtag: String! var testContext: TestContext! describe("flagRequestEtag") { @@ -512,7 +513,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func clearFlagRequestCacheSpec() { + private class func clearFlagRequestCacheSpec() { describe("clearFlagResponseCache") { it("clears cached etag") { let testContext = TestContext() @@ -523,7 +524,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func createEventSourceSpec() { + private class func createEventSourceSpec() { var testContext: TestContext! describe("createEventSource") { @@ -543,7 +544,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 +569,7 @@ final class DarklyServiceSpec: QuickSpec { } } - private func publishEventDataSpec() { + private class func publishEventDataSpec() { let testData = Data("abc".utf8) var testContext: TestContext! @@ -638,7 +639,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 +660,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 +756,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..212e5be61 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift @@ -4,7 +4,7 @@ import Nimble @testable import LaunchDarkly final class DiagnosticCacheSpec: QuickSpec { - override func spec() { + override class func spec() { context("DiagnosticCache") { getCurrentStatsAndResetSpec() incrementDroppedEventCountSpec() @@ -13,7 +13,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 +55,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 +94,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 +116,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 +179,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..92419f551 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,7 @@ final class EventReporterSpec: QuickSpec { } } - override func spec() { + override class func spec() { initSpec() isOnlineSpec() recordEventSpec() @@ -71,7 +75,7 @@ final class EventReporterSpec: QuickSpec { reportTimerSpec() } - private func initSpec() { + private class func initSpec() { describe("init") { var testContext: TestContext! beforeEach { @@ -87,7 +91,7 @@ final class EventReporterSpec: QuickSpec { } } - private func isOnlineSpec() { + private class func isOnlineSpec() { describe("isOnline") { var testContext: TestContext! beforeEach { @@ -163,7 +167,7 @@ final class EventReporterSpec: QuickSpec { } } - private func recordEventSpec() { + private class func recordEventSpec() { describe("recordEvent") { var testContext: TestContext! context("event store empty") { @@ -202,7 +206,7 @@ final class EventReporterSpec: QuickSpec { } } - private func reportEventsSpec() { + private class func reportEventsSpec() { describe("reportEvents") { var testContext: TestContext! var eventStubResponseDate: Date! @@ -474,7 +478,7 @@ final class EventReporterSpec: QuickSpec { } } - func testRecordFlagEvaluationEvents() { + class func testRecordFlagEvaluationEvents() { let context = LDContext.stub() let serviceMock = DarklyServiceMock() describe("recordFlagEvaluationEvents") { @@ -610,7 +614,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..408c501b4 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift @@ -134,7 +134,7 @@ final class FlagChangeNotifierSpec: QuickSpec { } } - override func spec() { + override class func spec() { describe("init") { it("no initial observers") { let notifier = FlagChangeNotifier(logger: OSLog(subsystem: "com.launchdarkly", category: "tests")) @@ -149,7 +149,7 @@ final class FlagChangeNotifierSpec: QuickSpec { notifyConnectionSpec() } - private func removeObserverSpec() { + private class func removeObserverSpec() { describe("removeObserver") { var testContext: TestContext! var removedOwner: FlagChangeHandlerOwnerMock! @@ -197,7 +197,7 @@ final class FlagChangeNotifierSpec: QuickSpec { } } - private func notifyObserverSpec() { + private class func notifyObserverSpec() { describe("notifyObservers") { var testContext: TestContext! beforeEach { @@ -395,7 +395,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..03ce239f9 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,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - override func spec() { + override class func spec() { initSpec() changeIsOnlineSpec() streamingEventSpec() @@ -42,7 +46,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 +95,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func changeIsOnlineSpec() { + class func changeIsOnlineSpec() { describe("change isOnline") { var testContext: TestContext! @@ -242,7 +246,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingEventSpec() { + class func streamingEventSpec() { describe("streaming events") { streamingPingEventSpec() streamingPutEventSpec() @@ -253,7 +257,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingPingEventSpec() { + class func streamingPingEventSpec() { var testContext: TestContext! context("ping") { context("success") { @@ -264,7 +268,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 +297,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 +328,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 +359,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 +384,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingPutEventSpec() { + class func streamingPutEventSpec() { var testContext: TestContext! var syncResult: FlagSyncResult? context("put") { @@ -428,7 +440,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingPatchEventSpec() { + class func streamingPatchEventSpec() { var testContext: TestContext! var syncResult: FlagSyncResult? context("patch") { @@ -486,7 +498,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingDeleteEventSpec() { + class func streamingDeleteEventSpec() { var testContext: TestContext! beforeEach { @@ -550,7 +562,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func streamingOtherEventSpec() { + class func streamingOtherEventSpec() { var syncError: SynchronizingError? context("other events") { @@ -795,7 +807,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - private func streamingProcessingSpec() { + private class func streamingProcessingSpec() { var testContext: TestContext! var syncError: SynchronizingError? @@ -880,7 +892,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func pollingTimerFiresSpec() { + class func pollingTimerFiresSpec() { describe("polling timer fires") { var testContext: TestContext! @@ -927,7 +939,7 @@ final class FlagSynchronizerSpec: QuickSpec { } } - func flagRequestSpec() { + class func flagRequestSpec() { describe("flag request") { var testContext: TestContext! context("using get method") { @@ -935,7 +947,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 +965,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 +983,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 +1003,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 +1021,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 +1039,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 +1069,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..03d79080e 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift @@ -18,13 +18,13 @@ final class LDTimerSpec: QuickSpec { } } - override func spec() { + override class func spec() { initSpec() timerFiredSpec() cancelSpec() } - private func initSpec() { + private class func initSpec() { describe("init") { it("creates a repeating timer") { let testContext = TestContext(execute: { }) @@ -38,7 +38,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 +66,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..70fd22f8d 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift @@ -10,22 +10,22 @@ 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) }) } - override func spec() { + override class func spec() { 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 +46,7 @@ final class ThrottlerSpec: QuickSpec { } } - func runSpec() { + class func runSpec() { describe("runThrottled") { context("throttling enabled") { firstRunsSpec() @@ -60,7 +60,7 @@ final class ThrottlerSpec: QuickSpec { } } - func firstRunsSpec() { + class func firstRunsSpec() { it("first runs immediate") { var hasRun = false let throttler = self.testThrottler() @@ -80,7 +80,7 @@ final class ThrottlerSpec: QuickSpec { } } - func immediateAfterDelaySpec() { + class func immediateAfterDelaySpec() { it("delay resets throttling") { let throttler = self.testThrottler() // First two run immediate @@ -99,7 +99,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 +119,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 +138,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 +152,7 @@ final class ThrottlerSpec: QuickSpec { } } - func cancelSpec() { + class func cancelSpec() { it("can be cancelled") { let throttler = self.testThrottler() // Two immediate runs diff --git a/Package.swift b/Package.swift index 87d81d6a8..12d426489 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,12 +22,13 @@ 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")), ], @@ -29,21 +39,42 @@ let package = Package( .product(name: "LDSwiftEventSource", package: "LDSwiftEventSource"), ], path: "LaunchDarkly/LaunchDarkly", - exclude: ["Support"], + exclude: osSpecificExcludes(), resources: [ .process("PrivacyInfo.xcprivacy") ]), .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]) + +func osSpecificLDTestsDependencies() -> [Target.Dependency] { + #if os(Linux) || os(Windows) + [ + "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 +} From ab8f7b746ed57d2affad782257aa6a39908d58ae Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Fri, 19 Jan 2024 20:17:31 -0500 Subject: [PATCH 2/9] Integrate the AnyURLSession library (#3) (cherry picked from commit f5b7abf0f8fbdad4022779a79a2281ce00f68a63) --- .../LaunchDarkly/Networking/DarklyService.swift | 14 ++++++++++---- Package.swift | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift index 2d6158659..a42bac436 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -3,7 +3,12 @@ import LDSwiftEventSource import OSLog #if os(Linux) || os(Windows) -import FoundationNetworking +import class FoundationNetworking.URLResponse +import class FoundationNetworking.HTTPURLResponse +import struct FoundationNetworking.URLRequest + +import class FoundationNetworking.URLSessionConfiguration +import AnyURLSession #endif // swiftlint:disable:next large_tuple @@ -120,9 +125,10 @@ 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 + let etag = self?.flagRequestEtag + self?.processEtag(from: (data: data, urlResponse: response, error: error, etag: etag)) + completion?((data: data, urlResponse: response, error: error, etag: etag)) } }.resume() } diff --git a/Package.swift b/Package.swift index 12d426489..c30995a44 100644 --- a/Package.swift +++ b/Package.swift @@ -30,7 +30,7 @@ let package = Package( .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: [ .target( From e9477d92e9a471b171cdc43ef4e0f2e5defd94d2 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Wed, 10 Jun 2026 15:51:59 -0400 Subject: [PATCH 3/9] Fix v11 Windows replay build issues --- .../LaunchDarkly/ServiceObjects/ClientServiceFactory.swift | 1 - LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift | 7 +++---- .../LaunchDarklyTests/Mocks/ClientServiceMockFactory.swift | 6 +++++- 3 files changed, 8 insertions(+), 6 deletions(-) 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/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index 78d983cc8..47d8a94f3 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -699,16 +699,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() 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 { From 98d4a1e5072d8a488fe6cc8c5238ece0e2d88198 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Wed, 10 Jun 2026 16:18:00 -0400 Subject: [PATCH 4/9] Update to Swift 6.3 for Windows --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 023692c43..0c9ddeda8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,7 +133,7 @@ jobs: - uses: ./.github/actions/test-swiftpm test-swiftpm-windows: - name: Windows - Swift 5.9 - SPM + name: Windows - Swift 6.3 - SPM runs-on: windows-latest steps: @@ -141,8 +141,7 @@ jobs: - name: Install Swift uses: compnerd/gha-setup-swift@main with: - branch: swift-5.9-release - tag: 5.9-RELEASE + swift-version: swift-6.3-release - name: Run tests run: swift test From 64bd70928d66913475bf1284adc72c8b79958aa9 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Wed, 10 Jun 2026 16:19:28 -0400 Subject: [PATCH 5/9] Add build --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c9ddeda8..8d3014d19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,7 @@ jobs: uses: compnerd/gha-setup-swift@main with: swift-version: swift-6.3-release + swift-build: 6.3-RELEASE - name: Run tests run: swift test From 162015e9f77786ed7d6a611a067875485080e092 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Mon, 15 Jun 2026 15:22:06 -0400 Subject: [PATCH 6/9] Test Codex goal output --- .../Extensions/Data+Compression.swift | 243 ++++++++++++++++++ .../Extensions/CompressionTest.swift | 6 + LaunchDarkly/OSLog/OSLog.swift | 45 ++++ Package.swift | 4 + 4 files changed, 298 insertions(+) create mode 100644 LaunchDarkly/OSLog/OSLog.swift diff --git a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift index 7ab1080ad..f03bd884c 100644 --- a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift +++ b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift @@ -26,7 +26,9 @@ /// import Foundation +#if !os(Windows) import Compression +#endif public extension Data { /// Compresses the data using the gzip algorithm. @@ -43,20 +45,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 +88,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 +118,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 +153,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 +179,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 +205,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 +274,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 +330,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 +423,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 +458,202 @@ fileprivate extension Data { } } +#if os(Windows) +private extension Data { + func windowsDeflate() -> Data? { + windowsStoredDeflate(self) + } + + func windowsInflate(expectedSize: Int? = nil) -> Data? { + guard !isEmpty else { return nil } + let inflated = windowsStoredInflate(self) + guard expectedSize == nil || inflated?.count == expectedSize else { return nil } + return inflated + } + + 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) + + result.append(windowsStoredDeflate(self)) + + 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 + + if flags & WindowsCompression.gzipExtraFlag != 0 { + guard position + 2 <= count else { return nil } + let extraLength = Int(littleEndianUInt16(at: position)) + position += 2 + guard position + extraLength <= count else { return nil } + position += extraLength + } + + if flags & WindowsCompression.gzipNameFlag != 0 { + guard let nextPosition = indexAfterNullTerminatedField(startingAt: position) else { + return nil + } + position = nextPosition + } + + if flags & WindowsCompression.gzipCommentFlag != 0 { + guard let nextPosition = indexAfterNullTerminatedField(startingAt: position) else { + return nil + } + position = nextPosition + } + + if flags & WindowsCompression.gzipHeaderCRCFlag != 0 { + guard position + 2 <= count else { return nil } + position += 2 + } + + guard position < count else { return nil } + let compressedStart = position + let footerStart = count - WindowsCompression.gzipFooterSize + 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 < count, self[offset] != 0x00 { + offset += 1 + } + guard offset < count 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 maxStoredBlockSize = Int(UInt16.max) + 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. Instead, the Windows path emits RFC1951 stored DEFLATE +// blocks, then wraps them in the same zlib/gzip containers and checksum validation used +// by the Apple path. Stored blocks trade compression ratio for a small, standards-compliant +// implementation that keeps the SDK's wire format behavior intact without vendoring zlib. +private func windowsStoredDeflate(_ input: Data) -> Data { + var result = Data() + + if input.isEmpty { + result.append(contentsOf: WindowsCompression.emptyStoredBlock) + return result + } + + 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? { + var result = Data() + var offset = 0 + + while offset < input.count { + let header = input[offset] + let isFinalBlock = header & 0x01 != 0 + let blockType = (header >> 1) & 0x03 + + // The Windows encoder above only writes stored blocks. Reject compressed block + // types here so malformed or unsupported deflate streams fail cleanly. + guard blockType == 0 else { return nil } + offset += 1 + + guard offset + 4 <= input.count 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 offset + blockLength <= input.count else { return nil } + result.append(input[offset..<(offset + blockLength)]) + offset += blockLength + + if isFinalBlock { + return offset == input.count ? result : nil + } + } + + return nil +} +#else fileprivate extension Data.CompressionAlgorithm { var lowLevelType: compression_algorithm { switch self { @@ -478,3 +720,4 @@ private func perform(_ config: Config, source: UnsafePointer, sourceSize: } } } +#endif diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift index f93a6276a..8b666e37b 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() } diff --git a/LaunchDarkly/OSLog/OSLog.swift b/LaunchDarkly/OSLog/OSLog.swift new file mode 100644 index 000000000..5810c1f0f --- /dev/null +++ b/LaunchDarkly/OSLog/OSLog.swift @@ -0,0 +1,45 @@ +#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 output = "[\(log.subsystem):\(log.category)] \(type.name): \(message.description)\n" + output.withCString(encodedAs: UTF16.self) { + OutputDebugStringW($0) + } +} +#endif diff --git a/Package.swift b/Package.swift index c30995a44..9d7eb65b0 100644 --- a/Package.swift +++ b/Package.swift @@ -37,12 +37,16 @@ let package = Package( name: "LaunchDarkly", dependencies: [ .product(name: "LDSwiftEventSource", package: "LDSwiftEventSource"), + .target(name: "OSLog", condition: .when(platforms: [.windows])), ], path: "LaunchDarkly/LaunchDarkly", exclude: osSpecificExcludes(), resources: [ .process("PrivacyInfo.xcprivacy") ]), + .target( + name: "OSLog", + path: "LaunchDarkly/OSLog"), .testTarget( name: "LaunchDarklyTests", dependencies: osSpecificLDTestsDependencies(), From 2b18ec87cab935d2cc7505d2cf45f49a61e2af84 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Mon, 15 Jun 2026 15:38:10 -0400 Subject: [PATCH 7/9] Review fixes --- .../Extensions/Data+Compression.swift | 32 ++++---- .../Extensions/CompressionTest.swift | 31 ++++++++ .../Extensions/OSLogTest.swift | 31 ++++++++ LaunchDarkly/OSLog/OSLog.swift | 73 ++++++++++++++++++- Package.swift | 1 + 5 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 LaunchDarkly/LaunchDarklyTests/Extensions/OSLogTest.swift diff --git a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift index f03bd884c..f07a636fe 100644 --- a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift +++ b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift @@ -464,11 +464,9 @@ private extension Data { windowsStoredDeflate(self) } - func windowsInflate(expectedSize: Int? = nil) -> Data? { + func windowsInflate() -> Data? { guard !isEmpty else { return nil } - let inflated = windowsStoredInflate(self) - guard expectedSize == nil || inflated?.count == expectedSize else { return nil } - return inflated + return windowsStoredInflate(self) } func windowsGzip() -> Data? { @@ -503,55 +501,55 @@ private extension Data { let flags = self[3] var position = WindowsCompression.gzipHeaderSize + let footerStart = count - WindowsCompression.gzipFooterSize if flags & WindowsCompression.gzipExtraFlag != 0 { - guard position + 2 <= count else { return nil } + guard footerStart - position >= 2 else { return nil } let extraLength = Int(littleEndianUInt16(at: position)) position += 2 - guard position + extraLength <= count else { return nil } + guard extraLength <= footerStart - position else { return nil } position += extraLength } if flags & WindowsCompression.gzipNameFlag != 0 { - guard let nextPosition = indexAfterNullTerminatedField(startingAt: position) else { + guard let nextPosition = indexAfterNullTerminatedField(startingAt: position, limit: footerStart) else { return nil } position = nextPosition } if flags & WindowsCompression.gzipCommentFlag != 0 { - guard let nextPosition = indexAfterNullTerminatedField(startingAt: position) else { + guard let nextPosition = indexAfterNullTerminatedField(startingAt: position, limit: footerStart) else { return nil } position = nextPosition } if flags & WindowsCompression.gzipHeaderCRCFlag != 0 { - guard position + 2 <= count else { return nil } + guard footerStart - position >= 2 else { return nil } position += 2 } - guard position < count else { return nil } + guard position < footerStart else { return nil } let compressedStart = position - let footerStart = count - WindowsCompression.gzipFooterSize let compressedEnd = footerStart let expectedCrc32 = littleEndianUInt32(at: footerStart) let expectedSize = littleEndianUInt32(at: footerStart + 4) let deflated = Data(self[compressedStart.. Int? { + private func indexAfterNullTerminatedField(startingAt offset: Int, limit: Int) -> Int? { var offset = offset - while offset < count, self[offset] != 0x00 { + while offset < limit, self[offset] != 0x00 { offset += 1 } - guard offset < count else { return nil } + guard offset < limit else { return nil } return offset + 1 } @@ -635,14 +633,14 @@ private func windowsStoredInflate(_ input: Data) -> Data? { guard blockType == 0 else { return nil } offset += 1 - guard offset + 4 <= input.count else { return nil } + 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 offset + blockLength <= input.count else { return nil } + guard blockLength <= input.count - offset else { return nil } result.append(input[offset..<(offset + blockLength)]) offset += blockLength diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift index 8b666e37b..dce019160 100644 --- a/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift @@ -96,4 +96,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/OSLog/OSLog.swift b/LaunchDarkly/OSLog/OSLog.swift index 5810c1f0f..23e298307 100644 --- a/LaunchDarkly/OSLog/OSLog.swift +++ b/LaunchDarkly/OSLog/OSLog.swift @@ -37,9 +37,80 @@ public struct OSLogType: Sendable { public func os_log(_ message: StaticString, log: OSLog = .default, type: OSLogType = .default, _ args: CVarArg...) { guard log.isEnabled else { return } - let output = "[\(log.subsystem):\(log.category)] \(type.name): \(message.description)\n" + 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 9d7eb65b0..e82ab1121 100644 --- a/Package.swift +++ b/Package.swift @@ -60,6 +60,7 @@ func osSpecificLDTestsDependencies() -> [Target.Dependency] { #if os(Linux) || os(Windows) [ "LaunchDarkly", + .target(name: "OSLog", condition: .when(platforms: [.windows])), .product(name: "Quick", package: "Quick"), .product(name: "Nimble", package: "Nimble") ] From 42fc66aa3ef6be5934f39a4e59dd2a7de32fb0d0 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Tue, 16 Jun 2026 11:27:35 -0400 Subject: [PATCH 8/9] Slop to get a better idea of the change shape --- .../Extensions/Data+Compression.swift | 166 ++++++++++++++++-- LaunchDarkly/LaunchDarkly/LDClient.swift | 91 +++++----- .../Networking/DarklyService.swift | 14 +- LaunchDarkly/LaunchDarkly/Util.swift | 4 +- .../Extensions/CompressionTest.swift | 8 + .../Extensions/ThreadSpec.swift | 10 ++ .../LaunchDarklyTests/LDClientSpec.swift | 10 ++ .../Mocks/DarklyServiceMock.swift | 2 + .../Models/DiagnosticEventSpec.swift | 10 ++ .../Networking/DarklyServiceSpec.swift | 10 ++ .../Cache/DiagnosticCacheSpec.swift | 10 ++ .../ServiceObjects/EventReporterSpec.swift | 10 ++ .../FlagChangeNotifierSpec.swift | 10 ++ .../ServiceObjects/FlagSynchronizerSpec.swift | 10 ++ .../ServiceObjects/LDTimerSpec.swift | 10 ++ .../ServiceObjects/ThrottlerSpec.swift | 10 ++ Package.swift | 51 ++++-- 17 files changed, 362 insertions(+), 74 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift index f07a636fe..34a7cfc0b 100644 --- a/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift +++ b/LaunchDarkly/LaunchDarkly/Extensions/Data+Compression.swift @@ -28,6 +28,8 @@ import Foundation #if !os(Windows) import Compression +#else +import WinSDK #endif public extension Data { @@ -461,12 +463,12 @@ fileprivate extension Data { #if os(Windows) private extension Data { func windowsDeflate() -> Data? { - windowsStoredDeflate(self) + windowsMSZIPDeflate(self) } func windowsInflate() -> Data? { guard !isEmpty else { return nil } - return windowsStoredInflate(self) + return windowsMSZIPInflate(self) } func windowsGzip() -> Data? { @@ -476,7 +478,8 @@ private extension Data { result.append(Data(bytes: &unixtime, count: MemoryLayout.size)) result.append(contentsOf: WindowsCompression.gzipTrailerHeader) - result.append(windowsStoredDeflate(self)) + guard let deflated = windowsDeflate() else { return nil } + result.append(deflated) var crc32 = self.crc32().checksum.littleEndian result.append(Data(bytes: &crc32, count: MemoryLayout.size)) @@ -537,7 +540,7 @@ private extension Data { let expectedCrc32 = littleEndianUInt32(at: footerStart) let expectedSize = littleEndianUInt32(at: footerStart + 4) let deflated = Data(self[compressedStart.. Data { - var result = Data() +// 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) + } - if input.isEmpty { - result.append(contentsOf: WindowsCompression.emptyStoredBlock) - return result + 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) @@ -619,6 +664,56 @@ private func windowsStoredDeflate(_ input: Data) -> Data { return result } +private func windowsMSZIPInflate(_ input: Data, expectedSize: Int? = nil) -> 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 @@ -628,8 +723,6 @@ private func windowsStoredInflate(_ input: Data) -> Data? { let isFinalBlock = header & 0x01 != 0 let blockType = (header >> 1) & 0x03 - // The Windows encoder above only writes stored blocks. Reject compressed block - // types here so malformed or unsupported deflate streams fail cleanly. guard blockType == 0 else { return nil } offset += 1 @@ -651,6 +744,49 @@ private func windowsStoredInflate(_ input: Data) -> Data? { 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 { diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 925461edb..70f4490f0 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -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 @@ -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) } } @@ -953,24 +957,8 @@ public class LDClient { 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() @@ -1004,23 +992,7 @@ public class LDClient { lastUpdated: cachedData.lastUpdated, service: service) - if let backgroundNotification = SystemCapabilities.backgroundNotification { - let background = NotificationCenter.default.addObserver(forName: backgroundNotification, object:nil, queue: OperationQueue.current, using: { [weak self] notification 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] notification 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) + addLifecycleObservers() eventReporter = self.serviceFactory.makeEventReporter(config: configuration, service: service, onSyncComplete: onEventSyncComplete) service.resetFlagResponseCache(etag: cachedData.etag) @@ -1046,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/Networking/DarklyService.swift b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift index a42bac436..6c90eb072 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -27,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?) @@ -64,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 @@ -126,9 +127,14 @@ final class DarklyService: DarklyServiceProvider { self.session.dataTask(with: request) { [weak self] data, response, error in DispatchQueue.main.async { [weak self] in - let etag = self?.flagRequestEtag - self?.processEtag(from: (data: data, urlResponse: response, error: error, etag: etag)) - completion?((data: data, urlResponse: response, error: error, etag: etag)) + 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/Util.swift b/LaunchDarkly/LaunchDarkly/Util.swift index 0cfbe022a..5711e4204 100644 --- a/LaunchDarkly/LaunchDarkly/Util.swift +++ b/LaunchDarkly/LaunchDarkly/Util.swift @@ -48,10 +48,10 @@ extension Data { if status < 0 { fatalError("Failed to create SHA256 digest using BCrypt APIs (NTSTATUS: \(status))") } } - let BCRYPT_SHA256_ALGORITHM = "SHA256" + let bcryptSHA256Algorithm = "SHA256" var algorithm: WinSDK.BCRYPT_ALG_HANDLE? - BCRYPT_SHA256_ALGORITHM.withCString(encodedAs: UTF16.self) { + bcryptSHA256Algorithm.withCString(encodedAs: UTF16.self) { handleError(WinSDK.BCryptOpenAlgorithmProvider(&algorithm, $0, nil, 0)) } defer { handleError(WinSDK.BCryptCloseAlgorithmProvider(algorithm, 0)) } diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift index dce019160..c641ccd18 100644 --- a/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/CompressionTest.swift @@ -73,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) diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift index 7193fd9b0..80d62964d 100644 --- a/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/ThreadSpec.swift @@ -4,7 +4,17 @@ 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() } diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index 47d8a94f3..811ca7aa9 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -17,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() diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift index 5fb61b9a7..62be1ae81 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift @@ -102,6 +102,7 @@ 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))]() @@ -124,6 +125,7 @@ final class DarklyServiceMock: DarklyServiceProvider { var clearFlagResponseCacheCallCount = 0 func resetFlagResponseCache(etag: String?) { clearFlagResponseCacheCallCount += 1 + flagRequestEtag = etag } var createdEventSource: DarklyStreamingProviderMock? diff --git a/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift index 835559fb8..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() diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift index affff3b87..2aa1d87d8 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift @@ -49,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() diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/DiagnosticCacheSpec.swift index 212e5be61..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() diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift index 92419f551..ab192ae05 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EventReporterSpec.swift @@ -66,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() diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagChangeNotifierSpec.swift index 408c501b4..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")) diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift index 03ce239f9..5b9b13225 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/FlagSynchronizerSpec.swift @@ -38,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() diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift index 03d79080e..bdaaa178c 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/LDTimerSpec.swift @@ -18,7 +18,17 @@ 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() diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift index 70fd22f8d..e7b2fd6da 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/ThrottlerSpec.swift @@ -19,7 +19,17 @@ final class ThrottlerSpec: QuickSpec { 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() diff --git a/Package.swift b/Package.swift index e82ab1121..6f32ea3b3 100644 --- a/Package.swift +++ b/Package.swift @@ -32,35 +32,62 @@ let package = Package( .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting", .exact("2.1.2")), .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"), - .target(name: "OSLog", condition: .when(platforms: [.windows])), - ], + dependencies: launchDarklyDependencies(), path: "LaunchDarkly/LaunchDarkly", exclude: osSpecificExcludes(), resources: [ .process("PrivacyInfo.xcprivacy") + ], + linkerSettings: [ + .linkedLibrary("Cabinet", .when(platforms: [.windows])) ]), - .target( - name: "OSLog", - path: "LaunchDarkly/OSLog"), .testTarget( name: "LaunchDarklyTests", 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(Linux) || os(Windows) + #if os(Windows) + [ + "LaunchDarkly", + .target(name: "OSLog"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble") + ] + #elseif os(Linux) [ "LaunchDarkly", - .target(name: "OSLog", condition: .when(platforms: [.windows])), .product(name: "Quick", package: "Quick"), .product(name: "Nimble", package: "Nimble") ] From 8b79913153092aeb99b4554dd4bfe574321dee95 Mon Sep 17 00:00:00 2001 From: Brian Michel Date: Tue, 16 Jun 2026 12:04:26 -0400 Subject: [PATCH 9/9] Try to fix iOS issues --- LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift b/LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift index 5e6fd14ee..cb00083fe 100644 --- a/LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift +++ b/LaunchDarkly/LaunchDarklyTests/Matcher/Match.swift @@ -17,21 +17,21 @@ public enum ToMatchResult { Return `.matched` when the validation succeeds. Return `.failed` with a failure reason when the validation fails. */ -public func match() -> Matcher<() -> ToMatchResult> { - Matcher.define { actualExpression in +public func match() -> Nimble.Predicate<() -> ToMatchResult> { + Nimble.Predicate.define { actualExpression in let optActual = try actualExpression.evaluate() guard let actual = optActual else { - return MatcherResult(status: .fail, message: .fail("expected a closure, got ")) + return Nimble.PredicateResult(status: .fail, message: .fail("expected a closure, got ")) } switch actual() { case .matched: - return MatcherResult( + return Nimble.PredicateResult( bool: true, message: .expectedCustomValueTo("match", actual: "") ) case .failed(let reason): - return MatcherResult( + return Nimble.PredicateResult( bool: false, message: .expectedCustomValueTo("match", actual: " because <\(reason)>") )