From 4124ff5caef151e73e87002e2f5df62404fec8c2 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 25 Sep 2024 19:32:07 +0200 Subject: [PATCH] Lock app with FaceID (#609) Restore feature as-is from v2. Closes #606 --- Passepartout.xcodeproj/project.pbxproj | 15 ++ Passepartout/App/PassepartoutApp.swift | 1 + Passepartout/App/en.lproj/AppPlist.strings | 3 + .../AppLibrary/L10n/SwiftGen+Strings.swift | 8 + .../Resources/en.lproj/Localizable.strings | 4 + .../Views/Advanced/AdvancedView.swift | 8 + .../Views/Advanced/iOS/AdvancedView+iOS.swift | 5 + .../Advanced/macOS/AdvancedView+macOS.swift | 17 ++- .../AppLibrary/Views/Theme/Theme+UI.swift | 39 +++++ .../AppLibrary/Views/Theme/Theme.swift | 8 + .../AppLibrary/Views/UI/LogoView.swift | 46 ++++++ .../Sources/CommonLibrary/AppPreference.swift | 4 +- .../UtilsLibrary/Views/LockableView.swift | 144 ++++++++++++++++++ 13 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 Passepartout/App/en.lproj/AppPlist.strings create mode 100644 Passepartout/Library/Sources/AppLibrary/Views/UI/LogoView.swift create mode 100644 Passepartout/Library/Sources/UtilsLibrary/Views/LockableView.swift diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index b2f7dd9b..70d59d3e 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 0E7E3D692B9345FD002BBDB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */; }; 0E7E3D6B2B9345FD002BBDB4 /* PassepartoutApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3D5F2B9345FD002BBDB4 /* PassepartoutApp.swift */; }; 0E94EE582B93554B00588243 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3D672B9345FD002BBDB4 /* PacketTunnelProvider.swift */; }; + 0EB08B982CA46F4900A02591 /* AppPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0EB08B962CA46F4900A02591 /* AppPlist.strings */; }; 0EBE80DA2BF55C0E00E36A20 /* AppLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 0EBE80D92BF55C0E00E36A20 /* AppLibrary */; }; 0EBE80DC2BF55C0E00E36A20 /* TunnelLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 0EBE80DB2BF55C0E00E36A20 /* TunnelLibrary */; }; 0EC066D12C7DC47600D88A94 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */; platformFilter = ios; }; @@ -55,6 +56,7 @@ 0E7E3D672B9345FD002BBDB4 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; 0E8D852F2C328CA1005493DE /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 0E94EE5C2B93570600588243 /* Tunnel.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Tunnel.plist; sourceTree = ""; }; + 0EB08B972CA46F4900A02591 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/AppPlist.strings; sourceTree = ""; }; 0EBE80DD2BF55C9100E36A20 /* Library */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Library; sourceTree = ""; }; 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 0EC332C82B8A1808000B9C2F /* PassepartoutTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PassepartoutTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -129,6 +131,7 @@ 0ED1EFDA2C33059600CBD9BD /* App.plist */, 0E7E3D5B2B9345FD002BBDB4 /* App.entitlements */, 0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */, + 0EB08B962CA46F4900A02591 /* AppPlist.strings */, 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */, 0E7E3D5F2B9345FD002BBDB4 /* PassepartoutApp.swift */, 0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */, @@ -246,6 +249,7 @@ buildActionMask = 2147483647; files = ( 0E7E3D692B9345FD002BBDB4 /* Assets.xcassets in Resources */, + 0EB08B982CA46F4900A02591 /* AppPlist.strings in Resources */, 0EC066D12C7DC47600D88A94 /* LaunchScreen.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -319,6 +323,17 @@ }; /* End PBXTargetDependency section */ +/* Begin PBXVariantGroup section */ + 0EB08B962CA46F4900A02591 /* AppPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 0EB08B972CA46F4900A02591 /* en */, + ); + name = AppPlist.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ 0E06D19C2B87629200176E1D /* Debug */ = { isa = XCBuildConfiguration; diff --git a/Passepartout/App/PassepartoutApp.swift b/Passepartout/App/PassepartoutApp.swift index 98515434..7b1662a9 100644 --- a/Passepartout/App/PassepartoutApp.swift +++ b/Passepartout/App/PassepartoutApp.swift @@ -59,6 +59,7 @@ struct PassepartoutApp: App { ) AppLibrary.configure(with: context) } + .themeLockScreen() .environmentObject(theme) .environmentObject(context.iapManager) .environmentObject(context.connectionObserver) diff --git a/Passepartout/App/en.lproj/AppPlist.strings b/Passepartout/App/en.lproj/AppPlist.strings new file mode 100644 index 00000000..f6e720ac --- /dev/null +++ b/Passepartout/App/en.lproj/AppPlist.strings @@ -0,0 +1,3 @@ +"NSLocationWhenInUseUsageDescription" = "Access name of current Wi-Fi"; + +"NSFaceIDUsageDescription" = "Unlock app with Face ID"; diff --git a/Passepartout/Library/Sources/AppLibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/AppLibrary/L10n/SwiftGen+Strings.swift index bf6d12b9..a4a52feb 100644 --- a/Passepartout/Library/Sources/AppLibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/AppLibrary/L10n/SwiftGen+Strings.swift @@ -245,6 +245,8 @@ internal enum Strings { internal static let server = Strings.tr("Localizable", "global.server", fallback: "Server") /// Servers internal static let servers = Strings.tr("Localizable", "global.servers", fallback: "Servers") + /// Settings + internal static let settings = Strings.tr("Localizable", "global.settings", fallback: "Settings") /// Status internal static let status = Strings.tr("Localizable", "global.status", fallback: "Status") /// Storage @@ -396,6 +398,8 @@ internal enum Strings { } internal enum Views { internal enum Advanced { + /// Lock app access + internal static let lockInBackground = Strings.tr("Localizable", "views.advanced.lock_in_background", fallback: "Lock app access") /// Advanced internal static let title = Strings.tr("Localizable", "views.advanced.title", fallback: "Advanced") internal enum Credits { @@ -475,6 +479,10 @@ internal enum Strings { /// Make a donation internal static let title = Strings.tr("Localizable", "views.donate.title", fallback: "Make a donation") } + internal enum Lockable { + /// Passepartout is locked + internal static let message = Strings.tr("Localizable", "views.lockable.message", fallback: "Passepartout is locked") + } internal enum Profile { internal enum ModuleList { internal enum Section { diff --git a/Passepartout/Library/Sources/AppLibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/AppLibrary/Resources/en.lproj/Localizable.strings index 7237b9b6..0df659ed 100644 --- a/Passepartout/Library/Sources/AppLibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/AppLibrary/Resources/en.lproj/Localizable.strings @@ -52,6 +52,7 @@ "global.save" = "Save"; "global.server" = "Server"; "global.servers" = "Servers"; +"global.settings" = "Settings"; "global.status" = "Status"; "global.storage" = "Storage"; "global.subnet" = "Subnet"; @@ -120,6 +121,7 @@ "views.advanced.title" = "Advanced"; "views.advanced.sections.resources" = "Resources"; +"views.advanced.lock_in_background" = "Lock app access"; "views.advanced.links.title" = "Links"; "views.advanced.links.sections.support" = "Support"; @@ -150,6 +152,8 @@ "views.diagnostics.report_issue.title" = "Report issue"; "views.diagnostics.alerts.report_issue.email" = "The device is not configured to send e-mails."; +"views.lockable.message" = "Passepartout is locked"; + // MARK: - Module views "modules.dns.servers.add" = "Add address"; diff --git a/Passepartout/Library/Sources/AppLibrary/Views/Advanced/AdvancedView.swift b/Passepartout/Library/Sources/AppLibrary/Views/Advanced/AdvancedView.swift index 5a5acedb..31293395 100644 --- a/Passepartout/Library/Sources/AppLibrary/Views/Advanced/AdvancedView.swift +++ b/Passepartout/Library/Sources/AppLibrary/Views/Advanced/AdvancedView.swift @@ -29,6 +29,10 @@ import SwiftUI import UtilsLibrary struct AdvancedView: View { + + @AppStorage(AppPreference.locksInBackground.key) + private var locksInBackground = false + let identifiers: Constants.Identifiers @Binding @@ -41,6 +45,10 @@ struct AdvancedView: View { } extension AdvancedView { + var lockInBackgroundToggle: some View { + Toggle(Strings.Views.Advanced.lockInBackground, isOn: $locksInBackground) + } + var donateLink: some View { navLink(Strings.Views.Donate.title, to: .donate) } diff --git a/Passepartout/Library/Sources/AppLibrary/Views/Advanced/iOS/AdvancedView+iOS.swift b/Passepartout/Library/Sources/AppLibrary/Views/Advanced/iOS/AdvancedView+iOS.swift index 8927f9bf..99006022 100644 --- a/Passepartout/Library/Sources/AppLibrary/Views/Advanced/iOS/AdvancedView+iOS.swift +++ b/Passepartout/Library/Sources/AppLibrary/Views/Advanced/iOS/AdvancedView+iOS.swift @@ -30,6 +30,11 @@ import SwiftUI extension AdvancedView { var listView: some View { List { + Section { + lockInBackgroundToggle + } header: { + Text(Strings.Global.settings) + } Section { // TODO: donations // donateLink diff --git a/Passepartout/Library/Sources/AppLibrary/Views/Advanced/macOS/AdvancedView+macOS.swift b/Passepartout/Library/Sources/AppLibrary/Views/Advanced/macOS/AdvancedView+macOS.swift index 0765a742..56ea5f16 100644 --- a/Passepartout/Library/Sources/AppLibrary/Views/Advanced/macOS/AdvancedView+macOS.swift +++ b/Passepartout/Library/Sources/AppLibrary/Views/Advanced/macOS/AdvancedView+macOS.swift @@ -30,11 +30,18 @@ import SwiftUI extension AdvancedView { var listView: some View { List(selection: $navigationRoute) { - // TODO: donations -// donateLink - linksLink - creditsLink - diagnosticsLink + Section { + lockInBackgroundToggle + } header: { + Text(Strings.Global.settings) + } + Section { + // TODO: donations +// donateLink + linksLink + creditsLink + diagnosticsLink + } } .safeAreaInset(edge: .bottom) { Text(identifiers.versionString) diff --git a/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme+UI.swift b/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme+UI.swift index 94af1603..fc9e66bb 100644 --- a/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme+UI.swift +++ b/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme+UI.swift @@ -23,6 +23,8 @@ // along with Passepartout. If not, see . // +import CommonLibrary +import LocalAuthentication import SwiftUI import UtilsLibrary @@ -62,6 +64,7 @@ struct ThemeBooleanModalModifier: ViewModifier where Modal: View { modal() .frame(minWidth: modalSize?.width, minHeight: modalSize?.height) .interactiveDismissDisabled(!isInteractive) + .themeLockScreen() } } @@ -90,6 +93,7 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden modal($0) .frame(minWidth: modalSize?.width, minHeight: modalSize?.height) .interactiveDismissDisabled(!isInteractive) + .themeLockScreen() } } @@ -198,6 +202,41 @@ struct ThemeHoverListRowModifier: ViewModifier { } } +struct ThemeLockScreenModifier: ViewModifier { + + @AppStorage(AppPreference.locksInBackground.key) + private var locksInBackground = false + + func body(content: Content) -> some View { + LockableView( + locksInBackground: $locksInBackground, + content: { + content + }, + lockedContent: LogoView.init, + unlockBlock: Self.unlockScreenBlock + ) + } + + private static func unlockScreenBlock() async -> Bool { + let context = LAContext() + let policy: LAPolicy = .deviceOwnerAuthentication + var error: NSError? + guard context.canEvaluatePolicy(policy, error: &error) else { + return true + } + do { + let isAuthorized = try await context.evaluatePolicy( + policy, + localizedReason: Strings.Views.Lockable.message + ) + return isAuthorized + } catch { + return false + } + } +} + // MARK: - Views public enum ThemeAnimationCategory: CaseIterable { diff --git a/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme.swift b/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme.swift index f6c5211a..d4b4b1ec 100644 --- a/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme.swift +++ b/Passepartout/Library/Sources/AppLibrary/Views/Theme/Theme.swift @@ -60,6 +60,8 @@ public final class Theme: ObservableObject { var emptyMessageColor: Color = .secondary + var primaryColor = Color(red: 0.318, green: 0.365, blue: 0.443) + var activeColor = Color(red: .zero, green: Double(0xAA) / 255.0, blue: .zero) var inactiveColor: Color = .gray @@ -72,6 +74,8 @@ public final class Theme: ObservableObject { var animationCategories: Set = Set(ThemeAnimationCategory.allCases) + var logoImage = "Logo" + var systemImage: (ImageName) -> String = { switch $0 { case .add: return "plus" @@ -207,6 +211,10 @@ extension View { public func themeHoverListRow() -> some View { modifier(ThemeHoverListRowModifier()) } + + public func themeLockScreen() -> some View { + modifier(ThemeLockScreenModifier()) + } } // MARK: - Views diff --git a/Passepartout/Library/Sources/AppLibrary/Views/UI/LogoView.swift b/Passepartout/Library/Sources/AppLibrary/Views/UI/LogoView.swift new file mode 100644 index 00000000..704554a7 --- /dev/null +++ b/Passepartout/Library/Sources/AppLibrary/Views/UI/LogoView.swift @@ -0,0 +1,46 @@ +// +// LogoView.swift +// Passepartout +// +// Created by Davide De Rosa on 3/20/23. +// Copyright (c) 2024 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 { + + @EnvironmentObject + private var theme: Theme + + var body: some View { + ZStack { + theme.primaryColor + .ignoresSafeArea() + Image(theme.logoImage) + } + .ignoresSafeArea() + } +} + +#Preview { + LogoView() + .environmentObject(Theme()) +} diff --git a/Passepartout/Library/Sources/CommonLibrary/AppPreference.swift b/Passepartout/Library/Sources/CommonLibrary/AppPreference.swift index f449d045..3749cfc5 100644 --- a/Passepartout/Library/Sources/CommonLibrary/AppPreference.swift +++ b/Passepartout/Library/Sources/CommonLibrary/AppPreference.swift @@ -26,10 +26,12 @@ import Foundation public enum AppPreference: String { - case profilesLayout + case locksInBackground case logsPrivateData + case profilesLayout + public var key: String { "App.\(rawValue)" } diff --git a/Passepartout/Library/Sources/UtilsLibrary/Views/LockableView.swift b/Passepartout/Library/Sources/UtilsLibrary/Views/LockableView.swift new file mode 100644 index 00000000..f38bd900 --- /dev/null +++ b/Passepartout/Library/Sources/UtilsLibrary/Views/LockableView.swift @@ -0,0 +1,144 @@ +// +// LockableView.swift +// Passepartout +// +// Created by Davide De Rosa on 3/20/23. +// Copyright (c) 2024 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 + +public struct LockableView: View { + + @Environment(\.scenePhase) + private var scenePhase + + @Binding + private var locksInBackground: Bool + + private let content: () -> Content + + private let lockedContent: () -> LockedContent + + private let unlockBlock: () async -> Bool + + @ObservedObject + private var lock: Lock = .shared + + @Binding + private var state: Lock.State + + public init( + locksInBackground: Binding, + content: @escaping () -> Content, + lockedContent: @escaping () -> LockedContent, + unlockBlock: @escaping () async -> Bool + ) { + _locksInBackground = locksInBackground + self.content = content + self.lockedContent = lockedContent + self.unlockBlock = unlockBlock + + _state = .init { + Lock.shared.state + } set: { + Lock.shared.state = $0 + } + } + + public var body: some View { + ZStack { + content() + if locksInBackground && state != .none { + lockedContent() + } + }.onChange(of: scenePhase, perform: onScenePhase) + } +} + +// MARK: - + +private final class Lock: ObservableObject { + enum State { + case none + + case covered + + case locked + } + + static let shared = Lock() + + @Published var state: State = .locked + + private init() { + } +} + +// MARK: - + +private extension LockableView { + func onScenePhase(_ scenePhase: ScenePhase) { + switch scenePhase { + case .active: + unlockIfNeeded() + + case .inactive: + if state == .none { + state = .covered + } + + case .background: + lockIfNeeded() + + default: + break + } + } + + func lockIfNeeded() { + guard locksInBackground else { + return + } + state = .locked + } + + func unlockIfNeeded() { + guard locksInBackground else { + state = .none + return + } + switch state { + case .none: + break + + case .covered: + state = .none + + case .locked: + Task { @MainActor in + guard await unlockBlock() else { + return + } + state = .none + } + } + } +}