Lock app with FaceID (#609)

Restore feature as-is from v2.

Closes #606
This commit is contained in:
Davide 2024-09-25 19:32:07 +02:00 committed by GitHub
parent 752dc6229f
commit 4124ff5cae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 296 additions and 6 deletions

View File

@ -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 = "<group>"; };
0E8D852F2C328CA1005493DE /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
0E94EE5C2B93570600588243 /* Tunnel.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Tunnel.plist; sourceTree = "<group>"; };
0EB08B972CA46F4900A02591 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/AppPlist.strings; sourceTree = "<group>"; };
0EBE80DD2BF55C9100E36A20 /* Library */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Library; sourceTree = "<group>"; };
0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
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 = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0E06D19C2B87629200176E1D /* Debug */ = {
isa = XCBuildConfiguration;

View File

@ -59,6 +59,7 @@ struct PassepartoutApp: App {
)
AppLibrary.configure(with: context)
}
.themeLockScreen()
.environmentObject(theme)
.environmentObject(context.iapManager)
.environmentObject(context.connectionObserver)

View File

@ -0,0 +1,3 @@
"NSLocationWhenInUseUsageDescription" = "Access name of current Wi-Fi";
"NSFaceIDUsageDescription" = "Unlock app with Face ID";

View File

@ -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 {

View File

@ -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";

View File

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

View File

@ -30,6 +30,11 @@ import SwiftUI
extension AdvancedView {
var listView: some View {
List {
Section {
lockInBackgroundToggle
} header: {
Text(Strings.Global.settings)
}
Section {
// TODO: donations
// donateLink

View File

@ -30,12 +30,19 @@ import SwiftUI
extension AdvancedView {
var listView: some View {
List(selection: $navigationRoute) {
Section {
lockInBackgroundToggle
} header: {
Text(Strings.Global.settings)
}
Section {
// TODO: donations
// donateLink
linksLink
creditsLink
diagnosticsLink
}
}
.safeAreaInset(edge: .bottom) {
Text(identifiers.versionString)
.padding(.bottom)

View File

@ -23,6 +23,8 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import LocalAuthentication
import SwiftUI
import UtilsLibrary
@ -62,6 +64,7 @@ struct ThemeBooleanModalModifier<Modal>: ViewModifier where Modal: View {
modal()
.frame(minWidth: modalSize?.width, minHeight: modalSize?.height)
.interactiveDismissDisabled(!isInteractive)
.themeLockScreen()
}
}
@ -90,6 +93,7 @@ struct ThemeItemModalModifier<Modal, T>: 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 {

View File

@ -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<ThemeAnimationCategory> = 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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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())
}

View File

@ -26,10 +26,12 @@
import Foundation
public enum AppPreference: String {
case profilesLayout
case locksInBackground
case logsPrivateData
case profilesLayout
public var key: String {
"App.\(rawValue)"
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import SwiftUI
public struct LockableView<Content: View, LockedContent: View>: 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<Bool>,
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
}
}
}
}