macos: intents all ask for permission

This commit is contained in:
Mitchell Hashimoto
2025-06-20 11:06:05 -07:00
parent 027171bd5d
commit 647f29bad1
11 changed files with 252 additions and 0 deletions

View File

@@ -16,6 +16,8 @@
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; };
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; };
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
A51194172E05D964007258CC /* PermissionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194162E05D95E007258CC /* PermissionRequest.swift */; };
A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194182E05DFBB007258CC /* IntentPermission.swift */; };
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
@@ -155,6 +157,8 @@
A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; };
A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = "<group>"; };
A51194162E05D95E007258CC /* PermissionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequest.swift; sourceTree = "<group>"; };
A51194182E05DFBB007258CC /* IntentPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentPermission.swift; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
@@ -355,6 +359,7 @@
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A51194162E05D95E007258CC /* PermissionRequest.swift */,
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */,
A5CA378D2D31D6C100931030 /* Weak.swift */,
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
@@ -640,6 +645,7 @@
A5E408462E0485270035FEAC /* InputIntent.swift */,
A5E408442E0483F80035FEAC /* KeybindIntent.swift */,
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */,
A51194182E05DFBB007258CC /* IntentPermission.swift */,
);
path = "App Intents";
sourceTree = "<group>";
@@ -821,6 +827,8 @@
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
A51194172E05D964007258CC /* PermissionRequest.swift in Sources */,
A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */,
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,

View File

@@ -17,6 +17,10 @@ struct CloseTerminalIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surfaceView = terminal.surfaceView else {
throw GhosttyIntentError.surfaceNotFound
}

View File

@@ -24,6 +24,10 @@ struct CommandPaletteIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}

View File

@@ -26,6 +26,10 @@ struct GetTerminalDetailsIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String?> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
switch detail {
case .title: return .result(value: terminal.title)
case .workingDirectory: return .result(value: terminal.workingDirectory)

View File

@@ -1,11 +1,13 @@
enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible {
case appUnavailable
case surfaceNotFound
case permissionDenied
var localizedStringResource: LocalizedStringResource {
switch self {
case .appUnavailable: return "The Ghostty app isn't properly initialized."
case .surfaceNotFound: return "The terminal no longer exists."
case .permissionDenied: return "Ghostty doesn't allow Shortcuts."
}
}
}

View File

@@ -29,6 +29,10 @@ struct InputTextIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -75,6 +79,10 @@ struct KeyEventIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -133,6 +141,10 @@ struct MouseButtonIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -190,6 +202,10 @@ struct MousePosIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -254,6 +270,10 @@ struct MouseScrollIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}

View File

@@ -0,0 +1,37 @@
/// Requests permission for Shortcuts app to interact with Ghostty
///
/// This function displays a permission dialog asking the user to allow Shortcuts
/// to interact with Ghostty. The permission is automatically cached for 10 minutes
/// if the user selects "Allow", meaning subsequent intent calls won't show the dialog
/// again during that time period.
///
/// The permission uses a shared UserDefaults key across all intents, so granting
/// permission for one intent allows all Ghostty intents to execute without additional
/// prompts for the duration of the cache period.
///
/// - Returns: `true` if permission is granted, `false` if denied
///
/// ## Usage
/// Add this check at the beginning of any App Intent's `perform()` method:
/// ```swift
/// @MainActor
/// func perform() async throws -> some IntentResult {
/// guard await requestIntentPermission() else {
/// throw GhosttyIntentError.permissionDenied
/// }
/// // ... continue with intent implementation
/// }
/// ```
func requestIntentPermission() async -> Bool {
await withCheckedContinuation { continuation in
Task { @MainActor in
PermissionRequest.show(
"org.mitchellh.ghostty.shortcutsPermission",
message: "Allow Shortcuts to interact with Ghostty for the next 10 minutes?",
allowDuration: .seconds(600),
) { response in
continuation.resume(returning: response)
}
}
}
}

View File

@@ -21,6 +21,10 @@ struct KeybindIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}

View File

@@ -51,6 +51,9 @@ struct NewTerminalIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let appDelegate = NSApp.delegate as? AppDelegate else {
throw GhosttyIntentError.appUnavailable
}

View File

@@ -10,6 +10,10 @@ struct QuickTerminalIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let delegate = NSApp.delegate as? AppDelegate else {
throw GhosttyIntentError.appUnavailable
}

View File

@@ -0,0 +1,162 @@
import AppKit
import Foundation
/// Displays a permission request dialog with optional caching of user decisions
class PermissionRequest {
/// 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
/// - 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 user checks "Remember my decision for one day", both allow/deny are cached for 24 hours
/// - 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: Duration? = nil,
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
let checkbox = NSButton(
checkboxWithTitle: "Remember my decision for one day",
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, completion: completion)
}
} else {
let response = alert.runModal()
handleResponse(response, rememberDecision: checkbox.state == .on, key: key, allowDuration: allowDuration, 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
/// - completion: Completion handler to call with the result
private static func handleResponse(
_ response: NSApplication.ModalResponse,
rememberDecision: Bool,
key: String,
allowDuration: 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 {
storeResult(result, for: key, duration: .seconds(86400))
} else if result, let allowDuration {
storeResult(result, for: key, duration: allowDuration)
}
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)
}
}
/// 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")
}
}
}