Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 207 additions & 50 deletions PureMac/Views/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,37 @@ struct OnboardingView: View {
@Binding var isComplete: Bool
@State private var currentPage = 0
@State private var hasFullDiskAccess = false
@State private var appeared = false

private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
// Per-path access checks
@State private var accessResults: [ProtectedPath] = ProtectedPath.allPaths

private let timer = Timer.publish(every: 1.5, on: .main, in: .common).autoconnect()

var body: some View {
VStack(spacing: 0) {
// Page content
TabView(selection: $currentPage) {
welcomePage.tag(0)
fdaPage.tag(1)
readyPage.tag(2)
Group {
switch currentPage {
case 0: welcomePage
case 1: fdaPage
case 2: readyPage
default: EmptyView()
}
}
.tabViewStyle(.automatic)
.frame(maxWidth: .infinity, maxHeight: .infinity)

Divider()

// Navigation
HStack {
if currentPage > 0 {
Button("Back") { withAnimation { currentPage -= 1 } }
Button("Back") {
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
currentPage -= 1
}
}
.transition(.opacity)
}

Spacer()
Expand All @@ -31,57 +45,99 @@ struct OnboardingView: View {
Circle()
.fill(i == currentPage ? Color.accentColor : Color.secondary.opacity(0.3))
.frame(width: 8, height: 8)
.scaleEffect(i == currentPage ? 1.2 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: currentPage)
}
}

Spacer()

if currentPage < 2 {
Button("Next") { withAnimation { currentPage += 1 } }
.buttonStyle(.borderedProminent)
Button("Next") {
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
currentPage += 1
}
}
.buttonStyle(.borderedProminent)
} else {
Button("Get Started") { isComplete = true }
.buttonStyle(.borderedProminent)
}
}
.padding()
}
.frame(width: 520, height: 400)
.frame(width: 560, height: 460)
.onAppear {
withAnimation(.easeOut(duration: 0.6).delay(0.1)) {
appeared = true
}
}
.onReceive(timer) { _ in
if currentPage == 1 {
checkFDA()
refreshAccessChecks()
}
}
}

// MARK: - Welcome

