diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 7b23bae6..62f47b91 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 0E0C0729236087A100155AAC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0E0C072B236087A100155AAC /* InfoPlist.strings */; }; 0E0F4C5A29C761850022E884 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0F4C5929C761850022E884 /* SceneDelegate.swift */; }; 0E0F4C5C29C76B790022E884 /* SceneDelegate+Shortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0F4C5B29C76B790022E884 /* SceneDelegate+Shortcuts.swift */; }; + 0E0F4C6429C84B5A0022E884 /* LockableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0F4C6329C84B5A0022E884 /* LockableView.swift */; }; + 0E0F4C6629C84CF60022E884 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0F4C6529C84CF60022E884 /* LogoView.swift */; }; 0E12BC8F27F62C8600B2F912 /* Validators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E12BC8E27F62C8500B2F912 /* Validators.swift */; }; 0E1B5F5C29C506AD00FE7D18 /* ProfileView+Diagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1B5F5B29C506AC00FE7D18 /* ProfileView+Diagnostics.swift */; }; 0E1F5628287F0ECB00F8ADD7 /* ProviderProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1F5627287F0ECB00F8ADD7 /* ProviderProfileItem.swift */; }; @@ -308,6 +310,8 @@ 0E0C072C236087C800155AAC /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; 0E0F4C5929C761850022E884 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 0E0F4C5B29C76B790022E884 /* SceneDelegate+Shortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SceneDelegate+Shortcuts.swift"; sourceTree = ""; }; + 0E0F4C6329C84B5A0022E884 /* LockableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockableView.swift; sourceTree = ""; }; + 0E0F4C6529C84CF60022E884 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = ""; }; 0E12BC8E27F62C8500B2F912 /* Validators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Validators.swift; sourceTree = ""; }; 0E1B5F5B29C506AC00FE7D18 /* ProfileView+Diagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileView+Diagnostics.swift"; sourceTree = ""; }; 0E1C0A52238FFF97009FC087 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -577,9 +581,9 @@ 0E29385A285A749E002A6E0E /* Context */ = { isa = PBXGroup; children = ( - 0E293850285A70AC002A6E0E /* AppPreference.swift */, 0E021D9B284E68580077EF5D /* AppContext.swift */, 0E293856285A73BC002A6E0E /* AppContext+Shared.swift */, + 0E293850285A70AC002A6E0E /* AppPreference.swift */, ); path = Context; sourceTree = ""; @@ -598,6 +602,7 @@ 0EF0FAF827DD212C007EB181 /* IntentActivity.swift */, 0ED89C1D27DE3F8D008B36D6 /* IntentAddView.swift */, 0ED89C1627DE0E05008B36D6 /* IntentEditView.swift */, + 0E0F4C6329C84B5A0022E884 /* LockableView.swift */, 0E5324A827D2AC55002565C3 /* LongContentView.swift */, 0EBC075427EBC83800208AD9 /* MailComposerView.swift */, 0ED30DCB27EA197C0057D8A3 /* RevealingSecureField.swift */, @@ -648,6 +653,7 @@ 0E5349C527C176C200C71BB3 /* EndpointView+OpenVPN.swift */, 0E5349C727C176D100C71BB3 /* EndpointView+WireGuard.swift */, 0EB90CC029C25BBD00E64628 /* InteractiveConnectionView.swift */, + 0E0F4C6529C84CF60022E884 /* LogoView.swift */, 0E0BD27227B2EA2C00583AC5 /* MainView.swift */, 0E71ACE827C1055200F85C4B /* NetworkSettingsView.swift */, 0EB34BC927C6A70200B126DA /* OnDemandView.swift */, @@ -1447,6 +1453,7 @@ 0E3B7FD627E5173A00C66F13 /* ProfileView+VPN.swift in Sources */, 0ED89C1E27DE3F8D008B36D6 /* IntentAddView.swift in Sources */, 0E5468002867AC9A00F74D1C /* MacUtils.swift in Sources */, + 0E0F4C6429C84B5A0022E884 /* LockableView.swift in Sources */, 0E96D3052872010A005EFBCF /* DefaultLightVPNManager.swift in Sources */, 0EBE880F281B18DE0090D9E6 /* OrganizerView+ProfileRow.swift in Sources */, 0ED30DCF27EA1EF80057D8A3 /* PaywallView+Beta.swift in Sources */, @@ -1491,6 +1498,7 @@ 0EF0FAF727DD159C007EB181 /* IntentDispatcher.swift in Sources */, 0E12BC8F27F62C8600B2F912 /* Validators.swift in Sources */, 0E0838FD2877334300A34EC0 /* DefaultLightProviderManager.swift in Sources */, + 0E0F4C6629C84CF60022E884 /* LogoView.swift in Sources */, 0E039279281890B100827C10 /* AddHostView.swift in Sources */, 0E5467F72867A57000F74D1C /* MacBridge.swift in Sources */, 0E9ED48127FD9BAE003B2316 /* CopySavingButton.swift in Sources */, diff --git a/Passepartout/App/Constants/Theme.swift b/Passepartout/App/Constants/Theme.swift index 46c4c921..790f4035 100644 --- a/Passepartout/App/Constants/Theme.swift +++ b/Passepartout/App/Constants/Theme.swift @@ -438,6 +438,17 @@ extension View { Text(L10n.Global.Strings.save) } + func themeSecureField(_ placeholder: String, text: Binding, contentType: UITextContentType = .password) -> some View { + RevealingSecureField(placeholder, text: text) { + themeConceilImage.asSystemImage + .themeAccentForegroundStyle() + } revealImage: { + themeRevealImage.asSystemImage + .themeAccentForegroundStyle() + }.textContentType(contentType) + .themeRawTextStyle() + } + func themeTextPicker(_ title: String, selection: Binding, values: [T], description: @escaping (T) -> String) -> some View { StyledPicker(title: title, selection: selection, values: values) { Text(description($0)) diff --git a/Passepartout/App/Context/AppPreference.swift b/Passepartout/App/Context/AppPreference.swift index bfff005a..f15ec4bc 100644 --- a/Passepartout/App/Context/AppPreference.swift +++ b/Passepartout/App/Context/AppPreference.swift @@ -33,6 +33,8 @@ enum AppPreference: String, KeyStoreDomainLocation { case didHandleSubreddit + case locksInBackground + var domain: String { "Passepartout.App" } diff --git a/Passepartout/App/PassepartoutApp.swift b/Passepartout/App/PassepartoutApp.swift index db0865a5..7c6ab2e4 100644 --- a/Passepartout/App/PassepartoutApp.swift +++ b/Passepartout/App/PassepartoutApp.swift @@ -24,24 +24,31 @@ // import SwiftUI +import LocalAuthentication import PassepartoutLibrary @main struct PassepartoutApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @AppStorage(AppPreference.locksInBackground.rawValue) private var locksInBackground = false + @SceneBuilder var body: some Scene { WindowGroup { - MainView() - .withoutTitleBar() - .onIntentActivity(IntentDispatcher.connectVPN) - .onIntentActivity(IntentDispatcher.disableVPN) - .onIntentActivity(IntentDispatcher.enableVPN) - .onIntentActivity(IntentDispatcher.moveToLocation) - .onIntentActivity(IntentDispatcher.trustCellularNetwork) - .onIntentActivity(IntentDispatcher.trustCurrentNetwork) - .onIntentActivity(IntentDispatcher.untrustCellularNetwork) - .onIntentActivity(IntentDispatcher.untrustCurrentNetwork) + LockableView( + reason: L10n.Global.Messages.unlockApp, + locksInBackground: $locksInBackground, + content: MainView.init, + lockedContent: LogoView.init + ).withoutTitleBar() + .onIntentActivity(IntentDispatcher.connectVPN) + .onIntentActivity(IntentDispatcher.disableVPN) + .onIntentActivity(IntentDispatcher.enableVPN) + .onIntentActivity(IntentDispatcher.moveToLocation) + .onIntentActivity(IntentDispatcher.trustCellularNetwork) + .onIntentActivity(IntentDispatcher.trustCurrentNetwork) + .onIntentActivity(IntentDispatcher.untrustCellularNetwork) + .onIntentActivity(IntentDispatcher.untrustCurrentNetwork) } } } diff --git a/Passepartout/App/Reusable/LockableView.swift b/Passepartout/App/Reusable/LockableView.swift new file mode 100644 index 00000000..c0fe6874 --- /dev/null +++ b/Passepartout/App/Reusable/LockableView.swift @@ -0,0 +1,105 @@ +// +// LockableView.swift +// Passepartout +// +// Created by Davide De Rosa on 3/20/23. +// Copyright (c) 2023 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 LocalAuthentication + +struct LockableView: View { + let reason: String + + @Binding var locksInBackground: Bool + + let content: () -> Content + + let lockedContent: () -> LockedContent + + @Environment(\.scenePhase) private var scenePhase + + @State private var didAppear = false + + @State private var isLocked = false + + var body: some View { + Group { + if !isLocked { + content() + } else { + lockedContent() + } + }.onChange(of: scenePhase, perform: onScenePhase) + } + + private func onScenePhase(_ scenePhase: ScenePhase) { + switch scenePhase { + case .active: + #if targetEnvironment(macCatalyst) + break + #else + if !didAppear { + didAppear = true + if locksInBackground { + isLocked = true + } + } + unlockIfNeeded() + #endif + + case .inactive: + #if targetEnvironment(macCatalyst) + break + #else + lockIfNeeded() + #endif + + default: + break + } + } + + func lockIfNeeded() { + guard locksInBackground else { + return + } + isLocked = true + } + + func unlockIfNeeded() { + guard isLocked else { + return + } + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + return + } + Task { + do { + let isAuthorized = try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + isLocked = !isAuthorized + } catch { + } + } + } +} diff --git a/Passepartout/App/Views/AccountView.swift b/Passepartout/App/Views/AccountView.swift index e4d78e01..839a0d23 100644 --- a/Passepartout/App/Views/AccountView.swift +++ b/Passepartout/App/Views/AccountView.swift @@ -59,6 +59,7 @@ struct AccountView: View { var body: some View { List { Section { + // FIXME: l10n themeTextPicker(L10n.Endpoint.Advanced.Openvpn.Items.Digest.caption, selection: $liveAccount.authenticationMethod ?? .persistent, values: [ .persistent, .interactive @@ -77,27 +78,13 @@ struct AccountView: View { 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() + themeSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password) .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() + themeSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password, contentType: .oneTimeCode) .withLeadingText(L10n.Account.Items.Seed.caption) } } footer: { diff --git a/Passepartout/App/Views/InteractiveConnectionView.swift b/Passepartout/App/Views/InteractiveConnectionView.swift index c2b61e64..7c60e1bc 100644 --- a/Passepartout/App/Views/InteractiveConnectionView.swift +++ b/Passepartout/App/Views/InteractiveConnectionView.swift @@ -50,15 +50,8 @@ struct InteractiveConnectionView: View { .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) + themeSecureField(L10n.Account.Items.Password.placeholder, text: $password) + .withLeadingText(L10n.Account.Items.Password.caption) } header: { Text(L10n.Account.title) } diff --git a/Passepartout/App/Views/LogoView.swift b/Passepartout/App/Views/LogoView.swift new file mode 100644 index 00000000..71342d68 --- /dev/null +++ b/Passepartout/App/Views/LogoView.swift @@ -0,0 +1,35 @@ +// +// LogoView.swift +// Passepartout +// +// Created by Davide De Rosa on 3/20/23. +// Copyright (c) 2023 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 + +struct LogoView: View { + var body: some View { + ZStack { + themePrimaryBackground + Image(themeAssetsLogoImage) + }.ignoresSafeArea() + } +} diff --git a/Passepartout/App/Views/SettingsView.swift b/Passepartout/App/Views/SettingsView.swift index 34574409..cffda505 100644 --- a/Passepartout/App/Views/SettingsView.swift +++ b/Passepartout/App/Views/SettingsView.swift @@ -33,11 +33,7 @@ struct SettingsView: View { @Environment(\.presentationMode) private var presentationMode -// private var isTestBuild: Bool { -// Constants.App.isBeta || Constants.InApp.appType == .beta -// } -// -// private let appName = Unlocalized.appName + @AppStorage(AppPreference.locksInBackground.rawValue) private var locksInBackground = false private let versionString = Constants.Global.appVersionString @@ -48,6 +44,7 @@ struct SettingsView: View { var body: some View { List { + preferencesSection aboutSection }.toolbar { themeCloseItem(presentationMode: presentationMode) @@ -55,6 +52,12 @@ struct SettingsView: View { .navigationTitle(L10n.Settings.title) } + private var preferencesSection: some View { + Section { + Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground) + } + } + private var aboutSection: some View { Section { NavigationLink { diff --git a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift index d377e0b2..1fe225d9 100644 --- a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift +++ b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift @@ -446,6 +446,8 @@ internal enum L10n { internal static let emailNotConfigured = L10n.tr("Localizable", "global.messages.email_not_configured", fallback: "No e-mail account is configured.") /// Passepartout is a user-friendly, open source OpenVPN / WireGuard client for iOS and macOS internal static let share = L10n.tr("Localizable", "global.messages.share", fallback: "Passepartout is a user-friendly, open source OpenVPN / WireGuard client for iOS and macOS") + /// Passepartout is locked + internal static let unlockApp = L10n.tr("Localizable", "global.messages.unlock_app", fallback: "Passepartout is locked") } internal enum Placeholders { /// My profile @@ -895,6 +897,10 @@ internal enum L10n { /// Make a donation internal static let caption = L10n.tr("Localizable", "settings.items.donate.caption", fallback: "Make a donation") } + internal enum LocksInBackground { + /// Lock app in background + internal static let caption = L10n.tr("Localizable", "settings.items.locks_in_background.caption", fallback: "Lock app in background") + } } } internal enum Shortcuts { diff --git a/Passepartout/AppShared/en.lproj/Localizable.strings b/Passepartout/AppShared/en.lproj/Localizable.strings index affcba1d..4ef72b7f 100644 --- a/Passepartout/AppShared/en.lproj/Localizable.strings +++ b/Passepartout/AppShared/en.lproj/Localizable.strings @@ -51,6 +51,7 @@ "global.strings.disconnect" = "Disconnect"; "global.strings.download" = "Download"; +"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"; @@ -320,6 +321,7 @@ /* MARK: SettingsView */ "settings.title" = "Settings"; +"settings.items.locks_in_background.caption" = "Lock app in background"; "settings.items.donate.caption" = "Make a donation"; /* MARK: AboutView */