Skip to content

Commit 99ec25f

Browse files
authored
Merge pull request #19 from HMAKT99/fix/show-denied-message-and-auth-redesign
fix: show denied/key-invalidated feedback and redesign auth sheet to match passkey dialog style
2 parents 3c0b7de + 6e4ca78 commit 99ec25f

2 files changed

Lines changed: 207 additions & 73 deletions

File tree

companion/TouchBridge/App/TouchBridgeApp.swift

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ class AppState: ObservableObject {
2424
@Published var statusMessage: String = "Not connected"
2525
@Published var challengeCount: Int = 0
2626
@Published var pendingChallenge: PendingChallenge?
27+
/// Set when the Secure Enclave signing key is invalidated due to a biometric enrollment change.
28+
/// Cleared when the user re-pairs or dismisses.
29+
@Published var keyInvalidated: Bool = false
30+
/// Set when the user explicitly cancelled or denied a Face ID / biometric prompt.
31+
/// Keeps the auth sheet open so the user sees feedback instead of a silent dismiss.
32+
@Published var challengeDenied: Bool = false
2733

2834
let coordinator: CompanionCoordinator
2935

@@ -58,17 +64,28 @@ class AppState: ObservableObject {
5864
}
5965
}
6066

61-
coordinator.onChallengeResult = { [weak self] challengeID, success in
67+
coordinator.onChallengeResult = { [weak self] challengeID, success, error in
6268
DispatchQueue.main.async {
6369
self?.challengeCount += 1
64-
self?.pendingChallenge = nil
65-
self?.lastChallenge = success
66-
? "Approved (\(challengeID.prefix(8))...)"
67-
: "Denied"
68-
69-
// Haptic feedback
70-
let generator = UINotificationFeedbackGenerator()
71-
generator.notificationOccurred(success ? .success : .error)
70+
71+
if case .keyInvalidated = error {
72+
self?.pendingChallenge = nil
73+
self?.keyInvalidated = true
74+
self?.lastChallenge = "Key invalidated — re-pair required"
75+
UINotificationFeedbackGenerator().notificationOccurred(.error)
76+
} else if case .biometricDenied = error {
77+
// Keep the sheet open so the user sees the denial — they dismissed Face ID
78+
// and would otherwise just see sudo silently ask for a password with no explanation.
79+
self?.challengeDenied = true
80+
self?.lastChallenge = "Denied"
81+
UINotificationFeedbackGenerator().notificationOccurred(.error)
82+
} else {
83+
self?.pendingChallenge = nil
84+
self?.lastChallenge = success
85+
? "Approved (\(challengeID.prefix(8))...)"
86+
: "Denied"
87+
UINotificationFeedbackGenerator().notificationOccurred(success ? .success : .error)
88+
}
7289
}
7390
}
7491

@@ -94,6 +111,8 @@ class AppState: ObservableObject {
94111
statusMessage = "Not connected"
95112
challengeCount = 0
96113
lastChallenge = nil
114+
keyInvalidated = false
115+
challengeDenied = false
97116
}
98117
}
99118

