diff --git a/Passepartout-iOS/AppDelegate.swift b/Passepartout-iOS/AppDelegate.swift index 21fea37f..4fdf55e3 100644 --- a/Passepartout-iOS/AppDelegate.swift +++ b/Passepartout-iOS/AppDelegate.swift @@ -25,6 +25,10 @@ import UIKit import TunnelKit +import Intents +import SwiftyBeaver + +private let log = SwiftyBeaver.self @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { @@ -153,3 +157,15 @@ extension UISplitViewController { return nil } } + +extension AppDelegate { + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + guard let interaction = userActivity.interaction else { + return false + } + if #available(iOS 12, *) { + InteractionsHandler.handleInteraction(interaction) + } + return true + } +} diff --git a/Passepartout-iOS/Info.plist b/Passepartout-iOS/Info.plist index a282fe02..6e4d51c0 100644 --- a/Passepartout-iOS/Info.plist +++ b/Passepartout-iOS/Info.plist @@ -43,6 +43,16 @@ LSSupportsOpeningDocumentsInPlace + NSUserActivityTypes + + ConnectVPNIntent + DisableVPNIntent + MoveToLocationIntent + TrustCellularNetworkIntent + TrustCurrentNetworkIntent + UntrustCellularNetworkIntent + UntrustCurrentNetworkIntent + UIFileSharingEnabled UILaunchStoryboardName diff --git a/Passepartout-iOS/Intents/Intents.intentdefinition b/Passepartout-iOS/Intents/Intents.intentdefinition new file mode 100644 index 00000000..f4bdc2f3 --- /dev/null +++ b/Passepartout-iOS/Intents/Intents.intentdefinition @@ -0,0 +1,585 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.0 + INIntentDefinitionSystemVersion + 17G3025 + INIntentDefinitionToolsBuildVersion + 10B61 + INIntentDefinitionToolsVersion + 10.1 + INIntents + + + INIntentCategory + generic + INIntentDescriptionID + eXXb2z + INIntentLastParameterTag + 5 + INIntentName + ConnectVPN + INIntentParameterCombinations + + context,profileId + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSubtitle + On-demand VPN service + INIntentParameterCombinationSubtitleID + uMFvJW + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + Connect to ${profileId} + INIntentParameterCombinationTitleID + U6o81V + + + INIntentParameters + + + INIntentParameterDisplayPriority + 1 + INIntentParameterName + context + INIntentParameterSupportsMultipleValues + + INIntentParameterTag + 5 + INIntentParameterType + String + + + INIntentParameterDisplayPriority + 2 + INIntentParameterName + profileId + INIntentParameterSupportsMultipleValues + + INIntentParameterTag + 4 + INIntentParameterType + String + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + Unable to connect + INIntentResponseCodeFormatStringID + uKU9RD + INIntentResponseCodeName + failure + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + Successfully connected! + INIntentResponseCodeFormatStringID + WNbRl5 + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseLastParameterTag + 0 + INIntentResponseParameters + + + INIntentRestrictions + 0 + INIntentTitle + Connect to VPN + INIntentTitleID + LA99yM + INIntentType + Custom + INIntentUserConfirmationRequired + + INIntentVerb + Do + + + INIntentCategory + generic + INIntentDescriptionID + BKxs8X + INIntentLastParameterTag + 0 + INIntentName + TrustCurrentNetwork + INIntentParameterCombinations + + + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSubtitle + + INIntentParameterCombinationSubtitleID + AxbXUn + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + + INIntentParameterCombinationTitleID + POyDPM + + + INIntentParameters + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + x3pVKZ + INIntentResponseCodeName + failure + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + qEDn4J + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseLastParameterTag + 0 + INIntentResponseParameters + + + INIntentRestrictions + 0 + INIntentTitle + Trust current network + INIntentTitleID + m2E7SI + INIntentType + Custom + INIntentUserConfirmationRequired + + INIntentVerb + Do + + + INIntentCategory + generic + INIntentDescriptionID + eQ1yzr + INIntentLastParameterTag + 0 + INIntentName + DisableVPN + INIntentParameterCombinations + + + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSubtitle + + INIntentParameterCombinationSubtitleID + 85kxu8 + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + + INIntentParameterCombinationTitleID + IeGsEq + + + INIntentParameters + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + fnSNbT + INIntentResponseCodeName + failure + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + oKHXZ3 + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseLastParameterTag + 0 + INIntentResponseParameters + + + INIntentRestrictions + 0 + INIntentTitle + Disable VPN service + INIntentTitleID + 1ZRTCZ + INIntentType + Custom + INIntentUserConfirmationRequired + + INIntentVerb + Do + + + INIntentCategory + generic + INIntentDescriptionID + 7eoAss + INIntentLastParameterTag + 0 + INIntentName + UntrustCurrentNetwork + INIntentParameterCombinations + + + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSubtitle + + INIntentParameterCombinationSubtitleID + pb3MGt + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + + INIntentParameterCombinationTitleID + 0Wu9nb + + + INIntentParameters + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + r0ZjO4 + INIntentResponseCodeName + failure + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + rtaAzk + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseLastParameterTag + 0 + INIntentResponseParameters + + + INIntentRestrictions + 0 + INIntentTitle + Untrust current network + INIntentTitleID + rd1T8p + INIntentType + Custom + INIntentUserConfirmationRequired + + INIntentVerb + Do + + + INIntentCategory + generic + INIntentDescriptionID + 9GpJt5 + INIntentLastParameterTag + 0 + INIntentName + TrustCellularNetwork + INIntentParameterCombinations + + + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSubtitle + + INIntentParameterCombinationSubtitleID + vIPVA5 + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + + INIntentParameterCombinationTitleID + NWWgCl + + + INIntentParameters + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + nMRaxS + INIntentResponseCodeName + failure + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + gSqy7Y + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseLastParameterTag + 0 + INIntentResponseParameters + + + INIntentRestrictions + 0 + INIntentTitle + Trust cellular network + INIntentTitleID + H4taev + INIntentType + Custom + INIntentUserConfirmationRequired + + INIntentVerb + Do + + + INIntentCategory + generic + INIntentDescriptionID + 0jRWn5 + INIntentLastParameterTag + 0 + INIntentName + UntrustCellularNetwork + INIntentParameterCombinations + + + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSubtitle + + INIntentParameterCombinationSubtitleID + qRkLSU + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + + INIntentParameterCombinationTitleID + ggzKA2 + + + INIntentParameters + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + YncGoj + INIntentResponseCodeName + failure + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + BW8KLX + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseLastParameterTag + 0 + INIntentResponseParameters + + + INIntentRestrictions + 0 + INIntentTitle + Untrust cellular network + INIntentTitleID + wB1iYX + INIntentType + Custom + INIntentUserConfirmationRequired + + INIntentVerb + Do + + + INIntentCategory + generic + INIntentDescriptionID + KjkCfU + INIntentLastParameterTag + 3 + INIntentName + MoveToLocation + INIntentParameterCombinations + + providerId,poolName,poolId + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSubtitle + With ${providerId} provider + INIntentParameterCombinationSubtitleID + 66bZBE + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + Move to ${poolName} + INIntentParameterCombinationTitleID + WnTPFg + + + INIntentParameters + + + INIntentParameterDisplayPriority + 1 + INIntentParameterName + providerId + INIntentParameterSupportsMultipleValues + + INIntentParameterTag + 2 + INIntentParameterType + String + + + INIntentParameterDisplayPriority + 2 + INIntentParameterName + poolId + INIntentParameterSupportsMultipleValues + + INIntentParameterTag + 3 + INIntentParameterType + String + + + INIntentParameterDisplayPriority + 3 + INIntentParameterName + poolName + INIntentParameterSupportsMultipleValues + + INIntentParameterTag + 1 + INIntentParameterType + String + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + PmWNkC + INIntentResponseCodeName + failure + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + + INIntentResponseCodeFormatStringID + O6nCLQ + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseLastParameterTag + 0 + INIntentResponseParameters + + + INIntentRestrictions + 0 + INIntentTitle + Move to provider location + INIntentTitleID + qo3Szz + INIntentType + Custom + INIntentUserConfirmationRequired + + INIntentVerb + Go + + + + diff --git a/Passepartout-iOS/Intents/InteractionsHandler.swift b/Passepartout-iOS/Intents/InteractionsHandler.swift new file mode 100644 index 00000000..d0fa6b53 --- /dev/null +++ b/Passepartout-iOS/Intents/InteractionsHandler.swift @@ -0,0 +1,267 @@ +// +// InteractionsHandler.swift +// Passepartout-iOS +// +// Created by Davide De Rosa on 3/8/19. +// Copyright (c) 2018 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import Foundation +import Intents +import SwiftyBeaver + +private let log = SwiftyBeaver.self + +@available(iOS 12, *) +class InteractionsHandler { + private class Groups { + static let vpn = "VPN" + + static let trust = "Trust" + } + + static func donateConnectVPN(with profile: ConnectionProfile) { + let profileKey = ProfileKey(profile) + + let intent = ConnectVPNIntent() + intent.context = profileKey.context.rawValue + intent.profileId = profileKey.id + + let interaction = INInteraction(intent: intent, response: nil) + interaction.groupIdentifier = profileKey.rawValue + interaction.donateAndLog() + } + + static func donateDisableVPN() { + let intent = DisableVPNIntent() + + let interaction = INInteraction(intent: intent, response: nil) + interaction.groupIdentifier = Groups.vpn + interaction.donateAndLog() + } + + static func donateMoveToLocation(with profile: ProviderConnectionProfile, pool: Pool) { + let profileKey = ProfileKey(profile) + + let intent = MoveToLocationIntent() + intent.providerId = profile.id + intent.poolId = pool.id + intent.poolName = pool.name + + let interaction = INInteraction(intent: intent, response: nil) + interaction.groupIdentifier = profileKey.rawValue + interaction.donateAndLog() + } + + static func donateTrustCurrentNetwork() { + let intent = TrustCurrentNetworkIntent() + + let interaction = INInteraction(intent: intent, response: nil) + interaction.groupIdentifier = Groups.trust + interaction.donateAndLog() + } + + static func donateUntrustCurrentNetwork() { + let intent = UntrustCurrentNetworkIntent() + + let interaction = INInteraction(intent: intent, response: nil) + interaction.groupIdentifier = Groups.trust + interaction.donateAndLog() + } + + static func donateTrustCellularNetwork() { + let intent = TrustCellularNetworkIntent() + + let interaction = INInteraction(intent: intent, response: nil) + interaction.groupIdentifier = Groups.trust + interaction.donateAndLog() + } + + static func donateUntrustCellularNetwork() { + let intent = UntrustCellularNetworkIntent() + + let interaction = INInteraction(intent: intent, response: nil) + interaction.groupIdentifier = Groups.trust + interaction.donateAndLog() + } + + // + + static func handleInteraction(_ interaction: INInteraction) { + if let custom = interaction.intent as? ConnectVPNIntent { + handleConnectVPN(custom, interaction: interaction) + } else if let custom = interaction.intent as? DisableVPNIntent { + handleDisableVPN(custom, interaction: interaction) + } else if let custom = interaction.intent as? MoveToLocationIntent { + handleMoveToLocation(custom, interaction: interaction) + } else if let _ = interaction.intent as? TrustCurrentNetworkIntent { + handleCurrentNetwork(trust: true, interaction: interaction) + } else if let _ = interaction.intent as? UntrustCurrentNetworkIntent { + handleCurrentNetwork(trust: false, interaction: interaction) + } else if let _ = interaction.intent as? TrustCellularNetworkIntent { + handleCellularNetwork(trust: true, interaction: interaction) + } else if let _ = interaction.intent as? UntrustCellularNetworkIntent { + handleCellularNetwork(trust: false, interaction: interaction) + } + } + + private static func handleConnectVPN(_ intent: ConnectVPNIntent, interaction: INInteraction) { + guard let contextValue = intent.context, let context = Context(rawValue: contextValue), let id = intent.profileId else { + INInteraction.delete(with: [interaction.identifier], completion: nil) + return + } + let profileKey = ProfileKey(context, id) + log.info("Connect to profile \(profileKey)") + + let service = TransientStore.shared.service + let vpn = VPN.shared + guard !(service.isActiveProfile(profileKey) && (vpn.status == .connected)) else { + log.info("Profile is already active and connected") + return + } + + guard let profile = service.profile(withContext: context, id: id) else { + return + } + service.activateProfile(profile) + + // FIXME: ServiceViewController is not updated + + let configuration: VPNConfiguration + do { + configuration = try service.vpnConfiguration() + } catch let e { + log.error("Unable to build VPN configuration: \(e)") + return + } + + vpn.reconnect(configuration: configuration) { (error) in + if let error = error { + log.error("Unable to connect to \(profileKey): \(error)") + return + } + log.info("Connecting to \(profileKey)...") + } + } + + private static func handleDisableVPN(_ intent: DisableVPNIntent, interaction: INInteraction) { + VPN.shared.disconnect(completionHandler: nil) + } + + private static func handleMoveToLocation(_ intent: MoveToLocationIntent, interaction: INInteraction) { + guard let providerId = intent.providerId, let poolId = intent.poolId else { + return + } + let service = TransientStore.shared.service + guard let providerProfile = service.profile(withContext: .provider, id: providerId) as? ProviderConnectionProfile else { + return + } + log.info("Move to provider \(providerId) @ [\(poolId)]") + + let vpn = VPN.shared + guard !(service.isActiveProfile(providerProfile) && (providerProfile.poolId == poolId) && (vpn.status == .connected)) else { + log.info("Profile is already active and connected to \(poolId)") + return + } + + providerProfile.poolId = poolId + service.activateProfile(providerProfile) + + let configuration: VPNConfiguration + do { + configuration = try service.vpnConfiguration() + } catch let e { + log.error("Unable to build VPN configuration: \(e)") + return + } + + vpn.reconnect(configuration: configuration) { (error) in + if let error = error { + log.error("Unable to connect to \(providerId) @ [\(poolId)]: \(error)") + return + } + log.info("Connecting to \(providerId) @ [\(poolId)]...") + } + } + + private static func handleCurrentNetwork(trust: Bool, interaction: INInteraction) { + guard let currentWifi = Utils.currentWifiNetworkName() else { + return + } + let service = TransientStore.shared.service + service.preferences.trustedWifis[currentWifi] = trust + TransientStore.shared.serialize(withProfiles: false) + + reconnectVPN(service: service) + } + + private static func handleCellularNetwork(trust: Bool, interaction: INInteraction) { + guard Utils.hasCellularData() else { + return + } + let service = TransientStore.shared.service + service.preferences.trustsMobileNetwork = trust + TransientStore.shared.serialize(withProfiles: false) + + reconnectVPN(service: service) + } + + private static func reconnectVPN(service: ConnectionService) { + let configuration: VPNConfiguration + do { + configuration = try service.vpnConfiguration() + } catch let e { + log.error("Unable to build VPN configuration: \(e)") + return + } + + let vpn = VPN.shared + switch vpn.status { + case .connected: + vpn.reconnect(configuration: configuration, completionHandler: nil) + + default: + vpn.install(configuration: configuration, completionHandler: nil) + } + } + + // + + static func forgetProfile(withKey profileKey: ProfileKey) { + INInteraction.delete(with: profileKey.rawValue) { (error) in + if let error = error { + log.error("Unable to forget interactions: \(error)") + return + } + log.debug("Removed profile \(profileKey) interactions") + } + } +} + +private extension INInteraction { + func donateAndLog() { + donate { (error) in + if let error = error { + log.error("Unable to donate interaction: \(error)") + } + log.debug("Donated \(self.intent)") + } + } +} diff --git a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift index c59e434e..f7029e7f 100644 --- a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift @@ -223,6 +223,9 @@ class OrganizerViewController: UITableViewController, TableModelHost { tableView.endUpdates() service.removeProfile(rowProfile) + if #available(iOS 12, *) { + InteractionsHandler.forgetProfile(withKey: rowProfile) + } } private func confirmVpnProfileDeletion() { diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index f6d58b94..6868b390 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -31,6 +31,8 @@ 0E4FD7E120D3E4C5002221FF /* MockVPNProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4FD7E020D3E4C5002221FF /* MockVPNProvider.swift */; }; 0E4FD7EE20D539A0002221FF /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4FD7ED20D539A0002221FF /* Utils.swift */; }; 0E4FD7F120D58618002221FF /* Macros.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4FD7F020D58618002221FF /* Macros.swift */; }; + 0E50E7C322330E4900D5F76C /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 0E50E7C222330E4900D5F76C /* Intents.intentdefinition */; }; + 0E50E7C6223318A500D5F76C /* InteractionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E50E7C5223318A500D5F76C /* InteractionsHandler.swift */; }; 0E57F63C20C83FC5008323CF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E57F63B20C83FC5008323CF /* AppDelegate.swift */; }; 0E57F63E20C83FC5008323CF /* ServiceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E57F63D20C83FC5008323CF /* ServiceViewController.swift */; }; 0E57F64120C83FC5008323CF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E57F63F20C83FC5008323CF /* Main.storyboard */; }; @@ -152,6 +154,8 @@ 0E4FD7ED20D539A0002221FF /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 0E4FD7F020D58618002221FF /* Macros.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Macros.swift; sourceTree = ""; }; 0E4FD80420D683A2002221FF /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk/System/Library/Frameworks/NetworkExtension.framework; sourceTree = DEVELOPER_DIR; }; + 0E50E7C222330E4900D5F76C /* Intents.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = Intents.intentdefinition; sourceTree = ""; }; + 0E50E7C5223318A500D5F76C /* InteractionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionsHandler.swift; sourceTree = ""; }; 0E57F63820C83FC5008323CF /* Passepartout.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Passepartout.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0E57F63B20C83FC5008323CF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 0E57F63D20C83FC5008323CF /* ServiceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceViewController.swift; sourceTree = ""; }; @@ -289,6 +293,15 @@ path = VPN; sourceTree = ""; }; + 0E50E7C422330E5100D5F76C /* Intents */ = { + isa = PBXGroup; + children = ( + 0E50E7C222330E4900D5F76C /* Intents.intentdefinition */, + 0E50E7C5223318A500D5F76C /* InteractionsHandler.swift */, + ); + path = Intents; + sourceTree = ""; + }; 0E57F62F20C83FC5008323CF = { isa = PBXGroup; children = ( @@ -319,6 +332,7 @@ 0E1066CA20E0F85C004F98B7 /* Cells */, 0ECEE44C20E1120F00A6BB43 /* Tables */, 0EDE8DF120C93ED8004C739C /* Scenes */, + 0E50E7C422330E5100D5F76C /* Intents */, 0EDE8DE220C86A13004C739C /* Passepartout.entitlements */, 0E57F63B20C83FC5008323CF /* AppDelegate.swift */, 0E57F63F20C83FC5008323CF /* Main.storyboard */, @@ -826,6 +840,7 @@ 0E1066C920E0F84A004F98B7 /* Cells.swift in Sources */, 0EF56BBB2185AC8500B0C8AB /* SwiftGen+Segues.swift in Sources */, 0EBE3AA6213DC1B000BFA2F5 /* ProviderConnectionProfile.swift in Sources */, + 0E50E7C322330E4900D5F76C /* Intents.intentdefinition in Sources */, 0E3DA371215CB5BF00B40FC9 /* VersionViewController.swift in Sources */, 0EBBE8F52182361800106008 /* ConnectionService+Migration.swift in Sources */, 0E39BCF3214DA9310035E9DE /* AppConstants.swift in Sources */, @@ -834,6 +849,7 @@ 0E79D14121919F5600BB5FB2 /* ProfileKey.swift in Sources */, 0E89DFC5213DF7AE00741BA1 /* Preferences.swift in Sources */, 0E6BE13A20CFB76800A6DD36 /* ApplicationError.swift in Sources */, + 0E50E7C6223318A500D5F76C /* InteractionsHandler.swift in Sources */, 0EFD9440215BED8E00529B64 /* LabelViewController.swift in Sources */, 0ED31C2C20CF2D6F0027975F /* ProviderPoolViewController.swift in Sources */, 0E2B494020FCFF990094784C /* Theme+Titles.swift in Sources */, diff --git a/Passepartout/Sources/Model/Profiles/ProfileKey.swift b/Passepartout/Sources/Model/Profiles/ProfileKey.swift index fe27ce52..3ee02f5c 100644 --- a/Passepartout/Sources/Model/Profiles/ProfileKey.swift +++ b/Passepartout/Sources/Model/Profiles/ProfileKey.swift @@ -25,7 +25,7 @@ import Foundation -struct ProfileKey: RawRepresentable, Hashable, Codable { +struct ProfileKey: RawRepresentable, Hashable, Codable, CustomStringConvertible { private static let separator: Character = "." let context: Context @@ -63,4 +63,10 @@ struct ProfileKey: RawRepresentable, Hashable, Codable { let idEnd = rawValue.endIndex id = String(rawValue[idStart..