357 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|