private var welcomePage: some View {
VStack(spacing: 20) {
VStack(spacing: 24) {
Spacer()

if let icon = NSImage(named: "AppIcon") {
Image(nsImage: icon)
.resizable()
.frame(width: 96, height: 96)
.scaleEffect(appeared ? 1.0 : 0.5)
.opacity(appeared ? 1 : 0)
}
Text("Welcome to PureMac")
.font(.largeTitle.bold())
Text("Free, open-source macOS app manager and system cleaner.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)

HStack(spacing: 24) {
featureCard(icon: "magnifyingglass", title: "Smart Scan", desc: "Find junk files across your system")
featureCard(icon: "trash", title: "App Uninstaller", desc: "Remove apps and all their files")
featureCard(icon: "doc.questionmark", title: "Orphan Finder", desc: "Find leftovers from deleted apps")
VStack(spacing: 8) {
Text("Welcome to PureMac")
.font(.largeTitle.bold())
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)

Text("Free, open-source macOS app manager and system cleaner.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
}

HStack(spacing: 20) {
featureCard(
icon: "magnifyingglass",
title: "Smart Scan",
desc: "Find junk files across your system",
delay: 0.15
)
featureCard(
icon: "trash",
title: "App Uninstaller",
desc: "Remove apps and all their files",
delay: 0.25
)
featureCard(
icon: "doc.questionmark",
title: "Orphan Finder",
desc: "Find leftovers from deleted apps",
delay: 0.35
)
}
.padding(.top)
.padding(.top, 4)

Spacer()
}
.padding()
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}

private func featureCard(icon: String, title: String, desc: String) -> some View {
private func featureCard(icon: String, title: String, desc: String, delay: Double) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
Expand All @@ -93,79 +149,180 @@ struct OnboardingView: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(width: 130)
.frame(width: 140)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.animation(.easeOut(duration: 0.5).delay(delay), value: appeared)
}

// MARK: - Full Disk Access

private var fdaPage: some View {
VStack(spacing: 20) {
VStack(spacing: 16) {
Spacer()

Image(systemName: hasFullDiskAccess ? "checkmark.shield.fill" : "lock.shield")
.font(.system(size: 48))
.font(.system(size: 44))
.foregroundStyle(hasFullDiskAccess ? .green : .orange)
.animation(.easeInOut(duration: 0.3), value: hasFullDiskAccess)

Text("Full Disk Access")
.font(.title2.bold())

if hasFullDiskAccess {
Text("Full Disk Access is granted. You're all set!")
Text("All permissions granted. You're all set!")
.foregroundStyle(.green)
.transition(.opacity.combined(with: .scale))
} else {
Text("PureMac needs Full Disk Access to scan all caches, Trash, and app data.")
Text("PureMac needs Full Disk Access to scan protected locations.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 380)
.frame(maxWidth: 400)
}

GroupBox {
VStack(alignment: .leading, spacing: 8) {
Label("Open System Settings > Privacy & Security", systemImage: "1.circle")
Label("Select Full Disk Access", systemImage: "2.circle")
Label("Enable PureMac", systemImage: "3.circle")
// Permission checklist
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(accessResults.enumerated()), id: \.element.id) { index, path in
HStack(spacing: 10) {
Image(systemName: path.accessible ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(path.accessible ? .green : .red.opacity(0.7))
.font(.system(size: 14))

Image(systemName: path.icon)
.foregroundStyle(.secondary)
.frame(width: 16)

Text(path.label)
.font(.callout)

Spacer()

Text(path.accessible ? "Accessible" : "Blocked")
.font(.caption)
.foregroundStyle(path.accessible ? .green : .orange)
}
.font(.callout)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(path.accessible ? Color.green.opacity(0.06) : Color.orange.opacity(0.06))
)
.opacity(appeared ? 1 : 0)
.offset(x: appeared ? 0 : -20)
.animation(.easeOut(duration: 0.4).delay(Double(index) * 0.05), value: appeared)
}
.frame(maxWidth: 380)
}
.frame(maxWidth: 400)
.padding(.vertical, 4)

Button("Open System Settings") {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")!)
if !hasFullDiskAccess {
Button {
FullDiskAccessManager.shared.openFullDiskAccessSettings()
} label: {
Label("Open System Settings", systemImage: "gear")
}
.buttonStyle(.borderedProminent)
.padding(.top, 4)

Text("Enable PureMac in Privacy & Security → Full Disk Access")
.font(.caption)
.foregroundStyle(.secondary)
}

Spacer()
}
.padding()
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}

// MARK: - Ready

private var readyPage: some View {
VStack(spacing: 20) {
Spacer()

Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
.scaleEffect(appeared ? 1.0 : 0.3)
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: currentPage)

Text("You're Ready")
.font(.title.bold())

HStack(spacing: 8) {
Image(systemName: hasFullDiskAccess ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(hasFullDiskAccess ? .green : .red)
Text("Full Disk Access: \(hasFullDiskAccess ? "Granted" : "Not Granted")")
.foregroundStyle(.secondary)
}
// Summary of access
let granted = accessResults.filter(\.accessible).count
let total = accessResults.count

if !hasFullDiskAccess {
Text("Some features may be limited without Full Disk Access.")
.font(.caption)
.foregroundStyle(.orange)
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: hasFullDiskAccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(hasFullDiskAccess ? .green : .orange)
Text("\(granted)/\(total) protected locations accessible")
.foregroundStyle(.secondary)
}

if !hasFullDiskAccess {
Text("Some features will be limited. You can grant Full Disk Access later in System Settings.")
.font(.caption)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
}
}

Spacer()
}
.padding()
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}

private func checkFDA() {
hasFullDiskAccess = FullDiskAccessManager.shared.hasFullDiskAccess
// MARK: - Permission Checking

private func refreshAccessChecks() {
for i in accessResults.indices {
let path = accessResults[i].path
let canAccess: Bool
if FileManager.default.fileExists(atPath: path) {
canAccess = FileManager.default.isReadableFile(atPath: path)
} else {
// Path doesn't exist on this system — not blocked, just absent
canAccess = true
}
if accessResults[i].accessible != canAccess {
withAnimation(.easeInOut(duration: 0.3)) {
accessResults[i].accessible = canAccess
}
}
}
hasFullDiskAccess = accessResults.allSatisfy(\.accessible)
}
}

// MARK: - Protected Path Model

struct ProtectedPath: Identifiable {
let id = UUID()
let label: String
let path: String
let icon: String
var accessible: Bool = false

static var allPaths: [ProtectedPath] {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return [
ProtectedPath(label: "Trash", path: "\(home)/.Trash", icon: "trash"),
ProtectedPath(label: "Mail Data", path: "\(home)/Library/Mail", icon: "envelope"),
ProtectedPath(label: "Safari Data", path: "\(home)/Library/Safari/Bookmarks.plist", icon: "safari"),
ProtectedPath(label: "Desktop", path: "\(home)/Desktop", icon: "menubar.dock.rectangle"),
ProtectedPath(label: "Documents", path: "\(home)/Documents", icon: "folder"),
ProtectedPath(label: "TCC Database", path: "/Library/Application Support/com.apple.TCC/TCC.db", icon: "lock.shield"),
]
}
}