Rework "Trusted networks" to be a generic "On-demand" (#333)

Extend the feature by also providing a complementary "include" policy,
i.e. activate the VPN _only_ on the specified networks. "Trusted
networks" was only providing the "exclude" counterpart, i.e. _except_
the specified networks.

Closes #119
This commit is contained in:
Davide De Rosa 2023-07-23 08:44:46 +02:00 committed by GitHub
parent 1c3cbe02e5
commit e0dbca224f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 188 additions and 77 deletions

View File

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- OpenVPN: Endpoint UX. [#332](https://github.com/passepartoutvpn/passepartout-apple/pull/332) - 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) ## 2.1.2 (2023-07-06)

View File

@ -64,18 +64,56 @@ extension Profile.Header: Comparable {
} }
} }
extension Profile.OpenVPNSettings { extension Profile.OpenVPNSettings: StyledOptionalLocalizableEntity {
var endpointDescription: String? { public enum OptionalStyle {
case endpoint
}
public func localizedDescription(optionalStyle: OptionalStyle) -> String? {
switch optionalStyle {
case .endpoint:
return endpointDescription
}
}
private var endpointDescription: String? {
customEndpoint?.address ?? configuration.remotes?.first?.address customEndpoint?.address ?? configuration.remotes?.first?.address
} }
} }
extension Profile.WireGuardSettings { extension Profile.WireGuardSettings: StyledOptionalLocalizableEntity {
var endpointDescription: String? { public enum OptionalStyle {
case endpoint
}
public func localizedDescription(optionalStyle: OptionalStyle) -> String? {
switch optionalStyle {
case .endpoint:
return endpointDescription
}
}
private var endpointDescription: String? {
configuration.tunnelConfiguration.peers.first?.endpoint?.stringRepresentation configuration.tunnelConfiguration.peers.first?.endpoint?.stringRepresentation
} }
} }
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 { extension Network.Choice: LocalizableEntity {
public var localizedDescription: String { public var localizedDescription: String {
switch self { switch self {

View File

@ -41,11 +41,10 @@ struct OnDemandView: View {
var body: some View { var body: some View {
debugChanges() debugChanges()
return List { return List {
// TODO: on-demand, restore when "trusted networks" -> "on-demand" enabledView
// enabledView if onDemand.isEnabled && onDemand.policy != .any {
// if onDemand.isEnabled {
mainView mainView
// } }
}.navigationTitle(L10n.OnDemand.title) }.navigationTitle(L10n.OnDemand.title)
.toolbar { .toolbar {
CopySavingButton( CopySavingButton(
@ -68,7 +67,37 @@ private extension OnDemandView {
var enabledView: some View { var enabledView: some View {
Section { Section {
Toggle(L10n.Global.Strings.enabled, isOn: $onDemand.isEnabled.themeAnimation()) 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 @ViewBuilder
@ -77,35 +106,24 @@ private extension OnDemandView {
Section { Section {
Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork) Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork)
} header: { } header: {
// TODO: on-demand, restore when "trusted networks" -> "on-demand" // FIXME: l10n, on-demand
// Text(L10n.Profile.Sections.Trusted.header) Text(L10n.Global.Strings.networks)
}
Section {
SSIDList(withSSIDs: $onDemand.withSSIDs)
} }
} else if Utils.hasEthernet() { } else if Utils.hasEthernet() {
Section { Section {
Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork) Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork)
} header: { } header: {
// TODO: on-demand, restore when "trusted networks" -> "on-demand" // FIXME: l10n, on-demand
// Text(L10n.Profile.Sections.Trusted.header) Text(L10n.Global.Strings.networks)
} }
Section {
SSIDList(withSSIDs: $onDemand.withSSIDs)
} }
} else {
Section { Section {
SSIDList(withSSIDs: $onDemand.withSSIDs) SSIDList(withSSIDs: $onDemand.withSSIDs)
} header: { } header: {
// TODO: on-demand, restore when "trusted networks" -> "on-demand" if !Utils.hasCellularData() && !Utils.hasEthernet() {
// 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 { var isEligibleForSiri: Bool {

View File

@ -51,6 +51,8 @@
"global.strings.disconnect" = "Disconnect"; "global.strings.disconnect" = "Disconnect";
"global.strings.download" = "Download"; "global.strings.download" = "Download";
"global.strings.authentication" = "Authentication"; "global.strings.authentication" = "Authentication";
"global.strings.policy" = "Policy";
"global.strings.networks" = "Networks";
"global.messages.unlock_app" = "Passepartout is locked"; "global.messages.unlock_app" = "Passepartout is locked";
"global.messages.email_not_configured" = "No e-mail account is configured."; "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"; "global.messages.share" = "Passepartout is a user-friendly, open source OpenVPN / WireGuard client for iOS and macOS";
@ -251,14 +253,20 @@
/* MARK: ProfileView -> OnDemandView */ /* MARK: ProfileView -> OnDemandView */
"on_demand.title" = "Trusted networks"; "on_demand.title" = "On-demand";
"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.sections.policy.footer" = "Activate the VPN %@.";
"on_demand.sections.policy.footer.any" = "in any network";
"on_demand.sections.policy.footer.matching" = "%@ the networks below";
"on_demand.sections.policy.footer.including" = "only in";
"on_demand.sections.policy.footer.excluding" = "except in";
"on_demand.items.add_ssid.caption" = "Add Wi-Fi"; "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.mobile.caption" = "Cellular network";
"on_demand.items.ethernet.caption" = "Trust wired connections"; "on_demand.items.ethernet.caption" = "Wired connections";
"on_demand.items.ethernet.description" = "Check to trust any wired cable connection."; "on_demand.items.ethernet.description" = "Check to match any wired cable connection.";
"on_demand.items.policy.caption" = "Trust disables VPN";
"on_demand.policy.any" = "All networks";
"on_demand.policy.including" = "Include";
"on_demand.policy.excluding" = "Exclude";
/* MARK: ProfileView -> DiagnosticsView */ /* MARK: ProfileView -> DiagnosticsView */

View File

@ -506,12 +506,16 @@ internal enum L10n {
internal static let manual = L10n.tr("Localizable", "global.strings.manual", fallback: "Manual") internal static let manual = L10n.tr("Localizable", "global.strings.manual", fallback: "Manual")
/// Name /// Name
internal static let name = L10n.tr("Localizable", "global.strings.name", fallback: "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 /// Next
internal static let next = L10n.tr("Localizable", "global.strings.next", fallback: "Next") internal static let next = L10n.tr("Localizable", "global.strings.next", fallback: "Next")
/// None /// None
internal static let `none` = L10n.tr("Localizable", "global.strings.none", fallback: "None") internal static let `none` = L10n.tr("Localizable", "global.strings.none", fallback: "None")
/// MARK: Global /// MARK: Global
internal static let ok = L10n.tr("Localizable", "global.strings.ok", fallback: "OK") 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 /// Port
internal static let port = L10n.tr("Localizable", "global.strings.port", fallback: "Port") internal static let port = L10n.tr("Localizable", "global.strings.port", fallback: "Port")
/// Private key /// Private key
@ -640,35 +644,49 @@ internal enum L10n {
} }
internal enum OnDemand { internal enum OnDemand {
/// MARK: ProfileView -> OnDemandView /// 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 Items {
internal enum Active {
/// Trust
internal static let caption = L10n.tr("Localizable", "on_demand.items.active.caption", fallback: "Trust")
}
internal enum AddSsid { internal enum AddSsid {
/// Add Wi-Fi /// Add Wi-Fi
internal static let caption = L10n.tr("Localizable", "on_demand.items.add_ssid.caption", fallback: "Add Wi-Fi") internal static let caption = L10n.tr("Localizable", "on_demand.items.add_ssid.caption", fallback: "Add Wi-Fi")
} }
internal enum Ethernet { internal enum Ethernet {
/// Trust wired connections /// Wired connections
internal static let caption = L10n.tr("Localizable", "on_demand.items.ethernet.caption", fallback: "Trust wired connections") internal static let caption = L10n.tr("Localizable", "on_demand.items.ethernet.caption", fallback: "Wired connections")
/// Check to trust any wired cable connection. /// Check to match any wired cable connection.
internal static let description = L10n.tr("Localizable", "on_demand.items.ethernet.description", fallback: "Check to trust 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 { internal enum Mobile {
/// Cellular network /// Cellular network
internal static let caption = L10n.tr("Localizable", "on_demand.items.mobile.caption", fallback: "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 Sections {
internal enum Policy { 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. /// Activate the VPN %@.
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.") 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 {
/// in any network
internal static let any = L10n.tr("Localizable", "on_demand.sections.policy.footer.any", fallback: "in any network")
/// except in
internal static let excluding = L10n.tr("Localizable", "on_demand.sections.policy.footer.excluding", fallback: "except in")
/// only in
internal static let including = L10n.tr("Localizable", "on_demand.sections.policy.footer.including", fallback: "only in")
/// %@ the networks below
internal static func matching(_ p1: Any) -> String {
return L10n.tr("Localizable", "on_demand.sections.policy.footer.matching", String(describing: p1), fallback: "%@ the networks below")
}
}
} }
} }
} }

View File

@ -41,18 +41,14 @@ extension Profile {
case ethernet case ethernet
} }
// hardcode this to keep "Trusted networks" semantics
public var isEnabled = true public var isEnabled = true
// hardcode this to keep "Trusted networks" semantics
public var policy: Policy = .excluding public var policy: Policy = .excluding
public var withSSIDs: [String: Bool] = [:] public var withSSIDs: [String: Bool] = [:]
public var withOtherNetworks: Set<OtherNetwork> = [] public var withOtherNetworks: Set<OtherNetwork> = []
public var disconnectsIfNotMatching = true
public init() { public init() {
} }
} }

View File

@ -31,7 +31,7 @@ import PassepartoutVPN
extension NEOnDemandRuleInterfaceType { extension NEOnDemandRuleInterfaceType {
static var compatibleEthernet: NEOnDemandRuleInterfaceType? { static var compatibleEthernet: NEOnDemandRuleInterfaceType? {
#if targetEnvironment(macCatalyst) #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 // https://developer.apple.com/documentation/networkextension/neondemandruleinterfacetype/ethernet
NEOnDemandRuleInterfaceType(rawValue: 1) NEOnDemandRuleInterfaceType(rawValue: 1)
#elseif os(macOS) #elseif os(macOS)
@ -54,24 +54,17 @@ private extension Profile.OnDemand {
return [] return []
} }
// TODO: on-demand, drop hardcoding when "trusted networks" -> "on-demand"
// isEnabled = true
// policy = .excluding
assert(policy == .excluding)
var rules: [NEOnDemandRule] = [] var rules: [NEOnDemandRule] = []
if withCustomRules {
// apply exceptions (unless .any)
if withCustomRules && policy != .any {
#if os(iOS) #if os(iOS)
if Utils.hasCellularData() && withMobileNetwork { if Utils.hasCellularData() && withMobileNetwork {
let rule = policyRule rules.append(cellularRule())
rule.interfaceTypeMatch = .cellular
rules.append(rule)
} }
#endif #endif
if Utils.hasEthernet() && withEthernetNetwork { if Utils.hasEthernet() && withEthernetNetwork {
if let compatibleEthernet = NEOnDemandRuleInterfaceType.compatibleEthernet { if let rule = ethernetRule() {
let rule = policyRule
rule.interfaceTypeMatch = compatibleEthernet
rules.append(rule) rules.append(rule)
} else { } else {
pp_log.warning("Unable to add rule for NEOnDemandRuleInterfaceType.ethernet (not compatible)") 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) let SSIDs = Array(withSSIDs.filter { $1 }.keys)
if !SSIDs.isEmpty { if !SSIDs.isEmpty {
let rule = policyRule rules.append(wifiRule(SSIDs: SSIDs))
rule.interfaceTypeMatch = .wiFi
rule.ssidMatch = SSIDs
rules.append(rule)
} }
} }
let connection = NEOnDemandRuleConnect()
connection.interfaceTypeMatch = .any
rules.append(connection)
return rules
}
var policyRule: NEOnDemandRule { // IMPORTANT: append fallback rule last
disconnectsIfNotMatching ? NEOnDemandRuleDisconnect() : NEOnDemandRuleIgnore() 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)
}
func wifiRule(SSIDs: [String]) -> NEOnDemandRule {
let rule = networkRule(matchingInterface: .wiFi)
rule.ssidMatch = SSIDs
return rule
} }
} }