πŸ›‘οΈ PermissionKit

톡합 κΆŒν•œ κ΄€λ¦¬λ‘œ μ‚¬μš©μž ν”„λΌμ΄λ²„μ‹œλ₯Ό μ‘΄μ€‘ν•˜λŠ” μ•± λ§Œλ“€κΈ°

iOS 18+πŸ†• 2024

✨ PermissionKitμ΄λž€?

PermissionKit은 iOS 18μ—μ„œ λ„μž…λœ 톡합 κΆŒν•œ 관리 ν”„λ ˆμž„μ›Œν¬μž…λ‹ˆλ‹€. 카메라, 마이크, μœ„μΉ˜, μ•Œλ¦Ό, μ—°λ½μ²˜ λ“± λ‹€μ–‘ν•œ μ‹œμŠ€ν…œ κΆŒν•œμ„ ν•˜λ‚˜μ˜ μΌκ΄€λœ API둜 관리할 수 μžˆμŠ΅λ‹ˆλ‹€. κΆŒν•œ μƒνƒœλ₯Ό μ‹€μ‹œκ°„μœΌλ‘œ μΆ”μ ν•˜κ³ , μ‚¬μš©μžκ°€ κ±°λΆ€ν•œ 경우 μ„€μ • μ•±μœΌλ‘œμ˜ λ”₯링크λ₯Ό μ œκ³΅ν•˜λ©°, κΆŒν•œ μš”μ²­ 전에 λ§₯락 μ„€λͺ…을 ν‘œμ‹œν•˜μ—¬ μ‚¬μš©μž κ²½ν—˜μ„ κ°œμ„ ν•©λ‹ˆλ‹€. ν”„λΌμ΄λ²„μ‹œλ₯Ό μ΅œμš°μ„ μœΌλ‘œ ν•˜λŠ” ν˜„λŒ€μ μΈ κΆŒν•œ 관리 μ†”λ£¨μ…˜μž…λ‹ˆλ‹€.

πŸ’‘ 핡심 κΈ°λŠ₯: 톡합 κΆŒν•œ API Β· μ‹€μ‹œκ°„ μƒνƒœ 좔적 Β· μ„€μ • λ”₯링크 Β· λ§₯락 μ„€λͺ… UI Β· λΆ€λΆ„ κΆŒν•œ 지원 Β· μž„μ‹œ κΆŒν•œ Β· κΆŒν•œ κ·Έλ£Ή Β· SwiftUI 톡합

🎯 1. κΈ°λ³Έ μ„€μ •

PermissionKit을 μ„€μ •ν•˜κ³  Info.plist에 κΆŒν•œ μ„€λͺ…을 μΆ”κ°€ν•©λ‹ˆλ‹€.

Info.plist β€” κΆŒν•œ μ„€λͺ… μΆ”κ°€
<!-- 카메라 -->
<key>NSCameraUsageDescription</key>
<string>사진을 μ΄¬μ˜ν•˜κ³  ν”„λ‘œν•„ 이미지λ₯Ό μ„€μ •ν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- 사진 라이브러리 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>사진을 μ €μž₯ν•˜κ³  λΆˆλŸ¬μ˜€λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μœ„μΉ˜ (μ‚¬μš© 쀑) -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>μ£Όλ³€ μž₯μ†Œλ₯Ό κ²€μƒ‰ν•˜κ³  날씨 정보λ₯Ό μ œκ³΅ν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μœ„μΉ˜ (항상) -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>μœ„μΉ˜ 기반 μ•Œλ¦Όμ„ λ³΄λ‚΄λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- 마이크 -->
<key>NSMicrophoneUsageDescription</key>
<string>μŒμ„± λ©”μ‹œμ§€λ₯Ό λ…ΉμŒν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μ—°λ½μ²˜ -->
<key>NSContactsUsageDescription</key>
<string>친ꡬλ₯Ό μ°Ύκ³  μ΄ˆλŒ€ν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μ•Œλ¦Ό -->
<key>NSUserNotificationsUsageDescription</key>
<string>μ€‘μš”ν•œ μ—…λ°μ΄νŠΈλ₯Ό μ•Œλ €λ“œλ¦¬λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>
PermissionManager.swift β€” κΆŒν•œ κ΄€λ¦¬μž
import SwiftUI
import AVFoundation
import CoreLocation
import UserNotifications
import Contacts
import Photos

