diff --git a/CHANGELOG.md b/CHANGELOG.md index a3fc7425..8a554f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Prompt for password interactively. [#3](https://github.com/passepartoutvpn/passepartout-apple/issues/3) - Ukranian translations (Dmitry Chirkin). [#243](https://github.com/passepartoutvpn/passepartout-apple/pull/243) - OpenVPN: Full implementation of Tunnelblick XOR patch (tmthecoder). [#245](https://github.com/passepartoutvpn/passepartout-apple/pull/245), [tunnelkit#255][https://github.com/passepartoutvpn/tunnelkit/pull/255] diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index e2cdda6c..bab8a5ae 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -146,12 +146,13 @@ 0EB34BCC27C6F41D00B126DA /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB34BCB27C6F41D00B126DA /* Theme.swift */; }; 0EB4042C27CA0E8C00378B1A /* Unlocalized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB4042B27CA0E8B00378B1A /* Unlocalized.swift */; }; 0EB4042E27CA136300378B1A /* AddingTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB4042D27CA136200378B1A /* AddingTextField.swift */; }; + 0EB90CC129C25BBD00E64628 /* InteractiveConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB90CC029C25BBD00E64628 /* InteractiveConnectionView.swift */; }; 0EBC074C27EB673C00208AD9 /* ProfileView+Rename.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBC074B27EB673C00208AD9 /* ProfileView+Rename.swift */; }; 0EBC075527EBC83800208AD9 /* MailComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBC075427EBC83800208AD9 /* MailComposerView.swift */; }; 0EBC075B27EC4FFF00208AD9 /* ReportIssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBC075A27EC4FFF00208AD9 /* ReportIssueView.swift */; }; 0EBC075D27EC529000208AD9 /* DebugLog+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBC075C27EC529000208AD9 /* DebugLog+Constants.swift */; }; 0EBC076027EC587900208AD9 /* SwiftGen+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBC075F27EC587900208AD9 /* SwiftGen+Strings.swift */; }; - 0EBE880F281B18DE0090D9E6 /* ProfileRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBE880E281B18DE0090D9E6 /* ProfileRow.swift */; }; + 0EBE880F281B18DE0090D9E6 /* OrganizerView+ProfileRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBE880E281B18DE0090D9E6 /* OrganizerView+ProfileRow.swift */; }; 0ECB78E9285F5DE300B0E460 /* PassepartoutMac.bundle in Embed Plugins */ = {isa = PBXBuildFile; fileRef = 0ECB78DA285F52F700B0E460 /* PassepartoutMac.bundle */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0ECB78EC2863A21600B0E460 /* PassepartoutLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 0ECB78EB2863A21600B0E460 /* PassepartoutLibrary */; }; 0ECF71EE27B6A99300CDB528 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ECF71ED27B6A99300CDB528 /* AccountView.swift */; }; @@ -445,6 +446,7 @@ 0EB34BCB27C6F41D00B126DA /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 0EB4042B27CA0E8B00378B1A /* Unlocalized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Unlocalized.swift; sourceTree = ""; }; 0EB4042D27CA136200378B1A /* AddingTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddingTextField.swift; sourceTree = ""; }; + 0EB90CC029C25BBD00E64628 /* InteractiveConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveConnectionView.swift; sourceTree = ""; }; 0EBC074B27EB673C00208AD9 /* ProfileView+Rename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileView+Rename.swift"; sourceTree = ""; }; 0EBC075427EBC83800208AD9 /* MailComposerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MailComposerView.swift; sourceTree = ""; }; 0EBC075A27EC4FFF00208AD9 /* ReportIssueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportIssueView.swift; sourceTree = ""; }; @@ -459,7 +461,7 @@ 0EBE2FD62360F89500F0D5AB /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; 0EBE2FD72360F89600F0D5AB /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 0EBE2FD82360F89600F0D5AB /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; - 0EBE880E281B18DE0090D9E6 /* ProfileRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRow.swift; sourceTree = ""; }; + 0EBE880E281B18DE0090D9E6 /* OrganizerView+ProfileRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrganizerView+ProfileRow.swift"; sourceTree = ""; }; 0ECB78DA285F52F700B0E460 /* PassepartoutMac.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PassepartoutMac.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; 0ECB78E1285F53ED00B0E460 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 0ECB78EA2861D1F300B0E460 /* PassepartoutLibrary */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PassepartoutLibrary; sourceTree = ""; }; @@ -639,17 +641,18 @@ 0E71ACEA27C1060D00F85C4B /* EndpointView.swift */, 0E5349C527C176C200C71BB3 /* EndpointView+OpenVPN.swift */, 0E5349C727C176D100C71BB3 /* EndpointView+WireGuard.swift */, + 0EB90CC029C25BBD00E64628 /* InteractiveConnectionView.swift */, 0E0BD27227B2EA2C00583AC5 /* MainView.swift */, 0E71ACE827C1055200F85C4B /* NetworkSettingsView.swift */, 0EB34BC927C6A70200B126DA /* OnDemandView.swift */, 0E34AC8127F892C40042F2AB /* OnDemandView+SSID.swift */, 0E2A8D4E27B04BB900207D04 /* OrganizerView.swift */, + 0EBE880E281B18DE0090D9E6 /* OrganizerView+ProfileRow.swift */, 0EF8C5A728213C510053CE89 /* OrganizerView+Profiles.swift */, 0E34AC7727F840890042F2AB /* OrganizerView+Scene.swift */, 0EF0FAF527DD0211007EB181 /* PaywallView.swift */, 0ED30DCE27EA1EF80057D8A3 /* PaywallView+Beta.swift */, 0ED30DD127EA1F650057D8A3 /* PaywallView+Purchase.swift */, - 0EBE880E281B18DE0090D9E6 /* ProfileRow.swift */, 0E44689527B051C300A14CE4 /* ProfileView.swift */, 0E92D7C527F103300033CB7B /* ProfileView+Configuration.swift */, 0E92D7C827F1042A0033CB7B /* ProfileView+Extra.swift */, @@ -1389,6 +1392,7 @@ 0E3B7FCD27E47B3700C66F13 /* AddHostView+Name.swift in Sources */, 0E7577D72816A3B200081CBE /* DestructiveButton.swift in Sources */, 0EF2212D27E66EB5001D0BD7 /* AddProviderView.swift in Sources */, + 0EB90CC129C25BBD00E64628 /* InteractiveConnectionView.swift in Sources */, 0E35C09A280E95BB0071FA35 /* ProviderProfileAvailability.swift in Sources */, 0E04F0092883466500BFCE1C /* DefaultLightUtils.swift in Sources */, 0E5349C827C176D100C71BB3 /* EndpointView+WireGuard.swift in Sources */, @@ -1415,7 +1419,7 @@ 0ED89C1E27DE3F8D008B36D6 /* IntentAddView.swift in Sources */, 0E5468002867AC9A00F74D1C /* MacUtils.swift in Sources */, 0E96D3052872010A005EFBCF /* DefaultLightVPNManager.swift in Sources */, - 0EBE880F281B18DE0090D9E6 /* ProfileRow.swift in Sources */, + 0EBE880F281B18DE0090D9E6 /* OrganizerView+ProfileRow.swift in Sources */, 0ED30DCF27EA1EF80057D8A3 /* PaywallView+Beta.swift in Sources */, 0ECF71EE27B6A99300CDB528 /* AccountView.swift in Sources */, 0E71ACF727C107CA00F85C4B /* DebugLogView.swift in Sources */, diff --git a/Passepartout/App/Views/AccountView.swift b/Passepartout/App/Views/AccountView.swift index 60b19b17..753d5204 100644 --- a/Passepartout/App/Views/AccountView.swift +++ b/Passepartout/App/Views/AccountView.swift @@ -41,8 +41,6 @@ struct AccountView: View { @State private var liveAccount = Profile.Account() - @State private var isPasswordRevealed = false - init( providerName: ProviderName?, vpnProtocol: VPNProtocolType, @@ -60,6 +58,13 @@ struct AccountView: View { var body: some View { List { + Section { + themeTextPicker(L10n.Endpoint.Advanced.Openvpn.Items.Digest.caption, selection: $liveAccount.authenticationMethod ?? .persistent, values: [ + .persistent, + .interactive +// .totp // TODO: interactive, support OTP-based authentication + ], description: \.localizedDescription) + } Section { TextField(usernamePlaceholder ?? L10n.Account.Items.Username.placeholder, text: $liveAccount.username) .textContentType(.username) @@ -67,15 +72,34 @@ struct AccountView: View { .themeRawTextStyle() .withLeadingText(L10n.Account.Items.Username.caption) - RevealingSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password) { - themeConceilImage.asSystemImage - .themeAccentForegroundStyle() - } revealImage: { - themeRevealImage.asSystemImage - .themeAccentForegroundStyle() - }.textContentType(.password) - .themeRawTextStyle() - .withLeadingText(L10n.Account.Items.Password.caption) + switch liveAccount.authenticationMethod { + case nil, .persistent, .interactive: + if liveAccount.authenticationMethod == .interactive { + EmptyView() + } else { + RevealingSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password) { + themeConceilImage.asSystemImage + .themeAccentForegroundStyle() + } revealImage: { + themeRevealImage.asSystemImage + .themeAccentForegroundStyle() + }.textContentType(.password) + .themeRawTextStyle() + .withLeadingText(L10n.Account.Items.Password.caption) + } + + // TODO: interactive, scan QR code + case .totp: + RevealingSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password) { + themeConceilImage.asSystemImage + .themeAccentForegroundStyle() + } revealImage: { + themeRevealImage.asSystemImage + .themeAccentForegroundStyle() + }.textContentType(.oneTimeCode) + .themeRawTextStyle() + .withLeadingText(L10n.Account.Items.Seed.caption) + } } footer: { metadata?.localizedGuidanceString.map { Text($0) @@ -125,3 +149,18 @@ extension AccountView { return providerManager.provider(withName: name) } } + +private extension Profile.Account.AuthenticationMethod { + var localizedDescription: String { + switch self { + case .persistent: + return L10n.Account.Items.AuthenticationMethod.persistent + + case .interactive: + return L10n.Account.Items.AuthenticationMethod.interactive + + case .totp: + return Unlocalized.Other.totp + } + } +} diff --git a/Passepartout/App/Views/InteractiveConnectionView.swift b/Passepartout/App/Views/InteractiveConnectionView.swift new file mode 100644 index 00000000..c790567c --- /dev/null +++ b/Passepartout/App/Views/InteractiveConnectionView.swift @@ -0,0 +1,81 @@ +// +// InteractiveConnectionView.swift +// Passepartout +// +// Created by Davide De Rosa on 3/15/23. +// Copyright (c) 2022 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 SwiftUI +import PassepartoutLibrary + +struct InteractiveConnectionView: View { + @Environment(\.presentationMode) private var presentationMode + + @ObservedObject private var profileManager: ProfileManager + + @ObservedObject private var vpnManager: VPNManager + + private let profile: Profile + + @State private var password = "" + + init(profile: Profile) { + profileManager = .shared + vpnManager = .shared + self.profile = profile + } + + var body: some View { + List { + Section { + TextField(L10n.Account.Items.Username.placeholder, text: .constant(profile.account.username)) + .withLeadingText(L10n.Account.Items.Username.caption) + .disabled(true) + + RevealingSecureField(L10n.Account.Items.Password.placeholder, text: $password) { + themeConceilImage.asSystemImage + .themeAccentForegroundStyle() + } revealImage: { + themeRevealImage.asSystemImage + .themeAccentForegroundStyle() + }.textContentType(.password) + .themeRawTextStyle() + .withLeadingText(L10n.Account.Items.Password.caption) + } header: { + Text(L10n.Account.title) + } + }.toolbar { + themeCloseItem(presentationMode: presentationMode) + ToolbarItem(placement: .confirmationAction) { + Button(action: saveAccount) { + Text(L10n.Global.Strings.connect) + } + } + }.navigationTitle(profile.header.name) + } + + private func saveAccount() { + Task { + try? await vpnManager.connect(with: profile.id, newPassword: password) + } + presentationMode.wrappedValue.dismiss() + } +} diff --git a/Passepartout/App/Views/OrganizerView+ProfileRow.swift b/Passepartout/App/Views/OrganizerView+ProfileRow.swift new file mode 100644 index 00000000..467b8426 --- /dev/null +++ b/Passepartout/App/Views/OrganizerView+ProfileRow.swift @@ -0,0 +1,79 @@ +// +// OrganizerView+ProfileRow.swift +// Passepartout +// +// Created by Davide De Rosa on 4/28/22. +// Copyright (c) 2022 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 SwiftUI +import PassepartoutLibrary + +extension OrganizerView { + struct ProfileRow: View { + private let profile: Profile + + private let isActiveProfile: Bool + + @Binding private var modalType: ModalType? + + private var interactiveProfile: Binding { + .init { + if case .interactiveAccount(let profile) = modalType { + return profile + } + return nil + } set: { + if let profile = $0 { + modalType = .interactiveAccount(profile: profile) + } else { + modalType = nil + } + } + } + + init(profile: Profile, isActiveProfile: Bool, modalType: Binding) { + self.profile = profile + self.isActiveProfile = isActiveProfile + _modalType = modalType + } + + var body: some View { + debugChanges() + return HStack { + VStack(alignment: .leading, spacing: 5) { + Text(profile.header.name) + .font(.headline) + .themeLongTextStyle() + + VPNStatusText(isActiveProfile: isActiveProfile) + .font(.subheadline) + .themeSecondaryTextStyle() + } + Spacer() + VPNToggle( + profile: profile, + interactiveProfile: interactiveProfile, + rateLimit: Constants.RateLimit.vpnToggle + ).labelsHidden() + }.padding([.top, .bottom], 10) + } + } +} diff --git a/Passepartout/App/Views/OrganizerView+Profiles.swift b/Passepartout/App/Views/OrganizerView+Profiles.swift index c26aacd2..7b22366d 100644 --- a/Passepartout/App/Views/OrganizerView+Profiles.swift +++ b/Passepartout/App/Views/OrganizerView+Profiles.swift @@ -29,11 +29,14 @@ import PassepartoutLibrary extension OrganizerView { struct ProfilesList: View { @ObservedObject private var profileManager: ProfileManager - - init() { - profileManager = .shared - } + @Binding private var modalType: ModalType? + + init(modalType: Binding) { + profileManager = .shared + _modalType = modalType + } + var body: some View { debugChanges() return Group { @@ -92,7 +95,8 @@ extension OrganizerView { private func profileLabel(forProfile profile: Profile) -> some View { ProfileRow( profile: profile, - isActiveProfile: profileManager.isActiveProfile(profile.id) + isActiveProfile: profileManager.isActiveProfile(profile.id), + modalType: $modalType ) } diff --git a/Passepartout/App/Views/OrganizerView.swift b/Passepartout/App/Views/OrganizerView.swift index 22a1bf57..ffdbe484 100644 --- a/Passepartout/App/Views/OrganizerView.swift +++ b/Passepartout/App/Views/OrganizerView.swift @@ -27,6 +27,17 @@ import SwiftUI import PassepartoutLibrary struct OrganizerView: View { + enum ModalType: Identifiable { + case interactiveAccount(profile: Profile) + + // XXX: alert ids + var id: Int { + switch self { + case .interactiveAccount: return 1 + } + } + } + enum AlertType: Identifiable { case subscribeReddit @@ -44,6 +55,8 @@ struct OrganizerView: View { @State private var addProfileModalType: AddProfileMenu.ModalType? + @State private var modalType: ModalType? + @State private var alertType: AlertType? @State private var isHostFileImporterPresented = false @@ -58,7 +71,7 @@ struct OrganizerView: View { debugChanges() return ZStack { hiddenSceneView - ProfilesList() + ProfilesList(modalType: $modalType) }.toolbar { ToolbarItem(placement: .primaryAction) { AddProfileMenu( @@ -71,7 +84,8 @@ struct OrganizerView: View { SettingsButton() } } - }.alert(item: $alertType, content: presentedAlert) + }.sheet(item: $modalType, content: presentedModal) + .alert(item: $alertType, content: presentedAlert) .fileImporter( isPresented: $isHostFileImporterPresented, allowedContentTypes: hostFileTypes, @@ -119,6 +133,16 @@ extension OrganizerView { addProfileModalType = .addHost(url, false) } + @ViewBuilder + private func presentedModal(_ modalType: ModalType) -> some View { + switch modalType { + case .interactiveAccount(let profile): + NavigationView { + InteractiveConnectionView(profile: profile) + }.themeGlobal() + } + } + private func presentedAlert(_ alertType: AlertType) -> Alert { switch alertType { case .subscribeReddit: diff --git a/Passepartout/App/Views/ProfileRow.swift b/Passepartout/App/Views/ProfileRow.swift deleted file mode 100644 index f34569b4..00000000 --- a/Passepartout/App/Views/ProfileRow.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// ProfileRow.swift -// Passepartout -// -// Created by Davide De Rosa on 4/28/22. -// Copyright (c) 2022 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 SwiftUI -import PassepartoutLibrary - -struct ProfileRow: View { - let profile: Profile - - let isActiveProfile: Bool - - var body: some View { - debugChanges() - return HStack { - VStack(alignment: .leading, spacing: 5) { - Text(profile.header.name) - .font(.headline) - .themeLongTextStyle() - - VPNStatusText(isActiveProfile: isActiveProfile) - .font(.subheadline) - .themeSecondaryTextStyle() - } - Spacer() - VPNToggle(profileId: profile.id, rateLimit: Constants.RateLimit.vpnToggle) - .labelsHidden() - }.padding([.top, .bottom], 10) - } -} diff --git a/Passepartout/App/Views/ProfileView+VPN.swift b/Passepartout/App/Views/ProfileView+VPN.swift index 48ae3fbb..94a133d6 100644 --- a/Passepartout/App/Views/ProfileView+VPN.swift +++ b/Passepartout/App/Views/ProfileView+VPN.swift @@ -30,15 +30,26 @@ extension ProfileView { struct VPNSection: View { @ObservedObject private var profileManager: ProfileManager - private let profileId: UUID + private let profile: Profile + + @Binding private var modalType: ModalType? + + private var interactiveProfile: Binding { + .init { + modalType == .interactiveAccount ? profile : nil + } set: { + modalType = $0 != nil ? .interactiveAccount : nil + } + } private var isActiveProfile: Bool { - profileManager.isActiveProfile(profileId) + profileManager.isActiveProfile(profile.id) } - init(profileId: UUID) { + init(profile: Profile, modalType: Binding) { profileManager = .shared - self.profileId = profileId + self.profile = profile + _modalType = modalType } var body: some View { @@ -54,7 +65,11 @@ extension ProfileView { } private var toggleView: some View { - VPNToggle(profileId: profileId, rateLimit: Constants.RateLimit.vpnToggle) + VPNToggle( + profile: profile, + interactiveProfile: interactiveProfile, + rateLimit: Constants.RateLimit.vpnToggle + ) } private var statusView: some View { diff --git a/Passepartout/App/Views/ProfileView.swift b/Passepartout/App/Views/ProfileView.swift index 523dfe5b..c81e37ee 100644 --- a/Passepartout/App/Views/ProfileView.swift +++ b/Passepartout/App/Views/ProfileView.swift @@ -28,6 +28,8 @@ import PassepartoutLibrary struct ProfileView: View { enum ModalType: Int, Identifiable { + case interactiveAccount + case shortcuts case rename @@ -89,7 +91,10 @@ struct ProfileView: View { private var mainView: some View { List { if !isLoading { - VPNSection(profileId: currentProfile.value.id) + VPNSection( + profile: currentProfile.value, + modalType: $modalType + ) ProviderSection(currentProfile: currentProfile) ConfigurationSection( currentProfile: currentProfile, @@ -105,6 +110,11 @@ struct ProfileView: View { @ViewBuilder private func presentedModal(_ modalType: ModalType) -> some View { switch modalType { + case .interactiveAccount: + NavigationView { + InteractiveConnectionView(profile: currentProfile.value) + }.themeGlobal() + case .shortcuts: NavigationView { ShortcutsView(target: currentProfile.value) diff --git a/Passepartout/App/Views/VPNToggle.swift b/Passepartout/App/Views/VPNToggle.swift index 7160050b..4ccce86d 100644 --- a/Passepartout/App/Views/VPNToggle.swift +++ b/Passepartout/App/Views/VPNToggle.swift @@ -35,14 +35,20 @@ struct VPNToggle: View { @ObservedObject private var productManager: ProductManager - private let profileId: UUID - + private let profile: Profile + + @Binding private var interactiveProfile: Profile? + private let rateLimit: Int private var isEnabled: Binding { .init { - isActiveProfile && currentVPNState.isEnabled + isActiveProfile && currentVPNState.isEnabled && !shouldPromptForAccount } set: { newValue in + guard !shouldPromptForAccount else { + interactiveProfile = profile + return + } guard newValue else { disableVPN() return @@ -52,7 +58,11 @@ struct VPNToggle: View { } private var isActiveProfile: Bool { - profileManager.isActiveProfile(profileId) + profileManager.isActiveProfile(profile.id) + } + + private var shouldPromptForAccount: Bool { + profile.account.authenticationMethod == .interactive && (currentVPNState.vpnStatus == .disconnecting || currentVPNState.vpnStatus == .disconnected) } private var isEligibleForSiri: Bool { @@ -61,12 +71,13 @@ struct VPNToggle: View { @State private var canToggle = true - init(profileId: UUID, rateLimit: Int) { + init(profile: Profile, interactiveProfile: Binding, rateLimit: Int) { profileManager = .shared vpnManager = .shared currentVPNState = .shared productManager = .shared - self.profileId = profileId + self.profile = profile + _interactiveProfile = interactiveProfile self.rateLimit = rateLimit } @@ -82,10 +93,10 @@ struct VPNToggle: View { await Task.maybeWait(forMilliseconds: rateLimit) canToggle = true do { - let profile = try await vpnManager.connect(with: profileId) + let profile = try await vpnManager.connect(with: profile.id) donateIntents(withProfile: profile) } catch { - pp_log.warning("Unable to connect to profile \(profileId): \(error)") + pp_log.warning("Unable to connect to profile \(profile.id): \(error)") canToggle = true } } diff --git a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift index 70ab6eaa..fecda60c 100644 --- a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift +++ b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift @@ -54,6 +54,12 @@ internal enum L10n { /// MARK: ProfileView -> AccountView internal static let title = L10n.tr("Localizable", "account.title", fallback: "Account") internal enum Items { + internal enum AuthenticationMethod { + /// Interactive + internal static let interactive = L10n.tr("Localizable", "account.items.authentication_method.interactive", fallback: "Interactive") + /// Persistent + internal static let persistent = L10n.tr("Localizable", "account.items.authentication_method.persistent", fallback: "Persistent") + } internal enum OpenGuide { /// See your credentials internal static let caption = L10n.tr("Localizable", "account.items.open_guide.caption", fallback: "See your credentials") @@ -64,6 +70,10 @@ internal enum L10n { /// secret internal static let placeholder = L10n.tr("Localizable", "account.items.password.placeholder", fallback: "secret") } + internal enum Seed { + /// Seed + internal static let caption = L10n.tr("Localizable", "account.items.seed.caption", fallback: "Seed") + } internal enum Signup { /// Register with %@ internal static func caption(_ p1: Any) -> String { diff --git a/Passepartout/AppShared/L10n/Unlocalized.swift b/Passepartout/AppShared/L10n/Unlocalized.swift index 6be2c370..903ad337 100644 --- a/Passepartout/AppShared/L10n/Unlocalized.swift +++ b/Passepartout/AppShared/L10n/Unlocalized.swift @@ -252,5 +252,7 @@ enum Unlocalized { enum Other { static let siri = "Siri" + + static let totp = "TOTP" } } diff --git a/Passepartout/AppShared/en.lproj/Localizable.strings b/Passepartout/AppShared/en.lproj/Localizable.strings index c5b557b5..a41c4b8d 100644 --- a/Passepartout/AppShared/en.lproj/Localizable.strings +++ b/Passepartout/AppShared/en.lproj/Localizable.strings @@ -179,10 +179,13 @@ "account.title" = "Account"; "account.sections.credentials.header" = "Credentials"; "account.sections.registration.footer" = "Go get an account on the %@ website."; +"account.items.authentication_method.persistent" = "Persistent"; +"account.items.authentication_method.interactive" = "Interactive"; "account.items.username.caption" = "Username"; "account.items.username.placeholder" = "username"; "account.items.password.caption" = "Password"; "account.items.password.placeholder" = "secret"; +"account.items.seed.caption" = "Seed"; "account.items.open_guide.caption" = "See your credentials"; "account.items.signup.caption" = "Register with %@"; diff --git a/PassepartoutLibrary/Sources/PassepartoutCore/Models/Profile+Account.swift b/PassepartoutLibrary/Sources/PassepartoutCore/Models/Profile+Account.swift index c640b07d..b4a1983f 100644 --- a/PassepartoutLibrary/Sources/PassepartoutCore/Models/Profile+Account.swift +++ b/PassepartoutLibrary/Sources/PassepartoutCore/Models/Profile+Account.swift @@ -27,6 +27,16 @@ import Foundation extension Profile { public struct Account: Codable, Equatable { + public enum AuthenticationMethod: String, Codable { + case persistent + + case interactive + + case totp + } + + public var authenticationMethod: AuthenticationMethod? + public var username: String public var password: String diff --git a/PassepartoutLibrary/Sources/PassepartoutVPN/Extensions/OnDemand+Rules.swift b/PassepartoutLibrary/Sources/PassepartoutVPN/Extensions/OnDemand+Rules.swift index e64466ce..b0e93098 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPN/Extensions/OnDemand+Rules.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPN/Extensions/OnDemand+Rules.swift @@ -42,8 +42,17 @@ extension NEOnDemandRuleInterfaceType { } } -extension Profile.OnDemand { - func rules(withCustomRules: Bool) -> [NEOnDemandRule] { +extension Profile { + func onDemandRules(withCustomRules: Bool) -> [NEOnDemandRule] { + onDemand.rules(isInteractive: account.authenticationMethod == .interactive, withCustomRules: withCustomRules) + } +} + +private extension Profile.OnDemand { + func rules(isInteractive: Bool, withCustomRules: Bool) -> [NEOnDemandRule] { + guard isEnabled && !isInteractive else { + return [] + } // TODO: on-demand, drop hardcoding when "trusted networks" -> "on-demand" // isEnabled = true diff --git a/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/VPNManager+Actions.swift b/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/VPNManager+Actions.swift index 9830ef66..0040cf34 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/VPNManager+Actions.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/VPNManager+Actions.swift @@ -49,9 +49,9 @@ extension VPNManager { } @discardableResult - public func connect(with profileId: UUID) async throws -> Profile { + public func connect(with profileId: UUID, newPassword: String? = nil) async throws -> Profile { let result = try profileManager.liveProfileEx(withId: profileId) - let profile = result.profile + var profile = result.profile guard !profileManager.isActiveProfile(profileId) || currentState.vpnStatus != .connected else { @@ -63,6 +63,9 @@ extension VPNManager { } pp_log.info("Connecting to: \(profile.logDescription)") + if let newPassword { + profile.account.password = newPassword + } let cfg = try vpnConfiguration(withProfile: profile) profileManager.activateProfile(profile) diff --git a/PassepartoutLibrary/Sources/PassepartoutVPN/Models/VPNConfigurationParameters.swift b/PassepartoutLibrary/Sources/PassepartoutVPN/Models/VPNConfigurationParameters.swift index 1accf5a3..1baa720a 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPN/Models/VPNConfigurationParameters.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPN/Models/VPNConfigurationParameters.swift @@ -60,7 +60,7 @@ struct VPNConfigurationParameters { username = !profile.account.username.isEmpty ? profile.account.username : nil self.passwordReference = passwordReference self.withNetworkSettings = withNetworkSettings - onDemandRules = profile.onDemand.rules(withCustomRules: withCustomRules) + onDemandRules = profile.onDemandRules(withCustomRules: withCustomRules) } }