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.") } }