updates for lock screen

This commit is contained in:
Mark Gregory
2026-11-04 15:19:49 +00:00
parent 5bebd30041
commit 66adec42e3
5 changed files with 59 additions and 16 deletions
+16 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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>
+20
View File
@@ -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.")
}
}