diff --git a/include/ghostty.h b/include/ghostty.h index b413dec41..c82411820 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -464,6 +464,8 @@ typedef void (*ghostty_runtime_show_desktop_notification_cb)(void*, typedef void ( *ghostty_runtime_update_renderer_health)(void*, ghostty_renderer_health_e); typedef void (*ghostty_runtime_mouse_over_link_cb)(void*, const char*, size_t); +typedef void (*ghostty_runtime_set_password_input_cb)(void*, bool); +typedef void (*ghostty_runtime_toggle_secure_input_cb)(); typedef struct { void* userdata; @@ -494,6 +496,8 @@ typedef struct { ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb; ghostty_runtime_update_renderer_health update_renderer_health_cb; ghostty_runtime_mouse_over_link_cb mouse_over_link_cb; + ghostty_runtime_set_password_input_cb set_password_input_cb; + ghostty_runtime_toggle_secure_input_cb toggle_secure_input_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b758411cf..0c0b95418 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; }; A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */; }; A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; + A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; }; A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A59630972AEE163600D64628 /* HostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630962AEE163600D64628 /* HostingWindow.swift */; }; @@ -57,6 +58,8 @@ A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; + A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */; }; A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */; }; @@ -105,6 +108,7 @@ A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Shell.swift; sourceTree = ""; }; A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = ""; }; A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; + A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = ""; }; @@ -122,6 +126,8 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; + A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = ""; }; A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = ""; }; @@ -191,6 +197,7 @@ A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, + A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, A51BFC292B30F69F00E92F16 /* Update */, @@ -211,6 +218,7 @@ C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, + A5CC36142C9CDA03004D6760 /* View+Extension.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, A5CEAFDA29B8005900646FDA /* SplitView */, @@ -295,6 +303,15 @@ path = Services; sourceTree = ""; }; + A57D79252C9C8782001D522E /* Secure Input */ = { + isa = PBXGroup; + children = ( + A57D79262C9C8798001D522E /* SecureInput.swift */, + A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */, + ); + path = "Secure Input"; + sourceTree = ""; + }; A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( @@ -496,6 +513,7 @@ A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, + A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, @@ -516,10 +534,12 @@ A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */, + A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, + A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 01031c9a5..41815631d 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -22,6 +22,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuCheckForUpdates: NSMenuItem? @IBOutlet private var menuOpenConfig: NSMenuItem? @IBOutlet private var menuReloadConfig: NSMenuItem? + @IBOutlet private var menuSecureInput: NSMenuItem? @IBOutlet private var menuQuit: NSMenuItem? @IBOutlet private var menuNewWindow: NSMenuItem? @@ -105,6 +106,11 @@ class AppDelegate: NSObject, "ApplePressAndHoldEnabled": false, ]) + // Check if secure input was enabled when we last quit. + if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) { + toggleSecureInput(self) + } + // Hook up updater menu menuCheckForUpdates?.target = updaterController menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) @@ -294,6 +300,8 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) + syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) + // This menu item is NOT synced with the configuration because it disables macOS // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue // to work but it won't be reflected in the menu item. @@ -484,4 +492,11 @@ class AppDelegate: NSObject, guard let url = URL(string: "https://github.com/ghostty-org/ghostty") else { return } NSWorkspace.shared.open(url) } + + @IBAction func toggleSecureInput(_ sender: Any) { + let input = SecureInput.shared + input.global.toggle() + self.menuSecureInput?.state = if (input.global) { .on } else { .off } + UserDefaults.standard.set(input.global, forKey: "SecureInput") + } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index bbfd59eae..beb411987 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -35,14 +35,15 @@ + - + @@ -76,6 +77,12 @@ + + + + + + diff --git a/macos/Sources/Features/Secure Input/SecureInput.swift b/macos/Sources/Features/Secure Input/SecureInput.swift new file mode 100644 index 000000000..f999ce5ca --- /dev/null +++ b/macos/Sources/Features/Secure Input/SecureInput.swift @@ -0,0 +1,135 @@ +import Carbon +import Cocoa +import OSLog + +// Manages the secure keyboard input state. Secure keyboard input is an old Carbon +// API still in use by applications such as Webkit. From the old Carbon docs: +// "When secure event input mode is enabled, keyboard input goes only to the +// application with keyboard focus and is not echoed to other applications that +// might be using the event monitor target to watch keyboard input." +// +// Secure input is global and stateful so you need a singleton class to manage +// it. You have to yield secure input on application deactivation (because +// it'll affect other apps) and reacquire on reactivation, and every enable +// needs to be balanced with a disable. +class SecureInput : ObservableObject { + static let shared = SecureInput() + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: SecureInput.self) + ) + + // True if you want to enable secure input globally. + var global: Bool = false { + didSet { + apply() + } + } + + // The scoped objects and whether they're currently in focus. + private var scoped: [ObjectIdentifier: Bool] = [:] + + // This is set to true when we've successfully called EnableSecureInput. + @Published private(set) var enabled: Bool = false + + // This is true if we want to enable secure input. We want to enable + // secure input if its enabled globally or any of the scoped objects are + // in focus. + private var desired: Bool { + global || scoped.contains(where: { $0.value }) + } + + private init() { + // Add notifications for application active/resign so we can disable + // secure input. This is only useful for global enabling of secure + // input. + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(onDidResignActive(notification:)), + name: NSApplication.didResignActiveNotification, + object: nil) + center.addObserver( + self, + selector: #selector(onDidBecomeActive(notification:)), + name: NSApplication.didBecomeActiveNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + + // Reset our state so that we can ensure we set the proper secure input + // system state + scoped.removeAll() + global = false + apply() + } + + // Add a scoped object that has secure input enabled. The focused value will + // determine if the object currently has focus. This is used so that secure + // input is only enabled while the object is focused. + func setScoped(_ object: ObjectIdentifier, focused: Bool) { + scoped[object] = focused + apply() + } + + // Remove a scoped object completely. + func removeScoped(_ object: ObjectIdentifier) { + scoped[object] = nil + apply() + } + + private func apply() { + // If we aren't active then we don't do anything. The become/resign + // active notifications will handle applying for us. + guard NSApp.isActive else { return } + + // We only need to apply if we're not in our desired state + guard enabled != desired else { return } + + let err: OSStatus + if (enabled) { + err = DisableSecureEventInput() + } else { + err = EnableSecureEventInput() + } + if (err == noErr) { + enabled = desired + Self.logger.debug("secure input state=\(self.enabled)") + return + } + + Self.logger.warning("secure input apply failed err=\(err)") + } + + // MARK: Notifications + + @objc private func onDidBecomeActive(notification: NSNotification) { + // We only want to re-enable if we're not already enabled and we + // desire to be enabled. + guard !enabled && desired else { return } + let err = EnableSecureEventInput() + if (err == noErr) { + enabled = true + Self.logger.debug("secure input enabled on activation") + return + } + + Self.logger.warning("secure input apply failed err=\(err)") + } + + @objc private func onDidResignActive(notification: NSNotification) { + // We only want to disable if we're enabled. + guard enabled else { return } + let err = DisableSecureEventInput() + if (err == noErr) { + enabled = false + Self.logger.debug("secure input disabled on deactivation") + return + } + + Self.logger.warning("secure input apply failed err=\(err)") + } +} diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift new file mode 100644 index 000000000..717eeb90c --- /dev/null +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct SecureInputOverlay: View { + // Animations + @State private var shadowAngle: Angle = .degrees(0) + @State private var shadowWidth: CGFloat = 6 + + // Popover explainer text + @State private var isPopover = false + + var body: some View { + VStack { + HStack { + Spacer() + + Image(systemName: "lock.shield.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 25, height: 25) + .foregroundColor(.primary) + .padding(5) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.background) + .innerShadow( + using: RoundedRectangle(cornerRadius: 12), + stroke: AngularGradient( + gradient: Gradient(colors: [.cyan, .blue, .yellow, .blue, .cyan]), + center: .center, + angle: shadowAngle + ), + width: shadowWidth + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray, lineWidth: 1) + ) + .onTapGesture { + isPopover = true + } + .padding(.top, 10) + .padding(.trailing, 10) + .popover(isPresented: $isPopover, arrowEdge: .bottom) { + Text(""" + Secure Input is active. Secure Input is a macOS security feature that + prevents applications from reading keyboard events. This is enabled + automatically whenever Ghostty detects a password prompt in the terminal, + or at all times if `Ghostty > Secure Keyboard Entry` is active. + """) + .padding(.all) + } + } + + Spacer() + } + .onAppear { + withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) { + shadowAngle = .degrees(360) + } + + withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: true)) { + shadowWidth = 12 + } + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 69cbfbfc6..7b8c5688f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -94,7 +94,9 @@ extension Ghostty { show_desktop_notification_cb: { userdata, title, body in App.showUserNotification(userdata, title: title, body: body) }, update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) }, - mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) } + mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) }, + set_password_input_cb: { userdata, value in App.setPasswordInput(userdata, value: value) }, + toggle_secure_input_cb: { App.toggleSecureInput() } ) // Create the ghostty app. @@ -299,6 +301,8 @@ extension Ghostty { static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) {} static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) {} + static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) {} + static func toggleSecureInput() {} #endif #if os(macOS) @@ -544,6 +548,18 @@ extension Ghostty { surfaceView.hoverUrl = String(data: buffer, encoding: .utf8) } + static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) { + let surfaceView = self.surfaceUserdata(from: userdata) + guard let appState = self.appState(fromView: surfaceView) else { return } + guard appState.config.autoSecureInput else { return } + surfaceView.passwordInput = value + } + + static func toggleSecureInput() { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.toggleSecureInput(self) + } + static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) { let surfaceView = self.surfaceUserdata(from: userdata) guard let title = String(cString: title!, encoding: .utf8) else { return } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 8ea9371fe..7ecd45cc4 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -371,6 +371,22 @@ extension Ghostty { let str = String(cString: ptr) return AutoUpdate(rawValue: str) ?? defaultValue } + + var autoSecureInput: Bool { + guard let config = self.config else { return true } + var v = false; + let key = "macos-auto-secure-input" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return v + } + + var secureInputIndication: Bool { + guard let config = self.config else { return true } + var v = false; + let key = "macos-secure-input-indication" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return v + } } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index cd3967052..6d20e1e82 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -52,6 +52,11 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false + #if canImport(AppKit) + // Observe SecureInput to detect when its enabled + @ObservedObject private var secureInput = SecureInput.shared + #endif + @EnvironmentObject private var ghostty: Ghostty.App var body: some View { @@ -197,6 +202,17 @@ extension Ghostty { } } + #if canImport(AppKit) + // If we have secure input enabled and we're the focused surface and window + // then we want to show the secure input overlay. + if (ghostty.config.secureInputIndication && + secureInput.enabled && + surfaceFocus && + windowFocus) { + SecureInputOverlay() + } + #endif + // If our surface is not healthy, then we render an error view over it. if (!surfaceView.healthy) { Rectangle().fill(ghostty.config.backgroundColor) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8a617fdd6..c3ae03138 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -42,6 +42,21 @@ extension Ghostty { // then the view is moved to a new window. var initialSize: NSSize? = nil + // Set whether the surface is currently on a password input or not. This is + // detected with the set_password_input_cb on the Ghostty state. + var passwordInput: Bool = false { + didSet { + // We need to update our state within the SecureInput manager. + let input = SecureInput.shared + let id = ObjectIdentifier(self) + if (passwordInput) { + input.setScoped(id, focused: focused) + } else { + input.removeScoped(id) + } + } + } + // Returns true if quit confirmation is required for this surface to // exit safely. var needsConfirmQuit: Bool { @@ -59,6 +74,7 @@ extension Ghostty { if (v.count == 0) { return nil } return v } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { @@ -185,6 +201,9 @@ extension Ghostty { mouseExited(with: NSEvent()) } + // Remove ourselves from secure input if we have to + SecureInput.shared.removeScoped(ObjectIdentifier(self)) + guard let surface = self.surface else { return } ghostty_surface_free(surface) } @@ -209,6 +228,11 @@ extension Ghostty { self.focused = focused ghostty_surface_set_focus(surface, focused) + // Update our secure input state if we are a password input + if (passwordInput) { + SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused) + } + // On macOS 13+ we can store our continuous clock... if #available(macOS 13, iOS 16, *) { if (focused) { diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/View+Extension.swift new file mode 100644 index 000000000..db17b441f --- /dev/null +++ b/macos/Sources/Helpers/View+Extension.swift @@ -0,0 +1,18 @@ +import SwiftUI + +extension View { + func innerShadow( + using shape: S = Rectangle(), + stroke: ST = Color.black, + width: CGFloat = 6, + blur: CGFloat = 6 + ) -> some View { + return self + .overlay( + shape + .stroke(stroke, lineWidth: width) + .blur(radius: blur) + .mask(shape) + ) + } +} diff --git a/pkg/macos/carbon.zig b/pkg/macos/carbon.zig new file mode 100644 index 000000000..8eafaffe6 --- /dev/null +++ b/pkg/macos/carbon.zig @@ -0,0 +1,5 @@ +pub const c = @import("carbon/c.zig").c; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/pkg/macos/carbon/c.zig b/pkg/macos/carbon/c.zig new file mode 100644 index 000000000..248af3c90 --- /dev/null +++ b/pkg/macos/carbon/c.zig @@ -0,0 +1,3 @@ +pub const c = @cImport({ + @cInclude("Carbon/Carbon.h"); +}); diff --git a/pkg/macos/main.zig b/pkg/macos/main.zig index 20274e9c0..ef244fc78 100644 --- a/pkg/macos/main.zig +++ b/pkg/macos/main.zig @@ -1,3 +1,4 @@ +pub const carbon = @import("carbon.zig"); pub const foundation = @import("foundation.zig"); pub const animation = @import("animation.zig"); pub const dispatch = @import("dispatch.zig"); diff --git a/src/Surface.zig b/src/Surface.zig index cb7f8a9ae..3700df7d9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -837,6 +837,11 @@ fn passwordInput(self: *Surface, v: bool) !void { self.io.terminal.flags.password_input = v; } + // Notify our apprt so it can do whatever it wants. + if (@hasDecl(apprt.Surface, "setPasswordInput")) { + self.rt_surface.setPasswordInput(v); + } + try self.queueRender(); } @@ -3717,6 +3722,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } else log.warn("runtime doesn't implement toggleWindowDecorations", .{}); }, + .toggle_secure_input => { + if (@hasDecl(apprt.Surface, "toggleSecureInput")) { + self.rt_surface.toggleSecureInput(); + } else log.warn("runtime doesn't implement toggleSecureInput", .{}); + }, + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 9127bb5bd..f57b16272 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -133,6 +133,14 @@ pub const App = struct { /// parameter. The link target will be null if the mouse is no longer /// over a link. mouse_over_link: ?*const fn (SurfaceUD, ?[*]const u8, usize) void = null, + + /// Notifies that a password input has been started for the given + /// surface. The apprt can use this to modify UI, enable features + /// such as macOS secure input, etc. + set_password_input: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, + + /// Toggle secure input for the application. + toggle_secure_input: ?*const fn () callconv(.C) void = null, }; core_app: *CoreApp, @@ -1005,6 +1013,24 @@ pub const Surface = struct { func(self.userdata, nonNativeFullscreen); } + pub fn toggleSecureInput(self: *Surface) void { + const func = self.app.opts.toggle_secure_input orelse { + log.info("runtime embedder does not toggle_secure_input", .{}); + return; + }; + + func(); + } + + pub fn setPasswordInput(self: *Surface, v: bool) void { + const func = self.app.opts.set_password_input orelse { + log.info("runtime embedder does not set_password_input", .{}); + return; + }; + + func(self.userdata, v); + } + pub fn newTab(self: *const Surface) !void { const func = self.app.opts.new_tab orelse { log.info("runtime embedder does not support new_tab", .{}); diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index b6fe9b0dc..0ff0a2163 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions(); fn comptimeGenerateFishCompletions() []const u8 { comptime { - @setEvalBranchQuota(16000); + @setEvalBranchQuota(17000); var counter = std.io.countingWriter(std.io.null_writer); try writeFishCompletions(&counter.writer()); diff --git a/src/config/Config.zig b/src/config/Config.zig index 9739b36b8..fd7ce996f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1348,6 +1348,34 @@ keybind: Keybinds = .{}, /// find false more visually appealing. @"macos-window-shadow": bool = true, +/// If true, Ghostty on macOS will automatically enable the "Secure Input" +/// feature when it detects that a password prompt is being displayed. +/// +/// "Secure Input" is a macOS security feature that prevents applications from +/// reading keyboard events. This can always be enabled manually using the +/// `Ghostty > Secure Keyboard Entry` menu item. +/// +/// Note that automatic password prompt detection is based on heuristics +/// and may not always work as expected. Specifically, it does not work +/// over SSH connections, but there may be other cases where it also +/// doesn't work. +/// +/// A reason to disable this feature is if you find that it is interfering +/// with legitimate accessibility software (or software that uses the +/// accessibility APIs), since secure input prevents any application from +/// reading keyboard events. +@"macos-auto-secure-input": bool = true, + +/// If true, Ghostty will show a graphical indication when secure input is +/// enabled. This indication is generally recommended to know when secure input +/// is enabled. +/// +/// Normally, secure input is only active when a password prompt is displayed +/// or it is manually (and typically temporarily) enabled. However, if you +/// always have secure input enabled, the indication can be distracting and +/// you may want to disable it. +@"macos-secure-input-indication": bool = true, + /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 37b18f581..b347d263b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -297,6 +297,16 @@ pub const Action = union(enum) { /// Toggle window decorations on and off. This only works on Linux. toggle_window_decorations: void, + /// Toggle secure input mode on or off. This is used to prevent apps + /// that monitor input from seeing what you type. This is useful for + /// entering passwords or other sensitive information. + /// + /// This applies to the entire application, not just the focused + /// terminal. You must toggle it off to disable it, or quit Ghostty. + /// + /// This only works on macOS, since this is a system API on macOS. + toggle_secure_input: void, + /// Quit ghostty. quit: void,