// κΆŒν•œ νƒ€μž…
enum PermissionType: String, CaseIterable, Identifiable {
    case camera = "카메라"
    case photoLibrary = "사진"
    case location = "μœ„μΉ˜"
    case microphone = "마이크"
    case contacts = "μ—°λ½μ²˜"
    case notifications = "μ•Œλ¦Ό"

    var id: String { rawValue }

    var icon: String {
        switch self {
        case .camera: return "camera.fill"
        case .photoLibrary: return "photo.fill"
        case .location: return "location.fill"
        case .microphone: return "mic.fill"
        case .contacts: return "person.2.fill"
        case .notifications: return "bell.fill"
        }
    }

    var description: String {
        switch self {
        case .camera: return "사진과 λΉ„λ””μ˜€λ₯Ό μ΄¬μ˜ν•˜λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .photoLibrary: return "사진을 μ €μž₯ν•˜κ³  λΆˆλŸ¬μ˜€λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .location: return "μ£Όλ³€ μž₯μ†Œλ₯Ό μ°ΎλŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .microphone: return "μŒμ„±μ„ λ…ΉμŒν•˜λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .contacts: return "친ꡬλ₯Ό μ°ΎλŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .notifications: return "μ€‘μš”ν•œ μ•Œλ¦Όμ„ λ°›λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        }
    }
}

// κΆŒν•œ μƒνƒœ
enum PermissionStatus {
    case notDetermined
    case authorized
    case denied
    case restricted
    case limited // λΆ€λΆ„ κΆŒν•œ (사진 λ“±)

    var color: Color {
        switch self {
        case .notDetermined: return .gray
        case .authorized: return .green
        case .denied: return .red
        case .restricted: return .orange
        case .limited: return .yellow
        }
    }

    var text: String {
        switch self {
        case .notDetermined: return "λ―Έμ„€μ •"
        case .authorized: return "ν—ˆμš©λ¨"
        case .denied: return "거뢀됨"
        case .restricted: return "μ œν•œλ¨"
        case .limited: return "μ œν•œμ  ν—ˆμš©"
        }
    }
}

// 톡합 κΆŒν•œ κ΄€λ¦¬μž
@Observable
class PermissionManager {
    var permissions: [PermissionType: PermissionStatus] = [:]

    init() {
        // 초기 μƒνƒœ 확인
        Task {
            await checkAllPermissions()
        }
    }

    // λͺ¨λ“  κΆŒν•œ μƒνƒœ 확인
    func checkAllPermissions() async {
        for type in PermissionType.allCases {
            let status = await checkPermission(type)
            await MainActor.run {
                permissions[type] = status
            }
        }
    }

    // κ°œλ³„ κΆŒν•œ 확인
    func checkPermission(_ type: PermissionType) async -> PermissionStatus {
        switch type {
        case .camera:
            return checkCameraPermission()

        case .photoLibrary:
            return checkPhotoLibraryPermission()

        case .location:
            return checkLocationPermission()

        case .microphone:
            return checkMicrophonePermission()

        case .contacts:
            return await checkContactsPermission()

        case .notifications:
            return await checkNotificationPermission()
        }
    }

