diff --git a/Example/Info.plist b/Example/Info.plist index 57e2846..7c5de4b 100644 --- a/Example/Info.plist +++ b/Example/Info.plist @@ -2,6 +2,17 @@ + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + Allow the Example app to take pictures + NSPhotoLibraryUsageDescription + Allow the Example app to select pictures CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/Example/ViewController.swift b/Example/ViewController.swift index ee55b5f..3fa3c3d 100644 --- a/Example/ViewController.swift +++ b/Example/ViewController.swift @@ -14,6 +14,7 @@ class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() webView.foremWebViewDelegate = self +// webView.load("http://localhost:3000") webView.load("https://dev.to") activityIndicator.startAnimating() diff --git a/ForemWebView.xcodeproj/project.pbxproj b/ForemWebView.xcodeproj/project.pbxproj index d4272a8..1425283 100644 --- a/ForemWebView.xcodeproj/project.pbxproj +++ b/ForemWebView.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ @@ -24,8 +24,14 @@ DC3DAE9725229FD2004DAC13 /* forem.dev-logged-in.html in Resources */ = {isa = PBXBuildFile; fileRef = DC3DAE9525229FD2004DAC13 /* forem.dev-logged-in.html */; }; DC6576432559CCDD00B5B46F /* ForemMediaManager+PodcastActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6576422559CCDC00B5B46F /* ForemMediaManager+PodcastActions.swift */; }; DC6576472559CCED00B5B46F /* ForemMediaManager+VideoActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6576462559CCED00B5B46F /* ForemMediaManager+VideoActions.swift */; }; + DC8AC9FF25850EF00012DFAC /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DC8AC9FE25850EF00012DFAC /* AlamofireImage */; }; + DC9C221A2581824B00B0F801 /* YPImagePicker in Frameworks */ = {isa = PBXBuildFile; productRef = DC9C22192581824B00B0F801 /* YPImagePicker */; }; DC9EF35D2549E013003A1BE7 /* ForemUserData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9EF35C2549E013003A1BE7 /* ForemUserData.swift */; }; DC9EF3612549FDD5003A1BE7 /* invertedImages.css in Resources */ = {isa = PBXBuildFile; fileRef = DC9EF3602549FDD5003A1BE7 /* invertedImages.css */; }; + DCA40E542582C73100D463DC /* UIImage+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA40E532582C73100D463DC /* UIImage+Utilities.swift */; }; + DCA40E552582C73100D463DC /* UIImage+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA40E532582C73100D463DC /* UIImage+Utilities.swift */; }; + DCA40E562582C73100D463DC /* UIImage+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA40E532582C73100D463DC /* UIImage+Utilities.swift */; }; + DCA40E772583C45E00D463DC /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = DCA40E762583C45E00D463DC /* Alamofire */; }; DCD7C211254A9B4D00FAFC1D /* forem.dev-logged-in-dark.html in Resources */ = {isa = PBXBuildFile; fileRef = DCD7C210254A9B4D00FAFC1D /* forem.dev-logged-in-dark.html */; }; DCD7C21F254A9EE600FAFC1D /* forem.dev-logged-in-pink.html in Resources */ = {isa = PBXBuildFile; fileRef = DCD7C21E254A9EE600FAFC1D /* forem.dev-logged-in-pink.html */; }; DCF27D5C255116EF0065770D /* ForemWebView+WKNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF27D5B255116EF0065770D /* ForemWebView+WKNavigationDelegate.swift */; }; @@ -91,6 +97,7 @@ DC6576462559CCED00B5B46F /* ForemMediaManager+VideoActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ForemMediaManager+VideoActions.swift"; sourceTree = ""; }; DC9EF35C2549E013003A1BE7 /* ForemUserData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForemUserData.swift; sourceTree = ""; }; DC9EF3602549FDD5003A1BE7 /* invertedImages.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = invertedImages.css; sourceTree = ""; }; + DCA40E532582C73100D463DC /* UIImage+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Utilities.swift"; sourceTree = ""; }; DCD7C210254A9B4D00FAFC1D /* forem.dev-logged-in-dark.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = "forem.dev-logged-in-dark.html"; sourceTree = ""; }; DCD7C21E254A9EE600FAFC1D /* forem.dev-logged-in-pink.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = "forem.dev-logged-in-pink.html"; sourceTree = ""; }; DCF27D5B255116EF0065770D /* ForemWebView+WKNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ForemWebView+WKNavigationDelegate.swift"; sourceTree = ""; }; @@ -106,6 +113,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DC9C221A2581824B00B0F801 /* YPImagePicker in Frameworks */, + DCA40E772583C45E00D463DC /* Alamofire in Frameworks */, + DC8AC9FF25850EF00012DFAC /* AlamofireImage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -157,6 +167,7 @@ DCF27D5B255116EF0065770D /* ForemWebView+WKNavigationDelegate.swift */, DC9EF35C2549E013003A1BE7 /* ForemUserData.swift */, DCF27D7725519F660065770D /* ForemInstanceMetadata.swift */, + DCA40E532582C73100D463DC /* UIImage+Utilities.swift */, DC3DAE3825229D8C004DAC13 /* Info.plist */, DC65764D2559CE1E00B5B46F /* Media Manager */, DC65764A2559CDA900B5B46F /* JavaScript & CSS */, @@ -249,6 +260,11 @@ dependencies = ( ); name = ForemWebView; + packageProductDependencies = ( + DC9C22192581824B00B0F801 /* YPImagePicker */, + DCA40E762583C45E00D463DC /* Alamofire */, + DC8AC9FE25850EF00012DFAC /* AlamofireImage */, + ); productName = ForemWebView; productReference = DC3DAE3425229D8C004DAC13 /* ForemWebView.framework */; productType = "com.apple.product-type.framework"; @@ -321,6 +337,11 @@ Base, ); mainGroup = DC3DAE2A25229D8C004DAC13; + packageReferences = ( + DC9C22182581824B00B0F801 /* XCRemoteSwiftPackageReference "YPImagePicker" */, + DCA40E752583C45D00D463DC /* XCRemoteSwiftPackageReference "Alamofire" */, + DC8AC9FD25850EF00012DFAC /* XCRemoteSwiftPackageReference "AlamofireImage" */, + ); productRefGroup = DC3DAE3525229D8C004DAC13 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -395,6 +416,7 @@ DC9EF35D2549E013003A1BE7 /* ForemUserData.swift in Sources */, DC3DAE4125229DA5004DAC13 /* ForemWebView.swift in Sources */, DC3DAE4425229DBB004DAC13 /* ForemWebView+WKScriptMessageHandler.swift in Sources */, + DCA40E542582C73100D463DC /* UIImage+Utilities.swift in Sources */, DCF27D5C255116EF0065770D /* ForemWebView+WKNavigationDelegate.swift in Sources */, DC6576472559CCED00B5B46F /* ForemMediaManager+VideoActions.swift in Sources */, DC3DAE4725229DD7004DAC13 /* ForemMediaManager.swift in Sources */, @@ -409,6 +431,7 @@ buildActionMask = 2147483647; files = ( DC3DAE5425229E08004DAC13 /* ViewController.swift in Sources */, + DCA40E552582C73100D463DC /* UIImage+Utilities.swift in Sources */, DC3DAE5025229E08004DAC13 /* AppDelegate.swift in Sources */, DC3DAE5225229E08004DAC13 /* SceneDelegate.swift in Sources */, ); @@ -419,6 +442,7 @@ buildActionMask = 2147483647; files = ( DC3DAE6725229E0B004DAC13 /* ExampleTests.swift in Sources */, + DCA40E562582C73100D463DC /* UIImage+Utilities.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -756,6 +780,51 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DC8AC9FD25850EF00012DFAC /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.1.0; + }; + }; + DC9C22182581824B00B0F801 /* XCRemoteSwiftPackageReference "YPImagePicker" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Yummypets/YPImagePicker"; + requirement = { + branch = spm; + kind = branch; + }; + }; + DCA40E752583C45D00D463DC /* XCRemoteSwiftPackageReference "Alamofire" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Alamofire/Alamofire.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.4.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DC8AC9FE25850EF00012DFAC /* AlamofireImage */ = { + isa = XCSwiftPackageProductDependency; + package = DC8AC9FD25850EF00012DFAC /* XCRemoteSwiftPackageReference "AlamofireImage" */; + productName = AlamofireImage; + }; + DC9C22192581824B00B0F801 /* YPImagePicker */ = { + isa = XCSwiftPackageProductDependency; + package = DC9C22182581824B00B0F801 /* XCRemoteSwiftPackageReference "YPImagePicker" */; + productName = YPImagePicker; + }; + DCA40E762583C45E00D463DC /* Alamofire */ = { + isa = XCSwiftPackageProductDependency; + package = DCA40E752583C45D00D463DC /* XCRemoteSwiftPackageReference "Alamofire" */; + productName = Alamofire; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = DC3DAE2B25229D8C004DAC13 /* Project object */; } diff --git a/ForemWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ForemWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..3405dce --- /dev/null +++ b/ForemWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,52 @@ +{ + "object": { + "pins": [ + { + "package": "Alamofire", + "repositoryURL": "https://github.com/Alamofire/Alamofire.git", + "state": { + "branch": null, + "revision": "9e0328127dfb801cefe8ac53a13c0c90a7770448", + "version": "5.4.0" + } + }, + { + "package": "AlamofireImage", + "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git", + "state": { + "branch": null, + "revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f", + "version": "4.1.0" + } + }, + { + "package": "PryntTrimmerView", + "repositoryURL": "https://github.com/HHK1/PryntTrimmerView", + "state": { + "branch": null, + "revision": "ac1b60a22c7e6a6514de7a66d2f3d5b537c956d5", + "version": "4.0.2" + } + }, + { + "package": "Stevia", + "repositoryURL": "https://github.com/freshOS/Stevia", + "state": { + "branch": null, + "revision": "e1e8dc40deab3a863a35a7a0aab10f7304f4b42a", + "version": "5.1.0" + } + }, + { + "package": "YPImagePicker", + "repositoryURL": "https://github.com/Yummypets/YPImagePicker", + "state": { + "branch": "spm", + "revision": "2cf2d150bb0861f2079bc44b56c17aabf5e5d5aa", + "version": null + } + } + ] + }, + "version": 1 +} diff --git a/ForemWebView/ForemWebView+WKNavigationDelegate.swift b/ForemWebView/ForemWebView+WKNavigationDelegate.swift index cb854b7..41a78a8 100644 --- a/ForemWebView/ForemWebView+WKNavigationDelegate.swift +++ b/ForemWebView/ForemWebView+WKNavigationDelegate.swift @@ -27,17 +27,23 @@ extension ForemWebView: WKNavigationDelegate { // MARK: - Action Policy func navigationPolicy(url: URL, navigationType: WKNavigationType) -> WKNavigationActionPolicy { - if foremInstance == nil { + guard let foremInstance = foremInstance else { // First load there will be no Instance Metadata available return .allow - } else if url.scheme == "mailto" { - foremWebViewDelegate?.requestedExternalSite(url: url) + } + + if url.scheme == "mailto" { + foremWebViewDelegate?.requestedMailto(url: url) return .cancel } else if url.absoluteString == "about:blank" { return .allow } else if isOAuthUrl(url) { return .allow - } else if url.host != foremInstance?.domain && navigationType.rawValue == 0 { + } + + // localhost gives simulator support with a local server running + let isExternalDomain = (url.host != foremInstance.domain) && (url.host != "localhost") + if isExternalDomain && navigationType == .linkActivated { foremWebViewDelegate?.requestedExternalSite(url: url) return .cancel } else { diff --git a/ForemWebView/ForemWebView+WKScriptMessageHandler.swift b/ForemWebView/ForemWebView+WKScriptMessageHandler.swift index e166cdc..92c5a93 100644 --- a/ForemWebView/ForemWebView+WKScriptMessageHandler.swift +++ b/ForemWebView/ForemWebView+WKScriptMessageHandler.swift @@ -1,4 +1,7 @@ +import UIKit import WebKit +import AlamofireImage +import YPImagePicker enum BridgeMessageType { case podcast, video @@ -14,6 +17,8 @@ extension ForemWebView: WKScriptMessageHandler { mediaManager.handleVideoMessage(message.body as? [String: String] ?? [:]) case "body": updateUserData() + case "imageUpload": + handleImagePicker(message.body as? [String: String] ?? [:]) case "haptic": guard let hapticType = message.body as? String else { return } handleHapticMessage(type: hapticType) @@ -62,4 +67,90 @@ extension ForemWebView: WKScriptMessageHandler { notification.notificationOccurred(.success) } } + + // MARK: - Image Uploads + + // Builds, configures and returns an YPImagePicker + func imagePicker() -> YPImagePicker { + var config = YPImagePickerConfiguration() + config.shouldSaveNewPicturesToAlbum = false + config.startOnScreen = YPPickerScreen.library + config.library.onlySquare = false + config.library.isSquareByDefault = false + config.library.mediaType = YPlibraryMediaType.photo + return YPImagePicker(configuration: config) + } + + // Whenever a request to select an image is triggered via WKScriptMessageHandler + func handleImagePicker(_ message: [String: String]) { + guard let targetElementId = message["id"] else { return } + + let picker = imagePicker() + picker.didFinishPicking { [unowned picker] items, _ in + // Callback for when the native image picker process is completed by the user + if let photo = items.singlePhoto { + // Image selected now start uploading process + let message = ["action": "uploading"] + self.injectImageMessage(message, targetElementId: targetElementId) + self.uploadImage(elementId: targetElementId, image: photo.image) + } + picker.dismiss(animated: true, completion: nil) + } + + // Use 'foremWebViewDelegate' as the 'pivot' ViewController to present the native picker + if let delegateViewController = foremWebViewDelegate as? UIViewController { + delegateViewController.present(picker, animated: true, completion: nil) + } + } + + // This function will inject a message back into the image selector that triggered the request + func injectImageMessage(_ message: [String: String], targetElementId: String) { + var jsonString = "" + let encoder = JSONEncoder() + if let jsonData = try? encoder.encode(message) { + jsonString = String(data: jsonData, encoding: .utf8) ?? "" + } + + // React doesn't trigger `onChange` when updating the value of inputs + // programmatically, so we are forced to dispatch the event manually + let javascript = """ + let element = document.getElementById('\(targetElementId)'); + element.value = `\(jsonString)`; + let changeEvent = new Event('change', { bubbles: true }); + element.dispatchEvent(changeEvent); + """ + evaluateJavaScript(wrappedJS(javascript)) { _, error in + guard error == nil else { + print(error.debugDescription) + return + } + } + } + + // Function that will upload a UIImage directly to the Forem instance + func uploadImage(elementId: String, image: UIImage) { + guard let token = csrfToken, let domain = self.foremInstance?.domain else { + let message = ["action": "error", "message": "Unexpected error"] + self.injectImageMessage(message, targetElementId: elementId) + return + } + + // Support the simulator + let requestProtocol = domain == "localhost:3000" ? "http://" : "https://" + let targetUrl = "\(requestProtocol)\(domain)/image_uploads" + + let optimizedImage = image.af.imageScaled(to: image.foremLimitedSize()) + optimizedImage.uploadTo(url: targetUrl, token: token) { (link, error) in + if let link = link as String? { + var message = ["action": "success", "link": link] + if !link.contains(requestProtocol) { + message["link"] = "\(requestProtocol)\(domain)\(link)" + } + self.injectImageMessage(message, targetElementId: elementId) + } else { + let message = ["action": "error", "error": error ?? "Unexpected error"] + self.injectImageMessage(message, targetElementId: elementId) + } + } + } } diff --git a/ForemWebView/ForemWebView.swift b/ForemWebView/ForemWebView.swift index 1026084..8cb8e45 100644 --- a/ForemWebView/ForemWebView.swift +++ b/ForemWebView/ForemWebView.swift @@ -24,6 +24,7 @@ open class ForemWebView: WKWebView { open weak var foremWebViewDelegate: ForemWebViewDelegate? open var foremInstance: ForemInstanceMetadata? + open var csrfToken: String? @objc open dynamic var userData: ForemUserData? @@ -61,6 +62,7 @@ open class ForemWebView: WKWebView { configuration.userContentController.add(self, name: "haptic") configuration.userContentController.add(self, name: "body") configuration.userContentController.add(self, name: "podcast") + configuration.userContentController.add(self, name: "imageUpload") if AVPictureInPictureController.isPictureInPictureSupported() { configuration.userContentController.add(self, name: "video") } @@ -129,11 +131,30 @@ open class ForemWebView: WKWebView { // MARK: - Non-open functions + // Function that fetches the CSRF Token required for direct interaction with the Forem servers + func fetchCSRF(completion: @escaping (String?) -> Void) { + evaluateJavaScript(wrappedJS("window.csrfToken")) { result, error in + if let error = error { + print("Unable to fetch CSRF Token: \(error.localizedDescription)") + completion(nil) + } else { + completion(result as? String ?? nil) + } + } + } + // Function that will update the observable userData variable by reusing `fetchUserData` func updateUserData() { self.fetchUserData { (userData) in + // This fetch will be executed whenever changes in the DOM trigger a `updateUserData` so we + // dont override `self.userData` on every call, only when it changes. This allows the + // consumers of the framework to tap into observing `self.userData` and expect changes when the + // data has actually changed (nil -> 'something' means user logged-in, the opposite for logged-out) if self.userData != userData { self.userData = userData + self.fetchCSRF { (token) in + self.csrfToken = token + } } } } @@ -195,7 +216,7 @@ open class ForemWebView: WKWebView { } // Helper function to wrap JS errors in a way we don't pollute the JS Context with Mobile specific errors - internal func wrappedJS(_ javascript: String) -> String { + func wrappedJS(_ javascript: String) -> String { // TODO: Consider using Honeybadger/Datadog/Ahoy/etc for these error handlers (JS side) return "try { \(javascript) } catch (err) { console.log(err) }" } diff --git a/ForemWebView/UIImage+Utilities.swift b/ForemWebView/UIImage+Utilities.swift new file mode 100644 index 0000000..093e594 --- /dev/null +++ b/ForemWebView/UIImage+Utilities.swift @@ -0,0 +1,74 @@ +// +// UIImage+Resize.swift +// ForemWebView +// +// Created by Fernando Valverde on 12/10/20. +// + +import UIKit +import Alamofire + +extension UIImage { + + // This function calculates the size of the image that will be uploaded (downsized if it exceeds the limit). + // If either width or height are larger than the `sideLimit` the returned size will be downscaled proportionally. + // Examples: + // - (downsizeTarget = 1000): 3000x3000 -> 1000x1000, 2000x1000 -> 1000x500, ... + // - (downsizeTarget = 500): 3000x3000 -> 500x500, 2000x1000 -> 500x250, ... + func foremLimitedSize() -> CGSize { + let sideLimit: CGFloat = 1000.0 + var ratio: CGFloat = 1.0 + + if size.width > size.height && size.width > sideLimit { + ratio = sideLimit / size.width + } else if size.height > sideLimit { + ratio = sideLimit / size.height + } + + if ratio < 1.0 { + return CGSize(width: floor(ratio * size.width), height: floor(ratio * size.height)) + } + + // The image is already appropriately sized + return size + } + + // This function will upload the UIImage to a Forem directly and will use a completion callback. + // The first param in the callback will provide the uploaded image URL on success and the second + // param will contain an error message if the upload was unsuccessful + func uploadTo(url: String, token: String, completion: @escaping (String?, String?) -> Void) { + guard let url = URL(string: url), let uploadData = jpegData(compressionQuality: 0.9) else { + completion(nil, nil) + return + } + + let uploadHeaders: HTTPHeaders = [ + HTTPHeader(name: "X-CSRF-Token", value: token), + HTTPHeader(name: "Content-Type", value: "multipart/form-data") + ] + AF.upload(multipartFormData: { (multipartFormData) in + multipartFormData.append(uploadData, + withName: "image", + fileName: "m-\(UUID().uuidString).jpeg", + mimeType: "image/jpeg") + multipartFormData.append(Data(token.utf8), withName: "authenticity_token") + }, to: url, method: .post, headers: uploadHeaders).responseJSON { (response) in + + guard let statusCode = response.response?.statusCode else { + completion(nil, nil) + return + } + + if statusCode == 200, let result = response.value as? [String: [String]] { + let links = result["links"] as [String]? + completion(links?.first, nil) + } else if let result = response.value as? [String: String] { + completion(nil, result["error"]) + } else if let error = response.error { + completion(nil, error.localizedDescription) + } else { + completion(nil, nil) + } + } + } +}