diff --git a/Tarnax/App.swift b/Tarnax/App.swift
index d0bee37..cca04f6 100644
--- a/Tarnax/App.swift
+++ b/Tarnax/App.swift
@@ -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)
}
diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift
index 7c62c66..799cdbb 100644
--- a/Tarnax/Core.swift
+++ b/Tarnax/Core.swift
@@ -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 {
diff --git a/Tarnax/Info.plist b/Tarnax/Info.plist
index 95fafb6..796156c 100644
--- a/Tarnax/Info.plist
+++ b/Tarnax/Info.plist
@@ -15,7 +15,7 @@
CFBundleShortVersionString
1.0
CFBundleVersion
- 2
+ 4
LSRequiresIPhoneOS
NSFaceIDUsageDescription
diff --git a/Tarnax/LaunchScreen.storyboard b/Tarnax/LaunchScreen.storyboard
index d6641f1..dfbe61c 100644
--- a/Tarnax/LaunchScreen.storyboard
+++ b/Tarnax/LaunchScreen.storyboard
@@ -2,5 +2,5 @@
-
-
+
+
diff --git a/Tests/ContractTests.swift b/Tests/ContractTests.swift
index 4f89803..5d4b5ba 100644
--- a/Tests/ContractTests.swift
+++ b/Tests/ContractTests.swift
@@ -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.")
}
}