Conversation
40c4c3d to
196106c
Compare
196106c to
ac406bf
Compare
922a3fa to
dc6aa77
Compare
bd45417 to
74aa869
Compare
This comment has been minimized.
This comment has been minimized.
74aa869 to
5177dbe
Compare
|
|
||
| CustomButton(title: t("common__ok")) { | ||
| sheets.hideSheet() | ||
| navigation.navigateBack() |
There was a problem hiding this comment.
Bug: navigateBack() pops an unrelated route when the security sheet is opened without a prior navigation push
The CustomButton action unconditionally calls navigation.navigateBack() after hiding the sheet. This is correct when the sheet is opened from ChangePinScreen — which pushes .changePin onto the app navigation stack first. But SecuritySuccess is also the final screen of the initial PIN setup flow, triggered via:
sheets.showSheet(.security)(from suggestion cards) — no route is pushedsheets.showSheet(.security, data: SecurityConfig(initialRoute: .setupPin))inSecuritySettingsView— no route is pushed
In those cases, navigateBack() pops whatever route happens to be at the top of the app navigation stack (e.g. Settings), sending the user to an unexpected screen after completing PIN setup.
The fix should make the navigateBack() call conditional — for example, pass a shouldNavigateBack: Bool parameter to SecuritySuccess (defaulting to false) and only call it when true. SecuritySheet can derive this from SecurityConfig.initialRoute.
| do { | ||
| try settings.removePin(pin: currentPin, resetSettings: false) | ||
| try settings.setPin(newPin) | ||
| pinInput = "" | ||
| errorMessage = "" | ||
| errorIdentifier = nil | ||
| navigationPath.append(.changePinSuccess) | ||
| } catch { |
There was a problem hiding this comment.
Bug: Non-atomic PIN change can silently remove PIN protection
try settings.removePin(pin: currentPin, resetSettings: false) // deletes PIN from Keychain, sets pinEnabled = false
try settings.setPin(newPin) // can throwIf removePin succeeds (the old PIN is permanently deleted from Keychain and pinEnabled is set to false) but setPin throws (e.g. a Keychain write error), the catch block only shows a generic error and the user silently loses all PIN protection — no rollback occurs.
Suggested fix: reverse the order to "set new, then remove old", so a failure on the first step leaves the old PIN intact:
try settings.setPin(newPin)
try settings.removePin(pin: currentPin, resetSettings: false)Alternatively, on catch after a successful removePin, attempt to restore the old PIN before surfacing the error.
| private func requestPinCheck() async -> Bool { | ||
| // Prevent stacking multiple PIN screens if already presented. | ||
| if navigationPath.last != .pin { | ||
| navigationPath.append(.pin) | ||
| } | ||
|
|
||
| return await withCheckedContinuation { continuation in | ||
| pinCheckContinuation = continuation |
There was a problem hiding this comment.
Bug: CheckedContinuation is overwritten without being resumed, causing a task leak
private func requestPinCheck() async -> Bool {
if navigationPath.last != .pin {
navigationPath.append(.pin)
}
// ⚠️ withCheckedContinuation runs unconditionally even when .pin is already shown
return await withCheckedContinuation { continuation in
pinCheckContinuation = continuation // overwrites any existing unresolved continuation
}
}If requestPinCheck() is called a second time while the PIN screen is already presented (e.g. two rapid taps on send), the guard only prevents appending a duplicate .pin route — the withCheckedContinuation block still executes unconditionally. The old continuation stored in pinCheckContinuation is replaced without ever being resumed, causing the first awaiting task to hang forever. Swift's CheckedContinuation requires exactly one resume; a leaked continuation triggers a runtime warning in debug builds.
Fix: move withCheckedContinuation inside the if block so it only runs when a new PIN screen is actually being pushed:
private func requestPinCheck() async -> Bool {
if navigationPath.last != .pin {
navigationPath.append(.pin)
return await withCheckedContinuation { continuation in
pinCheckContinuation = continuation
}
}
// PIN screen already showing — a result will arrive via resolvePinCheck
return await withCheckedContinuation { continuation in
pinCheckContinuation = continuation
}
}Or more simply, guard early and return false (or surface an error) if already pending.
Description
From design prototype changelog v60:
Closes #496
Screenshot / Video
Simulator.Screen.Recording.-.iPhone.17.-.2026-03-26.at.17.53.02.mov
QA Notes
Mostly a redesign. Testing is needed mostly in the updated PIN setup flow.