mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
macos: intents all ask for permission
This commit is contained in:
@@ -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 */,
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
37
macos/Sources/Features/App Intents/IntentPermission.swift
Normal file
37
macos/Sources/Features/App Intents/IntentPermission.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
162
macos/Sources/Helpers/PermissionRequest.swift
Normal file
162
macos/Sources/Helpers/PermissionRequest.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user