@@ -233,6 +252,31 @@ struct HomeView: View {
233252
var body: some View {
234253
ScrollView {
235254
VStack(spacing: 24) {
255+
// Key-invalidated banner
256+
if appState.keyInvalidated {
257+
HStack(spacing: 12) {
258+
Image(systemName: "exclamationmark.triangle.fill")
259+
.foregroundStyle(.yellow)
260+
VStack(alignment: .leading, spacing: 2) {
261+
Text("Re-pair required")
262+
.font(.subheadline.bold())
263+
Text("Your Face ID / Touch ID enrollment changed. Open Settings to re-pair.")
264+
.font(.caption)
265+
.foregroundStyle(.secondary)
266+
}
267+
Spacer()
268+
}
269+
.padding(12)
270+
.background(Color.yellow.opacity(0.15))
271+
.clipShape(RoundedRectangle(cornerRadius: 12))
272+
.overlay(
273+
RoundedRectangle(cornerRadius: 12)
274+
.stroke(Color.yellow.opacity(0.4), lineWidth: 1)
275+
)
276+
.padding(.horizontal)
277+
.padding(.top, 8)
278+
}
279+
236280
// Status card
237281
VStack(spacing: 16) {
238282
ZStack {
@@ -305,8 +349,13 @@ struct HomeView: View {
305349
AuthRequestView(
306350
reason: challenge.reason,
307351
macName: challenge.macName,
352+
keyInvalidated: appState.keyInvalidated,
353+
wasDenied: appState.challengeDenied,
308354
onApprove: { appState.pendingChallenge = nil },
309-
onDeny: { appState.pendingChallenge = nil }
355+
onDeny: {
356+
appState.challengeDenied = false
357+
appState.pendingChallenge = nil
358+
}
310359
)
311360
.interactiveDismissDisabled()
312361
}
Lines changed: 148 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,176 @@
11
import SwiftUI
22

33
/// Full-screen auth request displayed when a challenge arrives from the Mac.
4+
/// Styled to match the native macOS passkey "Sign In" dialog.
45
struct AuthRequestView: View {
56
let reason: String
67
let macName: String
8+
/// Observed from AppState — when true, replace the biometric prompt with a key-invalidated error.
9+
let keyInvalidated: Bool
10+
/// Observed from AppState — when true, show a denial message instead of dismissing silently.
11+
let wasDenied: Bool
712
let onApprove: () -> Void
813
let onDeny: () -> Void
914

1015
@State private var isAuthenticating = false
11-
@State private var pulse = false
1216

1317
var body: some View {
1418
VStack(spacing: 0) {
15-
// Header
16-
VStack(spacing: 16) {
19+
// Header — mirrors macOS passkey dialog header row
20+
HStack {
21+
Label("Sign In", systemImage: "person.badge.key.fill")
22+
.font(.title2.bold())
23+
Spacer()
24+
Button("Cancel") {
25+
onDeny()
26+
}
27+
.buttonStyle(.bordered)
28+
}
29+
.padding(.horizontal, 24)
30+
.padding(.top, 20)
31+
.padding(.bottom, 16)
32+
33+
Divider()
34+
35+
Spacer()
36+
37+
// App icon — rounded square, matches Passwords app style
38+
ZStack {
39+
RoundedRectangle(cornerRadius: 14)
40+
.fill(.regularMaterial)
41+
.frame(width: 64, height: 64)
42+
.shadow(color: .black.opacity(0.08), radius: 4, x: 0, y: 2)
43+
1744
Image(systemName: "touchid")
18-
.font(.system(size: 72))
45+
.font(.system(size: 36))
1946
.foregroundColor(.accentColor)
20-
.scaleEffect(pulse ? 1.05 : 1.0)
21-
.animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulse)
22-
.onAppear { pulse = true }
47+
}
48+
.padding(.bottom, 20)
2349

24-
Text("Authentication Request")
25-
.font(.title2.bold())
50+
if wasDenied {
51+
// Denied state
52+
deniedBody
53+
} else if keyInvalidated {
54+
// Key invalidated state
55+
keyInvalidatedBody
56+
} else {
57+
// Normal authentication state
58+
normalBody
2659
}
27-
.padding(.top, 48)
60+
61+
Spacer()
62+
}
63+
.background(Color(.systemBackground))
64+
}
65+
66+
// MARK: - Normal state
67+
68+
@ViewBuilder
69+
private var normalBody: some View {
70+
Text("Use Face ID to authenticate?")
71+
.font(.title3.bold())
72+
.multilineTextAlignment(.center)
73+
.padding(.bottom, 12)
74+
75+
Text("You will be authenticated on \"\(macName)\" for \"\(reason)\".")
76+
.font(.body)
77+
.foregroundStyle(.secondary)
78+
.multilineTextAlignment(.center)
79+
.padding(.horizontal, 32)
2880
.padding(.bottom, 32)
2981

30-
// Request details
31-
VStack(spacing: 16) {
32-
HStack {
33-
Image(systemName: "desktopcomputer")
34-
.foregroundColor(.accentColor)
35-
Text(macName)
36-
.font(.headline)
37-
Spacer()
38-
}
82+
// Biometric graphic — pink tint matches macOS passkey dialog
83+
VStack(spacing: 8) {
84+
Image(systemName: "touchid")
85+
.font(.system(size: 80))
86+
.foregroundStyle(.pink)
3987

40-
Divider()
88+
Text("Continue with Face ID")
89+
.font(.caption)
90+
.foregroundStyle(.secondary)
91+
}
92+
.padding(.bottom, 40)
4193

42-
HStack {
43-
Image(systemName: "lock.shield")
44-
.foregroundStyle(.secondary)
45-
Text(reason)
46-
.font(.body)
47-
Spacer()
94+
// Primary action button
95+
VStack(spacing: 0) {
96+
if isAuthenticating {
97+
ProgressView("Authenticating...")
98+
.padding()
99+
.frame(maxWidth: .infinity)
100+
} else {
101+
Button {
102+
isAuthenticating = true
103+
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
104+
onApprove()
105+
} label: {
106+
Text("Continue with Face ID")
107+
.font(.headline)
108+
.frame(maxWidth: .infinity)
109+
.padding(.vertical, 4)
48110
}
111+
.buttonStyle(.borderedProminent)
112+
.controlSize(.large)
113+
.disabled(isAuthenticating)
49114
}
50-
.padding()
51-
.background(.regularMaterial)
52-
.clipShape(RoundedRectangle(cornerRadius: 16))
53-
.padding(.horizontal, 24)
115+
}
116+
.padding(.horizontal, 24)
117+
}
54118

55-
Spacer()
119+
// MARK: - Key invalidated state
56120

57-
// Actions
58-
VStack(spacing: 12) {
59-
if isAuthenticating {
60-
ProgressView("Authenticating...")
61-
.padding()
62-
} else {
63-
Button {
64-
isAuthenticating = true
65-
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
66-
onApprove()
67-
} label: {
68-
Label("Approve with Face ID", systemImage: "faceid")
69-
.font(.headline)
70-
.frame(maxWidth: .infinity)
71-
.padding(.vertical, 4)
72-
}
73-
.buttonStyle(.borderedProminent)
74-
.controlSize(.large)
75-
76-
Button(role: .destructive) {
77-
UINotificationFeedbackGenerator().notificationOccurred(.warning)
78-
onDeny()
79-
} label: {
80-
Text("Deny")
81-
.frame(maxWidth: .infinity)
82-
}
83-
.buttonStyle(.bordered)
84-
.controlSize(.large)
85-
}
86-
}
87-
.padding(.horizontal, 24)
88-
.padding(.bottom, 32)
121+
@ViewBuilder
122+
private var keyInvalidatedBody: some View {
123+
Image(systemName: "exclamationmark.triangle.fill")
124+
.font(.system(size: 48))
125+
.foregroundStyle(.yellow)
126+
.padding(.bottom, 12)
127+
128+
Text("Signing key invalid")
129+
.font(.title3.bold())
130+
.padding(.bottom, 8)
131+
132+
Text("Your Face ID / Touch ID enrollment changed since pairing.\n\nOpen Settings → Unpair → Re-pair to restore authentication.")
133+
.font(.body)
134+
.foregroundStyle(.secondary)
135+
.multilineTextAlignment(.center)
136+
.padding(.horizontal, 32)
137+
.padding(.bottom, 40)
138+
139+
Button("Dismiss") {
140+
onDeny()
141+
}
142+
.buttonStyle(.bordered)
143+
.controlSize(.large)
144+
.frame(maxWidth: .infinity)
145+
.padding(.horizontal, 24)
146+
}
147+
148+
// MARK: - Denied state
149+
150+
@ViewBuilder
151+
private var deniedBody: some View {
152+
Image(systemName: "xmark.circle.fill")
153+
.font(.system(size: 48))
154+
.foregroundStyle(.red)
155+
.padding(.bottom, 12)
156+
157+
Text("Request denied")
158+
.font(.title3.bold())
159+
.padding(.bottom, 8)
160+
161+
Text("Your Mac will fall back to password authentication.\nRun sudo again on your Mac if you want to retry.")
162+
.font(.body)
163+
.foregroundStyle(.secondary)
164+
.multilineTextAlignment(.center)
165+
.padding(.horizontal, 32)
166+
.padding(.bottom, 40)
167+
168+
Button("Close") {
169+
onDeny()
89170
}
171+
.buttonStyle(.bordered)
172+
.controlSize(.large)
173+
.frame(maxWidth: .infinity)
174+
.padding(.horizontal, 24)
90175
}
91176
}

0 commit comments

Comments
 (0)