├── .gitignore ├── LICENSE.md ├── Package.swift ├── README.md ├── Sources ├── IACClients │ ├── FunboxIACClient.swift │ ├── GoogleChromeIACClient.swift │ └── InstapaperIACClient.swift └── IACCore │ ├── Extensions.swift │ ├── IAC.swift │ ├── IACClient.swift │ ├── IACDelegate.swift │ ├── IACManager.swift │ └── IACRequest.swift └── Tests └── IACTests └── InterAppCommunicationTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ## MIT License 4 | 5 | Copyright (c) 2013 Antonio Cabezuelo Vivo (http://tapsandswipes.com) 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Inter-AppCommunication", 7 | platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13)], 8 | products: [ 9 | .library(name: "IACCore", targets: ["IACCore"]), 10 | .library(name: "IACClients", targets: ["IACClients"]), 11 | ], 12 | dependencies: [ 13 | ], 14 | targets: [ 15 | .target( 16 | name: "IACCore"), 17 | .target( 18 | name: "IACClients", 19 | dependencies: ["IACCore"]), 20 | .testTarget( 21 | name: "IACTests", 22 | dependencies: ["IACCore"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inter-App Communication (Swift) 2 | 3 | ## x-callback-url made easy 4 | 5 | Inter-App Communication, **IAC** from now on, is a framework that allows your iOS app to communicate, very easily, with other iOS apps installed in the device that supports the [**x-callback-url**](http://x-callback-url.com/) protocol. With **IAC** you can also add an **x-callback-url** **API** to your app in a very easy and intuitive way. 6 | 7 | **IAC** currently supports the **x-callback-url** [1.0 DRAFT specification](http://x-callback-url.com/specifications/). 8 | 9 | This is the swift version of the original in Objective-C available [here](https://github.com/tapsandswipes/InterAppCommunication.git) 10 | 11 | ## Usage 12 | 13 | ### Call external app 14 | 15 | From anywhere in your app you can call any external app on the device with the following code 16 | 17 | ```swift 18 | import IACCore 19 | 20 | let client = IACClient(scheme: "appscheme") 21 | client.performAction("action" parameters: ["param1": "value1", "param2": "value2"]) 22 | ``` 23 | 24 | 25 | You can also use, if available, client subclasses for the app you are calling. Within the framework there are clients for Instapaper and Google Chrome and many more will be added in the future. 26 | 27 | For example, to add a url to Instapaper from your app, you can do: 28 | 29 | * Without specific client class: 30 | 31 | ```swift 32 | import IACCore 33 | 34 | let client = IACClient(scheme: "x-callback-instapaper") 35 | client.performAction("add", parameters: ["url": "http://tapsandswipes.com"]) 36 | ``` 37 | 38 | * With the client class specific for Instapaper: 39 | 40 | ```swift 41 | import IACClients 42 | 43 | InstapaperIACClient().add("http://tapsandswipes.com") 44 | ``` 45 | 46 | 47 | ### Receive callbacks from the external app 48 | 49 | If you want to be called back from the external app you can specify success and failure handler blocks, for example: 50 | 51 | ```swift 52 | let client = IACClient(scheme: "appscheme") 53 | client.performAction("action", 54 | parameters:["param1": "value1", "param2": "value2"], 55 | handler: { result in 56 | switch result { 57 | case .success(let data): 58 | print("OK: \(data)") 59 | case .cancelled: 60 | print("Canceller") 61 | case .failure(let error): 62 | print(error.localizedDescription) 63 | } 64 | } 65 | ) 66 | ``` 67 | 68 | 69 | For the callbacks to work, your app must support the **x-callback-url** protocol. The easiest way is to let **IAC** manage that. 70 | 71 | ### Add x-callback-url support to your app 72 | 73 | Follow these simple steps to add **x-callback-url** support to your app: 74 | 75 | 1. Define the url scheme that your app will respond to in the `Info.plist` of your app. See the section **Implementing Custom URL Schemes** in [this article](http://developer.apple.com/library/ios/#DOCUMENTATION/iPhone/Conceptual/iPhoneOSProgrammingGuide/AdvancedAppTricks/AdvancedAppTricks.html#//apple_ref/doc/uid/TP40007072-CH7-SW50). 76 | 77 | 2. Assign this scheme to the IACManager instance with `IACManager.shared.callbackURLScheme = "myappscheme"`. I recommend doing this in the delegate method `application(_: , didFinishLaunchingWithOptions: )` 78 | 79 | 3. Call `handleOpenURL(_:)` from the URL handling method in the app`s delegate. For example: 80 | 81 | ```swift 82 | func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:] ) -> Bool { 83 | return IACManager.shared.handleOpenURL(url) 84 | } 85 | ``` 86 | 87 | With these three steps your app will be available to call other apps and receive callbacks from them. 88 | 89 | ### Add an x-callback-url API to your app 90 | 91 | If you want to add an external **API** to your app through the **x-callback-url** protocol you can use any of these two options or both: 92 | 93 | - Add handler blocks for your actions directly to the `IACManager` instance calling `handleAction(_:, with:)` for each action. 94 | 95 | - Implement the `IACDelegate` protocol in any of your classes and assign the delegate to the `IACManager` instance, preferably in the app delegate `application(_:, didFinishLaunchingWithOptions:)` method. 96 | 97 | Action handlers take precedence over the delegate for the same action. 98 | 99 | Explore the sample code to see all of these in place. 100 | 101 | 102 | 103 | ## Installation 104 | 105 | #### Via [Swift Package Manager](https://github.com/apple/swift-package-manager) 106 | 107 | 1. Add `.Package(url: "https://github.com/tapsandswipes/Inter-AppCommunication.git", branch: "main")` to your `Package.swift` inside `dependencies`: 108 | ```swift 109 | import PackageDescription 110 | 111 | let package = Package( 112 | name: "yourapp", 113 | dependencies: [ 114 | .Package(url: "https://github.com/tapsandswipes/Inter-AppCommunication.git", branch: "main") 115 | ] 116 | ) 117 | ``` 118 | 2. Run `swift build`. 119 | 120 | #### Manual 121 | 122 | You can also install it manually by copying to your project the contents of the directory `Sources/IACCore`. 123 | 124 | Within the directory `Sources/IACClients` you can find clients for some apps, copy the files for the client you want to use to your project. 125 | 126 | 127 | 128 | ## Create an IAC client class for your app 129 | 130 | If you have an app that already have an x-callback-url API, you can help other apps to communicate with your app by creating an `IACClient` subclass and share these classes with them. 131 | 132 | This way you can implement the exposed API as if the app were an internal component within the caller app. You can implement the methods with the required parameters and even make some validation before the call is made. 133 | 134 | Inside the `Sources/IACClients` directory you can find all the client subclasses currently implemented. If you have implemented one for your own app, do not hesitate to contact me and I will add it to the repository. 135 | 136 | 137 | 138 | ## Contact 139 | 140 | - [Personal website](http://tapsandswipes.com) 141 | - [GitHub](http://github.com/tapsandswipes) 142 | - [Twitter](http://twitter.com/acvivo) 143 | - [LinkedIn](http://www.linkedin.com/in/acvivo) 144 | - [Email](mailto:antonio@tapsandswipes.com) 145 | 146 | If you use/enjoy Inter-app Communication framework, let me know! 147 | 148 | 149 | 150 | ## License 151 | 152 | ### MIT License 153 | 154 | Copyright (c) 2013 Antonio Cabezuelo Vivo (http://tapsandswipes.com) 155 | 156 | Permission is hereby granted, free of charge, to any person obtaining a copy 157 | of this software and associated documentation files (the "Software"), to deal 158 | in the Software without restriction, including without limitation the rights 159 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 160 | copies of the Software, and to permit persons to whom the Software is 161 | furnished to do so, subject to the following conditions: 162 | 163 | The above copyright notice and this permission notice shall be included in 164 | all copies or substantial portions of the Software. 165 | 166 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 167 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 168 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 169 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 170 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 171 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 172 | THE SOFTWARE. 173 | -------------------------------------------------------------------------------- /Sources/IACClients/FunboxIACClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import IACCore 3 | 4 | public 5 | class FunboxIACClient: IACClient { 6 | 7 | public 8 | enum Error: Int, Swift.Error { 9 | case soundNotFound = -1 10 | } 11 | 12 | public 13 | init() { 14 | super.init(scheme: "funbox") 15 | } 16 | 17 | } 18 | 19 | public 20 | extension FunboxIACClient { 21 | typealias ResultHandler = (Result) -> Void 22 | 23 | func playSound(_ sound: String, callback: ResultHandler? = nil) { 24 | do { 25 | if let callback = callback { 26 | try performAction("play", parameters: ["sound": sound]) { 27 | switch $0 { 28 | case .success: 29 | callback(.success(true)) 30 | case .cancelled: 31 | callback(.success(false)) 32 | case .failure(let error): 33 | callback(.failure(error)) 34 | } 35 | } 36 | } else { 37 | try performAction("play", parameters: ["sound": sound]) 38 | } 39 | } catch { 40 | callback?(.failure(error)) 41 | } 42 | } 43 | 44 | func downloadSoundFromUrl(_ url: URL) { 45 | try? performAction("dounload", parameters: ["url": url.absoluteString]) 46 | } 47 | 48 | func playSound(_ sound: String) async throws -> Bool { 49 | let result = try await performAction("play", parameters: ["sound": sound]) 50 | if case .cancelled = result { 51 | return false 52 | } else { 53 | return true 54 | } 55 | } 56 | 57 | func downloadSoundFromUrl(_ url: URL) async throws { 58 | try await performAction("dounload", parameters: ["url": url.absoluteString]) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Sources/IACClients/GoogleChromeIACClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import IACCore 3 | 4 | public 5 | class GoogleChromeIACClient: IACClient { 6 | 7 | public 8 | init() { 9 | super.init(scheme: "googlechrome-x-callback") 10 | } 11 | } 12 | 13 | public 14 | extension GoogleChromeIACClient { 15 | typealias ResultHandler = (Result) -> Void 16 | 17 | func openURL(_ url: URL, inNewTab: Bool = false, callback: ResultHandler? = nil) { 18 | var params: IACParameters = ["url": url.absoluteString] 19 | if inNewTab { 20 | params["create-new-tab"] = "" 21 | } 22 | 23 | do { 24 | if let callback = callback { 25 | try performAction("open", parameters: params) { 26 | switch $0 { 27 | case .success: 28 | callback(.success(true)) 29 | case .cancelled: 30 | callback(.success(false)) 31 | case .failure(let error): 32 | callback(.failure(error)) 33 | } 34 | } 35 | } else { 36 | try performAction("open", parameters: params) 37 | } 38 | } catch { 39 | callback?(.failure(error)) 40 | } 41 | } 42 | 43 | func openURL(_ url: URL, inNewTab: Bool = false) async throws -> Bool { 44 | var params: IACParameters = ["url": url.absoluteString] 45 | if inNewTab { 46 | params["create-new-tab"] = "" 47 | } 48 | 49 | let result = try await performAction("open", parameters: params) 50 | if case .cancelled = result { 51 | return false 52 | } else { 53 | return true 54 | } 55 | 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/IACClients/InstapaperIACClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import IACCore 3 | 4 | public 5 | class InstapaperIACClient: IACClient { 6 | 7 | public 8 | init() { 9 | super.init(scheme: "x-callback-instapaper") 10 | } 11 | } 12 | 13 | public 14 | extension InstapaperIACClient { 15 | typealias ResultHandler = (Result) -> Void 16 | 17 | func addUrl(_ url: URL, callback: ResultHandler? = nil) { 18 | do { 19 | if let callback = callback { 20 | try performAction("add", parameters: ["url": url.absoluteString]) { 21 | switch $0 { 22 | case .success: 23 | callback(.success(true)) 24 | case .cancelled: 25 | callback(.success(false)) 26 | case .failure(let error): 27 | callback(.failure(error)) 28 | } 29 | } 30 | } else { 31 | try performAction("add", parameters: ["url": url.absoluteString]) 32 | } 33 | } catch { 34 | callback?(.failure(error)) 35 | } 36 | } 37 | 38 | func addUrl(_ url: URL) async throws -> Bool { 39 | let result = try await performAction("add", parameters: ["url": url.absoluteString]) 40 | if case .cancelled = result { 41 | return false 42 | } else { 43 | return true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/IACCore/Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if os(OSX) 3 | import class AppKit.NSWorkspace 4 | #else 5 | import class UIKit.UIApplication 6 | #endif 7 | 8 | 9 | extension String { 10 | var toIACParameters: IACParameters { 11 | var result: IACParameters = [:] 12 | let pairs: [String] = self.components(separatedBy: "&") 13 | for pair in pairs { 14 | let comps: [String] = pair.components(separatedBy: "=") 15 | if comps.count >= 2 { 16 | let key = comps[0] 17 | let value = comps.dropFirst().joined(separator: "=") 18 | result[key.queryDecode] = value.queryDecode 19 | } 20 | } 21 | return result 22 | } 23 | 24 | var queryDecode: String { 25 | return self.removingPercentEncoding ?? self 26 | } 27 | } 28 | 29 | extension IACParameters { 30 | func removingProtocolParams() -> IACResultData { 31 | return self.filter { $0.key == kXCUSource || (!$0.key.hasPrefix(kXCUPrefix) && !$0.key.hasPrefix(kIACPrefix)) } 32 | } 33 | } 34 | 35 | func appName() -> String { 36 | if let appName = Bundle.main.localizedInfoDictionary?["CFBundleDisplayName"] as? String { 37 | return appName 38 | } 39 | else if let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String { 40 | return appName 41 | } 42 | else if let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String { 43 | return appName 44 | } 45 | return "IAC" 46 | } 47 | 48 | func open(_ url: URL) { 49 | #if os(OSX) 50 | NSWorkspace.shared.open(url) 51 | #else 52 | UIApplication.shared.open(url) 53 | #endif 54 | } 55 | 56 | func canOpen(_ url: URL) -> Bool { 57 | #if os(OSX) 58 | return NSWorkspace.shared.urlForApplication(toOpen: url) != nil 59 | #else 60 | return UIApplication.shared.canOpenURL(url) 61 | #endif 62 | } 63 | 64 | 65 | public 66 | func appURLSchemes() -> [String]? { 67 | guard let urlTypes = Bundle.main.infoDictionary?["CFBundleURLTypes"] as? [[String: AnyObject]] else { 68 | return nil 69 | } 70 | var result: [String] = [] 71 | for urlType in urlTypes { 72 | if let schemes = urlType["CFBundleURLSchemes"] as? [String] { 73 | result += schemes 74 | } 75 | } 76 | return result.isEmpty ? nil : result 77 | } 78 | -------------------------------------------------------------------------------- /Sources/IACCore/IAC.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias IACParameters = Dictionary 4 | public typealias IACResultData = Dictionary 5 | 6 | public 7 | enum IACResult: Sendable { 8 | case success(IACResultData) 9 | case failure(NSError) 10 | case cancelled 11 | } 12 | 13 | public typealias IACResultHandler = (IACResult) -> Void 14 | 15 | public 16 | enum IACAsyncResult: Sendable { 17 | case success(IACResultData) 18 | case cancelled 19 | } 20 | 21 | public 22 | enum IACError: Int, Error { 23 | case appNotIntalled = 1 24 | case actionNotSupported 25 | case invalidScheme 26 | case invalidURL 27 | } 28 | 29 | public typealias IACActionHandler = (IACParameters?, @escaping IACResultHandler) -> Void 30 | 31 | public let IACErrorDomain = "com.iac.manager.error" 32 | public let IACClientErrorDomain = "com.iac.client.error" 33 | 34 | let kXCUPrefix = "x-" 35 | let kXCUHost = "x-callback-url" 36 | let kXCUSource = "x-source" 37 | let kXCUSuccess = "x-success" 38 | let kXCUError = "x-error" 39 | let kXCUCancel = "x-cancel" 40 | let kXCUErrorCode = "error-Code" 41 | let kXCUErrorMessage = "errorMessage" 42 | 43 | // IAC strings 44 | let kIACPrefix = "IAC" 45 | let kIACResponse = "IACRequestResponse" 46 | let kIACRequest = "IACRequestID" 47 | let kIACResponseType = "IACResponseType" 48 | let kIACErrorDomain = "errorDomain" 49 | 50 | enum IACResponseType: Int, Sendable { 51 | case success 52 | case failure 53 | case cancel 54 | } 55 | -------------------------------------------------------------------------------- /Sources/IACCore/IACClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | open 4 | class IACClient { 5 | 6 | public let scheme: String 7 | 8 | public weak var manager: IACManager? 9 | 10 | public 11 | init(scheme: String) { 12 | self.scheme = scheme 13 | } 14 | 15 | open 16 | func NSErrorCodeForXCUErrorCode(_ code: String?) -> Int { 17 | code.flatMap { Int($0) } ?? 0 18 | } 19 | 20 | var canOpenURL = canOpen 21 | } 22 | 23 | public 24 | extension IACClient { 25 | func isAppInstalled() -> Bool { 26 | guard let url = URL(string: "\(scheme)://test") else { return false } 27 | return canOpenURL(url) 28 | } 29 | 30 | func performAction(_ action: String, parameters: IACParameters? = nil, handler: IACResultHandler? = nil) throws { 31 | let request = IACRequest(client: self, action: action, parametrs: parameters, handler: handler) 32 | 33 | if let manager = manager { 34 | try manager.sendRequest(request) 35 | } else { 36 | try IACManager.shared.sendRequest(request) 37 | } 38 | } 39 | 40 | @discardableResult 41 | func performAction(_ action: String, parameters: IACParameters? = nil) async throws -> IACAsyncResult { 42 | try await withCheckedThrowingContinuation { continuation in 43 | do { 44 | try performAction(action, parameters: parameters) { 45 | switch $0 { 46 | case .failure(let error): 47 | continuation.resume(throwing: error) 48 | case .cancelled: 49 | continuation.resume(returning: .cancelled) 50 | case .success(let result): 51 | continuation.resume(returning: .success(result)) 52 | } 53 | } 54 | } catch { 55 | continuation.resume(throwing: error) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/IACCore/IACDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | public 5 | protocol IACDelegate: AnyObject { 6 | func supportIACAction(_ action: String) -> Bool 7 | 8 | func performIACAction(_ action: String, parameters: IACParameters?, onCompletion: @escaping IACResultHandler) 9 | } 10 | -------------------------------------------------------------------------------- /Sources/IACCore/IACManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public 4 | class IACManager { 5 | 6 | public static let shared = IACManager() 7 | 8 | public weak var delegate: IACDelegate? 9 | 10 | public var callbackURLScheme: String? 11 | 12 | public 13 | init(callbackURLScheme: String? = nil) { 14 | self.callbackURLScheme = callbackURLScheme 15 | } 16 | 17 | private var pendingRequests: [String: IACRequest] = [:] 18 | private var actionsHandlers: [String: IACActionHandler] = [:] 19 | 20 | // Useful for testing 21 | var openURL = open(_:) 22 | } 23 | 24 | public 25 | extension IACManager { 26 | func handleOpenURL(_ url: URL) -> Bool { 27 | guard 28 | url.scheme == callbackURLScheme, 29 | url.host == kXCUHost 30 | else { return false } 31 | 32 | let action = String(url.path.dropFirst()) 33 | let parameters = url.query?.toIACParameters 34 | 35 | if action == kIACResponse { 36 | return handleResponse(for: parameters) 37 | } 38 | 39 | if let actionHandler = actionsHandlers[action] { 40 | actionHandler(parameters?.removingProtocolParams()) { 41 | self.handleResult($0, parameters: parameters) 42 | } 43 | return true 44 | } 45 | 46 | if delegate?.supportIACAction(action) == true { 47 | delegate?.performIACAction(action, parameters: parameters?.removingProtocolParams()) { 48 | self.handleResult($0, parameters: parameters) 49 | } 50 | return true 51 | } 52 | 53 | let data: IACResultData = [ 54 | kXCUErrorCode: "\(IACError.actionNotSupported.rawValue)", 55 | kXCUErrorMessage: String.localizedStringWithFormat(NSLocalizedString("'%@' is not an x-callback-url action supported by %@", comment: ""), action, appName()), 56 | kIACErrorDomain: IACErrorDomain 57 | ] 58 | 59 | if let url = self.url(from: parameters, key: kXCUError, appendingPrameters: data) { 60 | openURL(url) 61 | return true 62 | } 63 | 64 | return false 65 | } 66 | 67 | func handleAction(_ action: String, with handler: @escaping IACActionHandler) { 68 | actionsHandlers[action] = handler 69 | } 70 | 71 | func sendRequest(_ request: IACRequest) throws { 72 | guard request.client.isAppInstalled() else { 73 | let message = String.localizedStringWithFormat(NSLocalizedString("App with scheme '%@' is not installed in this device", comment: ""), request.client.scheme) 74 | let error = NSError(domain: IACErrorDomain, 75 | code: IACError.appNotIntalled.rawValue, 76 | userInfo: [NSLocalizedDescriptionKey: message]) 77 | if let handler = request.handler { 78 | handler(.failure(error)) 79 | return 80 | } else { 81 | throw IACError.appNotIntalled 82 | } 83 | } 84 | 85 | var requestComponents = try request.urlComponents() 86 | 87 | if let scheme = callbackURLScheme { 88 | guard var callbackComponents = URLComponents(string: "\(scheme)://\(kXCUHost)/\(kIACResponse)?") else { throw IACError.invalidURL } 89 | 90 | callbackComponents.queryItems = [URLQueryItem(name: kIACRequest, value: request.id)] 91 | 92 | if request.handler != nil { 93 | var extraParameters: [URLQueryItem] = [] 94 | 95 | var s = callbackComponents 96 | s.queryItems?.append(URLQueryItem(name: kIACResponseType, value: String(IACResponseType.success.rawValue))) 97 | extraParameters.append(URLQueryItem(name: kXCUSuccess, value: s.url?.absoluteString)) 98 | 99 | s = callbackComponents 100 | s.queryItems?.append(URLQueryItem(name: kIACResponseType, value: String(IACResponseType.cancel.rawValue))) 101 | extraParameters.append(URLQueryItem(name: kXCUCancel, value: s.url?.absoluteString)) 102 | 103 | s = callbackComponents 104 | s.queryItems?.append(URLQueryItem(name: kIACResponseType, value: String(IACResponseType.failure.rawValue))) 105 | extraParameters.append(URLQueryItem(name: kXCUError, value: s.url?.absoluteString)) 106 | 107 | requestComponents.queryItems?.append(contentsOf: extraParameters) 108 | } 109 | } else if request.handler != nil { 110 | throw IACError.invalidScheme 111 | } 112 | 113 | guard let url = requestComponents.url else { throw IACError.invalidURL } 114 | 115 | pendingRequests[request.id] = request 116 | 117 | openURL(url) 118 | } 119 | } 120 | 121 | private 122 | extension IACManager { 123 | func handleResponse(for parameters: IACParameters?) -> Bool { 124 | guard 125 | let parameters = parameters, 126 | let id = parameters[kIACRequest], 127 | let request = pendingRequests[id] 128 | else { return false } 129 | 130 | guard 131 | let responseValue = parameters[kIACResponseType].flatMap(Int.init), 132 | let responsetype = IACResponseType(rawValue: responseValue) 133 | else { 134 | pendingRequests.removeValue(forKey: id) 135 | return false 136 | } 137 | 138 | switch responsetype { 139 | case .success: 140 | request.handler?(.success(parameters.removingProtocolParams())) 141 | case .failure: 142 | let code = request.client.NSErrorCodeForXCUErrorCode(parameters[kXCUErrorCode]) 143 | let domain = parameters[kIACErrorDomain] ?? IACClientErrorDomain 144 | let error = NSError(domain: domain, code: code) 145 | request.handler?(.failure(error)) 146 | case .cancel: 147 | request.handler?(.cancelled) 148 | } 149 | 150 | pendingRequests.removeValue(forKey: id) 151 | return true 152 | } 153 | 154 | func handleResult(_ result: IACResult, parameters: IACParameters?) { 155 | switch result { 156 | case .cancelled: 157 | if let url = self.url(from: parameters, key: kXCUCancel, appendingPrameters: nil) { 158 | openURL(url) 159 | } 160 | case .success(let data): 161 | if let url = self.url(from: parameters, key: kXCUSuccess, appendingPrameters: data) { 162 | openURL(url) 163 | } 164 | case .failure(let error): 165 | let data: IACResultData = [ 166 | kXCUErrorCode: "\(error.code)", 167 | kXCUErrorMessage: error.localizedDescription, 168 | kIACErrorDomain: error.domain 169 | ] 170 | if let url = self.url(from: parameters, key: kXCUError, appendingPrameters: data) { 171 | openURL(url) 172 | } 173 | } 174 | } 175 | 176 | func url(from parameters: IACParameters?, key: String, appendingPrameters: IACResultData?) -> URL? { 177 | guard var components = parameters?[key].flatMap(URLComponents.init(string:)) else { return nil } 178 | 179 | if let itemsToAdd = appendingPrameters { 180 | var items = components.queryItems ?? [] 181 | items.append(contentsOf: itemsToAdd.map(URLQueryItem.init)) 182 | components.queryItems = items 183 | } 184 | 185 | return components.url 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Sources/IACCore/IACRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public 4 | struct IACRequest: Identifiable { 5 | public var id: String 6 | public var client: IACClient 7 | public var action: String 8 | public var parameters: IACParameters? 9 | public var handler: IACResultHandler? 10 | 11 | public init(id: String = UUID().uuidString, 12 | client: IACClient, 13 | action: String, 14 | parametrs: IACParameters? = nil, 15 | handler: IACResultHandler? = nil) { 16 | self.id = id 17 | self.client = client 18 | self.action = action 19 | self.parameters = parametrs 20 | self.handler = handler 21 | } 22 | } 23 | 24 | extension IACRequest { 25 | func urlComponents() throws -> URLComponents { 26 | guard var components = URLComponents(string: "\(client.scheme)://\(kXCUHost)/\(action)?") else { throw IACError.invalidURL } 27 | 28 | var params: [URLQueryItem] = [URLQueryItem(name: kXCUSource, value: appName())] 29 | parameters.map { params.append(contentsOf: $0.map(URLQueryItem.init)) } 30 | components.queryItems = params 31 | 32 | return components 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/IACTests/InterAppCommunicationTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import IACCore 3 | 4 | final class InterAppCommunicationTests: XCTestCase { 5 | 6 | let client: IACClient = IACClient(scheme: "testScheme") 7 | let opener = URLOpener() 8 | let appName: String = IACCore.appName() 9 | 10 | override func setUp() { 11 | client.canOpenURL = { _ in true } 12 | } 13 | 14 | func testRequest() throws { 15 | let sut = IACRequest(client: client, action: "testRequest") 16 | 17 | let url = try XCTUnwrap(sut.urlComponents().url) 18 | 19 | XCTAssertEqual(url.absoluteString, "testScheme://x-callback-url/testRequest?x-source=\(appName)") 20 | } 21 | 22 | func testRequestWithParams() throws { 23 | let params: IACParameters = ["p1": "v1", "p2": "v2"] 24 | let sut = IACRequest(client: client, action: "testRequest", parametrs: params) 25 | 26 | let url = try XCTUnwrap(sut.urlComponents().url) 27 | 28 | let c = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) 29 | 30 | XCTAssertEqual(c.scheme, "testScheme") 31 | XCTAssertEqual(c.host, "x-callback-url") 32 | XCTAssertEqual(c.path.dropFirst(), "testRequest") 33 | XCTAssertEqual(c.queryItems?.count, 3) 34 | try XCTAssertEqual(XCTUnwrap(c.queryItems?.first(where: { $0.name == "x-source"})).value, appName) 35 | try XCTAssertEqual(XCTUnwrap(c.queryItems?.first(where: { $0.name == "p1"})).value, "v1") 36 | try XCTAssertEqual(XCTUnwrap(c.queryItems?.first(where: { $0.name == "p2"})).value, "v2") 37 | } 38 | 39 | func testManagerHandling() throws { 40 | let sut = IACManager(callbackURLScheme: "testScheme") 41 | 42 | let expectation = XCTestExpectation() 43 | 44 | sut.handleAction("action") { p, cb in 45 | XCTAssertEqual(p?["x-source"], self.appName) 46 | expectation.fulfill() 47 | } 48 | 49 | let url = try XCTUnwrap(URL(string: "testScheme://x-callback-url/action?x-source=\(appName)")) 50 | 51 | let r = sut.handleOpenURL(url) 52 | 53 | XCTAssertTrue(r) 54 | 55 | wait(for: [expectation], timeout: 1) 56 | } 57 | 58 | func testManagerHandlingParameters() throws { 59 | let sut = IACManager(callbackURLScheme: "testScheme") 60 | 61 | let expectation = XCTestExpectation() 62 | 63 | sut.handleAction("action") { p, cb in 64 | expectation.fulfill() 65 | XCTAssertEqual(p?["x-source"], self.appName) 66 | XCTAssertEqual(p?["p1"], "v1") 67 | XCTAssertEqual(p?["p2"], "v2") 68 | } 69 | 70 | let url = try XCTUnwrap(URL(string: "testScheme://x-callback-url/action?x-source=\(appName)&p1=v1&p2=v2")) 71 | 72 | let r = sut.handleOpenURL(url) 73 | 74 | XCTAssertTrue(r) 75 | 76 | wait(for: [expectation], timeout: 1) 77 | } 78 | 79 | func testManagerSendSimpleRequest() throws { 80 | let sut = IACManager() 81 | sut.openURL = opener.openURL 82 | 83 | let request = IACRequest(client: client, action: "testRequest") 84 | 85 | try sut.sendRequest(request) 86 | 87 | XCTAssertEqual(opener.lastOpenedURL?.absoluteString, "testScheme://x-callback-url/testRequest?x-source=\(appName)") 88 | } 89 | 90 | func testManagerCallbacks() throws { 91 | let m1 = IACManager(callbackURLScheme: "provider") 92 | let m2 = IACManager(callbackURLScheme: "consumer") 93 | 94 | m1.openURL = { [unowned m2] in 95 | XCTAssertTrue(m2.handleOpenURL($0)) 96 | } 97 | 98 | m2.openURL = { [unowned m1] in 99 | XCTAssertTrue(m1.handleOpenURL($0)) 100 | } 101 | 102 | m1.handleAction("testRequest") { p, cb in 103 | cb(.success(["r1":"v1"])) 104 | } 105 | 106 | let client1 = IACClient(scheme: "provider") 107 | client1.canOpenURL = { _ in true } 108 | client1.manager = m2 109 | 110 | let expectation = XCTestExpectation() 111 | let request = IACRequest(client: client1, action: "testRequest") { 112 | expectation.fulfill() 113 | switch $0 { 114 | case .success(let data): 115 | XCTAssertEqual(data["r1"], "v1") 116 | default: 117 | XCTFail() 118 | } 119 | } 120 | try m2.sendRequest(request) 121 | 122 | wait(for: [expectation], timeout: 1) 123 | } 124 | 125 | } 126 | 127 | 128 | class URLOpener { 129 | var lastOpenedURL: URL? 130 | 131 | func openURL(_ url: URL) { 132 | lastOpenedURL = url 133 | } 134 | } 135 | --------------------------------------------------------------------------------