    // 카메라 κΆŒν•œ
    func checkCameraPermission() -> PermissionStatus {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .notDetermined: return .notDetermined
        case .authorized: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        @unknown default: return .notDetermined
        }
    }

    // 사진 κΆŒν•œ
    func checkPhotoLibraryPermission() -> PermissionStatus {
        switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
        case .notDetermined: return .notDetermined
        case .authorized: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        case .limited: return .limited
        @unknown default: return .notDetermined
        }
    }

    // μœ„μΉ˜ κΆŒν•œ
    func checkLocationPermission() -> PermissionStatus {
        let status = CLLocationManager().authorizationStatus

        switch status {
        case .notDetermined: return .notDetermined
        case .authorizedAlways, .authorizedWhenInUse: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        @unknown default: return .notDetermined
        }
    }

    // 마이크 κΆŒν•œ
    func checkMicrophonePermission() -> PermissionStatus {
        switch AVAudioSession.sharedInstance().recordPermission {
        case .undetermined: return .notDetermined
        case .granted: return .authorized
        case .denied: return .denied
        @unknown default: return .notDetermined
        }
    }

    // μ—°λ½μ²˜ κΆŒν•œ
    func checkContactsPermission() async -> PermissionStatus {
        switch CNContactStore.authorizationStatus(for: .contacts) {
        case .notDetermined: return .notDetermined
        case .authorized: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        @unknown default: return .notDetermined
        }
    }

    // μ•Œλ¦Ό κΆŒν•œ
    func checkNotificationPermission() async -> PermissionStatus {
        let settings = await UNUserNotificationCenter.current().notificationSettings()

        switch settings.authorizationStatus {
        case .notDetermined: return .notDetermined
        case .authorized, .provisional, .ephemeral: return .authorized
        case .denied: return .denied
        @unknown default: return .notDetermined
        }
    }
}

πŸ” 2. κΆŒν•œ μš”μ²­

μ‚¬μš©μžμ—κ²Œ κΆŒν•œμ„ μš”μ²­ν•˜κ³  κ²°κ³Όλ₯Ό μ²˜λ¦¬ν•©λ‹ˆλ‹€.

PermissionRequest.swift β€” κΆŒν•œ μš”μ²­
import SwiftUI

extension PermissionManager {
    // κΆŒν•œ μš”μ²­
    func requestPermission(_ type: PermissionType) async -> PermissionStatus {
        switch type {
        case .camera:
            return await requestCameraPermission()

        case .photoLibrary:
            return await requestPhotoLibraryPermission()

        case .location:
            // μœ„μΉ˜λŠ” CLLocationManager 델리게이트 ν•„μš”
            return .notDetermined

        case .microphone:
            return await requestMicrophonePermission()

        case .contacts:
            return await requestContactsPermission()

        case .notifications:
            return await requestNotificationPermission()
        }
    }

    func requestCameraPermission() async -> PermissionStatus {
        let granted = await AVCaptureDevice.requestAccess(for: .video)
        return granted ? .authorized : .denied
    }

    func requestPhotoLibraryPermission() async -> PermissionStatus {
        let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)

        switch status {
        case .authorized: return .authorized
        case .limited: return .limited
        case .denied: return .denied
        case .restricted: return .restricted
        default: return .notDetermined
        }
    }

    func requestMicrophonePermission() async -> PermissionStatus {
        let granted = await AVAudioSession.sharedInstance().requestRecordPermission()
        return granted ? .authorized : .denied
    }

    func requestContactsPermission() async -> PermissionStatus {
        do {
            let granted = try await CNContactStore().requestAccess(for: .contacts)
            return granted ? .authorized : .denied
        } catch {
            return .denied
        }
    }

    func requestNotificationPermission() async -> PermissionStatus {
        do {
            let granted = try await UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .badge, .sound])
            return granted ? .authorized : .denied
        } catch {
            return .denied
        }
    }

    // μ„€μ • μ•±μœΌλ‘œ 이동
    func openSettings() {
        guard let settingsURL = URL(string: UIApplication.openSettingsURLString)
        else { return }

        UIApplication.shared.open(settingsURL)
    }
}

πŸ“± 3. κΆŒν•œ UI

μ‚¬μš©μž μΉœν™”μ μΈ κΆŒν•œ μš”μ²­ 화면을 λ§Œλ“­λ‹ˆλ‹€.

PermissionView.swift β€” κΆŒν•œ ν™”λ©΄
import SwiftUI

struct PermissionListView: View {
    @State private var manager = PermissionManager()

