Skip to content

Commit 04a3fa0

Browse files
Drive splash permission pills via observable state, not identity churn
The permission pills read non-observable system APIs (CustomColorPickSession .isAvailable / AXIsProcessTrusted), so a 1-second poll bumped permissionTick and an .id(permissionTick) on permissionArea forced the subtree to rebuild each tick to pick up a change. That tore down and recreated the Grant buttons once a second for the whole time the splash was open; a button press whose mouseDown/mouseUp straddled one of those identity resets could be swallowed — on the exact flow that unblocks the Pro picker. Mirror the two permission checks into @State instead, refreshed by the same poll and notifications. SwiftUI coalesces equal writes, so a re-render happens only when a permission actually flips, the pills keep a real SwiftUI dependency (fixing the original stale-pill bug the intended way), and the subtree keeps stable identity — removing the swallow hazard by construction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 03729b6 commit 04a3fa0

1 file changed

Lines changed: 26 additions & 17 deletions

File tree

Pika/Views/SplashView.swift

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -290,17 +290,30 @@ struct ColorListPickerView: View {
290290
struct PickerChoiceView: View {
291291
@Binding var pendingRelaunch: Bool
292292
@Default(.pickerStyle) private var pickerStyle
293-
// Bumped on a timer so the permission pills re-read their (non-observable) status and
294-
// flip to the granted state without a relaunch when the user allows them in Settings.
295-
@State private var permissionTick = 0
296-
297-
private var hasPermission: Bool { CustomColorPickSession.isAvailable }
293+
// Backing state for the two permission checks. `CustomColorPickSession.isAvailable` and
294+
// `AXIsProcessTrusted()` read non-observable system APIs, so reading them establishes no
295+
// SwiftUI dependency on its own. Mirror them into @State, refreshed by the poll and the
296+
// system notifications below, so the pills re-render when a permission actually flips —
297+
// without forcing this subtree's identity to change every tick (an `.id()` on it would tear
298+
// down and rebuild the Grant buttons once a second, risking a swallowed click if a press
299+
// landed mid-reset).
300+
@State private var hasScreenRecording = CustomColorPickSession.isAvailable
301+
@State private var hasAccessibilityAccess = AXIsProcessTrusted()
302+
303+
private var hasPermission: Bool { hasScreenRecording }
298304
// Optional: unlocks global Escape / arrow-nudge while picking over other apps.
299-
private var hasAccessibility: Bool { AXIsProcessTrusted() }
305+
private var hasAccessibility: Bool { hasAccessibilityAccess }
300306
// Custom is only truly active once permission exists; until then System stays selected
301307
// and the Custom tile is disabled.
302308
private var customActive: Bool { hasPermission && pickerStyle == .custom }
303309

310+
/// Re-read the (non-observable) system permission state into @State. Equal writes are
311+
/// coalesced by SwiftUI, so this only drives a re-render when a permission actually changes.
312+
private func refreshPermissionState() {
313+
hasScreenRecording = CustomColorPickSession.isAvailable
314+
hasAccessibilityAccess = AXIsProcessTrusted()
315+
}
316+
304317
var body: some View {
305318
VStack(spacing: 10.0) {
306319
HStack(alignment: .top, spacing: 16.0) {
@@ -324,14 +337,10 @@ struct PickerChoiceView: View {
324337
)
325338
}
326339

327-
// `.id` (not just the poll/notification handlers bumping `permissionTick` below):
328-
// `hasPermission`/`hasAccessibility` call non-observable system APIs, so nothing
329-
// about reading them establishes a SwiftUI dependency on its own — without forcing
330-
// this subtree's identity to change on each tick, a bumped `permissionTick` wasn't
331-
// reliably refreshing the pills; that only happened to work when some other state
332-
// change (e.g. picking a tile) forced a re-render anyway. Scoped to `permissionArea`
333-
// alone, which owns no state of its own to lose on the identity change.
334-
permissionArea.id(permissionTick)
340+
// The pills read `hasScreenRecording`/`hasAccessibilityAccess` (@State), so they
341+
// re-render on their own when `refreshPermissionState` flips one — no `.id()` identity
342+
// churn needed to force it.
343+
permissionArea
335344
}
336345
.padding(12.0)
337346
.background(
@@ -348,19 +357,19 @@ struct PickerChoiceView: View {
348357
// the non-observable permission status so the pills confirm — or revert — on their own.
349358
.task {
350359
while !Task.isCancelled {
351-
permissionTick += 1
360+
refreshPermissionState()
352361
try? await Task.sleep(nanoseconds: 1_000_000_000)
353362
}
354363
}
355364
// Re-check the moment the user returns from System Settings (the most common way a
356365
// permission changes), and on the system's accessibility-changed broadcast.
357366
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
358-
permissionTick += 1
367+
refreshPermissionState()
359368
}
360369
.onReceive(DistributedNotificationCenter.default().publisher(
361370
for: Notification.Name("com.apple.accessibility.api")))
362371
{ _ in
363-
permissionTick += 1
372+
refreshPermissionState()
364373
}
365374
}
366375

0 commit comments

Comments
 (0)