From 8e91500f9a123f6a7335a3095712e2ae840b010c Mon Sep 17 00:00:00 2001
From: Philip Niedertscheider
Date: Mon, 29 Jul 2024 15:24:54 +0200
Subject: [PATCH] feat: adds action to open app in app store
---
Examples/ExampleSwiftUI/ContentView.swift | 2 +-
Examples/ExampleSwiftUI/MainApp.swift | 5 +-
Examples/ExampleUIKit/AppDelegate.swift | 8 ++-
Examples/ExampleUIKit/SceneDelegate.swift | 7 +-
Examples/ExampleUIKit/ViewController.swift | 6 +-
README.md | 6 +-
.../OnLaunch/API/ApiMessageResponseDto.swift | 25 ++++---
Sources/OnLaunch/Models/Action.swift | 13 ++--
Sources/OnLaunch/Models/Message.swift | 13 ++--
Sources/OnLaunch/OnLaunch.swift | 65 +++++++++++--------
Sources/OnLaunch/Storage/LocalStorage.swift | 3 +-
Sources/OnLaunch/SwiftUI/View+OnLaunch.swift | 8 +--
.../OnLaunch/Theme/Environment+Theme.swift | 3 +-
Sources/OnLaunch/Theme/Theme.swift | 7 +-
.../UI/Components/MessageActionView.swift | 63 ++++++++++++++++++
Sources/OnLaunch/UI/MessageView.swift | 55 +++++++++-------
Sources/OnLaunch/Utils/OSLog+OnLaunch.swift | 4 +-
Sources/OnLaunch/Utils/OnLaunchError.swift | 5 +-
Tests/OnLaunchTests/RequestContextTests.swift | 3 +-
19 files changed, 188 insertions(+), 113 deletions(-)
create mode 100644 Sources/OnLaunch/UI/Components/MessageActionView.swift
diff --git a/Examples/ExampleSwiftUI/ContentView.swift b/Examples/ExampleSwiftUI/ContentView.swift
index 2de6405..e5cdeed 100644
--- a/Examples/ExampleSwiftUI/ContentView.swift
+++ b/Examples/ExampleSwiftUI/ContentView.swift
@@ -1,5 +1,5 @@
-import SwiftUI
import OnLaunch
+import SwiftUI
struct ContentView: View {
var body: some View {
diff --git a/Examples/ExampleSwiftUI/MainApp.swift b/Examples/ExampleSwiftUI/MainApp.swift
index 8c32872..2b685c3 100644
--- a/Examples/ExampleSwiftUI/MainApp.swift
+++ b/Examples/ExampleSwiftUI/MainApp.swift
@@ -1,5 +1,5 @@
-import SwiftUI
import OnLaunch
+import SwiftUI
@main
struct MainApp: App {
@@ -12,6 +12,9 @@ struct MainApp: App {
// (Optional) Configure a custom base URL to your API host
// options.baseURL = "https://your-domain.com/api"
+
+ // (Optional) Configure the App Store id, required by the action "OPEN_IN_APP_STORE"
+ options.appStoreId = 409_201_541
}
}
}
diff --git a/Examples/ExampleUIKit/AppDelegate.swift b/Examples/ExampleUIKit/AppDelegate.swift
index 89a482a..f67c0d8 100644
--- a/Examples/ExampleUIKit/AppDelegate.swift
+++ b/Examples/ExampleUIKit/AppDelegate.swift
@@ -2,9 +2,11 @@ import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
-
- func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ func application(
+ _: UIApplication,
+ configurationForConnecting connectingSceneSession: UISceneSession,
+ options _: UIScene.ConnectionOptions
+ ) -> UISceneConfiguration {
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
}
-
diff --git a/Examples/ExampleUIKit/SceneDelegate.swift b/Examples/ExampleUIKit/SceneDelegate.swift
index 09b944e..7c4de52 100644
--- a/Examples/ExampleUIKit/SceneDelegate.swift
+++ b/Examples/ExampleUIKit/SceneDelegate.swift
@@ -1,8 +1,7 @@
-import UIKit
import OnLaunch
+import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
-
var window: UIWindow?
func sceneDidBecomeActive(_ scene: UIScene) {
@@ -17,7 +16,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// (Optional) Configure a custom base URL to your API host
// options.baseURL = "https://your-domain.com/api"
+
+ // (Optional) Configure the App Store id, required by the action 'OPEN_IN_APP_STORE'
+ options.appStoreId = 409_201_541
}
}
}
-
diff --git a/Examples/ExampleUIKit/ViewController.swift b/Examples/ExampleUIKit/ViewController.swift
index 5cf2fb1..c5b2d90 100644
--- a/Examples/ExampleUIKit/ViewController.swift
+++ b/Examples/ExampleUIKit/ViewController.swift
@@ -1,10 +1,8 @@
-import UIKit
import OnLaunch
+import UIKit
class ViewController: UIViewController {
-
- @IBAction func checkForMessagesActions(_ sender: Any) {
+ @IBAction func checkForMessagesActions(_: Any) {
OnLaunch.check()
}
}
-
diff --git a/README.md b/README.md
index ef3b43a..ee35632 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
Created and maintained by kula.app and all the amazing contributors.
-[OnLaunch](https://github.com/kula-app/OnLaunch) is a service allowing app developers to notify app users about updates, warnings and maintenance
+[OnLaunch](https://github.com/kula-app/OnLaunch) is a service allowing app developers to notify app users about updates, warnings and maintenance.
Our open-source framework provides an easy-to-integrate client to communicate with the backend and display the user interface.
@@ -135,6 +135,10 @@ The OnLaunch iOS client provides a couple of configuration options:
| `hostScene` | Scene used to host the OnLaunch client UI. Required if you use UIKit with scenes | |
| `hostViewController` | An instance of `UIViewController` used to host the OnLaunch client UI. Required if you use UIKit without scenes | |
| `theme` | Custom theme used by the OnLaunch client UI. Adapt the values to change the theme to match your preferences. To see all possible configuration values, see [`Theme.swift`](https://github.com/kula-app/OnLaunch-iOS-Client/blob/main/Sources/OnLaunch/Theme/Theme.swift) | Default values as defined in `Theme.standard` in [Theme.swift](https://github.com/kula-app/OnLaunch-iOS-Client/blob/main/Sources/OnLaunch/Theme/Theme.swift) |
+| `bundleId` | Bundle identifier used by server-side rules. | Uses value set in `Bundle.main.bundleIdentifier` |
+| `bundleVersion` | Version of the build that identifies an iteration of the bundle. | `CFBundleVersion` defined in `Info.plist` |
+| `releaseVersion` | Release or version number of the bundle. | `CFBundleShortVersionString` defined in `Info.plist` |
+| `appStoreId` | ID of the app in the App Store. If not defined, the `ActionType.openInAppStore` will throw a non-crashing assertion | |
## Contributing Guide
diff --git a/Sources/OnLaunch/API/ApiMessageResponseDto.swift b/Sources/OnLaunch/API/ApiMessageResponseDto.swift
index 38ab2c6..8cf3f2b 100644
--- a/Sources/OnLaunch/API/ApiMessageResponseDto.swift
+++ b/Sources/OnLaunch/API/ApiMessageResponseDto.swift
@@ -1,23 +1,20 @@
import Foundation
-internal struct ApiMessageResponseDto: Decodable {
-
- internal struct Action: Decodable {
-
- internal enum ActionType: String, Decodable {
+struct ApiMessageResponseDto: Decodable {
+ struct Action: Decodable {
+ enum ActionType: String, Decodable {
case button = "BUTTON"
case dismissButton = "DISMISS"
+ case openInAppStore = "OPEN_IN_APP_STORE"
}
- internal let actionType: ActionType
- internal let title: String
-
+ let actionType: ActionType
+ let title: String
}
- internal let id: Int
- internal let blocking: Bool
- internal let title: String
- internal let body: String
- internal let actions: [Action]
-
+ let id: Int
+ let blocking: Bool
+ let title: String
+ let body: String
+ let actions: [Action]
}
diff --git a/Sources/OnLaunch/Models/Action.swift b/Sources/OnLaunch/Models/Action.swift
index 2a73b67..14106e2 100644
--- a/Sources/OnLaunch/Models/Action.swift
+++ b/Sources/OnLaunch/Models/Action.swift
@@ -1,19 +1,20 @@
/// Structure used to define an user-interactable action
-internal struct Action {
-
+struct Action {
/// Different kinds of actions, used to implement different behaviour
- internal enum Kind {
-
+ enum Kind {
/// Action is implemented as a generic button with an associated action
case button
/// Action is represented as a button which dismisses the UI
case dismissButton
+
+ /// Action is dedicated to open the current app's App Store Page
+ case openAppInAppStore
}
/// Kind of the action
- internal let kind: Kind
+ let kind: Kind
/// Title of the action used to display in the UI
- internal let title: String
+ let title: String
}
diff --git a/Sources/OnLaunch/Models/Message.swift b/Sources/OnLaunch/Models/Message.swift
index 8025d62..bb2786c 100644
--- a/Sources/OnLaunch/Models/Message.swift
+++ b/Sources/OnLaunch/Models/Message.swift
@@ -1,22 +1,21 @@
import Foundation
/// Structure of a message to be displayed to the user
-internal struct Message: Identifiable {
-
+struct Message: Identifiable {
/// Unique identifier of this message.
///
/// This identifier is used to track if the message has already been presented to the user
- internal let id: Int
+ let id: Int
/// Title of the message
- internal let title: String
+ let title: String
/// Content of the message
- internal let body: String
+ let body: String
/// Flag indicating if this message is blocking the user
- internal let isBlocking: Bool
+ let isBlocking: Bool
/// Actions for the user to interact with
- internal let actions: [Action]
+ let actions: [Action]
}
diff --git a/Sources/OnLaunch/OnLaunch.swift b/Sources/OnLaunch/OnLaunch.swift
index 2e685ab..0e089a8 100644
--- a/Sources/OnLaunch/OnLaunch.swift
+++ b/Sources/OnLaunch/OnLaunch.swift
@@ -1,15 +1,13 @@
+import Combine
import OSLog
import SwiftUI
import UIKit
-import Combine
public class OnLaunch: NSObject {
-
// MARK: - Types
/// Options used to control the behaviour of OnLaunch
public class Options {
-
/// Base URL where the OnLaunch API is hosted at.
public var baseURL = "https://onlaunch.kula.app/api/"
@@ -31,23 +29,27 @@ public class OnLaunch: NSObject {
public var theme = Theme.standard
/// Internal flag used to indicate that the SwiftUI host system is used
- internal var isSwiftUIHost = false
+ var isSwiftUIHost = false
/// Bundle identifier used by server-side rules.
///
/// If not defined, it will fallback to `Bundle.main.bundleIdentifier`
public var bundleId: String?
- /// The version of the build that identifies an iteration of the bundle.
+ /// Version of the build that identifies an iteration of the bundle.
///
/// If not defined, it will fallback to the `CFBundleVersion` defined in `Info.plist`
public var bundleVersion: String?
- /// The release or version number of the bundle.
+ /// Release or version number of the bundle.
///
/// If not defined, it will fallback to the `CFBundleShortVersionString` defined in `Info.plist`
public var releaseVersion: String?
+ /// ID of the app in the App Store
+ ///
+ /// If not defined, the ``ActionType.openInAppStore`` will throw a non-crashing assertion
+ public var appStoreId: Int?
}
/// Closure used to modify the given options instance
@@ -69,7 +71,7 @@ public class OnLaunch: NSObject {
let client = try OnLaunch(options: options)
try client.configure()
- self.shared = client
+ shared = client
if options.shouldCheckOnConfigure {
check()
@@ -94,18 +96,18 @@ public class OnLaunch: NSObject {
// MARK: - Internal Static State
- internal static var shared: OnLaunch?
+ static var shared: OnLaunch?
// MARK: - Internal State
/// Options as defined by the library user
- internal var options: Options
+ var options: Options
/// Instance used to store data
- internal let storage: LocalStorage
+ let storage: LocalStorage
/// The `URLSession` used to send API requests
- internal let session: URLSession
+ let session: URLSession
/// URL used as the reference for all API calls, e.g. `https://onlaunch.kula.app/api`
private let baseURL: URL
@@ -114,14 +116,14 @@ public class OnLaunch: NSObject {
private var messageQueue: [Message] = []
/// The message which is currently presented
- internal var currentMessage = CurrentValueSubject(nil)
+ var currentMessage = CurrentValueSubject(nil)
/// Completion handler used by the SwiftUI implementation
- private var swiftUIDismissCompletionHandler: () -> Void = { }
+ private var swiftUIDismissCompletionHandler: () -> Void = {}
// MARK: - Internal Initializer
- internal init(options: Options, storage: LocalStorage = LocalStorage(), session: URLSession = URLSession(configuration: .ephemeral)) throws {
+ init(options: Options, storage: LocalStorage = LocalStorage(), session: URLSession = URLSession(configuration: .ephemeral)) throws {
self.options = options
guard let baseURL = URL(string: options.baseURL) else {
throw OnLaunchError.invalidBaseURL(options.baseURL)
@@ -135,14 +137,21 @@ public class OnLaunch: NSObject {
// MARK: - Internal Methods
/// Helper function used to setup the completion handler for SwiftUI hosted views
- internal func swiftUIContainerViewFor(message: Message) -> some View {
- containerViewFor(message: message, completionHandler: swiftUIDismissCompletionHandler)
+ func swiftUIContainerViewFor(message: Message) -> some View {
+ containerViewFor(
+ message: message,
+ completionHandler: swiftUIDismissCompletionHandler
+ )
}
/// Creates the fully configured message view for the given `message`
private func containerViewFor(message: Message, completionHandler: @escaping () -> Void) -> some View {
- MessageView(message: message, completionHandler: completionHandler)
- .environment(\.theme, options.theme)
+ MessageView(
+ message: message,
+ options: options,
+ completionHandler: completionHandler
+ )
+ .environment(\.theme, options.theme)
}
// MARK: - Private Methods
@@ -209,15 +218,17 @@ public class OnLaunch: NSObject {
body: message.body,
isBlocking: message.blocking,
actions: message.actions.compactMap { action in
- let kind: Action.Kind
- switch action.actionType {
- case .button:
- kind = .button
- case .dismissButton:
- kind = .dismissButton
- }
- return Action(kind: kind, title: action.title)
- })
+ let kind: Action.Kind
+ switch action.actionType {
+ case .button:
+ kind = .button
+ case .dismissButton:
+ kind = .dismissButton
+ case .openInAppStore:
+ kind = .openAppInAppStore
+ }
+ return Action(kind: kind, title: action.title)
+ })
}
let filteredMessages = messages.filter { message in
// Only include messages which are blocking or have not already been presented
diff --git a/Sources/OnLaunch/Storage/LocalStorage.swift b/Sources/OnLaunch/Storage/LocalStorage.swift
index 1cd7b47..e11487e 100644
--- a/Sources/OnLaunch/Storage/LocalStorage.swift
+++ b/Sources/OnLaunch/Storage/LocalStorage.swift
@@ -1,8 +1,7 @@
import Foundation
import os.log
-internal class LocalStorage {
-
+class LocalStorage {
private let defaults: UserDefaults
init(defaults: UserDefaults = .init(suiteName: "OnLaunch")!) {
diff --git a/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift b/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift
index 4ea0077..f7e4ef6 100644
--- a/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift
+++ b/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift
@@ -2,7 +2,6 @@ import SwiftUI
/// Modifier used to integrate OnLaunch with SwiftUI
public struct OnLaunchModifier: ViewModifier {
-
/// Environment value used to track for scene updates
@Environment(\.scenePhase) private var scenePhase
@@ -55,10 +54,9 @@ public struct OnLaunchModifier: ViewModifier {
}
}
-extension View {
-
+public extension View {
/// Configures OnLaunch with the given `configurationHandler` and conditionally presents the OnLaunch UI
- public func configureOnLaunch(_ configurationHandler: @escaping OnLaunch.ConfigurationHandler) -> some View {
- self.modifier(OnLaunchModifier(configurationHandler: configurationHandler))
+ func configureOnLaunch(_ configurationHandler: @escaping OnLaunch.ConfigurationHandler) -> some View {
+ modifier(OnLaunchModifier(configurationHandler: configurationHandler))
}
}
diff --git a/Sources/OnLaunch/Theme/Environment+Theme.swift b/Sources/OnLaunch/Theme/Environment+Theme.swift
index 1225b1c..8ace739 100644
--- a/Sources/OnLaunch/Theme/Environment+Theme.swift
+++ b/Sources/OnLaunch/Theme/Environment+Theme.swift
@@ -5,8 +5,7 @@ private struct ThemeEnvironmentKey: EnvironmentKey {
}
extension EnvironmentValues {
-
- internal var theme: Theme {
+ var theme: Theme {
get {
self[ThemeEnvironmentKey.self]
}
diff --git a/Sources/OnLaunch/Theme/Theme.swift b/Sources/OnLaunch/Theme/Theme.swift
index ff720b5..3cc760a 100644
--- a/Sources/OnLaunch/Theme/Theme.swift
+++ b/Sources/OnLaunch/Theme/Theme.swift
@@ -18,10 +18,8 @@ public struct Theme {
}
public struct Action {
-
/// Font used for the title of the action buttons
public var font: Font
-
}
/// Theme configuration of the message title
@@ -34,10 +32,9 @@ public struct Theme {
public var action: Action
}
-extension Theme {
-
+public extension Theme {
/// Default theme unless something different is configured
- public static let standard = Theme(
+ static let standard = Theme(
title: .init(
font: Font.system(size: 34, weight: .bold),
color: Color(UIColor.label)
diff --git a/Sources/OnLaunch/UI/Components/MessageActionView.swift b/Sources/OnLaunch/UI/Components/MessageActionView.swift
new file mode 100644
index 0000000..9fbe9b6
--- /dev/null
+++ b/Sources/OnLaunch/UI/Components/MessageActionView.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct MessageActionView: View {
+ @Environment(\.theme) private var theme: Theme
+
+ let action: Action
+ let dismiss: () -> Void
+ let openAppInAppStore: () -> Void
+
+ init(action: Action, dismiss: @escaping () -> Void, openAppInAppStore: @escaping () -> Void) {
+ self.action = action
+ self.dismiss = dismiss
+ self.openAppInAppStore = openAppInAppStore
+ }
+
+ @ViewBuilder
+ var body: some View {
+ switch action.kind {
+ case .button:
+ buttonView
+ case .dismissButton:
+ dismissButtonView
+ case .openAppInAppStore:
+ openAppInAppStoreButtonView
+ }
+ }
+
+ var buttonView: some View {
+ Button(action: {
+ dismiss()
+ }, label: {
+ Text(action.title)
+ .font(theme.action.font)
+ .frame(maxWidth: .infinity)
+ .frame(minHeight: 50)
+ })
+ .buttonStyle(.borderedProminent)
+ }
+
+ var dismissButtonView: some View {
+ Button(action: {
+ dismiss()
+ }, label: {
+ Text(action.title)
+ .font(theme.action.font)
+ .frame(maxWidth: .infinity)
+ .frame(minHeight: 50)
+ })
+ .buttonStyle(.borderedProminent)
+ }
+
+ var openAppInAppStoreButtonView: some View {
+ Button(action: {
+ openAppInAppStore()
+ }, label: {
+ Text(action.title)
+ .font(theme.action.font)
+ .frame(maxWidth: .infinity)
+ .frame(minHeight: 50)
+ })
+ .buttonStyle(.borderedProminent)
+ }
+}
diff --git a/Sources/OnLaunch/UI/MessageView.swift b/Sources/OnLaunch/UI/MessageView.swift
index 58a2aa4..736bb8e 100644
--- a/Sources/OnLaunch/UI/MessageView.swift
+++ b/Sources/OnLaunch/UI/MessageView.swift
@@ -1,7 +1,6 @@
import SwiftUI
-internal struct MessageView: View {
-
+struct MessageView: View {
// MARK: - Environment
@Environment(\.theme) private var theme: Theme
@@ -9,12 +8,13 @@ internal struct MessageView: View {
// MARK: - State
- internal let message: Message
- internal let completionHandler: () -> Void
+ let message: Message
+ let options: OnLaunch.Options
+ let completionHandler: () -> Void
// MARK: - View Body
- internal var body: some View {
+ var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Spacer()
@@ -45,25 +45,7 @@ internal struct MessageView: View {
.multilineTextAlignment(.leading)
}
.padding(.vertical, 20)
- VStack(spacing: 12) {
- ForEach(Array(message.actions.enumerated()), id: \.0) { _, action in
- Button(action: {
- switch action.kind {
- case .button:
- dismiss()
- case .dismissButton:
- dismiss()
- }
- completionHandler()
- }, label: {
- Text(action.title)
- .font(theme.action.font)
- .frame(maxWidth: .infinity)
- .frame(minHeight: 50)
- })
- .buttonStyle(.borderedProminent)
- }
- }
+ actionsView
}
.frame(maxWidth: .infinity)
.padding(.bottom, 12)
@@ -71,4 +53,29 @@ internal struct MessageView: View {
}
.padding(.vertical, 12)
}
+
+ var actionsView: some View {
+ VStack(spacing: 12) {
+ ForEach(Array(message.actions.enumerated()), id: \.0) { _, action in
+ MessageActionView(
+ action: action,
+ dismiss: {
+ dismiss()
+ completionHandler()
+ },
+ openAppInAppStore: {
+ guard let appStoreId = options.appStoreId,
+ let url = URL(string: "https://apps.apple.com/app/id\(appStoreId)") else {
+ return assertionFailure("Failed to create App Store store page url")
+ }
+ UIApplication.shared.open(url) { success in
+ if !success {
+ assertionFailure("Failed to open URL: \(url)")
+ }
+ }
+ }
+ )
+ }
+ }
+ }
}
diff --git a/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift b/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift
index 817be5b..41de578 100644
--- a/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift
+++ b/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift
@@ -1,8 +1,6 @@
import OSLog
extension OSLog {
-
/// `OSLog` used internally to log messages
- internal static let onlaunch = OSLog(subsystem: "app.kula.OnLaunch", category: "OnLaunch")
-
+ static let onlaunch = OSLog(subsystem: "app.kula.OnLaunch", category: "OnLaunch")
}
diff --git a/Sources/OnLaunch/Utils/OnLaunchError.swift b/Sources/OnLaunch/Utils/OnLaunchError.swift
index 8b37bb9..b05e3a0 100644
--- a/Sources/OnLaunch/Utils/OnLaunchError.swift
+++ b/Sources/OnLaunch/Utils/OnLaunchError.swift
@@ -1,18 +1,17 @@
import Foundation
public enum OnLaunchError: LocalizedError {
-
case invalidBaseURL(String)
case appIdNotConfigured
case failedToFetchMessages(response: HTTPURLResponse, data: String?)
public var errorDescription: String? {
switch self {
- case .invalidBaseURL(let url):
+ case let .invalidBaseURL(url):
return "The provided base URL '\(url)' is not valid"
case .appIdNotConfigured:
return "You must configure an app ID"
- case .failedToFetchMessages(response: let response, data: let data):
+ case let .failedToFetchMessages(response: response, data: data):
return "Failed to fetch messages, reason: \(response.statusCode) \(data ?? "")"
}
}
diff --git a/Tests/OnLaunchTests/RequestContextTests.swift b/Tests/OnLaunchTests/RequestContextTests.swift
index 615c13a..a467270 100644
--- a/Tests/OnLaunchTests/RequestContextTests.swift
+++ b/Tests/OnLaunchTests/RequestContextTests.swift
@@ -1,8 +1,7 @@
-import XCTest
@testable import OnLaunch
+import XCTest
final class RequestContextTests: XCTestCase {
-
func testApplyTo_bundleIdIsNotDefined_headerShouldNotBeSet() throws {
// -- Arrange --
var request = URLRequest(url: URL(string: "http://testing.local")!)