    var body: some View {
        NavigationStack {
            List(PermissionType.allCases) { type in
                PermissionRow(
                    type: type,
                    status: manager.permissions[type] ?? .notDetermined,
                    onRequest: {
                        Task {
                            let newStatus = await manager.requestPermission(type)
                            manager.permissions[type] = newStatus
                        }
                    },
                    onOpenSettings: {
                        manager.openSettings()
                    }
                )
            }
            .navigationTitle("κΆŒν•œ 관리")
            .task {
                await manager.checkAllPermissions()
            }
        }
    }
}

struct PermissionRow: View {
    let type: PermissionType
    let status: PermissionStatus
    let onRequest: () -> Void
    let onOpenSettings: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Image(systemName: type.icon)
                    .font(.title2)
                    .foregroundStyle(.blue)
                    .frame(width: 40)

                VStack(alignment: .leading, spacing: 4) {
                    Text(type.rawValue)
                        .font(.headline)

                    Text(type.description)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                // μƒνƒœ λ°°μ§€
                Text(status.text)
                    .font(.caption)
                    .fontWeight(.semibold)
                    .foregroundStyle(.white)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .background(status.color)
                    .cornerRadius(8)
            }

            // μ•‘μ…˜ λ²„νŠΌ
            switch status {
            case .notDetermined:
                Button {
                    onRequest()
                } label: {
                    Text("κΆŒν•œ ν—ˆμš©")
                        .frame(maxWidth: .infinity)
                        .padding(8)
                        .background(Color.blue)
                        .foregroundStyle(.white)
                        .cornerRadius(8)
                }
                .buttonStyle(.plain)

            case .denied:
                VStack(spacing: 8) {
                    Text("κΆŒν•œμ΄ κ±°λΆ€λ˜μ—ˆμŠ΅λ‹ˆλ‹€")
                        .font(.caption)
                        .foregroundStyle(.red)

                    Button {
                        onOpenSettings()
                    } label: {
                        Label("μ„€μ •μ—μ„œ ν—ˆμš©ν•˜κΈ°", systemImage: "gear")
                            .frame(maxWidth: .infinity)
                            .padding(8)
                            .background(Color.gray.opacity(0.2))
                            .foregroundStyle(.primary)
                            .cornerRadius(8)
                    }
                    .buttonStyle(.plain)
                }

            case .limited:
                Button {
                    onRequest()
                } label: {
                    Text("전체 μ•‘μ„ΈμŠ€ ν—ˆμš©")
                        .frame(maxWidth: .infinity)
                        .padding(8)
                        .background(Color.orange)
                        .foregroundStyle(.white)
                        .cornerRadius(8)
                }
                .buttonStyle(.plain)

            case .authorized:
                HStack {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(.green)
                    Text("κΆŒν•œμ΄ ν—ˆμš©λ˜μ—ˆμŠ΅λ‹ˆλ‹€")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

            case .restricted:
                Text("κΈ°κΈ° 섀정에 μ˜ν•΄ μ œν•œλ˜μ—ˆμŠ΅λ‹ˆλ‹€")
                    .font(.caption)
                    .foregroundStyle(.orange)
            }
        }
        .padding(.vertical, 8)
    }
}

🎭 4. μ˜¨λ³΄λ”© 톡합

μ•± 첫 μ‹€ν–‰ μ‹œ κΆŒν•œμ„ μ„€λͺ…ν•˜κ³  μš”μ²­ν•˜λŠ” μ˜¨λ³΄λ”© 화면을 λ§Œλ“­λ‹ˆλ‹€.

PermissionOnboardingView.swift β€” μ˜¨λ³΄λ”©
import SwiftUI

struct PermissionOnboardingView: View {
    @State private var manager = PermissionManager()
    @State private var currentStep = 0
    @Environment(\.dismiss) var dismiss

    let steps: [PermissionType] = [.camera, .photoLibrary, .location, .notifications]

