Skip to content

Extract WPUserAgent to WordPressShared #24216

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 19, 2025
24 changes: 24 additions & 0 deletions Modules/Sources/WordPressShared/WKWebView+UserAgent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import WebKit

@objc
public extension WKWebView {
static let userAgentKey = "_userAgent"

/// Call this method to get the user agent for the WKWebView
@objc
func userAgent() -> String {
guard let userAgent = value(forKey: WKWebView.userAgentKey) as? String, !userAgent.isEmpty else {
// TODO: Original implementation logged a message to Tracks/Sentry
print("This method for retrieveing the user agent seems to be no longer working. We need to figure out an alternative.")
return ""
}

return userAgent
}

/// Static version of the method that returns the current user agent.
@objc
static func userAgent() -> String {
return WKWebView().userAgent()
}
}
115 changes: 115 additions & 0 deletions Modules/Sources/WordPressShared/WPUserAgent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation
import WebKit

@objc
public class WPUserAgent: NSObject {

public static let userAgentKey = "UserAgent"

@objc
public static func defaultUserAgent(userDefaults: UserDefaults) -> String {
// FIXME: What's the point of reading from user defaults then returning a possibly different value?
// See original Objective-C implementation at
// https://github.com/wordpress-mobile/WordPress-iOS/blob/a6eaa7aa8acb50828449df2d3fccaa50d7def821/WordPress/Classes/Utility/WPUserAgent.m#L10-L29

// 1. Extract current stored user agent
let registrationDomain = userDefaults.volatileDomain(forName: UserDefaults.registrationDomain)
let storedUserAgent = registrationDomain[userAgentKey] as? String

// 2. Flush stored user agent
userDefaults.register(defaults: [userAgentKey: ""])

// 3. Reset the stored user agent if there was one in the first place (why??)
if let storedUserAgent {
userDefaults.register(defaults: [userAgentKey: storedUserAgent])
}

let userAgent = WPUserAgent.webViewUserAgent
assert(!userAgent.isEmpty, "User agent should not be empty")
return userAgent
}

// The original impelmentation had logic to only read this once if not nil.
// But why? The performance hit should be negligible.
//
// See original implementation at
// https://github.com/wordpress-mobile/WordPress-iOS/blob/a6eaa7aa8acb50828449df2d3fccaa50d7def821/WordPress/Classes/Utility/WPUserAgent.m#L31-L41
@objc
public static func wordPressUserAgent(userDefaults: UserDefaults, bundle: Bundle = .main) -> String {
let appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
return "\(defaultUserAgent(userDefaults: userDefaults)) wp-iphone/\(appVersion)"
}

@objc
public static func useWordPressInWebViews(userDefaults: UserDefaults) {
// Cleanup unused UserDefaults keys from older WPUserAgent implementation
// FIXME: How old are those older implementations? Can we remove this code because "old enough"?
userDefaults.removeObject(forKey: "DefaultUserAgent")
userDefaults.removeObject(forKey: "AppUserAgent")

let userAgent = wordPressUserAgent(userDefaults: userDefaults)

userDefaults.register(defaults: [userAgentKey: userAgent])

print("User-Agent set to \(userAgent)")
}

/// Returns a user agent string similar to (but may not exactly match) the one used in `WKWebView`.
@objc static var webViewUserAgent: String {
// Examples user agent strings from `WKWebView` in iOS simulators:
//
// ## iPhone 15 Pro (iOS 17.2)
// Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148
//
// ## iPad Pro (iOS 17.0.1)
// Mozilla/5.0 (iPad; CPU OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148
//
// Based on the WebKit implementation[^1], most of the components are hardcoded, and there are only a couple of dynamic components:
// 1. Device model. i.e. iPhone/iPad
// 2. OS name and version. i.e. iPhone OS 17_2
//
// Please note the "Mobile/15E148" part is WKWebView's default and hardcoded "application name"[^2].
//
// [^1]: https://github.com/WebKit/WebKit/blob/5fbb03ee1c6210c79779d6fa1a9e7290daa746d1/Source/WebCore/platform/ios/UserAgentIOS.mm#L88-L113
// [^2]: https://github.com/WebKit/WebKit/blob/492140d27dbe/Source/WebKit/UIProcess/API/Cocoa/WKWebViewConfiguration.mm#L612

let device = UIDevice.current

let deviceModel = device.model // Example: "iPhone"
var osName = device.systemName // Example: "iPhone OS"
let osVersion = device.systemVersion.replacingOccurrences(of: ".", with: "_") // Example: "17_2"

// WKWebView on iPad uses a static user agent.
// https://github.com/WebKit/WebKit/blob/6a053cfb431bd70d5017ba881a39f004e52effc2/Source/WebCore/platform/ios/UserAgentIOS.mm#L97
if device.userInterfaceIdiom == .pad {
osName = "OS"
}

// Use "iPhone OS" instead of "iOS", because that's what WKWebView uses.
if osName == "iOS" {
osName = "iPhone OS"
}

return "Mozilla/5.0 (\(deviceModel); CPU \(osName) \(osVersion) like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
}
}

