diff --git a/CHANGELOG.md b/CHANGELOG.md index 729bafc6f..cd0747d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - OpenVPN: Endpoint UX. [#332](https://github.com/passepartoutvpn/passepartout-apple/pull/332) +- Convert trusted networks to on-demand activation. [#119](https://github.com/passepartoutvpn/passepartout-apple/issues/119) ## 2.1.2 (2023-07-06) diff --git a/Passepartout/App/L10n/Core+L10n.swift b/Passepartout/App/L10n/Core+L10n.swift index 176d5d8c5..230758975 100644 --- a/Passepartout/App/L10n/Core+L10n.swift +++ b/Passepartout/App/L10n/Core+L10n.swift @@ -98,6 +98,22 @@ extension Profile.WireGuardSettings: StyledOptionalLocalizableEntity { } } +extension Profile.OnDemand.Policy: LocalizableEntity { + public var localizedDescription: String { + // FIXME: l10n, on-demand + switch self { + case .any: + return L10n.OnDemand.Policy.any + + case .including: + return L10n.OnDemand.Policy.including + + case .excluding: + return L10n.OnDemand.Policy.excluding + } + } +} + extension Network.Choice: LocalizableEntity { public var localizedDescription: String { switch self { diff --git a/Passepartout/App/Views/OnDemandView.swift b/Passepartout/App/Views/OnDemandView.swift index 2d5983505..f772dd40f 100644 --- a/Passepartout/App/Views/OnDemandView.swift +++ b/Passepartout/App/Views/OnDemandView.swift @@ -41,11 +41,10 @@ struct OnDemandView: View { var body: some View { debugChanges() return List { - // TODO: on-demand, restore when "trusted networks" -> "on-demand" -// enabledView -// if onDemand.isEnabled { + enabledView + if onDemand.isEnabled && onDemand.policy != .any { mainView -// } + } }.navigationTitle(L10n.OnDemand.title) .toolbar { CopySavingButton( @@ -68,17 +67,47 @@ private extension OnDemandView { var enabledView: some View { Section { Toggle(L10n.Global.Strings.enabled, isOn: $onDemand.isEnabled.themeAnimation()) + if onDemand.isEnabled { + themeTextPicker( + // FIXME: l10n, on-demand + L10n.Global.Strings.policy, + selection: $onDemand.policy, + values: [.any, .including, .excluding], + description: \.localizedDescription + ) + } + } footer: { + Text(policyFooterDescription) } } + // FIXME: l10n, on-demand + var policyFooterDescription: String { + let suffix: String + switch onDemand.policy { + case .any: + suffix = L10n.OnDemand.Sections.Policy.Footer.any + + case .including, .excluding: + let arg: String + if onDemand.policy == .including { + arg = L10n.OnDemand.Sections.Policy.Footer.including + } else { + arg = L10n.OnDemand.Sections.Policy.Footer.excluding + } + suffix = L10n.OnDemand.Sections.Policy.Footer.matching(arg) + } + return L10n.OnDemand.Sections.Policy.footer(suffix) + } + @ViewBuilder var mainView: some View { + // FIXME: l10n, on-demand if Utils.hasCellularData() { Section { Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork) } header: { - // TODO: on-demand, restore when "trusted networks" -> "on-demand" - // Text(L10n.Profile.Sections.Trusted.header) + Text(L10n.Global.Strings.networks) } Section { SSIDList(withSSIDs: $onDemand.withSSIDs) @@ -87,8 +116,7 @@ private extension OnDemandView { Section { Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork) } header: { - // TODO: on-demand, restore when "trusted networks" -> "on-demand" - // Text(L10n.Profile.Sections.Trusted.header) + Text(L10n.Global.Strings.networks) } Section { SSIDList(withSSIDs: $onDemand.withSSIDs) @@ -97,15 +125,9 @@ private extension OnDemandView { Section { SSIDList(withSSIDs: $onDemand.withSSIDs) } header: { - // TODO: on-demand, restore when "trusted networks" -> "on-demand" - // Text(L10n.Profile.Sections.Trusted.header) + Text(L10n.Global.Strings.networks) } } - Section { - Toggle(L10n.OnDemand.Items.Policy.caption, isOn: $onDemand.disconnectsIfNotMatching) - } footer: { - Text(L10n.OnDemand.Sections.Policy.footer) - } } var isEligibleForSiri: Bool { diff --git a/Passepartout/App/en.lproj/Localizable.strings b/Passepartout/App/en.lproj/Localizable.strings index 39dcd534a..38c6324ac 100644 --- a/Passepartout/App/en.lproj/Localizable.strings +++ b/Passepartout/App/en.lproj/Localizable.strings @@ -51,6 +51,8 @@ "global.strings.disconnect" = "Disconnect"; "global.strings.download" = "Download"; "global.strings.authentication" = "Authentication"; +"global.strings.policy" = "Policy"; +"global.strings.networks" = "Networks"; "global.messages.unlock_app" = "Passepartout is locked"; "global.messages.email_not_configured" = "No e-mail account is configured."; "global.messages.share" = "Passepartout is a user-friendly, open source OpenVPN / WireGuard client for iOS and macOS"; @@ -251,14 +253,20 @@ /* MARK: ProfileView -> OnDemandView */ -"on_demand.title" = "Trusted networks"; -"on_demand.sections.policy.footer" = "When entering a trusted network, the VPN is normally shut down and kept disconnected. Disable this option to not enforce such behavior."; +"on_demand.title" = "On-demand"; +"on_demand.sections.policy.footer" = "Activate the VPN %@."; +"on_demand.sections.policy.footer.any" = "on any network"; +"on_demand.sections.policy.footer.matching" = "%@ when connected to the networks below"; +"on_demand.sections.policy.footer.including" = "only"; +"on_demand.sections.policy.footer.excluding" = "except"; "on_demand.items.add_ssid.caption" = "Add Wi-Fi"; -"on_demand.items.active.caption" = "Trust"; "on_demand.items.mobile.caption" = "Cellular network"; -"on_demand.items.ethernet.caption" = "Trust wired connections"; -"on_demand.items.ethernet.description" = "Check to trust any wired cable connection."; -"on_demand.items.policy.caption" = "Trust disables VPN"; +"on_demand.items.ethernet.caption" = "Wired connections"; +"on_demand.items.ethernet.description" = "Check to match any wired cable connection."; + +"on_demand.policy.any" = "All networks"; +"on_demand.policy.including" = "Include"; +"on_demand.policy.excluding" = "Exclude"; /* MARK: ProfileView -> DiagnosticsView */ diff --git a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift index 8080652c0..95f626edf 100644 --- a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift +++ b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift @@ -506,12 +506,16 @@ internal enum L10n { internal static let manual = L10n.tr("Localizable", "global.strings.manual", fallback: "Manual") /// Name internal static let name = L10n.tr("Localizable", "global.strings.name", fallback: "Name") + /// Networks + internal static let networks = L10n.tr("Localizable", "global.strings.networks", fallback: "Networks") /// Next internal static let next = L10n.tr("Localizable", "global.strings.next", fallback: "Next") /// None internal static let `none` = L10n.tr("Localizable", "global.strings.none", fallback: "None") /// MARK: Global internal static let ok = L10n.tr("Localizable", "global.strings.ok", fallback: "OK") + /// Policy + internal static let policy = L10n.tr("Localizable", "global.strings.policy", fallback: "Policy") /// Port internal static let port = L10n.tr("Localizable", "global.strings.port", fallback: "Port") /// Private key @@ -640,35 +644,49 @@ internal enum L10n { } internal enum OnDemand { /// MARK: ProfileView -> OnDemandView - internal static let title = L10n.tr("Localizable", "on_demand.title", fallback: "Trusted networks") + internal static let title = L10n.tr("Localizable", "on_demand.title", fallback: "On-demand") internal enum Items { - internal enum Active { - /// Trust - internal static let caption = L10n.tr("Localizable", "on_demand.items.active.caption", fallback: "Trust") - } internal enum AddSsid { /// Add Wi-Fi internal static let caption = L10n.tr("Localizable", "on_demand.items.add_ssid.caption", fallback: "Add Wi-Fi") } internal enum Ethernet { - /// Trust wired connections - internal static let caption = L10n.tr("Localizable", "on_demand.items.ethernet.caption", fallback: "Trust wired connections") - /// Check to trust any wired cable connection. - internal static let description = L10n.tr("Localizable", "on_demand.items.ethernet.description", fallback: "Check to trust any wired cable connection.") + /// Wired connections + internal static let caption = L10n.tr("Localizable", "on_demand.items.ethernet.caption", fallback: "Wired connections") + /// Check to match any wired cable connection. + internal static let description = L10n.tr("Localizable", "on_demand.items.ethernet.description", fallback: "Check to match any wired cable connection.") } internal enum Mobile { /// Cellular network internal static let caption = L10n.tr("Localizable", "on_demand.items.mobile.caption", fallback: "Cellular network") } - internal enum Policy { - /// Trust disables VPN - internal static let caption = L10n.tr("Localizable", "on_demand.items.policy.caption", fallback: "Trust disables VPN") - } + } + internal enum Policy { + /// All networks + internal static let any = L10n.tr("Localizable", "on_demand.policy.any", fallback: "All networks") + /// Exclude + internal static let excluding = L10n.tr("Localizable", "on_demand.policy.excluding", fallback: "Exclude") + /// Include + internal static let including = L10n.tr("Localizable", "on_demand.policy.including", fallback: "Include") } internal enum Sections { internal enum Policy { - /// When entering a trusted network, the VPN is normally shut down and kept disconnected. Disable this option to not enforce such behavior. - internal static let footer = L10n.tr("Localizable", "on_demand.sections.policy.footer", fallback: "When entering a trusted network, the VPN is normally shut down and kept disconnected. Disable this option to not enforce such behavior.") + /// Activate the VPN %@. + internal static func footer(_ p1: Any) -> String { + return L10n.tr("Localizable", "on_demand.sections.policy.footer", String(describing: p1), fallback: "Activate the VPN %@.") + } + internal enum Footer { + /// on any network + internal static let any = L10n.tr("Localizable", "on_demand.sections.policy.footer.any", fallback: "on any network") + /// except + internal static let excluding = L10n.tr("Localizable", "on_demand.sections.policy.footer.excluding", fallback: "except") + /// only + internal static let including = L10n.tr("Localizable", "on_demand.sections.policy.footer.including", fallback: "only") + /// %@ when connected to the networks below + internal static func matching(_ p1: Any) -> String { + return L10n.tr("Localizable", "on_demand.sections.policy.footer.matching", String(describing: p1), fallback: "%@ when connected to the networks below") + } + } } } } diff --git a/PassepartoutLibrary/Sources/PassepartoutVPN/Domain/Profile+OnDemand.swift b/PassepartoutLibrary/Sources/PassepartoutVPN/Domain/Profile+OnDemand.swift index b2d1d02ac..38c45e8df 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPN/Domain/Profile+OnDemand.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPN/Domain/Profile+OnDemand.swift @@ -41,18 +41,14 @@ extension Profile { case ethernet } - // hardcode this to keep "Trusted networks" semantics public var isEnabled = true - // hardcode this to keep "Trusted networks" semantics public var policy: Policy = .excluding public var withSSIDs: [String: Bool] = [:] public var withOtherNetworks: Set = [] - public var disconnectsIfNotMatching = true - public init() { } } diff --git a/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Extensions/OnDemand+Rules.swift b/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Extensions/OnDemand+Rules.swift index da4ecfbce..f816cecfd 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Extensions/OnDemand+Rules.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Extensions/OnDemand+Rules.swift @@ -31,7 +31,7 @@ import PassepartoutVPN extension NEOnDemandRuleInterfaceType { static var compatibleEthernet: NEOnDemandRuleInterfaceType? { #if targetEnvironment(macCatalyst) - // FIXME: Catalyst, missing enum case, try hardcoding + // XXX: Catalyst, missing enum case, try hardcoding // https://developer.apple.com/documentation/networkextension/neondemandruleinterfacetype/ethernet NEOnDemandRuleInterfaceType(rawValue: 1) #elseif os(macOS) @@ -54,24 +54,17 @@ private extension Profile.OnDemand { return [] } - // TODO: on-demand, drop hardcoding when "trusted networks" -> "on-demand" -// isEnabled = true -// policy = .excluding - assert(policy == .excluding) - var rules: [NEOnDemandRule] = [] - if withCustomRules { - #if os(iOS) + + // apply exceptions (unless .any) + if withCustomRules && policy != .any { +#if os(iOS) if Utils.hasCellularData() && withMobileNetwork { - let rule = policyRule - rule.interfaceTypeMatch = .cellular - rules.append(rule) + rules.append(cellularRule()) } - #endif +#endif if Utils.hasEthernet() && withEthernetNetwork { - if let compatibleEthernet = NEOnDemandRuleInterfaceType.compatibleEthernet { - let rule = policyRule - rule.interfaceTypeMatch = compatibleEthernet + if let rule = ethernetRule() { rules.append(rule) } else { pp_log.warning("Unable to add rule for NEOnDemandRuleInterfaceType.ethernet (not compatible)") @@ -79,19 +72,58 @@ private extension Profile.OnDemand { } let SSIDs = Array(withSSIDs.filter { $1 }.keys) if !SSIDs.isEmpty { - let rule = policyRule - rule.interfaceTypeMatch = .wiFi - rule.ssidMatch = SSIDs - rules.append(rule) + rules.append(wifiRule(SSIDs: SSIDs)) } } - let connection = NEOnDemandRuleConnect() - connection.interfaceTypeMatch = .any - rules.append(connection) + + // IMPORTANT: append fallback rule last + rules.append(globalRule()) + return rules } +} + +private extension Profile.OnDemand { + func globalRule() -> NEOnDemandRule { + let rule: NEOnDemandRule + switch policy { + case .any, .excluding: + rule = NEOnDemandRuleConnect() + + case .including: + rule = NEOnDemandRuleDisconnect() + } + rule.interfaceTypeMatch = .any + return rule + } + + func networkRule(matchingInterface interfaceType: NEOnDemandRuleInterfaceType) -> NEOnDemandRule { + let rule: NEOnDemandRule + switch policy { + case .any, .excluding: + rule = NEOnDemandRuleDisconnect() + + case .including: + rule = NEOnDemandRuleConnect() + } + rule.interfaceTypeMatch = interfaceType + return rule + } + + func cellularRule() -> NEOnDemandRule { + networkRule(matchingInterface: .cellular) + } + + func ethernetRule() -> NEOnDemandRule? { + guard let compatibleEthernet = NEOnDemandRuleInterfaceType.compatibleEthernet else { + return nil + } + return networkRule(matchingInterface: compatibleEthernet) + } - var policyRule: NEOnDemandRule { - disconnectsIfNotMatching ? NEOnDemandRuleDisconnect() : NEOnDemandRuleIgnore() + func wifiRule(SSIDs: [String]) -> NEOnDemandRule { + let rule = networkRule(matchingInterface: .wiFi) + rule.ssidMatch = SSIDs + return rule } }