    var body: some View {
        VStack(spacing: 32) {
            // μ§„ν–‰ ν‘œμ‹œ
            HStack(spacing: 8) {
                ForEach(0..<4, id: \.self) { index in
                    Capsule()
                        .fill(index <= currentStep ? Color.blue : Color.gray.opacity(0.3))
                        .frame(height: 4)
                }
            }
            .padding(.horizontal)

            Spacer()

            // ν˜„μž¬ 단계 μ½˜ν…μΈ 
            if currentStep < steps.count {
                PermissionStepView(type: steps[currentStep])
            } else {
                CompletionView()
            }

            Spacer()

            // μ•‘μ…˜ λ²„νŠΌ
            VStack(spacing: 12) {
                if currentStep < steps.count {
                    Button {
                        Task {
                            let type = steps[currentStep]
                            let status = await manager.requestPermission(type)
                            manager.permissions[type] = status
                            currentStep += 1
                        }
                    } label: {
                        Text("ν—ˆμš©")
                            .font(.headline)
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color.blue)
                            .foregroundStyle(.white)
                            .cornerRadius(12)
                    }

                    Button {
                        currentStep += 1
                    } label: {
                        Text("λ‚˜μ€‘μ—")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                } else {
                    Button {
                        dismiss()
                    } label: {
                        Text("μ‹œμž‘ν•˜κΈ°")
                            .font(.headline)
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color.blue)
                            .foregroundStyle(.white)
                            .cornerRadius(12)
                    }
                }
            }
            .padding()
        }
    }
}

struct PermissionStepView: View {
    let type: PermissionType

    var body: some View {
        VStack(spacing: 24) {
            // μ•„μ΄μ½˜
            Image(systemName: type.icon)
                .font(.system(size: 80))
                .foregroundStyle(.blue)
                .symbolEffect(.bounce)

            // 제λͺ©κ³Ό μ„€λͺ…
            VStack(spacing: 8) {
                Text(type.rawValue)
                    .font(.title)
                    .fontWeight(.bold)

                Text(type.description)
                    .font(.body)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
            }
            .padding(.horizontal)

            // μ‚¬μš© μ˜ˆμ‹œ
            VStack(alignment: .leading, spacing: 12) {
                Text("이런 μš©λ„λ‘œ μ‚¬μš©λ©λ‹ˆλ‹€:")
                    .font(.headline)

                VStack(alignment: .leading, spacing: 8) {
                    usageExample("ν”„λ‘œν•„ 사진 촬영")
                    usageExample("λ¬Έμ„œ μŠ€μΊ”")
                    usageExample("AR κΈ°λŠ₯ μ‚¬μš©")
                }
            }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.gray.opacity(0.1))
            .cornerRadius(12)
            .padding(.horizontal)
        }
    }

    func usageExample(_ text: String) -> some View {
        HStack {
            Image(systemName: "checkmark.circle.fill")
                .foregroundStyle(.green)
            Text(text)
                .font(.subheadline)
        }
    }
}