public extension WPUserAgent {

private static let userDefaults = UserPersistentStoreFactory.userDefaultsInstance()

@objc
static func defaultUserAgent() -> String {
defaultUserAgent(userDefaults: userDefaults)
}

@objc(wordPressUserAgent)
static func wordPress() -> String {
wordPressUserAgent(userDefaults: userDefaults)
}

@objc
static func useWordPressInWebViews() {
useWordPressInWebViews(userDefaults: userDefaults)
}
}
Comment on lines +97 to +115
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote this thinking that the code would not have access to UserPersistentStoreFactory, so this would have stayed in the app target.

But I had forgotten about #24210

I suppose I could rewrite the definitions to use the default arguments? Or, this could stay the way it is and work as a reminder for code we should remove (i.e. the Objective-C implementation)?

115 changes: 115 additions & 0 deletions Modules/Tests/WordPressSharedTests/WPUserAgentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation
import Testing
import WebKit
import WordPressShared

class WPWPUserAgentTests {

@Test
func userAgentFormat() throws {
let userAgent = WPUserAgent.defaultUserAgent(userDefaults: .standard)

#expect(
try webKitUserAgentRegExp().numberOfMatches(
in: userAgent,
options: [],
range: NSRange(location: 0, length: userAgent.utf16.count)
) == 1
)
}

@Test
func wordPressUserAgentValue() throws {
let userDefaults = UserDefaults.standard
let appVersion = try #require(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)
let defaultUserAgent = WPUserAgent.defaultUserAgent(userDefaults: userDefaults)
let expectedUserAgent = String.init(format: "%@ wp-iphone/%@", defaultUserAgent, appVersion)

#expect(WPUserAgent.wordPressUserAgent(userDefaults: userDefaults) == expectedUserAgent)
}

@Test @MainActor
func usesWordPressUserAgentInWebViews() throws {
if #available(iOS 17, *) { // #available cannot go as an argument in @Test(.enabled(if: ..))
print("In iOS 17, WKWebView no longer reads User Agent from UserDefaults. Skipping while working on an alternative setup.")
return
}

let userDefaults = UserDefaults.standard
let defaultUserAgent = WPUserAgent.defaultUserAgent(userDefaults: userDefaults)
let wordPressUserAgent = WPUserAgent.wordPressUserAgent(userDefaults: userDefaults)

// FIXME: Is this necessary?
// See original implementation at
// https://github.com/wordpress-mobile/WordPress-iOS/blob/a6eaa7aa8acb50828449df2d3fccaa50d7def821/WordPress/WordPressTest/WPUserAgentTests.m#L57-L75
userDefaults.removeObject(forKey: WPUserAgent.userAgentKey)
userDefaults.register(defaults: [WPUserAgent.userAgentKey: defaultUserAgent])

WPUserAgent.useWordPressInWebViews(userDefaults: userDefaults)

#expect(try currentUserAgent(userDefaults: userDefaults) == wordPressUserAgent)
#expect(try currentUserAgentFromWebView() == wordPressUserAgent)
}

// FIXME: Is there even a point in testing for no throws when the method does not throw?
// See original implementation at
// https://github.com/wordpress-mobile/WordPress-iOS/blob/a6eaa7aa8acb50828449df2d3fccaa50d7def821/WordPress/WordPressTest/WPUserAgentTests.m#L102-L107
@Test
func accessingWordPressUserAgentOutsideMainThread() {
#expect(throws: Never.self, "Accessing outside the main thread should work") {
DispatchQueue.global(qos: .background).sync {
WPUserAgent.wordPressUserAgent(userDefaults: .standard)
}
}
}

func currentUserAgent(userDefaults: UserDefaults) throws -> String {
try #require(userDefaults.object(forKey: WPUserAgent.userAgentKey) as? String)
}

@MainActor
func currentUserAgentFromWebView() throws -> String {
try #require(WKWebView.userAgent())
}

