updates for lock screen
This commit is contained in:
+16
-11
@@ -3,13 +3,9 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
@main struct TarnaxApp: App {
|
@main struct TarnaxApp: App {
|
||||||
@StateObject private var model = AppModel()
|
@StateObject private var model = AppModel()
|
||||||
@Environment(\.scenePhase) private var phase
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView().environmentObject(model).tint(.indigo)
|
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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if model.locked {
|
if model.locked {
|
||||||
ContentUnavailableView { Label("Tarnax is locked", systemImage: "lock.shield") } actions: {
|
if model.unlockNeedsRetry {
|
||||||
Button(model.authenticating ? "Authenticating…" : "Unlock") { Task { await model.unlock() } }
|
ContentUnavailableView { Label("Unlock Tarnax", systemImage: "lock.shield") } description: {
|
||||||
.buttonStyle(.borderedProminent).disabled(model.authenticating)
|
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 {
|
} else if model.restoringSession {
|
||||||
TarnaxSplashView()
|
TarnaxSplashView()
|
||||||
@@ -60,6 +61,9 @@ struct RootView: View {
|
|||||||
.sheet(isPresented: $add) { AddView() }
|
.sheet(isPresented: $add) { AddView() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: phase, initial: true) { _, value in
|
||||||
|
Task { await model.updateScene(value) }
|
||||||
|
}
|
||||||
.task(id: model.locked) { await model.restoreSessionIfNeeded() }
|
.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 ?? "") }
|
.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)") {
|
.task(id: "\(phase)-\(model.connected)-\(model.locked)") {
|
||||||
@@ -90,18 +94,19 @@ struct RootView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
struct TarnaxSplashView: View {
|
struct TarnaxSplashView: View {
|
||||||
|
var status: LocalizedStringKey = "Connecting securely…"
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(uiColor: .systemGroupedBackground).ignoresSafeArea()
|
Color(red: 0, green: 0, blue: 0.055).ignoresSafeArea()
|
||||||
ScrollView { VStack(spacing: 24) {
|
ScrollView { VStack(spacing: 24) {
|
||||||
Image("TarnaxBrand").resizable().scaledToFit().frame(width: 152, height: 152)
|
Image("TarnaxBrand").resizable().scaledToFit().frame(width: 152, height: 152)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Text("Tarnax").font(.largeTitle.bold()).foregroundStyle(.primary)
|
Text("Tarnax").font(.largeTitle.bold()).foregroundStyle(.white)
|
||||||
Text("Your downloads. Your server.").font(.subheadline).foregroundStyle(.secondary)
|
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(.top, 12)
|
||||||
}.padding(32).frame(maxWidth: .infinity) }.defaultScrollAnchor(.center)
|
}.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 locked = UserDefaults.standard.bool(forKey: "biometricLock")
|
||||||
@Published var biometric = UserDefaults.standard.bool(forKey: "biometricLock")
|
@Published var biometric = UserDefaults.standard.bool(forKey: "biometricLock")
|
||||||
@Published private(set) var authenticating = false
|
@Published private(set) var authenticating = false
|
||||||
|
@Published private(set) var unlockStatus: String?
|
||||||
|
@Published private(set) var unlockNeedsRetry = false
|
||||||
private var automaticUnlockAttempted = false
|
private var automaticUnlockAttempted = false
|
||||||
private var foreground = false
|
private var foreground = false
|
||||||
private var lockCycle = 0
|
private var lockCycle = 0
|
||||||
@@ -251,6 +253,7 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|||||||
lockCycle += 1
|
lockCycle += 1
|
||||||
automaticUnlockAttempted = false
|
automaticUnlockAttempted = false
|
||||||
locked = true
|
locked = true
|
||||||
|
unlockNeedsRetry = false
|
||||||
unlockContext?.invalidate()
|
unlockContext?.invalidate()
|
||||||
}
|
}
|
||||||
if phase == .active { await unlockAutomaticallyIfNeeded() }
|
if phase == .active { await unlockAutomaticallyIfNeeded() }
|
||||||
@@ -263,6 +266,8 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|||||||
func unlock() async {
|
func unlock() async {
|
||||||
guard locked, !authenticating else { return }
|
guard locked, !authenticating else { return }
|
||||||
authenticating = true
|
authenticating = true
|
||||||
|
unlockNeedsRetry = false
|
||||||
|
unlockStatus = "Authenticate with Face ID or your device passcode…"
|
||||||
let cycle = lockCycle
|
let cycle = lockCycle
|
||||||
let context = LAContext()
|
let context = LAContext()
|
||||||
unlockContext = context
|
unlockContext = context
|
||||||
@@ -275,14 +280,27 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
do {
|
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
|
locked = false
|
||||||
|
unlockStatus = nil
|
||||||
} catch {
|
} catch {
|
||||||
guard cycle == lockCycle else { return }
|
guard cycle == lockCycle else { return }
|
||||||
|
unlockNeedsRetry = true
|
||||||
let failure = error as NSError
|
let failure = error as NSError
|
||||||
let expected = failure.domain == LAError.errorDomain &&
|
let expected = failure.domain == LAError.errorDomain &&
|
||||||
[LAError.userCancel.rawValue, LAError.appCancel.rawValue, LAError.systemCancel.rawValue].contains(failure.code)
|
[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 {
|
func enableLock(_ enabled: Bool) async {
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>2</string>
|
<string>4</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<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">
|
<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"/>
|
<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>
|
<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>
|
<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><systemColor name="systemGroupedBackgroundColor"><color red="0.949" green="0.949" blue="0.969" alpha="1" colorSpace="custom" customColorSpace="sRGB"/></systemColor></resources></document>
|
<resources><image name="TarnaxBrand" width="1024" height="1024"/></resources></document>
|
||||||
|
|||||||
@@ -198,6 +198,26 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
await protection.updateScene(.active)
|
await protection.updateScene(.active)
|
||||||
check(!protection.locked, "Face ID success while system prompt is inactive unlocks the app")
|
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.")
|
another.close(); transport.close(); print("\(checks) contract checks passed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user