diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 928a9829..7e008c69 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -1390,6 +1390,30 @@ final class AppState { workspaces[projectID]?.activeTab?.focusPane(paneID) } + /// Begin the sidebar rename flow for the tab containing `paneID` — the + /// ghostty `prompt_surface_title` keybind's Macterm mapping (titles live + /// on tabs here, not surfaces). No-op for panes outside any workspace + /// (the quick terminal has no tab UI to rename). + func renameTab(containing paneID: UUID, projectID: UUID) { + guard let tab = workspaces[projectID]?.tabs.first(where: { $0.splitRoot.contains(paneID: paneID) }) + else { return } + sidebarVisible = true + let tabID = tab.id + // Defer a tick so the sidebar row's TextField exists before it's asked + // to begin editing — same reason as the Rename Tab command. + DispatchQueue.main.async { self.renamingTabID = tabID } + } + + /// Set (or, with nil, clear) the custom title of the tab containing + /// `paneID` — the ghostty `set_tab_title` keybind's Macterm mapping, + /// writing the same field the sidebar rename edits. + func setTabTitle(containing paneID: UUID, projectID: UUID, title: String?) { + guard let tab = workspaces[projectID]?.tabs.first(where: { $0.splitRoot.contains(paneID: paneID) }) + else { return } + tab.customTitle = title + saveWorkspaces() + } + func navigateToPane(_ paneID: UUID, projectID: UUID) { guard workspaces[projectID] != nil else { NSApp.activate() diff --git a/Macterm/Ghostty/GhosttyApp.swift b/Macterm/Ghostty/GhosttyApp.swift index f6513d46..644c59f9 100644 --- a/Macterm/Ghostty/GhosttyApp.swift +++ b/Macterm/Ghostty/GhosttyApp.swift @@ -332,6 +332,66 @@ final class GhosttyApp { return String(bytes: UnsafeRawBufferPointer(start: ptr, count: Int(str.len)), encoding: .utf8) } + private func configBool(_ key: String, default defaultValue: Bool) -> Bool { + guard let config else { return defaultValue } + var value = defaultValue + guard ghostty_config_get(config, &value, key, UInt(key.utf8.count)) else { return defaultValue } + return value + } + + // MARK: - Bell & secure input config (read by GhosttyCallbacks) + + /// The user's `bell-features` set. Same bit layout as Ghostty.app's + /// `BellFeatures`; `title` and `border` exist in the config but Macterm + /// implements only the app-level features (see `GhosttyCallbacks`'s + /// `RING_BELL` case). + struct BellFeatures: OptionSet { + let rawValue: CUnsignedInt + static let system = BellFeatures(rawValue: 1 << 0) + static let audio = BellFeatures(rawValue: 1 << 1) + static let attention = BellFeatures(rawValue: 1 << 2) + static let title = BellFeatures(rawValue: 1 << 3) + static let border = BellFeatures(rawValue: 1 << 4) + } + + var bellFeatures: BellFeatures { + guard let config else { return [] } + var raw: CUnsignedInt = 0 + let key = "bell-features" + guard ghostty_config_get(config, &raw, key, UInt(key.utf8.count)) else { return [] } + return BellFeatures(rawValue: raw) + } + + /// Absolute path of the user's `bell-audio-path`, or nil when unset. + var bellAudioPath: String? { + guard let config else { return nil } + var value = ghostty_config_path_s() + let key = "bell-audio-path" + guard ghostty_config_get(config, &value, key, UInt(key.utf8.count)), let ptr = value.path else { return nil } + let path = String(cString: ptr) + return path.isEmpty ? nil : path + } + + var bellAudioVolume: Float { + guard let config else { return 0.5 } + var value = 0.5 + let key = "bell-audio-volume" + _ = ghostty_config_get(config, &value, key, UInt(key.utf8.count)) + return Float(value) + } + + /// `macos-auto-secure-input`: gate for enabling secure keyboard input + /// automatically while a surface reports a password prompt. + var autoSecureInput: Bool { + configBool("macos-auto-secure-input", default: true) + } + + /// `macos-secure-input-indication`: whether to show the per-pane lock + /// badge while secure input is active. + var secureInputIndication: Bool { + configBool("macos-secure-input-indication", default: true) + } + private func loadConfig() -> (ghostty_config_t?, ReloadResult) { var result = ReloadResult(diagnostics: []) guard let cfg = ghostty_config_new() else { return (nil, result) } diff --git a/Macterm/Ghostty/GhosttyCallbacks.swift b/Macterm/Ghostty/GhosttyCallbacks.swift index af8150ba..956c62ae 100644 --- a/Macterm/Ghostty/GhosttyCallbacks.swift +++ b/Macterm/Ghostty/GhosttyCallbacks.swift @@ -1,5 +1,9 @@ import AppKit import GhosttyKit +import os +import UniformTypeIdentifiers + +private let logger = Logger(subsystem: appBundleID, category: "GhosttyCallbacks") /// Routes libghostty runtime callbacks to the appropriate terminal views. final class GhosttyCallbacks: @unchecked Sendable { @@ -112,11 +116,230 @@ final class GhosttyCallbacks: @unchecked Sendable { let snapshot = GhosttyApp.readColors(from: cfg) DispatchQueue.main.async { GhosttyApp.shared.adoptResolvedColors(snapshot) } return true + case GHOSTTY_ACTION_OPEN_URL: + // Keybind actions that end in opening something — link clicks, + // `write_scrollback_file:open` and friends. Left unhandled, + // libghostty still opens the file itself (a spawned `open -t`), + // but logs it as an apprt failure; handling it here matches + // Ghostty.app. The url bytes are NOT null-terminated (`len` + // bounds them) and the pointer is owned by libghostty for this + // call only, so copy synchronously. + let payload = action.action.open_url + guard let ptr = payload.url, payload.len > 0 else { return true } + let urlString = String( + decoding: UnsafeRawBufferPointer(start: ptr, count: Int(payload.len)), + as: UTF8.self + ) + let kind = payload.kind + DispatchQueue.main.async { Self.openURL(urlString, kind: kind) } + return true + case GHOSTTY_ACTION_MOUSE_SHAPE: + // The pointer shape for the current mouse position — I-beam over + // text, pointing hand over an OSC 8 link, resize arrows for TUI + // drags. Applied as the scroll view's documentCursor. + guard let view = surfaceView(from: target) else { return true } + let shape = action.action.mouse_shape + DispatchQueue.main.async { view.surfaceDidChangeMouseShape(shape) } + return true + case GHOSTTY_ACTION_MOUSE_VISIBILITY: + // mouse-hide-while-typing. Hidden-until-move matches Ghostty.app: + // any physical mouse movement reveals the cursor again, so we + // never need to balance hide/unhide pairs. + guard surfaceView(from: target) != nil else { return true } + let hidden = action.action.mouse_visibility == GHOSTTY_MOUSE_HIDDEN + DispatchQueue.main.async { NSCursor.setHiddenUntilMouseMoves(hidden) } + return true + case GHOSTTY_ACTION_MOUSE_OVER_LINK: + // The URL under the pointer, for the pane's hover banner. Zero + // length means the pointer left the link. Bytes are len-bounded + // (not null-terminated) and owned by libghostty for this call. + guard let view = surfaceView(from: target) else { return true } + let link = action.action.mouse_over_link + var url: String? + if let ptr = link.url, link.len > 0 { + url = String( + decoding: UnsafeRawBufferPointer(start: ptr, count: Int(link.len)), + as: UTF8.self + ) + } + DispatchQueue.main.async { view.surfaceDidHoverLink(url) } + return true + case GHOSTTY_ACTION_SECURE_INPUT: + // App target: the user's `toggle_secure_input` keybind. Surface + // target: libghostty detected a password prompt — honored only + // when `macos-auto-secure-input` allows (checked on the main + // queue: config access is main-actor). + let mode = action.action.secure_input + switch target.tag { + case GHOSTTY_TARGET_APP: + DispatchQueue.main.async { + switch mode { + case GHOSTTY_SECURE_INPUT_ON: SecureInput.shared.setGlobal(true) + case GHOSTTY_SECURE_INPUT_OFF: SecureInput.shared.setGlobal(false) + default: SecureInput.shared.toggleGlobal() + } + } + return true + case GHOSTTY_TARGET_SURFACE: + guard let view = surfaceView(from: target) else { return true } + DispatchQueue.main.async { + guard GhosttyApp.shared.autoSecureInput else { return } + switch mode { + case GHOSTTY_SECURE_INPUT_ON: view.passwordInput = true + case GHOSTTY_SECURE_INPUT_OFF: view.passwordInput = false + default: view.passwordInput.toggle() + } + } + return true + default: + return false + } + case GHOSTTY_ACTION_RING_BELL: + // BEL. The `title`/`border` bell-features are per-tab UI Macterm + // doesn't implement; the app-level features (beep, custom sound, + // dock attention) are handled here for any surface. + guard target.tag == GHOSTTY_TARGET_SURFACE else { return false } + DispatchQueue.main.async { Self.ringBell() } + return true + case GHOSTTY_ACTION_OPEN_CONFIG: + // The `open_config` keybind. Macterm's source of truth is the + // user's own ghostty config file, so open that — created empty + // first if it doesn't exist yet, matching Ghostty.app. + DispatchQueue.main.async { Self.openUserConfig() } + return true + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: + // Same guard as the Check for Update command: no-op while a + // Sparkle check is already in flight. + DispatchQueue.main.async { + guard Updater.shared.canCheckForUpdates else { return } + Updater.shared.checkForUpdates() + } + return true + case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD: + // The pane owns title state (program title / process name), so it + // supplies the string via `titleProvider`. + guard let view = surfaceView(from: target) else { return true } + DispatchQueue.main.async { + guard let title = view.titleProvider?(), !title.isEmpty else { return } + Self.setPasteboardString(title) + } + return true + case GHOSTTY_ACTION_PROMPT_TITLE: + // `prompt_surface_title`. Macterm titles live on tabs (a surface + // has no separate title UI), so both the surface and tab variants + // route to the containing tab's rename flow. + guard let view = surfaceView(from: target) else { return true } + DispatchQueue.main.async { view.onPromptTitle?() } + return true + case GHOSTTY_ACTION_SET_TAB_TITLE: + // The `set_tab_title` keybind: an explicit user-driven title, so + // it maps to the tab's customTitle (what rename-tab sets). Empty + // restores the automatic title, same contract as Ghostty.app. + guard let view = surfaceView(from: target) else { return true } + let title = action.action.set_tab_title.title.flatMap { String(cString: $0) } ?? "" + DispatchQueue.main.async { view.onSetTabTitle?(title.isEmpty ? nil : title) } + return true + case GHOSTTY_ACTION_RENDERER_HEALTH: + // No recovery UI (Ghostty.app shows a banner); surfacing the + // transition in the log is what makes a black pane diagnosable. + let health = action.action.renderer_health + if health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY { + logger.error("renderer reported unhealthy for a surface") + } else { + logger.info("renderer recovered for a surface") + } + return true default: + // Deliberately unhandled: window/tab/split management actions + // (NEW_TAB, NEW_SPLIT, GOTO_*, TOGGLE_FULLSCREEN, QUIT, …) — + // those concepts are Macterm-owned via AppCommand/hotkeys, not + // ghostty keybinds; GTK/iOS-only actions (SHOW_GTK_INSPECTOR, + // SHOW_ON_SCREEN_KEYBOARD); the imgui INSPECTOR; sizing hints + // (CELL_SIZE, SIZE_LIMIT, INITIAL_SIZE, RESET_WINDOW_SIZE) that + // don't map onto a single shared window of splits; UNDO/REDO + // (no app-level undo stack); COLOR_CHANGE (OSC 4/10/11 recolors + // the grid core-side; Macterm's chrome follows the resolved + // theme via CONFIG_CHANGE, not per-surface dynamic colors); + // SELECTION_CHANGED (accessibility text APIs not implemented); + // and KEY_SEQUENCE/KEY_TABLE progress UI (sequences themselves + // still work core-side). SHOW_CHILD_EXITED stays unhandled on + // purpose: returning false makes the core render its own + // abnormal-exit overlay, which is the error UI Macterm relies on. return false } } + /// Ring the terminal bell per the user's `bell-features`: the system + /// beep, an optional custom sound, and a dock-bounce attention request + /// (which macOS shows only while the app is inactive). + @MainActor + private static func ringBell() { + let features = GhosttyApp.shared.bellFeatures + if features.contains(.system) { + NSSound.beep() + } + if features.contains(.audio), + let path = GhosttyApp.shared.bellAudioPath, + let sound = NSSound(contentsOfFile: path, byReference: false) + { + sound.volume = GhosttyApp.shared.bellAudioVolume + sound.play() + } + if features.contains(.attention) { + NSApp.requestUserAttention(.informationalRequest) + } + } + + /// Open the user's ghostty config in their text editor, creating an empty + /// file (and its directory) first so a fresh setup gets an editable file + /// rather than a silent no-op. + @MainActor + private static func openUserConfig() { + let path = Preferences.shared.expandedUserGhosttyConfigPath + guard !path.isEmpty else { return } + if !FileManager.default.fileExists(atPath: path) { + let url = URL(fileURLWithPath: path) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + FileManager.default.createFile(atPath: path, contents: nil) + } + openURL(path, kind: GHOSTTY_ACTION_OPEN_URL_KIND_TEXT) + } + + // MARK: - Open URL (GHOSTTY_ACTION_OPEN_URL) + + /// The URL a `GHOSTTY_ACTION_OPEN_URL` payload string denotes. A string + /// without a scheme is a file path — `URL(string:)` would happily build a + /// schemeless URL from it that no application can open (ghostty#8763) — + /// so those resolve via the file-URL initializer, with `~` expanded. + static func resolvedOpenTarget(_ string: String) -> URL { + if let candidate = URL(string: string), candidate.scheme != nil { + return candidate + } + return URL(fileURLWithPath: (string as NSString).standardizingPath) + } + + private static func openURL(_ string: String, kind: ghostty_action_open_url_kind_e) { + let url = resolvedOpenTarget(string) + // `.text` asks for the payload to be *viewed as text* (scrollback + // dumps land here): prefer the default app for the file's extension, + // then the system plain-text editor — same order as Ghostty.app. + // `.html`/`.unknown` just go to the default handler. + if kind == GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, let editor = defaultTextEditor(for: url) { + NSWorkspace.shared.open([url], withApplicationAt: editor, configuration: NSWorkspace.OpenConfiguration()) + return + } + NSWorkspace.shared.open(url) + } + + private static func defaultTextEditor(for url: URL) -> URL? { + let byExtension = UTType(filenameExtension: url.pathExtension) + .flatMap { NSWorkspace.shared.urlForApplication(toOpen: $0) } + return byExtension ?? NSWorkspace.shared.urlForApplication(toOpen: .plainText) + } + func readClipboard(ud: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) -> Bool { // `ghostty_surface_complete_clipboard_request`'s surface parameter is // non-null on the Zig side; a request completing during surface diff --git a/Macterm/Model/SplitNode.swift b/Macterm/Model/SplitNode.swift index 5dca7d44..c7cce631 100644 --- a/Macterm/Model/SplitNode.swift +++ b/Macterm/Model/SplitNode.swift @@ -489,6 +489,11 @@ final class Pane: Identifiable { private var agentIconPID: pid_t? let searchState = TerminalSearchState() + + /// The OSC 8 link URL under the mouse, for the pane's hover banner + /// (`GHOSTTY_ACTION_MOUSE_OVER_LINK`). Live UI state only — never + /// persisted. + var hoverURL: String? var executionState: TerminalExecutionState = .idle { didSet { guard executionState != oldValue else { return } @@ -885,6 +890,14 @@ final class Pane: Identifiable { view.onOutputActivity = nil view.onScrollbarUpdate = nil view.onScrollWheel = nil + view.onLinkHover = nil + view.onMouseShapeChange = nil + view.onPromptTitle = nil + view.onSetTabTitle = nil + view.titleProvider = nil + // Release any secure-input scope this view holds (a pane closed + // mid-password-prompt must not leave the OS state stuck on). + view.passwordInput = false view.destroySurface() let scroll = _scrollView _scrollView = nil diff --git a/Macterm/System/SecureInput.swift b/Macterm/System/SecureInput.swift new file mode 100644 index 00000000..7fb6f189 --- /dev/null +++ b/Macterm/System/SecureInput.swift @@ -0,0 +1,127 @@ +import AppKit +import Carbon +import os + +private let logger = Logger(subsystem: appBundleID, category: "SecureInput") + +/// Manages macOS secure keyboard input (the Carbon `EnableSecureEventInput` +/// API): while enabled, keystrokes go only to the focused app and are hidden +/// from event taps and keyboard monitors. Ported from Ghostty.app's manager; +/// the shape is dictated by the API's contract: +/// +/// - It's global, process-scoped, and *counted* — every enable must be +/// balanced by exactly one disable, so a singleton owns the one on/off edge. +/// - It must be released while the app is inactive (it affects other apps) +/// and reacquired on activation, hence the NSApplication observers. +/// +/// Two inputs decide the desired state: a `global` flag (the user's +/// `toggle_secure_input` keybind, app target) and per-surface scopes (a +/// terminal reporting a password prompt, enabled only while that surface has +/// keyboard focus). System-state calls are injectable so the balancing logic +/// is unit-testable without flipping real secure input under the test runner. +@MainActor +@Observable +final class SecureInput { + static let shared = SecureInput() + + /// True while secure input is actually enabled at the OS level. Drives + /// the per-pane lock indicator. + private(set) var enabled = false + + /// User-requested global secure input (`toggle_secure_input` keybind). + private(set) var global = false + + @ObservationIgnored + private var scoped: [ObjectIdentifier: Bool] = [:] + + /// System hooks, injectable for tests. Defaults are the real Carbon calls + /// and NSApp activity. + @ObservationIgnored + var enableHook: () -> OSStatus = { EnableSecureEventInput() } + @ObservationIgnored + var disableHook: () -> OSStatus = { DisableSecureEventInput() } + @ObservationIgnored + var isAppActive: () -> Bool = { NSApp.isActive } + + @ObservationIgnored + nonisolated(unsafe) private var observers: [NSObjectProtocol] = [] + + init() { + let center = NotificationCenter.default + let becameActive = center.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.appDidBecomeActive() } + } + let resignedActive = center.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.appDidResignActive() } + } + observers = [becameActive, resignedActive] + } + + deinit { + for token in observers { + NotificationCenter.default.removeObserver(token) + } + } + + func setGlobal(_ on: Bool) { + global = on + logger.info("secure input global=\(on, privacy: .public)") + apply() + } + + func toggleGlobal() { + setGlobal(!global) + } + + /// Record that `object` (a surface on a password prompt) wants secure + /// input, active only while it has keyboard focus. + func setScoped(_ object: ObjectIdentifier, focused: Bool) { + scoped[object] = focused + logger.info("secure input scoped add focused=\(focused, privacy: .public)") + apply() + } + + /// Drop a scoped object entirely (password prompt ended, or the surface + /// is being destroyed). + func removeScoped(_ object: ObjectIdentifier) { + scoped[object] = nil + logger.info("secure input scoped remove") + apply() + } + + private var desired: Bool { + global || scoped.contains { $0.value } + } + + private func apply() { + // While inactive we must stay released regardless of desire; the + // activation observer reacquires. + guard isAppActive() else { return } + setSystemState(desired) + } + + private func appDidBecomeActive() { + setSystemState(desired) + } + + private func appDidResignActive() { + setSystemState(false) + } + + /// The single place the OS state flips, so enable/disable stay balanced: + /// `enabled` tracks exactly one outstanding EnableSecureEventInput. + private func setSystemState(_ on: Bool) { + guard enabled != on else { return } + let err = on ? enableHook() : disableHook() + guard err == noErr else { + logger.warning("secure input \(on ? "enable" : "disable", privacy: .public) failed err=\(err, privacy: .public)") + return + } + enabled = on + logger.info("secure input enabled=\(on, privacy: .public)") + } +} diff --git a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift index 809e8c94..68b8a348 100644 --- a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift +++ b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift @@ -137,6 +137,57 @@ final class GhosttyTerminalNSView: NSView { onOutputActivity?(total) } + /// Apply the pointer shape libghostty computed for the current mouse + /// position. Unknown shapes are ignored (keep the last cursor) — same + /// policy as Ghostty.app. + func surfaceDidChangeMouseShape(_ shape: ghostty_action_mouse_shape_e) { + guard let cursor = Self.cursor(for: shape) else { return } + onMouseShapeChange?(cursor) + } + + /// The NSCursor for a libghostty mouse shape, or nil for shapes with no + /// macOS counterpart. Mirrors Ghostty.app's mapping (Cursor.swift), + /// including the macOS 15 directional resize variants. + static func cursor(for shape: ghostty_action_mouse_shape_e) -> NSCursor? { + switch shape { + case GHOSTTY_MOUSE_SHAPE_DEFAULT: return .arrow + case GHOSTTY_MOUSE_SHAPE_TEXT: return .iBeam + case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: return .iBeamCursorForVerticalLayout + case GHOSTTY_MOUSE_SHAPE_POINTER: return .pointingHand + case GHOSTTY_MOUSE_SHAPE_GRAB: return .openHand + case GHOSTTY_MOUSE_SHAPE_GRABBING: return .closedHand + case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: return .contextualMenu + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: return .crosshair + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: return .operationNotAllowed + case GHOSTTY_MOUSE_SHAPE_W_RESIZE: + if #available(macOS 15.0, *) { return .columnResize(directions: .left) } + return .resizeLeft + case GHOSTTY_MOUSE_SHAPE_E_RESIZE: + if #available(macOS 15.0, *) { return .columnResize(directions: .right) } + return .resizeRight + case GHOSTTY_MOUSE_SHAPE_N_RESIZE: + if #available(macOS 15.0, *) { return .rowResize(directions: .up) } + return .resizeUp + case GHOSTTY_MOUSE_SHAPE_S_RESIZE: + if #available(macOS 15.0, *) { return .rowResize(directions: .down) } + return .resizeDown + case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: + if #available(macOS 15.0, *) { return .rowResize } + return .resizeUpDown + case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: + if #available(macOS 15.0, *) { return .columnResize } + return .resizeLeftRight + default: + return nil + } + } + + /// Forward a link-hover change (`GHOSTTY_ACTION_MOUSE_OVER_LINK`). An + /// empty URL means the pointer left the link. + func surfaceDidHoverLink(_ url: String?) { + onLinkHover?(url?.isEmpty == true ? nil : url) + } + /// Record the actual payload resolved for a libghostty clipboard request. /// Unlike key-code inference, this distinguishes real content from an /// empty/whitespace clipboard or a remapped Command-V binding. @@ -190,9 +241,47 @@ final class GhosttyTerminalNSView: NSView { /// like less/vim fall through to libghostty for mouse reporting). Return /// false to let libghostty handle the event directly. var onScrollWheel: ((NSEvent) -> Bool)? + /// The link URL under the mouse (`GHOSTTY_ACTION_MOUSE_OVER_LINK`), nil + /// when the pointer leaves it. Drives the pane's hover-URL banner. + var onLinkHover: ((String?) -> Void)? + /// The pointer cursor libghostty wants over the grid + /// (`GHOSTTY_ACTION_MOUSE_SHAPE`) — I-beam over text, a pointing hand + /// over links. The hosting `SurfaceScrollView` applies it as its + /// `documentCursor`. + var onMouseShapeChange: ((NSCursor) -> Void)? + /// The `prompt_surface_title` keybind: ask the user for a title. Macterm + /// titles live on tabs, so this routes to the tab-rename flow. + var onPromptTitle: (() -> Void)? + /// The `set_tab_title` keybind: set (or, with nil, clear) the containing + /// tab's custom title. + var onSetTabTitle: ((String?) -> Void)? + /// The pane's current display title, for `copy_title_to_clipboard`. The + /// title is pane-derived state (program title / process name), so the + /// pane supplies it. + var titleProvider: (() -> String?)? var isFocused: Bool = false var currentPwd: String? + /// True while libghostty reports the surface is at a password prompt + /// (surface-target `GHOSTTY_ACTION_SECURE_INPUT`). Registers this view + /// with the `SecureInput` manager so keystrokes are shielded from event + /// taps exactly while the prompt is focused. + var passwordInput: Bool = false { + didSet { + guard passwordInput != oldValue else { return } + let id = ObjectIdentifier(self) + if passwordInput { + SecureInput.shared.setScoped(id, focused: hasKeyboardFocus) + } else { + SecureInput.shared.removeScoped(id) + } + } + } + + private var hasKeyboardFocus: Bool { + window?.firstResponder === self + } + private var lastScrollbarSnapshot: ScrollbarSnapshot? private var commandSubmissionEvidence = TerminalCommandSubmission.Evidence() private var commandSubmissionEvidenceReset: DispatchWorkItem? @@ -628,15 +717,25 @@ final class GhosttyTerminalNSView: NSView { ghostty_surface_set_focus(surface, true) onFocus?() } + if result { syncSecureInputFocus(true) } return result } override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() if result, let surface { ghostty_surface_set_focus(surface, false) } + if result { syncSecureInputFocus(false) } return result } + /// Secure input for a password prompt applies only while this view holds + /// keyboard focus — a prompt sitting in a background pane must not shield + /// (and so break) typing that's going elsewhere. + private func syncSecureInputFocus(_ focused: Bool) { + guard passwordInput else { return } + SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused) + } + // MARK: - Tracking area private var currentTrackingArea: NSTrackingArea? diff --git a/Macterm/Views/Terminal/SurfaceScrollView.swift b/Macterm/Views/Terminal/SurfaceScrollView.swift index 6f0eebd1..9baea437 100644 --- a/Macterm/Views/Terminal/SurfaceScrollView.swift +++ b/Macterm/Views/Terminal/SurfaceScrollView.swift @@ -96,6 +96,13 @@ final class SurfaceScrollView: NSScrollView { surfaceView.onScrollWheel = { [weak self] event in self?.handleSurfaceScrollWheel(event) ?? false } + // libghostty computes the pointer shape for the grid (I-beam over + // text, pointing hand over links, resize arrows for TUI drags); + // NSScrollView applies documentCursor over the document area, which + // is exactly the surface. Same mechanism as Ghostty's own scroll view. + surfaceView.onMouseShapeChange = { [weak self] cursor in + self?.documentCursor = cursor + } } @available(*, unavailable) diff --git a/Macterm/Views/TerminalPane.swift b/Macterm/Views/TerminalPane.swift index c77a47b4..861a7f3a 100644 --- a/Macterm/Views/TerminalPane.swift +++ b/Macterm/Views/TerminalPane.swift @@ -45,10 +45,62 @@ struct TerminalPane: View { onSplitRequest: onSplitRequest, onZoomRequest: onZoomRequest ) + // Overlays anchor to the surface, not the VStack, so the search + // bar (above) never shifts them. + .overlay(alignment: .bottomLeading) { + if let url = pane.hoverURL { + LinkHoverBanner(url: url) + } + } + .overlay(alignment: .topTrailing) { + // Secure-input badge: the OS is shielding keystrokes for this + // pane's password prompt (or the global toggle). Focus-scoped, + // so at most one pane shows it. + if focused, SecureInput.shared.enabled, GhosttyApp.shared.secureInputIndication { + SecureInputBadge() + } + } } } } +/// Safari-style link preview shown while the mouse hovers an OSC 8 / detected +/// URL in the terminal (`GHOSTTY_ACTION_MOUSE_OVER_LINK`). +private struct LinkHoverBanner: View { + let url: String + + var body: some View { + Text(url) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(MactermTheme.fgMuted) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(MactermTheme.bg, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(MactermTheme.border)) + .frame(maxWidth: 480, alignment: .leading) + .padding(6) + .allowsHitTesting(false) + } +} + +/// Lock badge shown while secure keyboard input is active for the focused +/// pane (`macos-secure-input-indication`). +private struct SecureInputBadge: View { + var body: some View { + Image(systemName: "lock.fill") + .font(.caption) + .foregroundStyle(MactermTheme.fgMuted) + .padding(5) + .background(MactermTheme.bg, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(MactermTheme.border)) + .padding(6) + .allowsHitTesting(false) + .help("Secure keyboard input is active") + } +} + /// The real terminal NSView, hosted via NSViewRepresentable. /// The NSView itself is owned by `Pane` — this representable just returns the /// stored instance so SwiftUI lifecycle events (tab switches, split reshapes) @@ -63,6 +115,11 @@ private struct TerminalSurface: NSViewRepresentable { let onSplitRequest: (SplitDirection, SplitPosition) -> Void let onZoomRequest: () -> Void + /// Optional on purpose: the quick terminal's hosting view has no AppState + /// in its environment, and its ephemeral tab has no rename UI anyway — + /// the tab-title actions just no-op there. + @Environment(AppState.self) private var appState: AppState? + final class Coordinator { var wasFocused = false } @@ -212,6 +269,18 @@ private struct TerminalSurface: NSViewRepresentable { pane.markProgressFinished() onCommandFinished() } + view.onLinkHover = { [weak pane] url in + pane?.hoverURL = url + } + view.titleProvider = { [weak pane] in pane?.displayTitle } + view.onPromptTitle = { [weak appState, weak pane] in + guard let pane else { return } + appState?.renameTab(containing: pane.id, projectID: pane.projectID) + } + view.onSetTabTitle = { [weak appState, weak pane] title in + guard let pane else { return } + appState?.setTabTitle(containing: pane.id, projectID: pane.projectID, title: title) + } view.onOutputActivity = { [weak pane] total in guard let pane, Preferences.shared.showTabStatusIndicator else { return } // The single activity source. Output heartbeats fire from the pty diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index 44f7cdcd..581806b6 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -527,6 +527,50 @@ struct AppStateTests { #expect(state.renamingTabID == nil) } + @Test + func renameTabContaining_targets_the_panes_tab() async throws { + let state = makeAppState() + let p = seedProject(state) + let tab = try #require(state.workspaces[p.id]?.activeTab) + let paneID = try #require(tab.splitRoot.allPanes().first?.id) + state.sidebarVisible = false + + state.renameTab(containing: paneID, projectID: p.id) + + #expect(state.sidebarVisible) + // The rename target lands a runloop tick later (the sidebar row's + // TextField must exist before it's asked to edit) — poll with sleeps. + for _ in 0 ..< 100 where state.renamingTabID == nil { + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(state.renamingTabID == tab.id) + } + + @Test + func renameTabContaining_unknown_pane_is_noop() { + let state = makeAppState() + let p = seedProject(state) + state.sidebarVisible = false + state.renameTab(containing: UUID(), projectID: p.id) + #expect(!state.sidebarVisible) + } + + @Test + func setTabTitleContaining_sets_and_clears_customTitle() throws { + let state = makeAppState() + let p = seedProject(state) + let tab = try #require(state.workspaces[p.id]?.activeTab) + let paneID = try #require(tab.splitRoot.allPanes().first?.id) + + state.setTabTitle(containing: paneID, projectID: p.id, title: "deploy") + #expect(tab.customTitle == "deploy") + + // Empty/nil restores the automatic title, same contract as the + // ghostty keybind. + state.setTabTitle(containing: paneID, projectID: p.id, title: nil) + #expect(tab.customTitle == nil) + } + @Test func renamingProjectID_defaults_to_nil() { let state = makeAppState() diff --git a/MactermTests/Ghostty/GhosttyCallbacksTests.swift b/MactermTests/Ghostty/GhosttyCallbacksTests.swift index 852b18a8..ab4702ad 100644 --- a/MactermTests/Ghostty/GhosttyCallbacksTests.swift +++ b/MactermTests/Ghostty/GhosttyCallbacksTests.swift @@ -109,4 +109,29 @@ struct GhosttyCallbacksTests { #expect(GhosttyCallbacks.hasPasteboardContent(in: pb) == true) #expect(GhosttyCallbacks.imagePasteboardPath(pb) != nil) } + + // MARK: - GHOSTTY_ACTION_OPEN_URL target resolution + + @Test + func openTarget_schemeURLsPassThrough() { + let url = GhosttyCallbacks.resolvedOpenTarget("https://example.com/a?b=c") + #expect(url.scheme == "https") + #expect(url.absoluteString == "https://example.com/a?b=c") + } + + @Test + func openTarget_plainPathsBecomeFileURLs() { + // `write_scrollback_file:open` hands over a bare temp path; a + // schemeless URL(string:) of it would be unopenable (ghostty#8763). + let url = GhosttyCallbacks.resolvedOpenTarget("/tmp/foo/history.txt") + #expect(url.isFileURL) + #expect(url.path == "/tmp/foo/history.txt") + } + + @Test + func openTarget_tildeExpands() { + let url = GhosttyCallbacks.resolvedOpenTarget("~/notes.txt") + #expect(url.isFileURL) + #expect(url.path == NSHomeDirectory() + "/notes.txt") + } } diff --git a/MactermTests/System/SecureInputTests.swift b/MactermTests/System/SecureInputTests.swift new file mode 100644 index 00000000..612e9545 --- /dev/null +++ b/MactermTests/System/SecureInputTests.swift @@ -0,0 +1,120 @@ +import Foundation +@testable import Macterm +import Testing + +/// Exercises the enable/disable balancing logic with injected system hooks — +/// the real Carbon calls must never fire under the test runner (they'd flip +/// actual secure input for the hosting app). +@MainActor +struct SecureInputTests { + /// Records hook calls; a class so closures share one instance. + private final class Recorder { + var enables = 0 + var disables = 0 + } + + private func makeInput(active: Bool = true, enableResult: OSStatus = noErr) -> (SecureInput, Recorder) { + let recorder = Recorder() + let input = SecureInput() + input.enableHook = { + recorder.enables += 1 + return enableResult + } + input.disableHook = { + recorder.disables += 1 + return noErr + } + input.isAppActive = { active } + return (input, recorder) + } + + @Test + func globalToggle_flipsSystemState() { + let (input, recorder) = makeInput() + input.setGlobal(true) + #expect(input.enabled) + #expect(recorder.enables == 1) + input.setGlobal(false) + #expect(!input.enabled) + #expect(recorder.disables == 1) + } + + @Test + func scopedObject_enablesOnlyWhileFocused() { + let (input, recorder) = makeInput() + // Keep the anchor object alive: an ObjectIdentifier of a deallocated + // object can collide with the next allocation at the same address. + let anchor = NSObject() + let object = ObjectIdentifier(anchor) + + input.setScoped(object, focused: false) + #expect(!input.enabled) + #expect(recorder.enables == 0) + + input.setScoped(object, focused: true) + #expect(input.enabled) + + input.setScoped(object, focused: false) + #expect(!input.enabled) + #expect(recorder.disables == 1) + } + + @Test + func removeScoped_releasesTheHold() { + let (input, recorder) = makeInput() + let anchor = NSObject() + let object = ObjectIdentifier(anchor) + input.setScoped(object, focused: true) + #expect(input.enabled) + input.removeScoped(object) + #expect(!input.enabled) + #expect(recorder.enables == 1) + #expect(recorder.disables == 1) + } + + @Test + func overlappingHolds_stayBalanced() { + // The OS call must flip exactly once per edge no matter how many + // inputs want it — an unbalanced EnableSecureEventInput would wedge + // system-wide secure input on. + let (input, recorder) = makeInput() + let anchorA = NSObject(), anchorB = NSObject() + let a = ObjectIdentifier(anchorA) + let b = ObjectIdentifier(anchorB) + + input.setGlobal(true) + input.setScoped(a, focused: true) + input.setScoped(b, focused: true) + #expect(recorder.enables == 1) + + input.setGlobal(false) + input.removeScoped(a) + #expect(input.enabled) // b still holds it + #expect(recorder.disables == 0) + + input.removeScoped(b) + #expect(!input.enabled) + #expect(recorder.disables == 1) + } + + @Test + func inactiveApp_neverTouchesSystemState() { + // Secure input is global: while another app is frontmost we must not + // grab it. The activation observer reacquires later. + let (input, recorder) = makeInput(active: false) + input.setGlobal(true) + #expect(!input.enabled) + #expect(recorder.enables == 0) + } + + @Test + func failedEnable_leavesStateOff() { + let (input, recorder) = makeInput(enableResult: OSStatus(-1)) + input.setGlobal(true) + #expect(!input.enabled) + #expect(recorder.enables == 1) + // No phantom outstanding enable to balance on the way down. + input.setGlobal(false) + #expect(recorder.disables == 0) + } +} diff --git a/MactermTests/Views/Terminal/GhosttyTerminalNSViewTests.swift b/MactermTests/Views/Terminal/GhosttyTerminalNSViewTests.swift new file mode 100644 index 00000000..1010e850 --- /dev/null +++ b/MactermTests/Views/Terminal/GhosttyTerminalNSViewTests.swift @@ -0,0 +1,32 @@ +import AppKit +import GhosttyKit +@testable import Macterm +import Testing + +/// Covers the pure libghostty-enum mappings on the terminal NSView. The view +/// itself (surface lifecycle, rendering) is deliberately not unit-tested. +@MainActor +struct GhosttyTerminalNSViewTests { + @Test + func cursorMapping_coversTheShapesGhosttyEmits() { + // The shapes the core actually sends over a terminal: text grid, + // links, and TUI drag affordances. + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_TEXT) == NSCursor.iBeam) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_POINTER) == NSCursor.pointingHand) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_DEFAULT) == NSCursor.arrow) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_GRAB) == NSCursor.openHand) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_GRABBING) == NSCursor.closedHand) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_CROSSHAIR) == NSCursor.crosshair) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED) == NSCursor.operationNotAllowed) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_NS_RESIZE) != nil) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_EW_RESIZE) != nil) + } + + @Test + func cursorMapping_ignoresShapesWithNoMacOSCounterpart() { + // Unknown → nil keeps the previous cursor, mirroring Ghostty.app. + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_ZOOM_IN) == nil) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_WAIT) == nil) + #expect(GhosttyTerminalNSView.cursor(for: GHOSTTY_MOUSE_SHAPE_PROGRESS) == nil) + } +}