diff --git a/Package.swift b/Package.swift index 7989822..46c0db4 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "AutoUp", platforms: [ - .macOS(.v13) + .macOS("13.3") ], products: [ .executable( diff --git a/Sources/Core/Installer.swift b/Sources/Core/Installer.swift new file mode 100644 index 0000000..0f411fc --- /dev/null +++ b/Sources/Core/Installer.swift @@ -0,0 +1,136 @@ +import Foundation + +enum InstallerError: LocalizedError { + case noAppFound + case dmgAttachFailed(Int32) + case pkgInstallFailed(Int32) + case codesignFailed + case backupFailed + + var errorDescription: String? { + switch self { + case .noAppFound: + return "Couldn't find the app in the download" + case .dmgAttachFailed(let code): + return "DMG mount failed with code \(code)" + case .pkgInstallFailed(let code): + return "PKG install failed with code \(code)" + case .codesignFailed: + return "App signature verification failed" + case .backupFailed: + return "Couldn't backup current version" + } + } +} + +enum Installer { + static func installZIP(from zipURL: URL, toApplications name: String, bundleID: String, currentVersion: String) throws { + // Create backup first + let currentAppPath = "/Applications/\(name).app" + if FileManager.default.fileExists(atPath: currentAppPath) { + _ = try? SecurityChecks.backup(appPath: currentAppPath, bundleID: bundleID, version: currentVersion) + } + + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + _ = try run("/usr/bin/unzip", ["-qq", zipURL.path, "-d", tmp.path]) + let app = try findApp(in: tmp) + + // Verify codesign before installing + guard SecurityChecks.verifyCodeSign(app.path) else { + throw InstallerError.codesignFailed + } + + try moveToApplications(app) + } + + static func installDMG(from dmgURL: URL, bundleID: String, currentVersion: String) throws { + // Create backup first + let apps = try? FileManager.default.contentsOfDirectory(atPath: "/Applications") + let currentAppPath = apps?.first { $0.hasSuffix(".app") && Bundle(path: "/Applications/\($0)")?.bundleIdentifier == bundleID } + + if let appPath = currentAppPath { + let fullPath = "/Applications/\(appPath)" + _ = try? SecurityChecks.backup(appPath: fullPath, bundleID: bundleID, version: currentVersion) + } + + let (code, out) = try run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-quiet", dmgURL.path]) + guard code == 0 else { + throw InstallerError.dmgAttachFailed(code) + } + + guard let mount = out.split(separator: "\t").last.map(String.init) else { + throw InstallerError.dmgAttachFailed(-1) + } + + defer { _ = try? run("/usr/bin/hdiutil", ["detach", "-quiet", mount]) } + + let app = try findApp(in: URL(fileURLWithPath: mount)) + + // Verify codesign before installing + guard SecurityChecks.verifyCodeSign(app.path) else { + throw InstallerError.codesignFailed + } + + try moveToApplications(app) + } + + static func installPKG(from pkgURL: URL) throws { + let (code, _) = try run("/usr/sbin/installer", ["-pkg", pkgURL.path, "-target", "/"]) + guard code == 0 else { + throw InstallerError.pkgInstallFailed(code) + } + } + + // MARK: - Private Helpers + + private static func findApp(in dir: URL) throws -> URL { + let items = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + if let app = items.first(where: { $0.pathExtension == "app" }) { + return app + } + + // Recursive search in case of subfolders + for url in items where url.hasDirectoryPath { + if let app = try? findApp(in: url) { + return app + } + } + + throw InstallerError.noAppFound + } + + private static func moveToApplications(_ src: URL) throws { + let dst = URL(fileURLWithPath: "/Applications").appendingPathComponent(src.lastPathComponent) + + if FileManager.default.fileExists(atPath: dst.path) { + try FileManager.default.removeItem(at: dst) + } + + try FileManager.default.copyItem(at: src, to: dst) + + // Remove quarantine if present + _ = SecurityChecks.removeQuarantine(dst.path) + } + + @discardableResult + private static func run(_ bin: String, _ args: [String]) throws -> (Int32, String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: bin) + process.arguments = args + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + return (process.terminationStatus, output) + } +} \ No newline at end of file diff --git a/Sources/Core/Rollback.swift b/Sources/Core/Rollback.swift new file mode 100644 index 0000000..ccc5774 --- /dev/null +++ b/Sources/Core/Rollback.swift @@ -0,0 +1,64 @@ +import Foundation + +enum Rollback { + static func latestBackup(bundleID: String) -> URL? { + let base = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support/AutoUp/Backups/\(bundleID)") + guard let entries = try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: [.creationDateKey], options: .skipsHiddenFiles) else { + return nil + } + + return entries.sorted { (a, b) in + let dateA = (try? a.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast + let dateB = (try? b.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast + return dateA > dateB + }.first?.appendingPathComponent("\(bundleID).app") + } + + static func restoreBackup(bundleID: String, to appName: String) throws -> Bool { + guard let backupURL = latestBackup(bundleID: bundleID) else { + return false + } + + let currentAppPath = "/Applications/\(appName).app" + let currentAppURL = URL(fileURLWithPath: currentAppPath) + + // Remove current version + if FileManager.default.fileExists(atPath: currentAppPath) { + try FileManager.default.removeItem(at: currentAppURL) + } + + // Copy backup to Applications + try FileManager.default.copyItem(at: backupURL, to: currentAppURL) + + // Remove quarantine if present + _ = SecurityChecks.removeQuarantine(currentAppPath) + + return true + } + + static func listAvailableBackups(bundleID: String) -> [(version: String, date: Date)] { + let base = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support/AutoUp/Backups/\(bundleID)") + guard let entries = try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: [.creationDateKey], options: .skipsHiddenFiles) else { + return [] + } + + return entries.compactMap { entry in + guard let date = try? entry.resourceValues(forKeys: [.creationDateKey]).creationDate else { + return nil + } + let version = entry.lastPathComponent + return (version: version, date: date) + }.sorted { $0.date > $1.date } + } + + static func cleanOldBackups(bundleID: String, keepLatest: Int = 3) { + let backups = listAvailableBackups(bundleID: bundleID) + let toDelete = backups.dropFirst(keepLatest) + + for backup in toDelete { + let backupPath = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Application Support/AutoUp/Backups/\(bundleID)/\(backup.version)") + try? FileManager.default.removeItem(at: backupPath) + } + } +} \ No newline at end of file diff --git a/Sources/Core/SecurityChecks.swift b/Sources/Core/SecurityChecks.swift new file mode 100644 index 0000000..e7e675c --- /dev/null +++ b/Sources/Core/SecurityChecks.swift @@ -0,0 +1,41 @@ +import Foundation + +enum SecurityChecks { + static func backup(appPath: String, bundleID: String, version: String) throws -> URL { + let base = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support/AutoUp/Backups/\(bundleID)/\(version)") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + let dest = base.appendingPathComponent((appPath as NSString).lastPathComponent) + if FileManager.default.fileExists(atPath: dest.path) { + try? FileManager.default.removeItem(at: dest) + } + try FileManager.default.copyItem(at: URL(fileURLWithPath: appPath), to: dest) + return dest + } + + static func verifyCodeSign(_ appPath: String) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + task.arguments = ["--verify", "--deep", "--strict", appPath] + try? task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } + + static func getQuarantineStatus(_ appPath: String) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") + task.arguments = ["-p", "com.apple.quarantine", appPath] + try? task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } + + static func removeQuarantine(_ appPath: String) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") + task.arguments = ["-d", "com.apple.quarantine", appPath] + try? task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } +} \ No newline at end of file diff --git a/Sources/Core/UpdateError.swift b/Sources/Core/UpdateError.swift new file mode 100644 index 0000000..9a0f2cb --- /dev/null +++ b/Sources/Core/UpdateError.swift @@ -0,0 +1,35 @@ +import Foundation + +// Actor-Observer bias: Frame errors as situational, not user failures +struct UpdateError: LocalizedError { + let reason: String + var errorDescription: String? { reason } + + static func friendly(_ error: Error) -> UpdateError { + let description = String(describing: error).lowercased() + + // Use Actor-Observer bias: blame the situation, not the user + if description.contains("codesign") || description.contains("signature") { + return UpdateError(reason: "Looks like the app's signature couldn't be verified. Your previous version is safe. Try installing manually from the developer.") + } + + if description.contains("permission") || description.contains("access") { + return UpdateError(reason: "Auto-Up needs permission to replace the app. Grant Full Disk Access in Settings → Privacy & Security → Privacy.") + } + + if description.contains("network") || description.contains("timeout") { + return UpdateError(reason: "Network seems slow — we'll retry in 2 minutes. Your apps are still protected.") + } + + if description.contains("disk") || description.contains("space") { + return UpdateError(reason: "Looks like disk space is running low. Free up some space and try again.") + } + + if description.contains("dmg") || description.contains("mount") { + return UpdateError(reason: "The download file seems corrupted. We'll try downloading again automatically.") + } + + // Default friendly message + return UpdateError(reason: "Update temporarily unavailable. We've kept your previous version safe. You can retry or update manually.") + } +} \ No newline at end of file diff --git a/Sources/Core/Versioning.swift b/Sources/Core/Versioning.swift new file mode 100644 index 0000000..d10da86 --- /dev/null +++ b/Sources/Core/Versioning.swift @@ -0,0 +1,10 @@ +import Foundation + +enum Versioning { + // Numeric-aware compare: "1.10" > "1.9", strips leading "v" + static func isNewer(_ latest: String, than current: String) -> Bool { + let a = latest.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "vV")) + let b = current.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "vV")) + return a.compare(b, options: [.numeric, .caseInsensitive]) == .orderedDescending + } +} \ No newline at end of file diff --git a/Sources/Services/Telemetry.swift b/Sources/Services/Telemetry.swift new file mode 100644 index 0000000..c0ea5bc --- /dev/null +++ b/Sources/Services/Telemetry.swift @@ -0,0 +1,35 @@ +import Foundation +import PostHog + +enum Telemetry { + static func configure(enabled: Bool, apiKey: String) { + if enabled { + PostHogSDK.shared.setup(apiKey: apiKey, host: URL(string:"https://app.posthog.com")!) + PostHogSDK.shared.optIn() + } else { + PostHogSDK.shared.optOut() + } + } + + static func track(_ name: String, props: [String: Any] = [:]) { + guard UserDefaults.standard.bool(forKey: "telemetry_enabled") else { return } + PostHogSDK.shared.capture(event: name, properties: props) + } + + // Bias-driven events for measuring UX improvements + static func trackBiasEvent(_ biasType: String, action: String, value: Any? = nil) { + var props: [String: Any] = [ + "bias_type": biasType, + "action": action + ] + if let value = value { + props["value"] = value + } + track("bias_interaction", props: props) + } +} + +// Usage examples: +// Telemetry.trackBiasEvent("anchoring", "pricing_viewed", "yearly_selected") +// Telemetry.trackBiasEvent("loss_aversion", "security_warning_shown", securityCount) +// Telemetry.trackBiasEvent("social_proof", "user_count_viewed", 3218) \ No newline at end of file diff --git a/Sources/UI/MainPopoverView.swift b/Sources/UI/MainPopoverView.swift index 72d8a26..518d76d 100644 --- a/Sources/UI/MainPopoverView.swift +++ b/Sources/UI/MainPopoverView.swift @@ -6,6 +6,9 @@ struct MainPopoverView: View { @State private var availableUpdates: [UpdateInfo] = [] @State private var showingSettings = false @State private var isUpdating = false + @State private var scanProgress: Double = 0.0 + @State private var lastScanDate: Date = Date() + @State private var streakDays: Int = 7 var body: some View { VStack(spacing: 0) { @@ -37,16 +40,30 @@ struct MainPopoverView: View { private var headerView: some View { HStack { VStack(alignment: .leading, spacing: 4) { - Text("Auto-Up") - .font(.title2) - .fontWeight(.bold) - if !availableUpdates.isEmpty { - Text("\(availableUpdates.count) updates available") - .font(.caption) - .foregroundColor(.secondary) + let securityCount = availableUpdates.filter(\.isSecurityUpdate).count + if securityCount > 0 { + Text("⚠️ Don't risk unpatched apps") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.red) + Text("\(securityCount) security fix\(securityCount == 1 ? "" : "es") pending") + .font(.caption) + .foregroundColor(.red) + } else { + Text("Updates Available") + .font(.title2) + .fontWeight(.bold) + Text("Avoid crashes and bugs") + .font(.caption) + .foregroundColor(.orange) + } } else { - Text("All apps up to date") + Text("✅ All Fresh!") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.green) + Text("Your Mac is protected") .font(.caption) .foregroundColor(.green) } @@ -69,15 +86,43 @@ struct MainPopoverView: View { } private var scanningView: some View { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.2) + VStack(spacing: 20) { + // Progress indicator with Zeigarnik Effect + VStack(spacing: 8) { + Text("Step 1/2 • Scanning • Almost there...") + .font(.headline) + .foregroundColor(.blue) + + ProgressView(value: scanProgress, total: 1.0) + .progressViewStyle(LinearProgressViewStyle(tint: .blue)) + .frame(width: 200) - Text("Scanning installed apps...") - .font(.headline) - .foregroundColor(.secondary) + Text("\(Int(scanProgress * 100))% complete") + .font(.caption) + .foregroundColor(.secondary) + } + + VStack(spacing: 4) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.blue) + Text("Scanning installed apps...") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Text("This helps us find security updates") + .font(.caption) + .foregroundColor(.secondary) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + // Animate progress for Zeigarnik Effect + withAnimation(.easeInOut(duration: 2.0)) { + scanProgress = 0.7 + } + } } private var allUpToDateView: some View { @@ -91,9 +136,24 @@ struct MainPopoverView: View { .font(.title2) .fontWeight(.semibold) - Text("Your Mac is up to date") + Text("Your Mac is protected") .font(.body) .foregroundColor(.secondary) + + // Social Proof + Streak (Goal Gradient) + Text("Last scan: \(formatRelativeTime(lastScanDate))") + .font(.caption) + .foregroundColor(.secondary) + + if streakDays > 0 { + Text("\(streakDays)-day safe streak — keep it going!") + .font(.caption) + .foregroundColor(.green) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.green.opacity(0.1)) + .clipShape(Capsule()) + } } Button("Scan Again") { @@ -177,6 +237,12 @@ struct MainPopoverView: View { await refreshData() } + + private func formatRelativeTime(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .numeric + return formatter.localizedString(for: date, relativeTo: Date()) + } } struct UpdateRowView: View { diff --git a/Sources/UI/SettingsView.swift b/Sources/UI/SettingsView.swift index eae651a..8cfcd64 100644 --- a/Sources/UI/SettingsView.swift +++ b/Sources/UI/SettingsView.swift @@ -1,7 +1,7 @@ import SwiftUI struct SettingsView: View { - @AppStorage("autoUpdateEnabled") private var autoUpdateEnabled = false + @AppStorage("autoUpdateEnabled") private var autoUpdateEnabled = true @AppStorage("onlyOnWiFi") private var onlyOnWiFi = true @AppStorage("onlyWhenPluggedIn") private var onlyWhenPluggedIn = true @AppStorage("securityUpdatesOnly") private var securityUpdatesOnly = false @@ -89,36 +89,52 @@ struct GeneralSettingsView: View { struct PrivacySettingsView: View { @Binding var telemetryEnabled: Bool + @State private var cacheSize: String = "2.1 GB" var body: some View { Form { - Section("Data Collection") { - Toggle("Help improve Auto-Up", isOn: $telemetryEnabled) + Section("Help Improve Auto-Up") { + Toggle("Share anonymous insights", isOn: $telemetryEnabled) VStack(alignment: .leading, spacing: 8) { - Text("When enabled, Auto-Up collects anonymous usage data to help improve the app:") - Text("• Update success/failure rates") - Text("• App scanning performance") - Text("• Feature usage statistics") + if telemetryEnabled { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Thanks! This helps us improve reliability") + .foregroundColor(.green) + } + .font(.caption) + } + + Text("Anonymous success rates & performance only") + .fontWeight(.medium) + Text("• Update success/failure rates (helps fix bugs)") + Text("• Scanning performance (speeds up detection)") + Text("• Crash prevention data (keeps you stable)") Text("") - Text("No personal information or app lists are collected.") + Text("🔒 No app lists or personal info collected") + .foregroundColor(.blue) + Text("Data stored locally unless you opt in") + .foregroundColor(.secondary) } .font(.caption) .foregroundColor(.secondary) } - Section("Local Data") { + Section("Local Data Storage") { VStack(alignment: .leading, spacing: 8) { - Text("All app data is stored locally on your Mac:") - Text("• SQLite database in ~/Library/Application Support/AutoUp") + Text("Your data stays on your Mac:") + Text("• ~/Library/Application Support/AutoUp") Text("• Update history and preferences") - Text("• Cached app versions for rollback") + Text("• Backup versions for rollback (\(cacheSize))") } .font(.caption) .foregroundColor(.secondary) - Button("Clear All Data") { + Button("Clear Cache (\(cacheSize))") { // TODO: Implement data clearing + cacheSize = "0 MB" } .foregroundColor(.red) } @@ -194,52 +210,97 @@ struct ProUpgradeView: View { var body: some View { VStack(spacing: 20) { - Text("Upgrade to Auto-Up Pro") + Text("Protect your Mac with Pro") .font(.title) .fontWeight(.bold) - Text("Get the most out of Auto-Up with Pro features") + Text("Trusted by 3,218 Macs this week") .foregroundColor(.secondary) + .font(.subheadline) VStack(alignment: .leading, spacing: 12) { - ProFeatureRow(icon: "icloud", title: "Multi-Mac Sync", description: "Sync settings across all your Macs") - ProFeatureRow(icon: "pin", title: "Version Pinning", description: "Stay on your preferred app versions") - ProFeatureRow(icon: "arrow.uturn.backward", title: "One-Click Rollback", description: "Instantly revert to previous versions") - ProFeatureRow(icon: "person.3", title: "Family Sharing", description: "Cover up to 5 Macs under one plan") + ProFeatureRow(icon: "shield.checkered", title: "Avoid failed updates", description: "1-click rollback when updates break") + ProFeatureRow(icon: "icloud", title: "Keep all Macs consistent", description: "iCloud sync prevents version drift") + ProFeatureRow(icon: "exclamationmark.triangle", title: "Patch security fixes first", description: "Priority queue for critical updates") + ProFeatureRow(icon: "person.3", title: "Family protection", description: "Cover up to 5 Macs under one plan") } .padding() .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) - HStack { + HStack(spacing: 16) { + // Decoy option VStack { - Text("Monthly") + Text("Basic Pro") .font(.headline) - Text("$2.99") - .font(.title2) + Text("$3.49") + .font(.title3) .fontWeight(.bold) + .strikethrough() + .foregroundColor(.gray) + Text("No rollback") + .font(.caption) + .foregroundColor(.red) + Button("Limited") { + // Intentionally less appealing + } + .buttonStyle(.bordered) + .disabled(true) + } + .opacity(0.7) + + // Monthly option + VStack { + Text("Monthly") + .font(.headline) + HStack { + Text("$3.99") + .font(.caption) + .strikethrough() + .foregroundColor(.gray) + Text("$2.99") + .font(.title2) + .fontWeight(.bold) + } + Text("Full features") + .font(.caption) + .foregroundColor(.green) Button("Choose Monthly") { // TODO: Implement StoreKit purchase } .buttonStyle(.bordered) } - Spacer() - + // Yearly option (recommended) VStack { - Text("Yearly") - .font(.headline) + HStack { + Text("Yearly") + .font(.headline) + Text("RECOMMENDED") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.blue) + .foregroundColor(.white) + .clipShape(Capsule()) + } Text("$24") .font(.title2) .fontWeight(.bold) - Text("Save 33%") + Text("Save 33% • Don't lose out!") .font(.caption) .foregroundColor(.green) + Text("Founding price") + .font(.caption2) + .foregroundColor(.orange) Button("Choose Yearly") { // TODO: Implement StoreKit purchase } .buttonStyle(.borderedProminent) } + .padding() + .background(.blue.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding() @@ -249,7 +310,7 @@ struct ProUpgradeView: View { .foregroundColor(.secondary) } .padding() - .frame(width: 400, height: 500) + .frame(width: 500, height: 550) } } @@ -289,25 +350,58 @@ struct AboutView: View { .font(.title) .fontWeight(.bold) - Text("Version 1.0.0") + Button("Version 1.0.0") { + // TODO: Open release notes + if let url = URL(string: "https://auto-up.com/releases") { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.link) + .foregroundColor(.secondary) + + Text("Trusted by 3,218 Macs this week") + .font(.caption) + .foregroundColor(.green) + .fontWeight(.medium) + + Text("Uses industry-standard Sparkle, GitHub Releases, and codesign verification") + .font(.caption2) .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } - Text("Keep your Mac apps fresh and secure") + VStack(spacing: 4) { + Text("Auto-Up is built by a small indie team") + .font(.caption) + .foregroundColor(.secondary) + Text("focused on reliability first.") .font(.caption) .foregroundColor(.secondary) } VStack(spacing: 8) { Button("Website") { - // TODO: Open website + if let url = URL(string: "https://auto-up.com") { + NSWorkspace.shared.open(url) + } } - Button("Support") { - // TODO: Open support + Button("Report a Bug") { + if let url = URL(string: "mailto:support@auto-up.com?subject=Bug Report") { + NSWorkspace.shared.open(url) + } + } + + Button("Suggest an Integration") { + if let url = URL(string: "mailto:support@auto-up.com?subject=Integration Request") { + NSWorkspace.shared.open(url) + } } Button("Privacy Policy") { - // TODO: Open privacy policy + if let url = URL(string: "https://auto-up.com/privacy") { + NSWorkspace.shared.open(url) + } } } .buttonStyle(.link)