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
- 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)

View File

@ -64,18 +64,56 @@ extension Profile.Header: Comparable {
}
}
extension Profile.OpenVPNSettings {
var endpointDescription: String? {
extension Profile.OpenVPNSettings: StyledOptionalLocalizableEntity {
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
}
}
extension Profile.WireGuardSettings {
var endpointDescription: String? {
extension Profile.WireGuardSettings: StyledOptionalLocalizableEntity {
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
}
}
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 {

View File

@ -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,43 +67,62 @@ 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 {
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)
}
Section {
SSIDList(withSSIDs: $onDemand.withSSIDs)
// FIXME: l10n, on-demand
Text(L10n.Global.Strings.networks)
}
} else if Utils.hasEthernet() {
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)
}
Section {
SSIDList(withSSIDs: $onDemand.withSSIDs)
}
} else {
Section {
SSIDList(withSSIDs: $onDemand.withSSIDs)
} header: {
// TODO: on-demand, restore when "trusted networks" -> "on-demand"
// Text(L10n.Profile.Sections.Trusted.header)
// FIXME: l10n, on-demand
Text(L10n.Global.Strings.networks)
}
}
Section {
Toggle(L10n.OnDemand.Items.Policy.caption, isOn: $onDemand.disconnectsIfNotMatching)
} footer: {
Text(L10n.OnDemand.Sections.Policy.footer)
SSIDList(withSSIDs: $onDemand.withSSIDs)
} header: {
if !Utils.hasCellularData() && !Utils.hasEthernet() {
Text(L10n.Global.Strings.networks)
}
}
}

View File

@ -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" = "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.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 */

View File

@ -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 {
/// 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
}
// 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<OtherNetwork> = []
public var disconnectsIfNotMatching = true
public init() {
}
}

View File

@ -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
}
}
var policyRule: NEOnDemandRule {
disconnectsIfNotMatching ? NEOnDemandRuleDisconnect() : NEOnDemandRuleIgnore()
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
}
}