Skip to content

Commit

Permalink
Start generating UserAgent instead of relying on Webkit (#341)
Browse files Browse the repository at this point in the history
* Added user-agent sim and tests

* Updated vendor files to pull from UserAgent.

* Removed webkit dependency
  • Loading branch information
bsneed authored May 7, 2024
1 parent eded4ac commit 3b2093c
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 36 deletions.
38 changes: 3 additions & 35 deletions Sources/Segment/Plugins/Platforms/Vendors/AppleUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,9 @@ import Foundation

import SystemConfiguration
import UIKit
#if !os(tvOS)
import WebKit
#endif

internal class iOSVendorSystem: VendorSystem {
private let device = UIDevice.current
@Atomic private static var asyncUserAgent: String? = nil

override var manufacturer: String {
return "Apple"
Expand Down Expand Up @@ -72,23 +68,7 @@ internal class iOSVendorSystem: VendorSystem {
}

override var userAgent: String? {
#if !os(tvOS)
// BKS: It was discovered that on some platforms there can be a delay in retrieval.
// It has to be fetched on the main thread, so we've spun it off
// async and cache it when it comes back.
// Note that due to how the `@Atomic` wrapper works, this boolean check may pass twice or more
// times before the value is updated, fetching the user agent multiple times as the result.
// This is not a big deal as the `userAgent` value is not expected to change often.
if Self.asyncUserAgent == nil {
DispatchQueue.main.async {
Self.asyncUserAgent = WKWebView().value(forKey: "userAgent") as? String
}
}
return Self.asyncUserAgent
#else
// webkit isn't on tvos
return "unknown"
#endif
return UserAgent.value
}

override var connection: ConnectionStatus {
Expand Down Expand Up @@ -156,7 +136,7 @@ internal class watchOSVendorSystem: VendorSystem {
}

override var userAgent: String? {
return nil
return UserAgent.value
}

override var connection: ConnectionStatus {
Expand Down Expand Up @@ -207,7 +187,6 @@ import WebKit

internal class MacOSVendorSystem: VendorSystem {
private let device = ProcessInfo.processInfo
@Atomic private static var asyncUserAgent: String? = nil

override var manufacturer: String {
return "Apple"
Expand Down Expand Up @@ -248,18 +227,7 @@ internal class MacOSVendorSystem: VendorSystem {
}

override var userAgent: String? {
// BKS: It was discovered that on some platforms there can be a delay in retrieval.
// It has to be fetched on the main thread, so we've spun it off
// async and cache it when it comes back.
// Note that due to how the `@Atomic` wrapper works, this boolean check may pass twice or more
// times before the value is updated, fetching the user agent multiple times as the result.
// This is not a big deal as the `userAgent` value is not expected to change often.
if Self.asyncUserAgent == nil {
DispatchQueue.main.async {
Self.asyncUserAgent = WKWebView().value(forKey: "userAgent") as? String
}
}
return Self.asyncUserAgent
return UserAgent.value
}

override var connection: ConnectionStatus {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Segment/Plugins/Platforms/Vendors/LinuxUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class LinuxVendorSystem: VendorSystem {
}

override var userAgent: String? {
return "unknown"
return UserAgent.value
}

override var connection: ConnectionStatus {
Expand Down
86 changes: 86 additions & 0 deletions Sources/Segment/Utilities/UserAgent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// UserAgent.swift
//
//
// Created by Brandon Sneed on 5/6/24.
//

import Foundation

#if os(iOS) || os(visionOS)
import UIKit
#endif

// macOS: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)"
// iOS: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
// iPad: "Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
// visionOS: "Mozilla/5.0 (iPad; CPU OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
// catalyst: "Mozilla/5.0 (iPad; CPU OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
// appleTV: no-webkit
// watchOS: no-webkit
// linux: no-webkit

internal struct UserAgent {
// Duplicate the app names that webkit uses on a given platform.
// Broken out in case they change in the future.
#if os(macOS)
private static let defaultWebKitAppName = ""
#elseif targetEnvironment(macCatalyst)
private static let defaultWebKitAppName = "Mobile/15E148"
#elseif os(iOS)
private static let defaultWebKitAppName = "Mobile/15E148"
#elseif os(visionOS)
private static let defaultWebKitAppName = "Mobile/15E148"
#else
private static let defaultWebKitAppName = ""
#endif

internal static var _value: String = ""

public static var value: String {
if _value.isEmpty {
_value = value(applicationName: defaultWebKitAppName)
}
return _value
}

private static func version() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
var result: String
if v.patchVersion > 0 {
result = "\(v.majorVersion)_\(v.minorVersion)_\(v.patchVersion)"
} else {
// webkit leaves the patch version off if it's zero.
result = "\(v.majorVersion)_\(v.minorVersion)"
}
return result
}

public static func value(applicationName: String) -> String {
let separator: String = applicationName.isEmpty ? "" : " "
#if os(macOS)
// Webkit hard-codes the info if it's on mac desktop
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)\(separator)\(applicationName)"
#elseif os(iOS) || os(visionOS) || targetEnvironment(macCatalyst)
var model = UIDevice.current.model

// doing this just in case ... i don't have all these devices to test, only sims.
if model.contains("iPhone") { model = "iPhone" }
else if model.contains("iPad") { model = "iPad" }
// it's not one of the two above .. webkit defaults to iPad (ie: visionOS, catalyst), so use that instead of whatever we got.
else { model = "iPad" }

let osVersion = Self.version()
#if os(iOS)
// ios likes to tell you it's an iphone twice.
return "Mozilla/5.0 (\(model); CPU \(model) OS \(osVersion) like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)\(separator)\(applicationName)"
#else
return "Mozilla/5.0 (\(model); CPU OS \(osVersion) like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)\(separator)\(applicationName)"
#endif

#else
return "unknown"
#endif
}
}

34 changes: 34 additions & 0 deletions Tests/Segment-Tests/UserAgentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// UserAgentTests.swift
//
//
// Created by Brandon Sneed on 5/6/24.
//

import XCTest
#if canImport(WebKit)
import WebKit
#endif
@testable import Segment

final class UserAgentTests: XCTestCase {

override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}

override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}

func testUserAgent() throws {
#if canImport(WebKit)
let wkUserAgent = WKWebView().value(forKey: "userAgent") as! String
#else
let wkUserAgent = "unknown"
#endif
let userAgent = UserAgent.value
XCTAssertEqual(wkUserAgent, userAgent, "UserAgent's don't match! system: \(wkUserAgent), generated: \(userAgent)")
}

}

0 comments on commit 3b2093c

Please sign in to comment.