summaryrefslogtreecommitdiff
path: root/macos/Sources/Helpers/PermissionRequest.swift
blob: 9c16c7163eea53f91a0a74f632deaa7d6b1bfb19 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import AppKit
import Foundation

/// Displays a permission request dialog with optional caching of user decisions
class PermissionRequest {
    /// Specifies how long a permission decision should be cached
    enum AllowDuration {
        case once
        case forever
        case duration(Duration)
    }

    /// Shows a permission request dialog with customizable caching behavior
    /// - Parameters:
    ///   - key: Unique identifier for storing/retrieving cached decisions in UserDefaults
    ///   - message: The message to display in the alert dialog
    ///   - allowText: Custom text for the allow button (defaults to "Allow")
    ///   - allowDuration: If provided, automatically cache "Allow" responses for this duration
    ///   - rememberDuration: If provided, shows a checkbox to remember the decision for this duration
    ///   - window: If provided, shows the alert as a sheet attached to this window
    ///   - completion: Called with the user's decision (true for allow, false for deny)
    /// 
    /// Caching behavior:
    /// - If rememberDuration is provided and user checks "Remember my decision", both allow/deny are cached for that duration
    /// - If allowDuration is provided and user selects allow (without checkbox), decision is cached for that duration
    /// - Cached decisions are automatically returned without showing the dialog
    @MainActor
    static func show(
        _ key: String,
        message: String,
        informative: String = "",
        allowText: String = "Allow",
        allowDuration: AllowDuration = .once,
        rememberDuration: Duration? = .seconds(86400),
        window: NSWindow? = nil,
        completion: @escaping (Bool) -> Void
    ) {
        // Check if we have a stored decision that hasn't expired
        if let storedResult = getStoredResult(for: key) {
            completion(storedResult)
            return
        }
        
        let alert = NSAlert()
        alert.messageText = message
        alert.informativeText = informative
        alert.alertStyle = .informational

        // Add buttons (they appear in reverse order)
        alert.addButton(withTitle: allowText)
        alert.addButton(withTitle: "Don't Allow")

        // Create checkbox for remembering if duration is provided
        var checkbox: NSButton?
        if let rememberDuration = rememberDuration {
            let checkboxTitle = formatRememberText(for: rememberDuration)
            checkbox = NSButton(
                checkboxWithTitle: checkboxTitle,
                target: nil,
                action: nil)
            checkbox!.state = .off
            
            // Set checkbox as accessory view
            alert.accessoryView = checkbox
        }

        // Show the alert
        if let window = window {
            alert.beginSheetModal(for: window) { response in
                handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion)
            }
        } else {
            let response = alert.runModal()
            handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion)
        }
    }
    
    /// Handles the alert response and processes caching logic
    /// - Parameters:
    ///   - response: The alert response from the user
    ///   - rememberDecision: Whether the remember checkbox was checked
    ///   - key: The UserDefaults key for caching
    ///   - allowDuration: Optional duration for auto-caching allow responses
    ///   - rememberDuration: Optional duration for the remember checkbox
    ///   - completion: Completion handler to call with the result
    private static func handleResponse(
        _ response: NSApplication.ModalResponse,
        rememberDecision: Bool,
        key: String,
        allowDuration: AllowDuration,
        rememberDuration: Duration?,
        completion: @escaping (Bool) -> Void) {
        
        let result: Bool
        switch response {
        case .alertFirstButtonReturn: // Allow
            result = true
        case .alertSecondButtonReturn: // Don't Allow
            result = false
        default:
            result = false
        }
        
        // Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set
        if rememberDecision, let rememberDuration = rememberDuration {
            storeResult(result, for: key, duration: rememberDuration)
        } else if result {
            switch allowDuration {
            case .once:
                // Don't store anything for once
                break
            case .forever:
                // Store for a very long time (100 years). When the bug comes in that
                // 100 years has passed and their forever permission expired I'll be
                // dead so it won't be my problem.
                storeResult(result, for: key, duration: .seconds(3153600000))
            case .duration(let duration):
                storeResult(result, for: key, duration: duration)
            }
        }
        
        completion(result)
    }
    
    /// Retrieves a cached permission decision if it hasn't expired
    /// - Parameter key: The UserDefaults key to check
    /// - Returns: The cached decision, or nil if no valid cached decision exists
    private static func getStoredResult(for key: String) -> Bool? {
        let userDefaults = UserDefaults.standard
        guard let data = userDefaults.data(forKey: key),
              let storedPermission = try? NSKeyedUnarchiver.unarchivedObject(
                ofClass: StoredPermission.self, from: data) else {
            return nil
        }
        
        if Date() > storedPermission.expiry {
            // Decision has expired, remove stored value
            userDefaults.removeObject(forKey: key)
            return nil
        }
        
        return storedPermission.result
    }
    
    /// Stores a permission decision in UserDefaults with an expiration date
    /// - Parameters:
    ///   - result: The permission decision to store
    ///   - key: The UserDefaults key to store under
    ///   - duration: How long the decision should be cached
    private static func storeResult(_ result: Bool, for key: String, duration: Duration) {
        let expiryDate = Date().addingTimeInterval(duration.timeInterval)
        let storedPermission = StoredPermission(result: result, expiry: expiryDate)
        if let data = try? NSKeyedArchiver.archivedData(withRootObject: storedPermission, requiringSecureCoding: true) {
            let userDefaults = UserDefaults.standard
            userDefaults.set(data, forKey: key)
        }
    }

    /// Formats the remember checkbox text based on the duration
    /// - Parameter duration: The duration to format
    /// - Returns: A human-readable string for the checkbox
    private static func formatRememberText(for duration: Duration) -> String {
        let seconds = duration.timeInterval

        // Warning: this probably isn't localization friendly at all so we're
        // going to have to redo this for that.
        switch seconds {
        case 0..<60:
            return "Remember my decision for \(Int(seconds)) seconds"
        case 60..<3600:
            let minutes = Int(seconds / 60)
            return "Remember my decision for \(minutes) minute\(minutes == 1 ? "" : "s")"
        case 3600..<86400:
            let hours = Int(seconds / 3600)
            return "Remember my decision for \(hours) hour\(hours == 1 ? "" : "s")"
        case 86400:
            return "Remember my decision for one day"
        default:
            let days = Int(seconds / 86400)
            return "Remember my decision for \(days) day\(days == 1 ? "" : "s")"
        }
    }
    
    /// Internal class for storing permission decisions with expiration dates in UserDefaults
    /// Conforms to NSSecureCoding for safe archiving/unarchiving
    @objc(StoredPermission)
    private class StoredPermission: NSObject, NSSecureCoding {
        static var supportsSecureCoding: Bool = true

        let result: Bool
        let expiry: Date

        init(result: Bool, expiry: Date) {
            self.result = result
            self.expiry = expiry
            super.init()
        }

        required init?(coder: NSCoder) {
            self.result = coder.decodeBool(forKey: "result")
            guard let expiry = coder.decodeObject(of: NSDate.self, forKey: "expiry") as? Date else {
                return nil
            }
            self.expiry = expiry
            super.init()
        }

        func encode(with coder: NSCoder) {
            coder.encode(result, forKey: "result")
            coder.encode(expiry, forKey: "expiry")
        }
    }
}