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")
}
}
}
|