From 0c38f40f0a4e24b00c993a7b2942cccdaec0962c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 10:11:10 -0700 Subject: [PATCH] macos: secure input manager, global option in app --- macos/Ghostty.xcodeproj/project.pbxproj | 12 ++ macos/Sources/App/macOS/AppDelegate.swift | 9 ++ macos/Sources/App/macOS/MainMenu.xib | 13 +- .../Features/Secure Input/SecureInput.swift | 135 ++++++++++++++++++ 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Features/Secure Input/SecureInput.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b758411cf..88c90ba33 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 */; }; @@ -105,6 +106,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 = ""; }; @@ -191,6 +193,7 @@ A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, + A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, A51BFC292B30F69F00E92F16 /* Update */, @@ -295,6 +298,14 @@ path = Services; sourceTree = ""; }; + A57D79252C9C8782001D522E /* Secure Input */ = { + isa = PBXGroup; + children = ( + A57D79262C9C8798001D522E /* SecureInput.swift */, + ); + path = "Secure Input"; + sourceTree = ""; + }; A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( @@ -516,6 +527,7 @@ 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 */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 01031c9a5..60750517c 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? @@ -294,6 +295,8 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) + // TODO: sync secure keyboard entry toggle + // 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 +487,10 @@ 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 } + } } 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..231306f5d --- /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 { + 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. + private 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)") + } +}