diff --git a/Fakes/Fakes/Networking.generated.swift b/Fakes/Fakes/Networking.generated.swift index 92703032655..94036db5dee 100644 --- a/Fakes/Fakes/Networking.generated.swift +++ b/Fakes/Fakes/Networking.generated.swift @@ -1474,6 +1474,7 @@ extension Site { description: .fake(), url: .fake(), adminURL: .fake(), + loginURL: .fake(), plan: .fake(), isJetpackThePluginInstalled: .fake(), isJetpackConnected: .fake(), diff --git a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift index 117dc48d6bc..aaf92b7e01e 100644 --- a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -1724,6 +1724,7 @@ extension Site { description: CopiableProp = .copy, url: CopiableProp = .copy, adminURL: CopiableProp = .copy, + loginURL: CopiableProp = .copy, plan: CopiableProp = .copy, isJetpackThePluginInstalled: CopiableProp = .copy, isJetpackConnected: CopiableProp = .copy, @@ -1738,6 +1739,7 @@ extension Site { let description = description ?? self.description let url = url ?? self.url let adminURL = adminURL ?? self.adminURL + let loginURL = loginURL ?? self.loginURL let plan = plan ?? self.plan let isJetpackThePluginInstalled = isJetpackThePluginInstalled ?? self.isJetpackThePluginInstalled let isJetpackConnected = isJetpackConnected ?? self.isJetpackConnected @@ -1753,6 +1755,7 @@ extension Site { description: description, url: url, adminURL: adminURL, + loginURL: loginURL, plan: plan, isJetpackThePluginInstalled: isJetpackThePluginInstalled, isJetpackConnected: isJetpackConnected, diff --git a/Networking/Networking/Model/Site.swift b/Networking/Networking/Model/Site.swift index 0172fefd57f..0ba74fd99cb 100644 --- a/Networking/Networking/Model/Site.swift +++ b/Networking/Networking/Model/Site.swift @@ -25,6 +25,10 @@ public struct Site: Decodable, Equatable, GeneratedFakeable, GeneratedCopiable { /// public let adminURL: String + /// Site's login URL. + /// + public let loginURL: String + /// Short name for site's plan. /// public let plan: String @@ -77,12 +81,14 @@ public struct Site: Decodable, Equatable, GeneratedFakeable, GeneratedCopiable { let timezone = try optionsContainer.decode(String.self, forKey: .timezone) let gmtOffset = try optionsContainer.decode(Double.self, forKey: .gmtOffset) let adminURL = try optionsContainer.decode(String.self, forKey: .adminURL) + let loginURL = try optionsContainer.decode(String.self, forKey: .loginURL) self.init(siteID: siteID, name: name, description: description, url: url, adminURL: adminURL, + loginURL: loginURL, plan: String(), // Not created on init. Added in supplementary API request. isJetpackThePluginInstalled: isJetpackThePluginInstalled, isJetpackConnected: isJetpackConnected, @@ -100,6 +106,7 @@ public struct Site: Decodable, Equatable, GeneratedFakeable, GeneratedCopiable { description: String, url: String, adminURL: String, + loginURL: String, plan: String, isJetpackThePluginInstalled: Bool, isJetpackConnected: Bool, @@ -113,6 +120,7 @@ public struct Site: Decodable, Equatable, GeneratedFakeable, GeneratedCopiable { self.description = description self.url = url self.adminURL = adminURL + self.loginURL = loginURL self.plan = plan self.isJetpackThePluginInstalled = isJetpackThePluginInstalled self.isJetpackConnected = isJetpackConnected @@ -154,6 +162,7 @@ private extension Site { case gmtOffset = "gmt_offset" case jetpackConnectionActivePlugins = "jetpack_connection_active_plugins" case adminURL = "admin_url" + case loginURL = "login_url" } enum PlanKeys: String, CodingKey { diff --git a/Networking/Networking/Remote/AccountRemote.swift b/Networking/Networking/Remote/AccountRemote.swift index f3cd0ff94e9..b505a9ff051 100644 --- a/Networking/Networking/Remote/AccountRemote.swift +++ b/Networking/Networking/Remote/AccountRemote.swift @@ -69,7 +69,7 @@ public class AccountRemote: Remote, AccountRemoteProtocol { let path = "me/sites" let parameters = [ "fields": "ID,name,description,URL,options,jetpack,jetpack_connection", - "options": "timezone,is_wpcom_store,woocommerce_is_active,gmt_offset,jetpack_connection_active_plugins,admin_url" + "options": "timezone,is_wpcom_store,woocommerce_is_active,gmt_offset,jetpack_connection_active_plugins,admin_url,login_url" ] let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .get, path: path, parameters: parameters) diff --git a/Storage/Storage.xcodeproj/project.pbxproj b/Storage/Storage.xcodeproj/project.pbxproj index f6003005786..9873a9be253 100644 --- a/Storage/Storage.xcodeproj/project.pbxproj +++ b/Storage/Storage.xcodeproj/project.pbxproj @@ -393,6 +393,7 @@ 933A272F2222344D00C2143A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; A3821B262583F14863740A37 /* Pods-Storage.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Storage.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-Storage/Pods-Storage.debug.xcconfig"; sourceTree = ""; }; AE93BE8F272C0E9F001B55EA /* GeneralStoreSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralStoreSettings.swift; sourceTree = ""; }; + AEC4481B290853C300BAA299 /* Model 76.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 76.xcdatamodel"; sourceTree = ""; }; B505255320EE6914008090F5 /* StorageType+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "StorageType+Extensions.swift"; sourceTree = ""; }; B505F6D820BEEA3100BB1B69 /* Account+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Account+CoreDataProperties.swift"; sourceTree = ""; }; B505F6D920BEEA3200BB1B69 /* Account+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Account+CoreDataClass.swift"; sourceTree = ""; }; @@ -1812,6 +1813,7 @@ DEC51AA4275B41BE009F3DF4 /* WooCommerce.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + AEC4481B290853C300BAA299 /* Model 76.xcdatamodel */, 688908A328F8EB360081A07E /* Model 75.xcdatamodel */, 6889088D28F668330081A07E /* Model 74.xcdatamodel */, 027CE79D28D167D0001B8D0E /* Model 73.xcdatamodel */, @@ -1888,7 +1890,7 @@ DEC51ADE275B41BE009F3DF4 /* Model 47.xcdatamodel */, DEC51ADF275B41BE009F3DF4 /* Model 19.xcdatamodel */, ); - currentVersion = 688908A328F8EB360081A07E /* Model 75.xcdatamodel */; + currentVersion = AEC4481B290853C300BAA299 /* Model 76.xcdatamodel */; path = WooCommerce.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Storage/Storage/Model/Site+CoreDataProperties.swift b/Storage/Storage/Model/Site+CoreDataProperties.swift index 28c303018dc..a80e78665a1 100644 --- a/Storage/Storage/Model/Site+CoreDataProperties.swift +++ b/Storage/Storage/Model/Site+CoreDataProperties.swift @@ -12,6 +12,7 @@ extension Site { @NSManaged public var tagline: String? @NSManaged public var url: String? @NSManaged public var adminURL: String? + @NSManaged public var loginURL: String? @NSManaged public var plan: String? @NSManaged public var isWooCommerceActive: NSNumber? @NSManaged public var isWordPressStore: NSNumber? diff --git a/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion b/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion index 4a54445039c..4d5aca11a5b 100644 --- a/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion +++ b/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model 75.xcdatamodel + Model 76.xcdatamodel diff --git a/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 76.xcdatamodel/contents b/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 76.xcdatamodel/contents new file mode 100644 index 00000000000..f28dca16101 --- /dev/null +++ b/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 76.xcdatamodel/contents @@ -0,0 +1,837 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift index 4b7ab91ee9a..97210340d5a 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift @@ -803,7 +803,17 @@ private extension ProductFormViewController { return } - WebviewHelper.launch(url, with: self) + let credentials = ServiceLocator.stores.sessionManager.defaultCredentials + guard let username = credentials?.username, + let token = credentials?.authToken, + let site = ServiceLocator.stores.sessionManager.defaultSite else { + return + } + + let configuration = WebViewControllerConfiguration(url: url) + configuration.authenticate(site: site, username: username, token: token) + let vc = WebKitViewController(configuration: configuration) + present(vc, animated: true) } func duplicateProduct() { diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/AuthenticationService.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/AuthenticationService.swift new file mode 100644 index 00000000000..fa4a437305f --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/AuthenticationService.swift @@ -0,0 +1,221 @@ +import AutomatticTracks +import Foundation + +/// Full copy of the same file from WP-iOS +/// Couple of crash logging calls replaced with WC-iOS counterparts +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/9b1e03b7b89db0eff3075e4460de5c78280f89de/WordPress/Classes/Services/AuthenticationService.swift +/// +class AuthenticationService { + + static let wpComLoginEndpoint = "https://wordpress.com/wp-login.php" + + enum RequestAuthCookieError: Error, LocalizedError { + case wpcomCookieNotReturned + + public var errorDescription: String? { + switch self { + case .wpcomCookieNotReturned: + return "Response to request for auth cookie for WP.com site failed to return cookie." + } + } + } + + // MARK: - Self Hosted + + func loadAuthCookiesForSelfHosted( + into cookieJar: CookieJar, + loginURL: URL, + username: String, + password: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in + guard !hasCookie else { + success() + return + } + + self.getAuthCookiesForSelfHosted(loginURL: loginURL, username: username, password: password, success: { cookies in + cookieJar.setCookies(cookies) { + success() + } + + cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in + print("Has cookie: \(hasCookie)") + } + }) { error in + // Make sure this error scenario isn't silently ignored. + ServiceLocator.crashLogging.logError(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + failure(error) + } + } + } + + func getAuthCookiesForSelfHosted( + loginURL: URL, + username: String, + password: String, + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + let headers = [String: String]() + let parameters = [ + "log": username, + "pwd": password, + "rememberme": "true" + ] + + requestAuthCookies( + from: loginURL, + headers: headers, + parameters: parameters, + success: success, + failure: failure) + } + + // MARK: - WP.com + + func loadAuthCookiesForWPCom( + into cookieJar: CookieJar, + username: String, + authToken: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + cookieJar.hasWordPressComAuthCookie( + username: username, + atomicSite: false) { hasCookie in + + guard !hasCookie else { + // The stored cookie can be stale but we'll try to use it and refresh it if the request fails. + success() + return + } + + self.getAuthCookiesForWPCom(username: username, authToken: authToken, success: { cookies in + cookieJar.setCookies(cookies) { + + cookieJar.hasWordPressComAuthCookie(username: username, atomicSite: false) { hasCookie in + guard hasCookie else { + failure(RequestAuthCookieError.wpcomCookieNotReturned) + return + } + success() + } + + } + }) { error in + // Make sure this error scenario isn't silently ignored. + ServiceLocator.crashLogging.logError(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + failure(error) + } + } + } + + func getAuthCookiesForWPCom( + username: String, + authToken: String, + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + let loginURL = URL(string: AuthenticationService.wpComLoginEndpoint)! + let headers = [ + "Authorization": "Bearer \(authToken)" + ] + let parameters = [ + "log": username, + "rememberme": "true" + ] + + requestAuthCookies( + from: loginURL, + headers: headers, + parameters: parameters, + success: success, + failure: failure) + } + + // MARK: - Request Construction + + private func requestAuthCookies( + from url: URL, + headers: [String: String], + parameters: [String: String], + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + // We don't want these cookies persisted in other sessions + let session = URLSession(configuration: .ephemeral) + var request = URLRequest(url: url) + + request.httpMethod = "POST" + request.httpBody = body(withParameters: parameters) + + headers.forEach { (key, value) in + request.setValue(value, forHTTPHeaderField: key) + } +// request.setValue(WPUserAgent.wordPress(), forHTTPHeaderField: "User-Agent") + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + DispatchQueue.main.async { + failure(error) + } + return + } + + // The following code is a bit complicated to read, apologies. + // We're retrieving all cookies from the "Set-Cookie" header manually, and combining + // those cookies with the ones from the current session. The reason behind this is that + // iOS's URLSession processes the cookies from such header before this callback is executed, + // whereas OHTTPStubs.framework doesn't (the cookies are left in the header fields of + // the response). The only way to combine both is to just add them together here manually. + // + // To know if you can remove this, you'll have to test this code live and in our unit tests + // and compare the session cookies. + let responseCookies = self.cookies(from: response, loginURL: url) + let cookies = (session.configuration.httpCookieStorage?.cookies ?? [HTTPCookie]()) + responseCookies + DispatchQueue.main.async { + success(cookies) + } + } + + task.resume() + } + + private func body(withParameters parameters: [String: String]) -> Data? { + var queryItems = [URLQueryItem]() + + for parameter in parameters { + let queryItem = URLQueryItem(name: parameter.key, value: parameter.value) + queryItems.append(queryItem) + } + + var components = URLComponents() + components.queryItems = queryItems + + return components.percentEncodedQuery?.data(using: .utf8) + } + + // MARK: - Response Parsing + + private func cookies(from response: URLResponse?, loginURL: URL) -> [HTTPCookie] { + guard let httpResponse = response as? HTTPURLResponse, + let headers = httpResponse.allHeaderFields as? [String: String] else { + return [] + } + + return HTTPCookie.cookies(withResponseHeaderFields: headers, for: loginURL) + } +} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/CookieJar.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/CookieJar.swift new file mode 100644 index 00000000000..e6e9edaa655 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/CookieJar.swift @@ -0,0 +1,229 @@ +import Foundation +import WebKit + +/// Full copy of the same file from WP-iOS +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/9b1e03b7b89db0eff3075e4460de5c78280f89de/WordPress/Classes/Utility/CookieJar.swift + +/// Provides a common interface to look for a logged-in WordPress cookie in different +/// cookie storage systems. +/// +@objc protocol CookieJar { + func getCookies(url: URL, completion: @escaping ([HTTPCookie]) -> Void) + func getCookies(completion: @escaping ([HTTPCookie]) -> Void) + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) + func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) + func removeWordPressComCookies(completion: @escaping () -> Void) + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) +} + +// As long as CookieJar is @objc, we can't have shared methods in protocol +// extensions, as it needs to be accessible to Obj-C. +// Whenever we migrate enough code so this doesn't need to be called from Swift, +// a regular CookieJar protocol with shared implementation on an extension would suffice. +// +// Also, although you're not supposed to use this outside this file, it can't be private +// since we're subclassing HTTPCookieStorage (which conforms to this) in MockCookieJar in +// the test target, and the swift compiler will crash when doing that ¯\_(ツ)_/¯ +// +// https://bugs.swift.org/browse/SR-2370 +// +protocol CookieJarSharedImplementation: CookieJar { +} + +extension CookieJarSharedImplementation { + func _hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + let url = URL(string: "https://wordpress.com/")! + + return _hasWordPressAuthCookie(for: url, username: username, atomicSite: atomicSite, completion: completion) + } + + func _hasWordPressAuthCookie(for url: URL, username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + getCookies(url: url) { (cookies) in + let cookie = cookies + .contains(where: { cookie in + return cookie.isWordPressLoggedIn(username: username, atomic: atomicSite) + }) + + completion(cookie) + } + } + + func _removeWordPressComCookies(completion: @escaping () -> Void) { + getCookies { [unowned self] (cookies) in + self.removeCookies(cookies.filter({ $0.domain.hasSuffix(".wordpress.com") }), completion: completion) + } + } +} + +extension HTTPCookieStorage: CookieJarSharedImplementation { + func getCookies(url: URL, completion: @escaping ([HTTPCookie]) -> Void) { + completion(cookies(for: url) ?? []) + } + + func getCookies(completion: @escaping ([HTTPCookie]) -> Void) { + completion(cookies ?? []) + } + + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + _hasWordPressComAuthCookie(username: username, atomicSite: atomicSite, completion: completion) + } + + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) { + _hasWordPressAuthCookie(for: url, username: username, atomicSite: false, completion: completion) + } + + func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + cookies.forEach(deleteCookie(_:)) + completion() + } + + func removeWordPressComCookies(completion: @escaping () -> Void) { + _removeWordPressComCookies(completion: completion) + } + + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + for cookie in cookies { + setCookie(cookie) + } + + completion() + } +} + +extension WKHTTPCookieStore: CookieJarSharedImplementation { + func getCookies(url: URL, completion: @escaping ([HTTPCookie]) -> Void) { + + // This fixes an issue with `getAllCookies` not calling its completion block (related: https://stackoverflow.com/q/55565188) + // - adds timeout so the above failure will eventually return + // - waits for the cookies on a background thread so that: + // 1. we are not blocking the main thread for UI reasons + // 2. cookies seem to never load when main thread is blocked (perhaps they dispatch to the main thread later on) + + DispatchQueue.global(qos: .userInitiated).async { + let group = DispatchGroup() + group.enter() + + var urlCookies: [HTTPCookie] = [] + + DispatchQueue.main.async { + self.getAllCookies { (cookies) in + urlCookies = cookies.filter({ (cookie) in + return cookie.matches(url: url) + }) + group.leave() + } + } + + let result = group.wait(timeout: .now() + .seconds(2)) + if result == .timedOut { + DDLogWarn("Time out waiting for WKHTTPCookieStore to get cookies") + } + + DispatchQueue.main.async { + completion(urlCookies) + } + } + } + + func getCookies(completion: @escaping ([HTTPCookie]) -> Void) { + getAllCookies(completion) + } + + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + _hasWordPressComAuthCookie(username: username, atomicSite: atomicSite, completion: completion) + } + + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) { + _hasWordPressAuthCookie(for: url, username: username, atomicSite: false, completion: completion) + } + + func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + let group = DispatchGroup() + cookies + .forEach({ [unowned self] (cookie) in + group.enter() + self.delete(cookie, completionHandler: { + group.leave() + }) + }) + let result = group.wait(timeout: .now() + .seconds(2)) + if result == .timedOut { + DDLogWarn("Time out waiting for WKHTTPCookieStore to remove cookies") + } + completion() + } + + func removeWordPressComCookies(completion: @escaping () -> Void) { + _removeWordPressComCookies(completion: completion) + } + + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + guard let cookie = cookies.last else { + return completion() + } + + DispatchQueue.main.async { + self.setCookie(cookie) { [weak self] in + self?.setCookies(cookies.dropLast(), completion: completion) + } + } + } +} + +#if DEBUG + func __removeAllWordPressComCookies() { + var jars = [CookieJarSharedImplementation]() + jars.append(HTTPCookieStorage.shared) + jars.append(WKWebsiteDataStore.default().httpCookieStore) + + let group = DispatchGroup() + jars.forEach({ jar in + group.enter() + jar.removeWordPressComCookies { + group.leave() + } + }) + _ = group.wait(timeout: .now() + .seconds(5)) + } +#endif + +private let atomicLoggedInCookieNamePrefix = "wordpress_logged_in_" +private let loggedInCookieName = "wordpress_logged_in" + +private extension HTTPCookie { + func isWordPressLoggedIn(username: String, atomic: Bool) -> Bool { + guard !atomic else { + return isWordPressLoggedInAtomic(username: username) + } + + return isWordPressLoggedIn(username: username) + } + + private func isWordPressLoggedIn(username: String) -> Bool { + return name.hasPrefix(loggedInCookieName) + && value.components(separatedBy: "%").first == username + } + + private func isWordPressLoggedInAtomic(username: String) -> Bool { + return name.hasPrefix(atomicLoggedInCookieNamePrefix) + && value.components(separatedBy: "|").first == username + } + + func matches(url: URL) -> Bool { + guard let host = url.host else { + return false + } + + let matchesDomain: Bool + if domain.hasPrefix(".") { + matchesDomain = host.hasSuffix(domain) + || host == domain.dropFirst() + } else { + matchesDomain = host == domain + } + return matchesDomain + && url.path.hasPrefix(path) + && (!isSecure || (url.scheme == "https")) + } +} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/NavigationTitleView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/NavigationTitleView.swift new file mode 100644 index 00000000000..53d13a9d62f --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/NavigationTitleView.swift @@ -0,0 +1,62 @@ +import Foundation +import UIKit +import WordPressShared.WPFontManager + +open class NavigationTitleView: UIView { + @objc public let titleLabel = UILabel(frame: defaultTitleFrame) + @objc public let subtitleLabel = UILabel(frame: defaultSubtitleFrame) + + + // MARK: - UIView's Methods + convenience init() { + self.init(frame: NavigationTitleView.defaultViewFrame) + } + + @objc convenience init(title: String?, subtitle: String?) { + self.init() + titleLabel.text = title ?? String() + subtitleLabel.text = subtitle ?? String() + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupSubviews() + } + + required public init(coder aDecoder: NSCoder) { + super.init(coder: aDecoder)! + setupSubviews() + } + + + // MARK: - Helpers + fileprivate func setupSubviews() { + titleLabel.font = WPFontManager.systemSemiBoldFont(ofSize: NavigationTitleView.defaultTitleFontSize) + titleLabel.textColor = .white + titleLabel.textAlignment = .center + titleLabel.backgroundColor = .clear + titleLabel.autoresizingMask = .flexibleWidth + + subtitleLabel.font = WPFontManager.systemRegularFont(ofSize: NavigationTitleView.defaultSubtitleFontSize) + subtitleLabel.textColor = .white + subtitleLabel.textAlignment = .center + subtitleLabel.backgroundColor = .clear + subtitleLabel.autoresizingMask = .flexibleWidth + + backgroundColor = UIColor.clear + autoresizingMask = [.flexibleWidth, .flexibleBottomMargin, .flexibleTopMargin] + clipsToBounds = true + + addSubview(titleLabel) + addSubview(subtitleLabel) + } + + // MARK: - Static Constants + fileprivate static let defaultViewFrame = CGRect(x: 0.0, y: 0.0, width: 200.0, height: 35.0) + + fileprivate static let defaultTitleFontSize = CGFloat(15) + fileprivate static let defaultTitleFrame = CGRect(x: 0.0, y: 0.0, width: 200.0, height: 19.0) + + fileprivate static let defaultSubtitleFontSize = CGFloat(10) + fileprivate static let defaultSubtitleFrame = CGRect(x: 0.0, y: 19.0, width: 200.0, height: 16.0) +} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/RequestAuthenticator.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/RequestAuthenticator.swift new file mode 100644 index 00000000000..9503c7bcda5 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/RequestAuthenticator.swift @@ -0,0 +1,330 @@ +import AutomatticTracks +import struct Networking.Site +import Foundation + + +/// Partial copy of the same file from WP-iOS +/// Trimmed down and adapted for WC +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/9b1e03b/WordPress/Classes/Utility/Networking/RequestAuthenticator.swift + +/// Authenticator for requests to self-hosted sites, wp.com sites, including private +/// sites and atomic sites. +/// +class RequestAuthenticator: NSObject { + + enum DotComAuthenticationType { + case regular + case regularMapped(siteID: Int) + case atomic(loginURL: String) + case privateAtomic(blogID: Int) + } + + enum WPNavigationActionType { + case reload + case allow + } + + enum Credentials { + case dotCom(username: String, authToken: String, authenticationType: DotComAuthenticationType) + case siteLogin(loginURL: URL, username: String, password: String) + } + + fileprivate let credentials: Credentials + + // MARK: - Services + + private let authenticationService: AuthenticationService + + // MARK: - Initializers + + init(credentials: Credentials, authenticationService: AuthenticationService = AuthenticationService()) { + self.credentials = credentials + self.authenticationService = authenticationService + } + + convenience init?(site: Site, username: String, token: String) { + var authenticationType: DotComAuthenticationType = .regular + + if site.isWordPressStore { + authenticationType = .atomic(loginURL: site.loginURL) + // TODO: consider private atomic case + } + + self.init(credentials: .dotCom(username: username, authToken: token, authenticationType: authenticationType)) + } + + /// Potentially rewrites a request for authentication. + /// + /// This method will call the completion block with the request to be used. + /// + /// - Warning: On WordPress.com, this uses a special redirect system. It + /// requires the web view to call `interceptRedirect(request:)` before + /// loading any request. + /// + /// - Parameters: + /// - url: the URL to be loaded. + /// - cookieJar: a CookieJar object where the authenticator will look + /// for existing cookies. + /// - completion: this will be called with either the request for + /// authentication, or a request for the original URL. + /// + @objc func request(url: URL, cookieJar: CookieJar, completion: @escaping (URLRequest) -> Void) { + switch self.credentials { + case .dotCom(let username, let authToken, let authenticationType): + requestForWPCom( + url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + authenticationType: authenticationType, + completion: completion) + case .siteLogin(let loginURL, let username, let password): + requestForSelfHosted( + url: url, + loginURL: loginURL, + cookieJar: cookieJar, + username: username, + password: password, + completion: completion) + } + } + + private func requestForWPCom(url: URL, + cookieJar: CookieJar, + username: String, + authToken: String, + authenticationType: DotComAuthenticationType, + completion: @escaping (URLRequest) -> Void) { + + switch authenticationType { + case .regular: + requestForWPCom( + url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + completion: completion) + case .regularMapped(let siteID): + requestForMappedWPCom(url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + siteID: siteID, + completion: completion) + + case .privateAtomic: + // not supported + return + case .atomic(let loginURL): + requestForAtomicWPCom( + url: url, + loginURL: loginURL, + cookieJar: cookieJar, + username: username, + authToken: authToken, + completion: completion) + } + } + + private func requestForSelfHosted(url: URL, + loginURL: URL, + cookieJar: CookieJar, + username: String, + password: String, + completion: @escaping (URLRequest) -> Void) { + + func done() { + let request = URLRequest(url: url) + completion(request) + } + + authenticationService.loadAuthCookiesForSelfHosted(into: cookieJar, loginURL: loginURL, username: username, password: password, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForAtomicWPCom(url: URL, + loginURL: String, + cookieJar: CookieJar, + username: String, + authToken: String, + completion: @escaping (URLRequest) -> Void) { + + func done() { + // For non-private Atomic sites, proxy the request through wp-login like Calypso does. + // If the site has SSO enabled auth should happen and we get redirected to our preview. + // If SSO is not enabled wp-admin prompts for credentials, then redirected. + var components = URLComponents(string: loginURL) + var queryItems = components?.queryItems ?? [] + queryItems.append(URLQueryItem(name: "redirect_to", value: url.absoluteString)) + components?.queryItems = queryItems + let requestURL = components?.url ?? url + + let request = URLRequest(url: requestURL) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForMappedWPCom(url: URL, + cookieJar: CookieJar, + username: String, + authToken: String, + siteID: Int, + completion: @escaping (URLRequest) -> Void) { + func done() { + guard + let host = url.host, + !host.contains("wordpress.com") + else { + // The requested URL is to the unmapped version of the domain, + // so skip proxying the request through r-login. + completion(URLRequest(url: url)) + return + } + + let rlogin = "https://r-login.wordpress.com/remote-login.php?action=auth" + guard var components = URLComponents(string: rlogin) else { + // Safety net in case something unexpected changes in the future. + DDLogError("There was an unexpected problem initializing URLComponents via the rlogin string.") + completion(URLRequest(url: url)) + return + } + var queryItems = components.queryItems ?? [] + queryItems.append(contentsOf: [ + URLQueryItem(name: "host", value: host), + URLQueryItem(name: "id", value: String(siteID)), + URLQueryItem(name: "back", value: url.absoluteString) + ]) + components.queryItems = queryItems + let requestURL = components.url ?? url + + let request = URLRequest(url: requestURL) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForWPCom(url: URL, cookieJar: CookieJar, username: String, authToken: String, completion: @escaping (URLRequest) -> Void) { + + func done() { + let request = URLRequest(url: url) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func logErrorIfNeeded(_ error: Swift.Error) { + let nsError = error as NSError + + switch nsError.code { + case NSURLErrorTimedOut, NSURLErrorNotConnectedToInternet: + return + default: + ServiceLocator.crashLogging.logError(error) + } + } +} + +private extension RequestAuthenticator { + static let wordPressComLoginUrl = URL(string: "https://wordpress.com/wp-login.php")! +} + +extension RequestAuthenticator { + func isLogin(url: URL) -> Bool { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = nil + + return components?.url == RequestAuthenticator.wordPressComLoginUrl + } +} + +// MARK: Navigation Validator +extension RequestAuthenticator { + /// Validates that the navigation worked as expected then provides a recommendation on if the screen should reload or not. + func decideActionFor(response: URLResponse, cookieJar: CookieJar, completion: @escaping (WPNavigationActionType) -> Void) { + switch self.credentials { + case .dotCom(let username, _, let authenticationType): + decideActionForWPCom(response: response, cookieJar: cookieJar, username: username, authenticationType: authenticationType, completion: completion) + case .siteLogin: + completion(.allow) + } + } + + private func decideActionForWPCom(response: URLResponse, + cookieJar: CookieJar, + username: String, + authenticationType: DotComAuthenticationType, + completion: @escaping (WPNavigationActionType) -> Void) { + + guard didEncouterRecoverableChallenge(response) else { + completion(.allow) + return + } + + cookieJar.removeWordPressComCookies { + completion(.reload) + } + } + + private func didEncouterRecoverableChallenge(_ response: URLResponse) -> Bool { + guard let url = response.url?.absoluteString else { + return false + } + + if url.contains("r-login.wordpress.com") || url.contains("wordpress.com/log-in?") { + return true + } + + guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { + return false + } + + return 400 <= statusCode && statusCode < 500 + } +} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebKitViewController.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebKitViewController.swift new file mode 100644 index 00000000000..ad48d3a04a0 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebKitViewController.swift @@ -0,0 +1,601 @@ +import Foundation +import Gridicons +import UIKit +import WebKit +import WordPressShared + +/// Partial copy of the same file from WP-iOS +/// https://github.com/wordpress-mobile/WordPress-iOS/blob/c027ccf05ba839d658f8496e62b7bfdae6608a10/WordPress/Classes/Utility/WebKitViewController.swift + +protocol WebKitAuthenticatable { + var authenticator: RequestAuthenticator? { get } + func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void) +} + +extension WebKitAuthenticatable { + func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void) { + guard let authenticator = authenticator else { + return completion(URLRequest(url: url)) + } + + print("YOLO calling authenticatedRequest for \(url)") + + DispatchQueue.main.async { + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + authenticator.request(url: url, cookieJar: cookieStore) { (request) in + completion(request) + } + } + } +} + +class WebKitViewController: UIViewController, WebKitAuthenticatable { + @objc let webView: WKWebView + @objc let progressView = WebProgressView() + @objc let titleView = NavigationTitleView() + + @objc lazy var backButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: UIImage.gridicon(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(), + style: .plain, + target: self, + action: #selector(goBack)) + button.title = NSLocalizedString("Back", comment: "Previous web page") + return button + }() + @objc lazy var forwardButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.chevronRight), + style: .plain, + target: self, + action: #selector(goForward)) + button.title = NSLocalizedString("Forward", comment: "Next web page") + return button + }() + @objc lazy var shareButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.shareiOS), + style: .plain, + target: self, + action: #selector(share)) + button.title = NSLocalizedString("Share", comment: "Button label to share a web page") + return button + }() + @objc lazy var safariButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.globe), + style: .plain, + target: self, + action: #selector(openInSafari)) + button.title = NSLocalizedString("Safari", comment: "Button label to open web page in Safari") + button.accessibilityHint = NSLocalizedString("Opens the web page in Safari", comment: "Accessibility hint to open web page in Safari") + return button + }() + @objc lazy var refreshButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.refresh), style: .plain, target: self, action: #selector(WebKitViewController.refresh)) + button.title = NSLocalizedString("Refresh", comment: "Button label to refres a web page") + return button + }() + @objc lazy var closeButton: UIBarButtonItem = { + let button = UIBarButtonItem(image: .gridicon(.cross), style: .plain, target: self, action: #selector(WebKitViewController.close)) + button.title = NSLocalizedString("webKit.button.dismiss", value: "Dismiss", comment: "Verb. Dismiss the web view screen.") + return button + }() + + @objc var customOptionsButton: UIBarButtonItem? + + @objc let url: URL? + @objc let authenticator: RequestAuthenticator? + @objc var customTitle: String? + + private var reachabilityObserver: Any? + private var tapLocation = CGPoint(x: 0.0, y: 0.0) + private var widthConstraint: NSLayoutConstraint? + private var stackViewBottomAnchor: NSLayoutConstraint? + private var onClose: (() -> Void)? + + private var barButtonTintColor: UIColor { + .listIcon + } + + private var navBarTitleColor: UIColor { + .text + } + + + private struct WebViewErrors { + static let frameLoadInterrupted = 102 + } + + /// Precautionary variable that's in place to make sure the web view doesn't run into an endless loop of reloads if it encounters an error. + private var hasAttemptedAuthRecovery = false + + @objc init(configuration: WebViewControllerConfiguration) { + let config = WKWebViewConfiguration() + // The default on iPad is true. We want the iPhone to be true as well. +// config.allowsInlineMediaPlayback = true + + webView = WKWebView(frame: .zero, configuration: config) + url = configuration.url +// customOptionsButton = configuration.optionsButton +// secureInteraction = configuration.secureInteraction +// addsWPComReferrer = configuration.addsWPComReferrer +// customTitle = configuration.customTitle + authenticator = configuration.authenticator +// onClose = configuration.onClose + + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + startObservingWebView() + } + + fileprivate init(url: URL, parent: WebKitViewController, configuration: WKWebViewConfiguration, source: String? = nil) { + webView = WKWebView(frame: .zero, configuration: configuration) + self.url = url + customOptionsButton = parent.customOptionsButton + customTitle = parent.customTitle + authenticator = parent.authenticator + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + startObservingWebView() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.title)) + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.url)) + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress)) + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.isLoading)) + } + + private func startObservingWebView() { + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.title), options: [.new], context: nil) + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.url), options: [.new], context: nil) + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [.new], context: nil) + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.isLoading), options: [], context: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = UIColor(light: UIColor.gray(.shade0), dark: .basicBackground) + + let stackView = UIStackView(arrangedSubviews: [ + progressView, + webView + ]) + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stackView) + + let edgeConstraints = [ + view.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + view.topAnchor.constraint(equalTo: stackView.topAnchor), + view.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ] + edgeConstraints.forEach({ $0.priority = UILayoutPriority(rawValue: UILayoutPriority.defaultHigh.rawValue - 1) }) + + NSLayoutConstraint.activate(edgeConstraints) + + // we are pinning the top and bottom of the stack view to the safe area to prevent unintentionally hidden content/overlaps (ie cookie acceptance popup) then center the horizontal constraints vertically + let safeArea = self.view.safeAreaLayoutGuide + + stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + stackView.topAnchor.constraint(equalTo: safeArea.topAnchor).isActive = true + + // this constraint saved as a varible so it can be deactivated when the toolbar is hidden, to prevent unintended pinning to the safe area + let stackViewBottom = stackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor) + stackViewBottomAnchor = stackViewBottom + NSLayoutConstraint.activate([stackViewBottom]) + + let stackWidthConstraint = stackView.widthAnchor.constraint(equalToConstant: 0) + stackWidthConstraint.priority = UILayoutPriority.defaultLow + widthConstraint = stackWidthConstraint + NSLayoutConstraint.activate([stackWidthConstraint]) + + configureNavigation() + configureToolbar() + addTapGesture() +// webView.customUserAgent = WPUserAgent.wordPress() + webView.navigationDelegate = self + webView.uiDelegate = self + + loadWebViewRequest() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopWaitingForConnectionRestored() +// ReachabilityUtils.dismissNoInternetConnectionNotice() + } + + @objc func loadWebViewRequest() { +// if ReachabilityUtils.alertIsShowing() { +// dismiss(animated: false) +// } + guard let url = url else { + return + } + + authenticatedRequest(for: url, on: webView) { [weak self] (request) in + self?.load(request: request) + } + } + + @objc func load(request: URLRequest) { +// var request = request +// if addsWPComReferrer { +// request.setValue(WPComReferrerURL, forHTTPHeaderField: "Referer") +// } + + webView.load(request) + } + + // MARK: Navigation bar setup + + @objc func configureNavigation() { + setupNavBarTitleView() + setupRefreshButton() + + // Modal styling + // Proceed only if this Modal, and it's the only view in the stack. + // We're not changing the NavigationBar style, if we're sharing it with someone else! + guard isModal() else { + return + } + + setupCloseButton() + styleNavBar() + } + + private func setupRefreshButton() { + if let customOptionsButton = customOptionsButton { + navigationItem.rightBarButtonItems = [refreshButton, customOptionsButton] + } else { // if !secureInteraction + navigationItem.rightBarButtonItem = refreshButton + } + } + + private func setupCloseButton() { + navigationItem.leftBarButtonItem = closeButton + } + + private func setupNavBarTitleView() { + titleView.titleLabel.text = NSLocalizedString("Loading...", comment: "Loading. Verb") + + titleView.titleLabel.textColor = navBarTitleColor + titleView.subtitleLabel.textColor = .neutral(.shade30) + + if let title = customTitle { + self.title = title + } else { + navigationItem.titleView = titleView + } + } + + private func styleNavBar() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + navigationBar.barStyle = .default + + // Remove serif title bar formatting + navigationBar.standardAppearance.titleTextAttributes = [:] + +// navigationBar.shadowImage = UIImage(color: WPStyleGuide.webViewModalNavigationBarShadow()) +// navigationBar.setBackgroundImage(UIImage(color: WPStyleGuide.webViewModalNavigationBarBackground()), for: .default) + + fixBarButtonsColorForBoldText(on: navigationBar) + } + + // MARK: ToolBar setup + + @objc func configureToolbar() { +// navigationController?.isToolbarHidden = secureInteraction + +// guard !secureInteraction else { +// // if not a secure interaction/view, no toolbar is displayed, so deactivate constraint pinning stack view to safe area +// stackViewBottomAnchor?.isActive = false +// return +// } + + styleToolBar() + configureToolbarButtons() + styleToolBarButtons() + } + + func configureToolbarButtons() { + + let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + + let items = [ + backButton, + space, + forwardButton, + space, + shareButton, + space, + safariButton + ] + setToolbarItems(items, animated: false) + } + + private func styleToolBar() { + guard let toolBar = navigationController?.toolbar else { + return + } + + let appearance = UIToolbarAppearance() + appearance.configureWithDefaultBackground() + appearance.backgroundColor = UIColor(light: .white, dark: .gray) + + toolBar.standardAppearance = appearance + + if #available(iOS 15.0, *) { + toolBar.scrollEdgeAppearance = appearance + } + + fixBarButtonsColorForBoldText(on: toolBar) + } + + private func styleToolBarButtons() { + navigationController?.toolbar.items?.forEach(styleToolBarButton) + } + + /// Sets the width of the web preview + /// - Parameter width: The width value to set the webView to + /// - Parameter viewWidth: The view width the webView must fit within, used to manage view transitions, e.g. orientation change + func setWidth(_ width: CGFloat?, viewWidth: CGFloat? = nil) { + if let width = width { + let horizontalViewBound: CGFloat + if let viewWidth = viewWidth { + horizontalViewBound = viewWidth + } else if let superViewWidth = view.superview?.frame.width { + horizontalViewBound = superViewWidth + } else { + horizontalViewBound = width + } + + widthConstraint?.constant = min(width, horizontalViewBound) + widthConstraint?.priority = UILayoutPriority.defaultHigh + } else { + widthConstraint?.priority = UILayoutPriority.defaultLow + } + } + + // MARK: Helpers + + private func fixBarButtonsColorForBoldText(on bar: UIView) { + if UIAccessibility.isBoldTextEnabled { + bar.tintColor = .listIcon + } + } + + private func styleBarButton(_ button: UIBarButtonItem) { + button.tintColor = barButtonTintColor + } + + private func styleToolBarButton(_ button: UIBarButtonItem) { + button.tintColor = .listIcon + } + + // MARK: Reachability Helpers + + private func reloadWhenConnectionRestored() { +// reachabilityObserver = ReachabilityUtils.observeOnceInternetAvailable { [weak self] in +// self?.loadWebViewRequest() +// } + } + + private func stopWaitingForConnectionRestored() { + guard let reachabilityObserver = reachabilityObserver else { + return + } + + NotificationCenter.default.removeObserver(reachabilityObserver) + self.reachabilityObserver = nil + } + + private func addTapGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(webViewTapped(_:))) + tapGesture.delegate = self + webView.addGestureRecognizer(tapGesture) + } + + // MARK: User Actions + @objc func close() { + dismiss(animated: true, completion: onClose) + } + + @objc func share() { + guard let url = webView.url else { + return + } + + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activityViewController.modalPresentationStyle = .popover + activityViewController.popoverPresentationController?.barButtonItem = shareButton + + activityViewController.completionWithItemsHandler = { (type, completed, _, _) in +// if completed, let type = type?.rawValue { +// WPActivityDefaults.trackActivityType(type) +// } + } + present(activityViewController, animated: true) +// track(.webKitViewShareTapped) + } + + @objc func refresh() { + webView.reload() +// track(.webKitViewReloadTapped) + } + + @objc func goBack() { + webView.goBack() +// track(.webKitViewNavigatedBack) + } + + @objc func goForward() { + webView.goForward() +// track(.webKitViewNavigatedForward) + } + + @objc func openInSafari() { + guard let url = webView.url else { + return + } + UIApplication.shared.open(url) +// track(.webKitViewOpenInSafariTapped) + } + + ///location is used to present a document menu in tap location on iOS 13 + @objc func webViewTapped(_ sender: UITapGestureRecognizer) { + self.tapLocation = sender.location(in: view) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard let object = object as? WKWebView, + object == webView, + let keyPath = keyPath else { + return + } + + switch keyPath { + case #keyPath(WKWebView.title): + titleView.titleLabel.text = webView.title + case #keyPath(WKWebView.url): + // If the site has no title, use the url. + if webView.title?.nonEmptyString() == nil { + titleView.titleLabel.text = webView.url?.host + } + titleView.subtitleLabel.text = webView.url?.host + let haveUrl = webView.url != nil + shareButton.isEnabled = haveUrl + safariButton.isEnabled = haveUrl + navigationItem.rightBarButtonItems?.forEach { $0.isEnabled = haveUrl } + case #keyPath(WKWebView.estimatedProgress): + progressView.progress = Float(webView.estimatedProgress) + progressView.isHidden = webView.estimatedProgress == 1 + case #keyPath(WKWebView.isLoading): + backButton.isEnabled = webView.canGoBack + forwardButton.isEnabled = webView.canGoForward + default: + assertionFailure("Observed change to web view that we are not handling") + } + + // Set the title for the HUD which shows up on tap+hold w/ accessibile font sizes enabled + navigationItem.title = "\(titleView.titleLabel.text ?? "")\n\n\(String(describing: titleView.subtitleLabel.text ?? ""))" + + // Accessibility values which emulate those found in Safari + navigationItem.accessibilityLabel = NSLocalizedString("Title", comment: "Accessibility label for web page preview title") + navigationItem.titleView?.accessibilityValue = titleView.titleLabel.text + navigationItem.titleView?.accessibilityTraits = .updatesFrequently + } +} + +extension WebKitViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + + print("YOLO navigationAction \(navigationAction.request.url)") + +// if let delegate = navigationDelegate { +// let policy = delegate.shouldNavigate(request: navigationAction.request) +// if let redirect = policy.redirectRequest { +// load(request: redirect) +// } +// decisionHandler(policy.action) +// return +// } +// +// // Allow request if it is to `wp-login` for 2fa +// if let url = navigationAction.request.url, authenticator?.isLogin(url: url) == true { +// decisionHandler(.allow) +// return +// } +// +// // Check for link protocols such as `tel:` and set the correct behavior +// if let url = navigationAction.request.url, let scheme = url.scheme { +// let linkProtocols = ["tel", "sms", "mailto"] +// if linkProtocols.contains(scheme) && UIApplication.shared.canOpenURL(url) { +// UIApplication.shared.open(url, options: [:], completionHandler: nil) +// decisionHandler(.cancel) +// return +// } +// } +// +// let policy = linkBehavior.handle(navigationAction: navigationAction, for: webView) + + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { +// guard navigationResponse.isForMainFrame, let authenticator = authenticator, !hasAttemptedAuthRecovery else { + decisionHandler(.allow) +// return +// } +// +// let cookieStore = webView.configuration.websiteDataStore.httpCookieStore +// authenticator.decideActionFor(response: navigationResponse.response, cookieJar: cookieStore) { [unowned self] action in +// switch action { +// case .reload: +// decisionHandler(.cancel) +// +// /// We've cleared the stored cookies so let's try again. +// self.hasAttemptedAuthRecovery = true +// self.loadWebViewRequest() +// case .allow: +// decisionHandler(.allow) +// } +// } + } +} + +extension WebKitViewController: WKUIDelegate { + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + if navigationAction.targetFrame == nil, + let url = navigationAction.request.url { + +// if opensNewInSafari { +// UIApplication.shared.open(url, options: [:], completionHandler: nil) +// } else { + let controller = WebKitViewController(url: url, parent: self, configuration: configuration) + let navController = UINavigationController(rootViewController: controller) + present(navController, animated: true) + return controller.webView +// } + } + return nil + } + + func webViewDidClose(_ webView: WKWebView) { + dismiss(animated: true) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + DDLogInfo("\(NSStringFromClass(type(of: self))) Error Loading [\(error)]") + + // Don't show Frame Load Interrupted errors + let code = (error as NSError).code + if code == WebViewErrors.frameLoadInterrupted { + return + } + +// if !ReachabilityUtils.isInternetReachable() { +// ReachabilityUtils.showNoInternetConnectionNotice() +// reloadWhenConnectionRestored() +// } else { + DDLogError("WebView \(webView) didFailProvisionalNavigation: \(error.localizedDescription)") +// } + } +} + +extension WebKitViewController: UIPopoverPresentationControllerDelegate { + func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) { + handleDocumentMenuPresentation(presented: popoverPresentationController) + } + + private func handleDocumentMenuPresentation(presented: UIPopoverPresentationController) { + presented.sourceView = webView + presented.sourceRect = CGRect(origin: tapLocation, size: CGSize(width: 0, height: 0)) + } +} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebProgressView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebProgressView.swift new file mode 100644 index 00000000000..c66e854a2f3 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebProgressView.swift @@ -0,0 +1,73 @@ +import UIKit +import WebKit +import WordPressShared + +/// A view to show progress when loading web pages. +/// +/// Since UIWebView doesn't offer any real or estimate loading progress, this +/// shows an initial indication of progress and animates to a full bar when the +/// web view finishes loading. +/// +class WebProgressView: UIProgressView { + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + configure() + } + + @objc func startedLoading() { + alpha = Animation.visibleAlpha + progress = Progress.initial + } + + @objc func finishedLoading() { + UIView.animate(withDuration: Animation.longDuration, animations: { [weak self] in + self?.progress = Progress.final + }, completion: { [weak self] _ in + UIView.animate(withDuration: Animation.shortDuration, animations: { + self?.alpha = Animation.hiddenAlhpa + }) + }) + } + + func observeProgress(webView: WKWebView) { + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [.new], context: nil) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard let webView = object as? WKWebView, + let keyPath = keyPath else { + return + } + + switch keyPath { + case #keyPath(WKWebView.estimatedProgress): + progress = Float(webView.estimatedProgress) + isHidden = webView.estimatedProgress == 1 + default: + assertionFailure("Observed change to web view that we are not handling") + } + } + + private func configure() { + progressTintColor = .primary + backgroundColor = .listBackground + progressViewStyle = .bar + } + + private enum Progress { + static let initial = Float(0.1) + static let final = Float(1.0) + } + + private enum Animation { + static let shortDuration = 0.1 + static let longDuration = 0.4 + static let visibleAlpha = CGFloat(1.0) + static let hiddenAlhpa = CGFloat(0.0) + } +} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebViewControllerConfiguration.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebViewControllerConfiguration.swift new file mode 100644 index 00000000000..f3999fc4f17 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/AuthWebView/WebViewControllerConfiguration.swift @@ -0,0 +1,20 @@ +import UIKit +import WebKit +import struct Networking.Site + +class WebViewControllerConfiguration: NSObject { + @objc var url: URL? + + @objc var customTitle: String? + @objc var authenticator: RequestAuthenticator? + var onClose: (() -> Void)? + + @objc init(url: URL?) { + self.url = url + super.init() + } + + func authenticate(site: Site, username: String, token: String) { + self.authenticator = RequestAuthenticator(site: site, username: username, token: token) + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 3275e84f5c8..596c3d6b043 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1153,6 +1153,13 @@ AEB73C1725CD8E5800A8454A /* AttributePickerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB73C1625CD8E5800A8454A /* AttributePickerViewModelTests.swift */; }; AEBFD13F28E7655F00F598C6 /* StoreInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEBFD13E28E7655F00F598C6 /* StoreInfoView.swift */; }; AEC12B7A2758D55900845F97 /* OrderStatusList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC12B792758D55900845F97 /* OrderStatusList.swift */; }; + AEC4480E29081BB400BAA299 /* CookieJar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC4480D29081BB400BAA299 /* CookieJar.swift */; }; + AEC4481029081C4B00BAA299 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC4480F29081C4B00BAA299 /* AuthenticationService.swift */; }; + AEC4481229081C9F00BAA299 /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC4481129081C9F00BAA299 /* RequestAuthenticator.swift */; }; + AEC4481429081EEA00BAA299 /* WebViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC4481329081EEA00BAA299 /* WebViewControllerConfiguration.swift */; }; + AEC4481629081FE200BAA299 /* WebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC4481529081FE200BAA299 /* WebKitViewController.swift */; }; + AEC448182908202500BAA299 /* WebProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC448172908202500BAA299 /* WebProgressView.swift */; }; + AEC4481A2908204100BAA299 /* NavigationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC448192908204100BAA299 /* NavigationTitleView.swift */; }; AEC95D412774C5AE001571F5 /* AddressFormViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC95D402774C5AE001571F5 /* AddressFormViewModelProtocol.swift */; }; AEC95D432774D07B001571F5 /* CreateOrderAddressFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC95D422774D07B001571F5 /* CreateOrderAddressFormViewModel.swift */; }; AECD57D226DFDF7500A3B580 /* EditOrderAddressForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = AECD57D126DFDF7500A3B580 /* EditOrderAddressForm.swift */; }; @@ -3051,6 +3058,13 @@ AEB73C1625CD8E5800A8454A /* AttributePickerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributePickerViewModelTests.swift; sourceTree = ""; }; AEBFD13E28E7655F00F598C6 /* StoreInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInfoView.swift; sourceTree = ""; }; AEC12B792758D55900845F97 /* OrderStatusList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderStatusList.swift; sourceTree = ""; }; + AEC4480D29081BB400BAA299 /* CookieJar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieJar.swift; sourceTree = ""; }; + AEC4480F29081C4B00BAA299 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; + AEC4481129081C9F00BAA299 /* RequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticator.swift; sourceTree = ""; }; + AEC4481329081EEA00BAA299 /* WebViewControllerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerConfiguration.swift; sourceTree = ""; }; + AEC4481529081FE200BAA299 /* WebKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitViewController.swift; sourceTree = ""; }; + AEC448172908202500BAA299 /* WebProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebProgressView.swift; sourceTree = ""; }; + AEC448192908204100BAA299 /* NavigationTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTitleView.swift; sourceTree = ""; }; AEC95D402774C5AE001571F5 /* AddressFormViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressFormViewModelProtocol.swift; sourceTree = ""; }; AEC95D422774D07B001571F5 /* CreateOrderAddressFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateOrderAddressFormViewModel.swift; sourceTree = ""; }; AECD57D126DFDF7500A3B580 /* EditOrderAddressForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOrderAddressForm.swift; sourceTree = ""; }; @@ -6489,6 +6503,20 @@ path = Homescreen; sourceTree = ""; }; + AEC4480C29081B9400BAA299 /* AuthWebView */ = { + isa = PBXGroup; + children = ( + AEC4480D29081BB400BAA299 /* CookieJar.swift */, + AEC4480F29081C4B00BAA299 /* AuthenticationService.swift */, + AEC4481129081C9F00BAA299 /* RequestAuthenticator.swift */, + AEC4481329081EEA00BAA299 /* WebViewControllerConfiguration.swift */, + AEC4481529081FE200BAA299 /* WebKitViewController.swift */, + AEC448172908202500BAA299 /* WebProgressView.swift */, + AEC448192908204100BAA299 /* NavigationTitleView.swift */, + ); + path = AuthWebView; + sourceTree = ""; + }; AECD57D026DFDF3E00A3B580 /* Address Edit */ = { isa = PBXGroup; children = ( @@ -7876,6 +7904,7 @@ CED6021A20B35FBF0032C639 /* ReusableViews */ = { isa = PBXGroup; children = ( + AEC4480C29081B9400BAA299 /* AuthWebView */, 45D875D72611EA3D00226C3F /* SwiftUI Components */, 57CFCD26248844A0003F51EC /* Section Headers */, 57448D26242E772300A56A74 /* EmptyStateViewController */, @@ -9509,6 +9538,7 @@ CCE4CD282669324300E09FD4 /* ShippingLabelPaymentMethodsTopBanner.swift in Sources */, 02CA63DD23D1ADD100BBF148 /* MediaPickingContext.swift in Sources */, 31B19B67263B5E580099DAA6 /* CardReaderSettingsSearchingViewModel.swift in Sources */, + AEC4481A2908204100BAA299 /* NavigationTitleView.swift in Sources */, 265284022624937600F91BA1 /* AddOnCrossreferenceUseCase.swift in Sources */, 02CA63DB23D1ADD100BBF148 /* MediaPickingCoordinator.swift in Sources */, 028FA466257E021100F88A48 /* RefundShippingLabelViewModel.swift in Sources */, @@ -9580,6 +9610,7 @@ B57C743D20F5493300EEFC87 /* AccountHeaderView.swift in Sources */, 31AD0B1126E9575F000B6391 /* CardPresentModalConnectingFailed.swift in Sources */, 576EA39425264C9B00AFC0B3 /* RefundConfirmationViewModel.swift in Sources */, + AEC448182908202500BAA299 /* WebProgressView.swift in Sources */, DEC51B00276AEE91009F3DF4 /* SystemStatusReportViewModel.swift in Sources */, CC4A4E962655273D00B75DCD /* ShippingLabelPaymentMethods.swift in Sources */, 45DB6D972632CF9300E83C1A /* ActivityIndicator.swift in Sources */, @@ -9653,6 +9684,7 @@ 024DF32123744798006658FE /* AztecFormatBarCommandCoordinator.swift in Sources */, B5AA7B3F20ED81C2004DA14F /* UserDefaults+Woo.swift in Sources */, 318477E527A33C650058C7E9 /* CardPresentModalConnectingFailedChargeReader.swift in Sources */, + AEC4480E29081BB400BAA299 /* CookieJar.swift in Sources */, DE46133926B2BEB8001DE59C /* ShippingLabelCountryListSelectorCommand.swift in Sources */, 68E952D22875A44B0095A23D /* CardReaderType+Manual.swift in Sources */, 0211259F2578DE310075AD2A /* ShippingLabelPrintingStepView.swift in Sources */, @@ -9737,6 +9769,7 @@ 02490D1A284DE664002096EF /* ProductImagesSaver.swift in Sources */, 0215320B24231D5A003F2BBD /* UIStackView+Subviews.swift in Sources */, 02F4F50B237AEB8A00E13A9C /* ProductFormTableViewDataSource.swift in Sources */, + AEC4481629081FE200BAA299 /* WebKitViewController.swift in Sources */, B57C5C9621B80E5500FF82B2 /* Dictionary+Woo.swift in Sources */, CE27257F21925AE8002B22EB /* ValueOneTableViewCell.swift in Sources */, 028296EC237D28B600E84012 /* TextViewViewController.swift in Sources */, @@ -9784,6 +9817,7 @@ B58B4AC02108FF6100076FDD /* Array+Helpers.swift in Sources */, 028AFFB32484ED2800693C09 /* Dictionary+Logging.swift in Sources */, 45BBFBC1274FD94300213001 /* HubMenuCoordinator.swift in Sources */, + AEC4481429081EEA00BAA299 /* WebViewControllerConfiguration.swift in Sources */, D8652E582630BFF500350F37 /* OrderDetailsPaymentAlerts.swift in Sources */, B5A56BF0219F2CE90065A902 /* VerticalButton.swift in Sources */, D831E2DC230E0558000037D0 /* Authentication.swift in Sources */, @@ -9820,6 +9854,7 @@ CE27257C21924A8C002B22EB /* HelpAndSupportViewController.swift in Sources */, 025B174A237AA49D00C780B4 /* Product+ProductForm.swift in Sources */, B57C744720F55BC800EEFC87 /* UIView+Helpers.swift in Sources */, + AEC4481229081C9F00BAA299 /* RequestAuthenticator.swift in Sources */, 45912FE32526642200982948 /* ProductFormViewController+Helpers.swift in Sources */, B55D4C0620B6027200D7A50F /* AuthenticationManager.swift in Sources */, 451A04F02386F7B500E368C9 /* ProductImageCollectionViewCell.swift in Sources */, @@ -10424,6 +10459,7 @@ 45A0E4CB2566B56000D4E8C3 /* NumberOfLinkedProductsTableViewCell.swift in Sources */, E16715CB26663B0B00326230 /* CardPresentModalSuccessWithoutEmail.swift in Sources */, 311F827426CD897900DF5BAD /* CardReaderSettingsAlertsProvider.swift in Sources */, + AEC4481029081C4B00BAA299 /* AuthenticationService.swift in Sources */, CC4D1D8625E6CDDE00B6E4E7 /* RenameAttributesViewModel.swift in Sources */, DEFA3D932897D8930076FAE1 /* NoWooErrorViewModel.swift in Sources */, 020A55F127F6C605007843F0 /* CardReaderConnectionAnalyticsTracker.swift in Sources */, diff --git a/Yosemite/Yosemite/Model/Mocks/Graphs/ScreenshotsObjectGraph.swift b/Yosemite/Yosemite/Model/Mocks/Graphs/ScreenshotsObjectGraph.swift index f3ae562d720..71a875b340a 100644 --- a/Yosemite/Yosemite/Model/Mocks/Graphs/ScreenshotsObjectGraph.swift +++ b/Yosemite/Yosemite/Model/Mocks/Graphs/ScreenshotsObjectGraph.swift @@ -12,6 +12,7 @@ struct i18n { static let name = NSLocalizedString("Your WooCommerce Store", comment: "Store Name for the screenshot demo account") static let url = NSLocalizedString("example.com", comment: "") static let adminURL = NSLocalizedString("example.com/wp-admin", comment: "") + static let loginURL = NSLocalizedString("example.com/wp-login.php", comment: "") } } @@ -37,6 +38,7 @@ struct ScreenshotObjectGraph: MockObjectGraph { description: "", url: i18n.DefaultSite.url, adminURL: i18n.DefaultSite.adminURL, + loginURL: i18n.DefaultSite.loginURL, plan: "", isJetpackThePluginInstalled: true, isJetpackConnected: true, diff --git a/Yosemite/Yosemite/Model/Storage/Site+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/Site+ReadOnlyConvertible.swift index 09610ee89ee..ab813140138 100644 --- a/Yosemite/Yosemite/Model/Storage/Site+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/Site+ReadOnlyConvertible.swift @@ -14,6 +14,7 @@ extension Storage.Site: ReadOnlyConvertible { tagline = site.description url = site.url adminURL = site.adminURL + loginURL = site.loginURL // plan = site.plan // We're not assigning the plan here because it's not sent on the intial API request. isJetpackThePluginInstalled = site.isJetpackThePluginInstalled isJetpackConnected = site.isJetpackConnected @@ -32,6 +33,7 @@ extension Storage.Site: ReadOnlyConvertible { description: tagline ?? "", url: url ?? "", adminURL: adminURL ?? "", + loginURL: loginURL ?? "", plan: plan ?? "", isJetpackThePluginInstalled: isJetpackThePluginInstalled, isJetpackConnected: isJetpackConnected,