mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-03 12:50:27 +00:00
105 lines
4.0 KiB
Swift
105 lines
4.0 KiB
Swift
import AppKit
|
|
|
|
/// Presents decisions for untrusted URLs at the AppKit boundary.
|
|
enum UntrustedURLAlert {
|
|
static func presentConfirmation(for url: URL, displayString: String) {
|
|
deferPresentation {
|
|
let workspace = NSWorkspace.shared
|
|
let handler = workspace.urlForApplication(toOpen: url)
|
|
.map { "\u{201c}\($0.deletingPathExtension().lastPathComponent)\u{201d}" }
|
|
?? "the default application"
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.icon = NSImage(named: NSImage.cautionName)
|
|
alert.messageText = "Open Link from Terminal Output?"
|
|
alert.informativeText = """
|
|
This link will open in \(handler). Only continue if you recognize \
|
|
and trust the destination.
|
|
"""
|
|
alert.accessoryView = targetView(displayString)
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.addButton(withTitle: "Open Link")
|
|
|
|
present(alert) { response in
|
|
// Cancel is deliberately the default action.
|
|
guard response == .alertSecondButtonReturn else { return }
|
|
_ = workspace.open(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func presentBlock(
|
|
reason: UntrustedURL.DenialReason,
|
|
displayString: String
|
|
) {
|
|
deferPresentation {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.icon = NSImage(named: NSImage.cautionName)
|
|
alert.messageText = "Ghostty Blocked This Link"
|
|
alert.informativeText = reason.message
|
|
alert.accessoryView = targetView(displayString)
|
|
alert.addButton(withTitle: "OK")
|
|
alert.addButton(withTitle: "Copy Link")
|
|
|
|
present(alert) { response in
|
|
// Keep blocked targets out of Launch Services. Copying the
|
|
// displayed, sanitized value gives the user an explicit path
|
|
// forward without adding a one-click policy bypass.
|
|
guard response == .alertSecondButtonReturn else { return }
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(displayString, forType: .string)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The core action callback runs with the renderer mutex held. Queue modal
|
|
/// presentation for the next main-loop turn so AppKit cannot reenter a
|
|
/// render callback before that mutex is released.
|
|
private static func deferPresentation(_ action: @escaping () -> Void) {
|
|
DispatchQueue.main.async(execute: action)
|
|
}
|
|
|
|
private static func present(
|
|
_ alert: NSAlert,
|
|
completion: @escaping (NSApplication.ModalResponse) -> Void
|
|
) {
|
|
if let window = NSApp.keyWindow {
|
|
alert.beginSheetModal(for: window, completionHandler: completion)
|
|
} else {
|
|
completion(alert.runModal())
|
|
}
|
|
}
|
|
|
|
private static func targetView(_ target: String) -> NSView {
|
|
let scrollView = NSScrollView(frame: NSRect(
|
|
x: 0,
|
|
y: 0,
|
|
width: 480,
|
|
height: 96
|
|
))
|
|
scrollView.borderType = .bezelBorder
|
|
scrollView.hasVerticalScroller = true
|
|
scrollView.autohidesScrollers = true
|
|
|
|
let textView = NSTextView(frame: scrollView.contentView.bounds)
|
|
textView.isEditable = false
|
|
textView.isSelectable = true
|
|
textView.isRichText = false
|
|
textView.font = .monospacedSystemFont(
|
|
ofSize: NSFont.systemFontSize,
|
|
weight: .regular
|
|
)
|
|
textView.textContainerInset = NSSize(width: 6, height: 6)
|
|
textView.string = target
|
|
textView.textContainer?.widthTracksTextView = true
|
|
textView.textContainer?.containerSize = NSSize(
|
|
width: scrollView.contentSize.width,
|
|
height: .greatestFiniteMagnitude
|
|
)
|
|
scrollView.documentView = textView
|
|
return scrollView
|
|
}
|
|
}
|