Files
wehub-resource-sync 869b84f27c
Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

357 lines
13 KiB
Swift

import SwiftUI
/// Premium Full Disk Access prompt that replaces the bare permission-denied
/// alert. Auto-polls FDA state, auto-retries the failed operation on grant,
/// and offers escape hatches for the "PureMac isn't in the list" case.
struct PermissionSheet: View {
@ObservedObject private var coordinator = PermissionCoordinator.shared
@State private var appeared = false
@State private var pulse = false
@State private var showAdvanced = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 0) {
header
Divider().opacity(0.4)
body_
Divider().opacity(0.4)
footer
}
.frame(width: 540)
.background(Color(nsColor: .windowBackgroundColor))
.animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.8),
value: coordinator.hasFullDiskAccess)
.onAppear {
if reduceMotion {
appeared = true
return
}
withAnimation(.easeOut(duration: 0.4)) { appeared = true }
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
pulse = true
}
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 14) {
ZStack {
let tint = coordinator.hasFullDiskAccess ? Tint.green : Tint.blue
Circle()
.fill(tint.opacity(0.14))
.frame(width: 40, height: 40)
headerIcon
.foregroundStyle(tint)
}
VStack(alignment: .leading, spacing: 2) {
Text(coordinator.context.headline)
.font(.system(size: 16, weight: .bold))
Text(coordinator.hasFullDiskAccess
? "Access granted. Retrying…"
: "1-tap setup. We'll detect the change automatically.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : -6)
}
@ViewBuilder
private var headerIcon: some View {
let name = coordinator.hasFullDiskAccess ? "checkmark.shield.fill" : "lock.shield.fill"
let base = Image(systemName: name)
.font(.system(size: 18, weight: .semibold))
if #available(macOS 14.0, *) {
base.contentTransition(.symbolEffect(.replace))
} else {
base
}
}
// MARK: - Body
@ViewBuilder
private var body_: some View {
if coordinator.hasFullDiskAccess {
grantedBody
.transition(
reduceMotion
? .opacity
: .scale(scale: 0.9).combined(with: .opacity)
)
} else {
requestBody
.transition(.opacity)
}
}
private var requestBody: some View {
VStack(alignment: .leading, spacing: 18) {
// Two paths the user can choose between: drag the PureMac bundle
// straight into the FDA list, OR have us reveal it in Finder so
// they can drag from there. Drag-from-our-sheet is faster but
// some users will still prefer the Finder flow they recognize.
HStack(alignment: .center, spacing: 14) {
AppBundleDragHandle()
Divider().frame(maxHeight: 110)
VStack(spacing: 8) {
Button {
Haptics.tap()
coordinator.openSettingsAndReveal()
} label: {
Label("Open Settings & reveal PureMac", systemImage: "gear")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.keyboardShortcut(.defaultAction)
Text("Or drag the icon on the left straight into Settings.")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
// Step-by-step strip
HStack(spacing: 0) {
stepCell(number: 1, title: "Turn on PureMac",
caption: "Toggle the row that appears.")
stepDivider
stepCell(number: 2, title: "Authenticate",
caption: "Touch ID or password.")
stepDivider
stepCell(number: 3, title: "Done",
caption: "We auto-retry — no need to come back.")
}
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.primary.opacity(0.04))
)
// Listening indicator
HStack(spacing: 8) {
ZStack {
Circle()
.stroke(Tint.blue.opacity(0.3), lineWidth: 1.5)
.frame(width: 10, height: 10)
Circle()
.fill(Tint.blue)
.frame(width: 6, height: 6)
.scaleEffect(pulse ? 1.4 : 0.8)
.opacity(pulse ? 0.4 : 1)
}
Text("Watching for permission change…")
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
Spacer()
Button(showAdvanced ? "Hide help" : "PureMac not in the list?") {
withAnimation(.easeInOut(duration: 0.25)) { showAdvanced.toggle() }
}
.buttonStyle(.link)
.font(.system(size: 11.5))
}
if showAdvanced {
advancedPanel
.transition(.opacity.combined(with: .move(edge: .top)))
}
if !coordinator.failedItemPaths.isEmpty {
blockedList
}
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
.opacity(appeared ? 1 : 0)
}
private var advancedPanel: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Try this if PureMac doesn't appear:")
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
advancedButton(
icon: "folder.badge.gearshape",
title: "Reveal app",
subtitle: "Drag PureMac.app into the list"
) {
FullDiskAccessManager.shared.revealAppInFinder()
}
advancedButton(
icon: "arrow.clockwise.circle",
title: "Reset + reprompt",
subtitle: "Clear stale TCC entry"
) {
coordinator.resetAndReprime()
}
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.primary.opacity(0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5)
)
}
private func advancedButton(icon: String, title: LocalizedStringKey,
subtitle: LocalizedStringKey,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
IconTile(systemName: icon, tint: Tint.orange, size: 30, corner: 7)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(.primary)
Text(subtitle)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}
private var blockedList: some View {
DisclosureGroup {
VStack(alignment: .leading, spacing: 4) {
ForEach(coordinator.failedItemPaths.prefix(6), id: \.self) { path in
Text(path)
.font(.system(size: 10.5, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
if coordinator.failedItemPaths.count > 6 {
Text(remainingText(coordinator.failedItemPaths.count - 6))
.font(.system(size: 10.5))
.foregroundStyle(.tertiary)
}
}
.padding(.top, 4)
} label: {
Text(blockedHeaderText(coordinator.failedItemPaths.count))
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.secondary)
}
}
private func remainingText(_ count: Int) -> String {
String(format: String(localized: "+ %lld more"), Int64(count))
}
private func blockedHeaderText(_ count: Int) -> String {
String(
format: String(localized: "%lld blocked path(s)"),
Int64(count)
)
}
private func stepCell(number: Int, title: LocalizedStringKey,
caption: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("\(number)")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(Circle().fill(Tint.blue))
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(size: 11.5, weight: .semibold))
Text(caption)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var stepDivider: some View {
Rectangle()
.fill(Color.primary.opacity(0.08))
.frame(width: 0.5)
.padding(.vertical, 6)
}
private var grantedBody: some View {
VStack(spacing: 10) {
// SuccessMedal pops + ripples once on its own and already
// honors Reduce Motion — no shared `pulse` state reuse.
SuccessMedal(size: 72)
Text("Access granted")
.font(.system(size: 15, weight: .bold))
Text("Retrying the operation now.")
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
}
// MARK: - Footer
private var footer: some View {
HStack(spacing: 12) {
Button("Skip for now") {
coordinator.dismiss(callRetry: false)
}
.buttonStyle(.link)
.foregroundStyle(.secondary)
Spacer()
if !coordinator.hasFullDiskAccess {
Button("I granted it — retry") {
coordinator.refreshStatus()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
if coordinator.hasFullDiskAccess {
coordinator.dismiss(callRetry: true)
}
}
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
}
}
.padding(.horizontal, 22)
.padding(.vertical, 14)
}
}