struct CompletionView: View {
    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 80))
                .foregroundStyle(.green)

            VStack(spacing: 8) {
                Text("λͺ¨λ‘ μ™„λ£Œ!")
                    .font(.title)
                    .fontWeight(.bold)

                Text("이제 μ•±μ˜ λͺ¨λ“  κΈ°λŠ₯을 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€")
                    .font(.body)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

πŸ”„ 5. μ‹€μ‹œκ°„ κΆŒν•œ λͺ¨λ‹ˆν„°λ§

앱이 μ‹€ν–‰ 쀑일 λ•Œ κΆŒν•œ 변경을 κ°μ§€ν•˜κ³  λŒ€μ‘ν•©λ‹ˆλ‹€.

PermissionMonitor.swift β€” μ‹€μ‹œκ°„ λͺ¨λ‹ˆν„°λ§
import SwiftUI
import Combine

@Observable
class PermissionMonitor {
    var cameraAvailable = false
    var locationAvailable = false
    var showPermissionAlert = false
    var alertMessage = ""

    private var cancellables = Set<AnyCancellable>()

    init() {
        startMonitoring()
    }

    func startMonitoring() {
        // μ•± ν¬κ·ΈλΌμš΄λ“œ μ§„μž… μ‹œ κΆŒν•œ μž¬ν™•μΈ
        NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
            .sink { _ in
                Task {
                    await self.checkPermissions()
                }
            }
            .store(in: &cancellables)
    }

    func checkPermissions() async {
        // 카메라 κΆŒν•œ 확인
        let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video)
        let cameraWasAvailable = cameraAvailable
        cameraAvailable = cameraStatus == .authorized

        // κΆŒν•œμ΄ μ·¨μ†Œλœ 경우
        if cameraWasAvailable && !cameraAvailable {
            await showAlert("카메라 κΆŒν•œμ΄ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ„€μ •μ—μ„œ λ‹€μ‹œ ν—ˆμš©ν•΄μ£Όμ„Έμš”.")
        }

        // μœ„μΉ˜ κΆŒν•œ 확인
        let locationStatus = CLLocationManager().authorizationStatus
        let locationWasAvailable = locationAvailable
        locationAvailable = locationStatus == .authorizedWhenInUse || locationStatus == .authorizedAlways

        if locationWasAvailable && !locationAvailable {
            await showAlert("μœ„μΉ˜ κΆŒν•œμ΄ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ„€μ •μ—μ„œ λ‹€μ‹œ ν—ˆμš©ν•΄μ£Όμ„Έμš”.")
        }
    }

    func showAlert(_ message: String) async {
        await MainActor.run {
            alertMessage = message
            showPermissionAlert = true
        }
    }
}

// μ‚¬μš© 예제
struct CameraView: View {
    @State private var monitor = PermissionMonitor()

    var body: some View {
        VStack {
            if monitor.cameraAvailable {
                Text("카메라 μ‚¬μš© κ°€λŠ₯")
                // 카메라 UI
            } else {
                ContentUnavailableView {
                    Label("카메라 κΆŒν•œ ν•„μš”", systemImage: "camera.fill")
                } description: {
                    Text("μ„€μ •μ—μ„œ 카메라 κΆŒν•œμ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”")
                } actions: {
                    Button("μ„€μ • μ—΄κΈ°") {
                        PermissionManager().openSettings()
                    }
                }
            }
        }
        .alert("κΆŒν•œ λ³€κ²½", isPresented: $monitor.showPermissionAlert) {
            Button("μ„€μ • μ—΄κΈ°") {
                PermissionManager().openSettings()
            }
            Button("확인", role: .cancel) { }
        } message: {
            Text(monitor.alertMessage)
        }
        .task {
            await monitor.checkPermissions()
        }
    }
}

πŸ’‘ HIG κ°€μ΄λ“œλΌμΈ

🎯 μ‹€μ „ ν™œμš©

πŸ“š 더 μ•Œμ•„λ³΄κΈ°

⚑️ 팁: PermissionKit은 μ‚¬μš©μž ν”„λΌμ΄λ²„μ‹œλ₯Ό μ΅œμš°μ„ μœΌλ‘œ ν•©λ‹ˆλ‹€. κΆŒν•œμ„ μš”μ²­ν•˜κΈ° 전에 항상 λ§₯락을 μ„€λͺ…ν•˜κ³ , κ±°λΆ€ μ‹œμ—λ„ 앱이 μ •μƒμ μœΌλ‘œ μž‘λ™ν•˜λ„λ‘ μ„€κ³„ν•˜μ„Έμš”. λΆ€λΆ„ κΆŒν•œ(예: μ„ νƒλœ μ‚¬μ§„λ§Œ μ ‘κ·Ό)을 ν™œμš©ν•˜λ©΄ μ‚¬μš©μž 신뒰도λ₯Ό 높일 수 μžˆμŠ΅λ‹ˆλ‹€. 앱이 ν¬κ·ΈλΌμš΄λ“œλ‘œ λŒμ•„μ˜¬ λ•Œλ§ˆλ‹€ κΆŒν•œ μƒνƒœλ₯Ό μž¬ν™•μΈν•˜μ—¬ μΌκ΄€λœ κ²½ν—˜μ„ μ œκ³΅ν•˜μ„Έμš”.