updates for lock screen
This commit is contained in:
+16
-11
@@ -3,13 +3,9 @@ import UniformTypeIdentifiers
|
||||
|
||||
@main struct TarnaxApp: App {
|
||||
@StateObject private var model = AppModel()
|
||||
@Environment(\.scenePhase) private var phase
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView().environmentObject(model).tint(.indigo)
|
||||
.onChange(of: phase, initial: true) { _, value in
|
||||
Task { await model.updateScene(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,9 +20,14 @@ struct RootView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.locked {
|
||||
ContentUnavailableView { Label("Tarnax is locked", systemImage: "lock.shield") } actions: {
|
||||
Button(model.authenticating ? "Authenticating…" : "Unlock") { Task { await model.unlock() } }
|
||||
.buttonStyle(.borderedProminent).disabled(model.authenticating)
|
||||
if model.unlockNeedsRetry {
|
||||
ContentUnavailableView { Label("Unlock Tarnax", systemImage: "lock.shield") } description: {
|
||||
Text(model.unlockStatus ?? "Use Face ID or your device passcode to continue.")
|
||||
} actions: {
|
||||
Button("Unlock") { Task { await model.unlock() } }.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
TarnaxSplashView(status: "Unlocking securely…")
|
||||
}
|
||||
} else if model.restoringSession {
|
||||
TarnaxSplashView()
|
||||
@@ -60,6 +61,9 @@ struct RootView: View {
|
||||
.sheet(isPresented: $add) { AddView() }
|
||||
}
|
||||
}
|
||||
.onChange(of: phase, initial: true) { _, value in
|
||||
Task { await model.updateScene(value) }
|
||||
}
|
||||
.task(id: model.locked) { await model.restoreSessionIfNeeded() }
|
||||
.alert("Tarnax", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") }
|
||||
.task(id: "\(phase)-\(model.connected)-\(model.locked)") {
|
||||
@@ -90,18 +94,19 @@ struct RootView: View {
|
||||
|
||||
}
|
||||
struct TarnaxSplashView: View {
|
||||
var status: LocalizedStringKey = "Connecting securely…"
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(uiColor: .systemGroupedBackground).ignoresSafeArea()
|
||||
Color(red: 0, green: 0, blue: 0.055).ignoresSafeArea()
|
||||
ScrollView { VStack(spacing: 24) {
|
||||
Image("TarnaxBrand").resizable().scaledToFit().frame(width: 152, height: 152)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
VStack(spacing: 10) {
|
||||
Text("Tarnax").font(.largeTitle.bold()).foregroundStyle(.primary)
|
||||
Text("Your downloads. Your server.").font(.subheadline).foregroundStyle(.secondary)
|
||||
Text("Tarnax").font(.largeTitle.bold()).foregroundStyle(.white)
|
||||
Text("Your downloads. Your server.").font(.subheadline).foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
ProgressView("Connecting securely…").font(.body)
|
||||
ProgressView(status).font(.body).tint(.white).foregroundStyle(.white)
|
||||
.padding(.top, 12)
|
||||
}.padding(32).frame(maxWidth: .infinity) }.defaultScrollAnchor(.center)
|
||||
}
|
||||
|
||||
+20
-2
@@ -143,6 +143,8 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
@Published var locked = UserDefaults.standard.bool(forKey: "biometricLock")
|
||||
@Published var biometric = UserDefaults.standard.bool(forKey: "biometricLock")
|
||||
@Published private(set) var authenticating = false
|
||||
@Published private(set) var unlockStatus: String?
|
||||
@Published private(set) var unlockNeedsRetry = false
|
||||
private var automaticUnlockAttempted = false
|
||||
private var foreground = false
|
||||
private var lockCycle = 0
|
||||
@@ -251,6 +253,7 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
lockCycle += 1
|
||||
automaticUnlockAttempted = false
|
||||
locked = true
|
||||
unlockNeedsRetry = false
|
||||
unlockContext?.invalidate()
|
||||
}
|
||||
if phase == .active { await unlockAutomaticallyIfNeeded() }
|
||||
@@ -263,6 +266,8 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func unlock() async {
|
||||
guard locked, !authenticating else { return }
|
||||
authenticating = true
|
||||
unlockNeedsRetry = false
|
||||
unlockStatus = "Authenticate with Face ID or your device passcode…"
|
||||
let cycle = lockCycle
|
||||
let context = LAContext()
|
||||
unlockContext = context
|
||||
@@ -275,14 +280,27 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
do {
|
||||
guard try await authenticate(context), cycle == lockCycle else { return }
|
||||
let success = try await authenticate(context)
|
||||
guard cycle == lockCycle else { return }
|
||||
guard success else {
|
||||
unlockNeedsRetry = true
|
||||
unlockStatus = "Authentication did not complete. Tap Unlock to try again."
|
||||
return
|
||||
}
|
||||
locked = false
|
||||
unlockStatus = nil
|
||||
} catch {
|
||||
guard cycle == lockCycle else { return }
|
||||
unlockNeedsRetry = true
|
||||
let failure = error as NSError
|
||||
let expected = failure.domain == LAError.errorDomain &&
|
||||
[LAError.userCancel.rawValue, LAError.appCancel.rawValue, LAError.systemCancel.rawValue].contains(failure.code)
|
||||
if !expected && !(error is CancellationError) { self.error = error.localizedDescription }
|
||||
if expected || error is CancellationError {
|
||||
unlockStatus = "Authentication was cancelled or interrupted. Tap Unlock to try again."
|
||||
} else {
|
||||
unlockStatus = error.localizedDescription
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
func enableLock(_ enabled: Bool) async {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>4</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23501" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="launch-controller">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies><plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23500"/><capability name="System colors in document resources" minToolsVersion="11.0"/><capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/></dependencies>
|
||||
<scenes><scene sceneID="launch-scene"><objects><viewController id="launch-controller" sceneMemberID="viewController"><view key="view" contentMode="scaleToFill" id="launch-view"><rect key="frame" x="0.0" y="0.0" width="393" height="852"/><autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/><color key="backgroundColor" systemColor="systemGroupedBackgroundColor"/></view></viewController><placeholder placeholderIdentifier="IBFirstResponder" id="first-responder" sceneMemberID="firstResponder"/></objects></scene></scenes>
|
||||
<resources><systemColor name="systemGroupedBackgroundColor"><color red="0.949" green="0.949" blue="0.969" alpha="1" colorSpace="custom" customColorSpace="sRGB"/></systemColor></resources></document>
|
||||
<scenes><scene sceneID="launch-scene"><objects><viewController id="launch-controller" sceneMemberID="viewController"><view key="view" contentMode="scaleToFill" id="launch-view"><rect key="frame" x="0.0" y="0.0" width="393" height="852"/><autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/><subviews><imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="TarnaxBrand" translatesAutoresizingMaskIntoConstraints="NO" id="launch-mark"><rect key="frame" x="120.5" y="350" width="152" height="152"/><constraints><constraint firstAttribute="width" constant="152" id="mark-width"/><constraint firstAttribute="height" constant="152" id="mark-height"/></constraints></imageView></subviews><color key="backgroundColor" red="0" green="0" blue="0.055" alpha="1" colorSpace="custom" customColorSpace="sRGB"/><constraints><constraint firstItem="launch-mark" firstAttribute="centerX" secondItem="launch-view" secondAttribute="centerX" id="mark-x"/><constraint firstItem="launch-mark" firstAttribute="centerY" secondItem="launch-view" secondAttribute="centerY" id="mark-y"/></constraints></view></viewController><placeholder placeholderIdentifier="IBFirstResponder" id="first-responder" sceneMemberID="firstResponder"/></objects></scene></scenes>
|
||||
<resources><image name="TarnaxBrand" width="1024" height="1024"/></resources></document>
|
||||
|
||||
@@ -198,6 +198,26 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
|
||||
}
|
||||
await protection.updateScene(.active)
|
||||
check(!protection.locked, "Face ID success while system prompt is inactive unlocks the app")
|
||||
let presentation = AppModel()
|
||||
presentation.biometric = true; presentation.locked = true
|
||||
check(!presentation.unlockNeedsRetry, "Fresh protected launch shows branding instead of lock fallback")
|
||||
presentation.authenticate = { _ in
|
||||
check(!presentation.unlockNeedsRetry, "Face ID prompt keeps lock fallback hidden")
|
||||
throw LAError(.userCancel)
|
||||
}
|
||||
await presentation.updateScene(.active)
|
||||
check(presentation.locked && presentation.unlockNeedsRetry, "Only unsuccessful authentication shows the Unlock fallback")
|
||||
presentation.authenticate = { _ in
|
||||
check(!presentation.unlockNeedsRetry, "Retry returns to branded authentication screen")
|
||||
return true
|
||||
}
|
||||
await presentation.unlock()
|
||||
check(!presentation.locked && !presentation.unlockNeedsRetry, "Successful authentication opens app without lock screen")
|
||||
await presentation.updateScene(.background)
|
||||
check(!presentation.unlockNeedsRetry, "New background lock clears previous fallback state")
|
||||
presentation.authenticate = { _ in false }
|
||||
await presentation.updateScene(.active)
|
||||
check(presentation.locked && presentation.unlockNeedsRetry, "Incomplete authentication offers a retry instead of a permanent spinner")
|
||||
another.close(); transport.close(); print("\(checks) contract checks passed.")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user