func webKitUserAgentRegExp() throws -> NSRegularExpression {
try NSRegularExpression(
pattern: "^Mozilla/5\\.0 \\([a-zA-Z]+; CPU [\\sa-zA-Z]+ [_0-9]+ like Mac OS X\\) AppleWebKit/605\\.1\\.15 \\(KHTML, like Gecko\\) Mobile/15E148$"
)
}

// MARK: - Tests for underlying assumptions

@Test
func registerInUserDefaultsAdds() throws {
let userDefaults = UserDefaults.standard
let domainName = try #require(userDefaults.volatileDomainNames.first)
let originalDomain = userDefaults.volatileDomain(forName: domainName)

userDefaults.register(defaults: ["test-key": 0])

let updatedDomain = userDefaults.volatileDomain(forName: domainName)

// From the docs:
// Registered defaults are never stored between runs of an application, and are visible only to the application that registers them
//
// So we expect the count to be +1
#expect(updatedDomain.count == originalDomain.count + 1)
}

// If this test fails, it may mean `WKWebView` uses a user agent with an unexpected format (see `webKitUserAgentRegExp`)
// and we may need to adjust our implementation to match the new `WKWebView` user agent.
@Test @MainActor
func testWebKitUserAgentFormat() throws {
let regExp = try webKitUserAgentRegExp()
// Please note: WKWebView's user agent may be different on different test device types.
let userAgent = try currentUserAgentFromWebView()
#expect(
try webKitUserAgentRegExp().numberOfMatches(
in: userAgent,
options: [],
range: NSRange(location: 0, length: userAgent.utf16.count)
) == 1
)
}
}
1 change: 1 addition & 0 deletions WordPress/Classes/Login/WordPressDotComAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AuthenticationServices
import Foundation
import UIKit
import WordPressData
import WordPressShared

/// Log in or sign up a WordPress.com account via web.
///
Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/Models/Blog/Blog.m
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#import "WPAccount.h"
#import "AccountService.h"
@import WordPressDataObjC;
#import "WPUserAgent.h"
@import WordPressShared;
#import "WordPress-Swift.h"
@import SFHFKeychainUtils;

Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Models/ReaderListTopic+Creation.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WordPressShared

extension ReaderListTopic {

Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/Models/WPAccount+RestApi.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

import Foundation
import WordPressShared
import WordPressKit

extension WPAccount {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WordPressShared
import WordPressKit

private func apiBase(blog: Blog) -> URL? {
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Services/AuthenticationService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AutomatticTracks
import Foundation
import WordPressShared

class AuthenticationService {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CoreData
import WordPressData
import WordPressKit
import WordPressShared

class BloggingPromptsService {
let siteID: NSNumber
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import UIKit
import CoreData
import Gutenberg
import WordPressKit
import WordPressShared

class PageLayoutService {
private struct Parameters {
Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/Services/PostServiceRemoteFactory.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

import Foundation
import WordPressKit
import WordPressShared

@objc class PostServiceRemoteFactory: NSObject {
@objc func forBlog(_ blog: Blog) -> PostServiceRemote? {
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Services/PushAuthenticationService.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WordPressShared

/// The purpose of this service is to encapsulate the Restful API that performs Mobile 2FA
/// Code Verification.
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Services/ReaderCardService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import WordPressKit
import WordPressShared

protocol ReaderCardServiceRemote {
func fetchStreamCards(stream: ReaderStream,
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Services/ReaderSiteSearchService.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WordPressShared

typealias ReaderSiteSearchSuccessBlock = (_ feeds: [ReaderFeed], _ hasMore: Bool, _ feedCount: Int) -> Void
typealias ReaderSiteSearchFailureBlock = (_ error: Error?) -> Void
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WordPressShared

// MARK: - ReaderFollowedInterestsService

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import WordPressShared

extension ReaderTopicService {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import WordPressKit
import WordPressShared

// MARK: - ReaderInterestsService

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WordPressShared

/// Protocol representing a service that retrieves the users followed interests/tags
protocol ReaderSiteInfoService: AnyObject {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import WordPressKit
import WordPressShared

private enum SubscriptionAction {
case notifications(siteId: Int)
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Services/SiteAddressService.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import WordPressKit
import WordPressShared

// MARK: - SiteAddressService

Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Services/SiteAssemblyService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import WordPressData
import WordPressShared

/// Site Creation Notification
///
Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/Services/SiteSegmentsService.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

import Foundation
import WordPressKit
import WordPressShared

/// Abstracts the service to obtain site types
typealias SiteSegmentsServiceCompletion = (SiteSegmentsResult) -> Void
Expand Down
Loading