ghostty/macos/Sources/Helpers/PermissionRequest.swift
2025-06-21 06:53:09 -07:00

214 lines
8.7 KiB
Swift

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