diff --git a/README.md b/README.md index ab2d303..84c56b9 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,24 @@ brew install xcodegen `gterm.xcodeproj`, `Info.plist`, and `GhosttyKit.xcframework` are generated and git-ignored. +## Testing SSH startup + +Run the Herdr auto-attach regressions on macOS (no SSH credentials or running +Herdr server required): + +```sh +xcodegen generate +xcodebuild -project gterm.xcodeproj -scheme gtermSSHTests \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test +``` + +These tests exercise the PTY handler's request/reply flow and execute its +startup command with isolated shell profiles and a fixture `herdr` executable. +They cover PATH initialization, request failures, nonzero exit status, clean +detach, and terminal input/output. Channel tests also run in the iOS +`gtermTests` scheme; testing attachment to a real remote Herdr session still +requires a simulator or device smoke test. + ## Releasing for AltStore `scripts/build-altstore-ipa.sh` produces an **unsigned** `.ipa` suitable for @@ -96,4 +114,3 @@ Bundled / dependency components keep their own licenses: the swift-crypto, and swift-nio are Apache-2.0; the OpenAI and SwiftAnthropic SDKs are MIT. - diff --git a/Sources/Ghostty/AccessoryKeyboardView.swift b/Sources/Ghostty/AccessoryKeyboardView.swift index 67a8e47..d1aaf57 100644 --- a/Sources/Ghostty/AccessoryKeyboardView.swift +++ b/Sources/Ghostty/AccessoryKeyboardView.swift @@ -7,6 +7,10 @@ import UIKit /// /// When the user is typing a command, a history-based autocomplete row appears /// above the key bar; tapping a suggestion sends its remaining characters. +/// +/// When the connection attaches with Herdr, a shortcut row is inserted above +/// the key bar. Each button sends the prefix (`ctrl+b`) then the action key +/// so prefix chords do not have to be tapped out on glass. final class AccessoryKeyboardView: UIInputView { private weak var target: TerminalSurfaceView? private var ctrlButton: UIButton? @@ -19,6 +23,9 @@ final class AccessoryKeyboardView: UIInputView { private var suggestionStack: UIStackView? private var suggestionsVisible = false + private var herdrScroll: UIScrollView? + private var herdrVisible = false + /// History-based suggestions (instant) and the AI suggestion (async). The AI /// chip is rendered first, distinctly; both insert a full command line. private var historySuggestions: [String] = [] @@ -38,12 +45,15 @@ final class AccessoryKeyboardView: UIInputView { allowsSelfSizing = true translatesAutoresizingMaskIntoConstraints = false build() + setHerdrEnabled(target.attachHerdr) } required init?(coder: NSCoder) { fatalError("not supported") } override var intrinsicContentSize: CGSize { - let height = Self.keysHeight + (suggestionsVisible ? Self.suggestionHeight : 0) + var height = Self.keysHeight + if suggestionsVisible { height += Self.suggestionHeight } + if herdrVisible { height += Self.keysHeight } return CGSize(width: UIView.noIntrinsicMetric, height: height) } @@ -60,9 +70,70 @@ final class AccessoryKeyboardView: UIInputView { root.bottomAnchor.constraint(equalTo: bottomAnchor), ]) root.addArrangedSubview(buildSuggestionRow()) + root.addArrangedSubview(buildHerdrRow()) root.addArrangedSubview(buildKeysRow()) } + // MARK: Herdr shortcut row + + /// Show or hide the one-tap Herdr prefix-chord row. Driven by the + /// connection's `attachHerdr` flag via `HerdrSupport.keyboardShortcuts`. + func setHerdrEnabled(_ enabled: Bool) { + let show = !HerdrSupport.keyboardShortcuts(attachHerdr: enabled).isEmpty + guard show != herdrVisible else { return } + herdrVisible = show + herdrScroll?.isHidden = !show + invalidateIntrinsicContentSize() + } + + private func buildHerdrRow() -> UIView { + let scroll = UIScrollView() + scroll.translatesAutoresizingMaskIntoConstraints = false + scroll.showsHorizontalScrollIndicator = false + scroll.alwaysBounceHorizontal = true + scroll.keyboardDismissMode = .none + + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 6 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + scroll.addSubview(stack) + + NSLayoutConstraint.activate([ + scroll.heightAnchor.constraint(equalToConstant: Self.keysHeight), + stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8), + stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 6), + stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -6), + stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor, constant: -12), + ]) + + for shortcut in HerdrSupport.keyboardShortcuts(attachHerdr: true) { + stack.addArrangedSubview(herdrButton(shortcut)) + } + + scroll.isHidden = true + herdrScroll = scroll + return scroll + } + + private func herdrButton(_ shortcut: HerdrSupport.Shortcut) -> UIButton { + let button = UIButton(type: .system) + var config = UIButton.Configuration.tinted() + config.title = shortcut.title + config.baseForegroundColor = .label + config.cornerStyle = .medium + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + button.configuration = config + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.accessibilityLabel = shortcut.accessibilityLabel + button.addAction(UIAction { [weak self] _ in + self?.target?.sendHerdrShortcut(shortcut) + }, for: .touchUpInside) + return button + } + // MARK: Suggestion row private func buildSuggestionRow() -> UIView { diff --git a/Sources/Ghostty/TerminalSurfaceView+Link.swift b/Sources/Ghostty/TerminalSurfaceView+Link.swift index 39fd235..f025450 100644 --- a/Sources/Ghostty/TerminalSurfaceView+Link.swift +++ b/Sources/Ghostty/TerminalSurfaceView+Link.swift @@ -1,14 +1,17 @@ import UIKit import GhosttyKit -/// Tap-to-open-URL support. libghostty detects links (auto-detected URLs and -/// OSC 8 hyperlinks) and opens the one under the cursor on a left-click release, -/// but only treats a cell as "over a link" when the mouse modifiers match -/// `ctrlOrSuper` — which is `super` (⌘) on Apple platforms. iOS has no hover, so -/// a single tap synthesizes a super-modified move + click at the tapped point; -/// if a link is there, libghostty fires the OPEN_URL action (handled in -/// `Ghostty.App.action`), which we route to the in-app browser. A tap on a -/// non-link is a harmless super-click. +/// Tap handling. By default this is tap-to-open-URL: libghostty detects links +/// (auto-detected URLs and OSC 8 hyperlinks) and opens the one under the cursor +/// on a left-click release, but only treats a cell as "over a link" when the +/// mouse modifiers match `ctrlOrSuper` — which is `super` (⌘) on Apple +/// platforms. iOS has no hover, so a single tap synthesizes a super-modified +/// move + click at the tapped point; if a link is there, libghostty fires the +/// OPEN_URL action (handled in `Ghostty.App.action`), which we route to the +/// in-app browser. A tap on a non-link is a harmless super-click. +/// +/// When `attachHerdr` is on, the same tap is instead an unmodified left +/// press+release so Herdr's mouse-native TUI can receive the click. extension TerminalSurfaceView { func setupLinkTapGesture() { let tap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTap(_:))) @@ -22,12 +25,22 @@ extension TerminalSurfaceView { // A tap is also the affordance for re-summoning a dismissed keyboard. showKeyboardIfNeeded() let loc = gesture.location(in: self) - let superMods = GHOSTTY_MODS_SUPER - // Move with super so the cell registers as over_link, then click it. - ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), superMods) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, superMods) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, superMods) - // Clear the hover modifiers so the link highlight doesn't stick. - ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), Ghostty.Mods.none.cMods) + switch HerdrSupport.tapDecision(attachHerdr: attachHerdr, x: Double(loc.x), y: Double(loc.y)) { + case .mouseLeftClick(let x, let y): + // Unmodified left press+release at the tap so Herdr (and other + // mouse-mode programs) receive a real click. + let mods = Ghostty.Mods.none.cMods + ghostty_surface_mouse_pos(surface, x, y, mods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + case .existingGestures: + let superMods = GHOSTTY_MODS_SUPER + // Move with super so the cell registers as over_link, then click it. + ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), superMods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, superMods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, superMods) + // Clear the hover modifiers so the link highlight doesn't stick. + ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), Ghostty.Mods.none.cMods) + } } } diff --git a/Sources/Ghostty/TerminalSurfaceView.swift b/Sources/Ghostty/TerminalSurfaceView.swift index 54719f2..bfc2667 100644 --- a/Sources/Ghostty/TerminalSurfaceView.swift +++ b/Sources/Ghostty/TerminalSurfaceView.swift @@ -14,10 +14,15 @@ protocol TerminalSurfaceViewDelegate: AnyObject { /// The terminal title changed (OSC 0/2). func terminalSurface(_ view: TerminalSurfaceView, didChangeTitle title: String) + + /// The app (or this view) became visible again after being frozen or + /// off-screen. The session should ask the remote to redraw (window-change). + func terminalSurfaceDidResume(_ view: TerminalSurfaceView, cols: Int, rows: Int) } extension TerminalSurfaceViewDelegate { func terminalSurface(_ view: TerminalSurfaceView, didChangeTitle title: String) {} + func terminalSurfaceDidResume(_ view: TerminalSurfaceView, cols: Int, rows: Int) {} } /// A UIView that hosts a libghostty terminal surface (rendered into its @@ -42,7 +47,7 @@ final class TerminalSurfaceView: UIView { isOpaque = true contentMode = .redraw - // Pinch (two-finger) to change the font size, which reflows the grid. + // Pinch: font size normally; Herdr pane zoom when attachHerdr is on. let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:))) addGestureRecognizer(pinch) @@ -59,6 +64,14 @@ final class TerminalSurfaceView: UIView { self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil ) + nc.addObserver( + self, selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil + ) + nc.addObserver( + self, selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, object: nil + ) guard let app = ghostty.app else { Ghostty.logger.error("TerminalSurfaceView created without a ghostty app") @@ -97,6 +110,22 @@ final class TerminalSurfaceView: UIView { // MARK: Link opening + /// When true, a tap is delivered as an unmodified mouse left-click so + /// Herdr's mouse-native TUI is reachable, and the accessory bar shows + /// one-tap Herdr prefix chords. Set from the saved connection. + var attachHerdr = false { + didSet { + if !attachHerdr { herdrPaneZoomed = false } + accessory.setHerdrEnabled(attachHerdr) + } + } + + /// Best-effort local mirror of Herdr pane-zoom (`prefix+z`). Used so a + /// pinch-out zooms in and a pinch-in zooms out instead of toggling blindly. + /// Desyncs if the user zooms from Herdr's own UI; the next matching pinch + /// or Zoom key resyncs. + private var herdrPaneZoomed = false + /// Called when the user taps a URL in the terminal; the SwiftUI layer routes /// it to the in-app browser. Set by the hosting view. var onOpenURL: ((URL) -> Void)? @@ -141,13 +170,36 @@ final class TerminalSurfaceView: UIView { override func didMoveToWindow() { super.didMoveToWindow() - syncSize() - sizeRenderLayers() if window != nil { + restoreDisplay() _ = becomeFirstResponder() } } + @objc private func appWillResignActive() { + guard let surface else { return } + ghostty_surface_set_occlusion(surface, false) + } + + @objc private func appDidBecomeActive() { + restoreDisplay() + let (cols, rows) = gridSize + delegate?.terminalSurfaceDidResume(self, cols: cols, rows: rows) + } + + /// Un-occlude, resize the Metal layer, and force a frame. Needed after + /// iOS suspends the process (IOSurface/drawables go stale) and after the + /// surface is reinserted into the hierarchy. + private func restoreDisplay() { + guard let surface else { return } + ghostty_surface_set_occlusion(surface, true) + syncSize() + sizeRenderLayers() + ghostty_surface_refresh(surface) + ghostty_surface_draw(surface) + ghostty?.tick() + } + override func layoutSubviews() { super.layoutSubviews() syncSize() @@ -242,22 +294,39 @@ final class TerminalSurfaceView: UIView { return action.withCString { ghostty_surface_binding_action(surface, $0, UInt(len)) } } - /// Two-finger pinch adjusts the font size one point at a time. Each ~12% - /// of pinch crosses a step; we reset the recognizer's scale after stepping - /// so a continuous pinch keeps zooming. Changing the font size reflows the - /// grid, which fires the resize callback (-> SSH window-change). + /// Two-finger pinch: font-size steps when Herdr is off; pane zoom + /// (`prefix+z`) when it is on. Font-size changes reflow the grid and + /// fire a resize callback (-> SSH window-change), which fights Herdr's + /// layout, so they are not used on that path. @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { - guard gesture.state == .changed else { return } - let step: CGFloat = 1.12 - if gesture.scale >= step { - performBindingAction("increase_font_size:1") - gesture.scale = 1 - } else if gesture.scale <= 1 / step { - performBindingAction("decrease_font_size:1") + let phase: HerdrSupport.PinchPhase + switch gesture.state { + case .changed: phase = .changed + case .ended, .cancelled: phase = .ended + default: return + } + switch HerdrSupport.pinchAction( + attachHerdr: attachHerdr, + scale: Double(gesture.scale), + phase: phase + ) { + case .changeFontSize(let increase): + performBindingAction(increase ? "increase_font_size:1" : "decrease_font_size:1") gesture.scale = 1 + case .herdrPaneZoom(let zoomIn): + applyHerdrPaneZoom(zoomIn: zoomIn) + case .none: + break } } + /// Send Herdr's zoom chord only when the local zoomed flag disagrees. + private func applyHerdrPaneZoom(zoomIn: Bool) { + guard herdrPaneZoomed != zoomIn, + let shortcut = HerdrSupport.shortcut(.zoom) else { return } + sendHerdrShortcut(shortcut) + } + // MARK: Accessory keyboard + sticky modifiers /// Modifiers "armed" by the accessory bar, applied to the next keypress. @@ -297,6 +366,33 @@ final class TerminalSurfaceView: UIView { clearStickyMods() } + /// Play a Herdr accessory-bar shortcut: prefix (`ctrl+b`) then the + /// follow-up key, using the same encoder path as typing those keys. + func sendHerdrShortcut(_ shortcut: HerdrSupport.Shortcut) { + clearStickyMods() + for stroke in shortcut.strokes { + sendHerdrStroke(stroke) + } + if shortcut.id == .zoom { herdrPaneZoomed.toggle() } + } + + private func sendHerdrStroke(_ stroke: HerdrSupport.Keystroke) { + guard let ch = stroke.character.first, + let (key, charShift) = Ghostty.Key.physical(for: ch) else { return } + let shift = stroke.shift || charShift + if stroke.ctrl || stroke.alt || shift { + var mods = Ghostty.Mods.none + if stroke.ctrl { mods.insert(.ctrl) } + if stroke.alt { mods.insert(.alt) } + if shift { mods.insert(.shift) } + sendKey(key, mods: mods) + } else { + // Bare follow-up after the prefix: typed-key path so it is not + // wrapped in bracketed paste (same reason as tmux prefix + ":"). + sendCharacter(stroke.character) + } + } + /// Insert a printable symbol from the accessory bar, applying armed /// modifiers if any (e.g. Ctrl-/, Alt-|). func insertSymbol(_ s: String) { diff --git a/Sources/Model/ConnectionStore.swift b/Sources/Model/ConnectionStore.swift index 79f1bf2..e40ec93 100644 --- a/Sources/Model/ConnectionStore.swift +++ b/Sources/Model/ConnectionStore.swift @@ -1,9 +1,10 @@ +import Combine import Foundation /// A persisted SSH connection. Secrets are never stored here: the password (if /// saved) lives in the Keychain keyed by `id`; private keys are referenced by /// `keyIDs` and managed by KeyStore. -struct SavedConnection: Identifiable, Codable, Equatable { +struct SavedConnection: Identifiable, Equatable { var id = UUID() var name: String = "" var host: String = "" @@ -13,6 +14,10 @@ struct SavedConnection: Identifiable, Codable, Equatable { var keyIDs: [UUID] = [] /// Whether a password is saved in the Keychain for this connection. var savePassword: Bool = false + /// When true, the SSH PTY execs `herdr` (start or attach to the default + /// background session) instead of a login shell. Off by default so hosts + /// without Herdr on `PATH` keep working. + var attachHerdr: Bool = false var title: String { name.isEmpty ? "\(username)@\(host)" : name } @@ -23,25 +28,59 @@ struct SavedConnection: Identifiable, Codable, Equatable { } } +extension SavedConnection: Codable { + enum CodingKeys: String, CodingKey { + case id, name, host, port, username, keyIDs, savePassword, attachHerdr + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "" + host = try c.decodeIfPresent(String.self, forKey: .host) ?? "" + port = try c.decodeIfPresent(Int.self, forKey: .port) ?? 22 + username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" + keyIDs = try c.decodeIfPresent([UUID].self, forKey: .keyIDs) ?? [] + savePassword = try c.decodeIfPresent(Bool.self, forKey: .savePassword) ?? false + attachHerdr = try c.decodeIfPresent(Bool.self, forKey: .attachHerdr) ?? false + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(host, forKey: .host) + try c.encode(port, forKey: .port) + try c.encode(username, forKey: .username) + try c.encode(keyIDs, forKey: .keyIDs) + try c.encode(savePassword, forKey: .savePassword) + try c.encode(attachHerdr, forKey: .attachHerdr) + } +} + /// Stores saved connections (metadata in UserDefaults, password in Keychain). final class ConnectionStore: ObservableObject { @Published private(set) var connections: [SavedConnection] = [] private let key = "savedConnections" + private let defaults: UserDefaults private func passwordAccount(_ c: SavedConnection) -> String { c.id.uuidString } - init() { load() } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } private func load() { - guard let data = UserDefaults.standard.data(forKey: key), - let decoded = try? JSONDecoder().decode([SavedConnection].self, from: data) + guard let data = defaults.data(forKey: key), + let decoded = try? SavedConnectionCodec.decode(data) else { return } connections = decoded } private func persist() { - if let data = try? JSONEncoder().encode(connections) { - UserDefaults.standard.set(data, forKey: key) + if let data = try? SavedConnectionCodec.encode(connections) { + defaults.set(data, forKey: key) } } diff --git a/Sources/Model/HerdrSupport.swift b/Sources/Model/HerdrSupport.swift new file mode 100644 index 0000000..0072a5a --- /dev/null +++ b/Sources/Model/HerdrSupport.swift @@ -0,0 +1,204 @@ +import Foundation + +/// Maps the per-connection Herdr option onto SSH PTY start and terminal tap +/// delivery. Foundation-only so `gtermTests` can drive the same functions the +/// session and surface use, without UIKit or a live host. +enum HerdrSupport { + /// SSH exec uses a non-login shell, which often lacks ~/.local/bin or + /// ~/.cargo/bin. Load the user's login and interactive shell environment + /// just as a manual SSH login would, then replace the shell with Herdr. + static let command = "exec \"$SHELL\" -ilc 'exec herdr'" + + /// How the SSH session channel should be started. + enum PTYStart: Equatable { + /// Today's path: request a login shell. + case loginShell + /// Exec `herdr` on the remote (with the already-requested PTY). + case exec(String) + } + + static func ptyStart(attachHerdr: Bool) -> PTYStart { + attachHerdr ? .exec(command) : .loginShell + } + + /// Command to send as an SSH exec request, or `nil` for a login shell. + static func execCommand(attachHerdr: Bool) -> String? { + switch ptyStart(attachHerdr: attachHerdr) { + case .loginShell: return nil + case .exec(let command): return command + } + } + + /// How a completed tap on the terminal surface should be delivered. + enum TapDecision: Equatable { + /// Keep the existing tap path (super-modified link probe; selection and + /// scroll gestures are unchanged). + case existingGestures + /// Unmodified left press+release at the tap point so a mouse-native TUI + /// (Herdr tabs, panes, menus) can receive the click. + case mouseLeftClick(x: Double, y: Double) + } + + static func tapDecision(attachHerdr: Bool, x: Double, y: Double) -> TapDecision { + attachHerdr ? .mouseLeftClick(x: x, y: y) : .existingGestures + } + + // MARK: Pinch (font size vs Herdr pane zoom) + + /// Matches the existing font-size pinch step (~12%). + static let pinchStep = 1.12 + + enum PinchPhase: Equatable { + /// Continuous update; used for font-size stepping. + case changed + /// Gesture finished; used for one-shot Herdr pane zoom. + case ended + } + + enum PinchAction: Equatable { + /// Existing path: bump ghostty font size (reflows the grid). + case changeFontSize(increase: Bool) + /// Send Herdr pane zoom (`prefix+z`): pinch-out zooms in, pinch-in zooms out. + case herdrPaneZoom(zoomIn: Bool) + case none + } + + /// When Herdr is on, a completed pinch maps to pane zoom instead of font + /// size — reflowing cols/rows mid-session fights Herdr's layout. When off, + /// pinch-changed still steps the font size as today. + /// A PTY grid size to send as an SSH window-change. + struct GridSize: Equatable { + var cols: Int + var rows: Int + } + + /// After the app returns from the background, Herdr (and other full-screen + /// TUIs) need a SIGWINCH to emit a complete frame — live updates were + /// frozen with the process. Same-size window-change is often ignored, so + /// Herdr gets a one-row bump then restore. A login shell just resends the + /// current size. + static func windowChangesForForeground(attachHerdr: Bool, cols: Int, rows: Int) -> [GridSize] { + let cols = max(cols, 1) + let rows = max(rows, 1) + if attachHerdr { + let bumped = rows > 1 ? rows - 1 : rows + 1 + return [GridSize(cols: cols, rows: bumped), GridSize(cols: cols, rows: rows)] + } + return [GridSize(cols: cols, rows: rows)] + } + + static func pinchAction(attachHerdr: Bool, scale: Double, phase: PinchPhase) -> PinchAction { + if attachHerdr { + guard phase == .ended else { return .none } + if scale >= pinchStep { return .herdrPaneZoom(zoomIn: true) } + if scale <= 1 / pinchStep { return .herdrPaneZoom(zoomIn: false) } + return .none + } + guard phase == .changed else { return .none } + if scale >= pinchStep { return .changeFontSize(increase: true) } + if scale <= 1 / pinchStep { return .changeFontSize(increase: false) } + return .none + } + + // MARK: Accessory keyboard (phone path) + + /// One key event. `character` is the unshifted or shifted glyph ("b", "q", + /// "-", "?"); modifiers are applied on top. Foundation-only so tests can + /// assert the sequence without Ghostty types. + struct Keystroke: Equatable { + var character: String + var ctrl: Bool = false + var shift: Bool = false + var alt: Bool = false + } + + /// A one-tap Herdr action on the accessory bar. Chords are sequential: + /// prefix (`ctrl+b`) is sent and released, then the follow-up key — the + /// same model as typing `ctrl+b` then `q` on a hardware keyboard. + struct Shortcut: Equatable { + var id: ShortcutID + var title: String + var accessibilityLabel: String + var strokes: [Keystroke] + } + + enum ShortcutID: String, Equatable, CaseIterable { + case prefix + case detach + case workspace + case goto + case newTab + case previousTab + case nextTab + case splitRight + case splitDown + case paneLeft + case paneDown + case paneUp + case paneRight + case zoom + case sidebar + case help + } + + /// Default Herdr prefix: `ctrl+b`. + static let prefixStroke = Keystroke(character: "b", ctrl: true) + + /// Shortcuts shown on the accessory bar when the Herdr option is on. + /// Empty when the option is off so the regular key bar is unchanged. + static func keyboardShortcuts(attachHerdr: Bool) -> [Shortcut] { + attachHerdr ? phoneShortcuts : [] + } + + static func shortcut(_ id: ShortcutID) -> Shortcut? { + phoneShortcuts.first { $0.id == id } + } + + /// The documented first-five bindings plus the phone-path extras + /// (workspace picker, tab cycle, zoom, help, sidebar). + private static let phoneShortcuts: [Shortcut] = [ + Shortcut(id: .prefix, title: "Prefix", accessibilityLabel: "Herdr prefix Control-B", + strokes: [prefixStroke]), + chord(.detach, title: "Detach", label: "Detach Herdr session", then: "q"), + chord(.workspace, title: "Space", label: "Workspace picker", then: "w"), + chord(.goto, title: "Go", label: "Goto picker", then: "g"), + chord(.newTab, title: "Tab+", label: "New tab", then: "c"), + chord(.previousTab, title: "Tab<", label: "Previous tab", then: "p"), + chord(.nextTab, title: "Tab>", label: "Next tab", then: "n"), + chord(.splitRight, title: "Split", label: "Split pane right", then: "v"), + chord(.splitDown, title: "Split-", label: "Split pane down", then: "-"), + chord(.paneLeft, title: "H", label: "Focus pane left", then: "h"), + chord(.paneDown, title: "J", label: "Focus pane down", then: "j"), + chord(.paneUp, title: "K", label: "Focus pane up", then: "k"), + chord(.paneRight, title: "L", label: "Focus pane right", then: "l"), + chord(.zoom, title: "Zoom", label: "Zoom focused pane", then: "z"), + chord(.sidebar, title: "Bar", label: "Toggle sidebar", then: "b"), + chord(.help, title: "?", label: "Show keybindings", then: "?"), + ] + + private static func chord( + _ id: ShortcutID, + title: String, + label: String, + then character: String + ) -> Shortcut { + Shortcut( + id: id, + title: title, + accessibilityLabel: label, + strokes: [prefixStroke, Keystroke(character: character)] + ) + } +} + +/// Encode/decode the saved-connection list. `ConnectionStore` persist/load uses +/// this so tests drive the same codec as the app. +enum SavedConnectionCodec { + static func encode(_ connections: [SavedConnection]) throws -> Data { + try JSONEncoder().encode(connections) + } + + static func decode(_ data: Data) throws -> [SavedConnection] { + try JSONDecoder().decode([SavedConnection].self, from: data) + } +} diff --git a/Sources/SSH/PTYChannelHandler.swift b/Sources/SSH/PTYChannelHandler.swift index 7198e44..43163c1 100644 --- a/Sources/SSH/PTYChannelHandler.swift +++ b/Sources/SSH/PTYChannelHandler.swift @@ -1,9 +1,10 @@ +import Foundation import NIOCore import NIOSSH -/// Handles a single SSH "session" child channel running an interactive shell on -/// a PTY. On activation it requests a pseudo-terminal and a shell, then pipes -/// raw bytes both ways: +/// Handles a single SSH "session" child channel running an interactive PTY. +/// On activation it requests a pseudo-terminal, then either a login shell or +/// an exec (e.g. `herdr`), and pipes raw bytes both ways: /// * inbound SSHChannelData (server stdout/stderr) -> onOutput (to terminal) /// * outbound bytes (keyboard/responses) -> SSHChannelData (write) /// Window resizes are sent as window-change requests. @@ -21,20 +22,43 @@ final class PTYChannelHandler: ChannelDuplexHandler { private let onOutput: (ByteBuffer) -> Void /// Called when the shell channel closes / errors. private let onClose: (Error?) -> Void + /// Login shell vs exec (e.g. `herdr`). Mapped by `HerdrSupport.ptyStart`. + private let start: HerdrSupport.PTYStart private var context: ChannelHandlerContext? private var closeOnce = ChannelCloseOnce() + private enum StartupState { + case idle, awaitingPTY, awaitingProgram, running, closed + } + private var startupState = StartupState.idle + private var exitError: Error? + + private enum StartupError: LocalizedError { + case ptyRejected + case programRejected + case remoteExit(Int) + + var errorDescription: String? { + switch self { + case .ptyRejected: return "The SSH server rejected the terminal request." + case .programRejected: return "The SSH server rejected the shell or Herdr startup request." + case .remoteExit(let status): return "The remote shell or Herdr exited with status \(status)." + } + } + } init( term: String, cols: Int, rows: Int, + start: HerdrSupport.PTYStart = .loginShell, onOutput: @escaping (ByteBuffer) -> Void, onClose: @escaping (Error?) -> Void ) { self.term = term self.cols = max(cols, 1) self.rows = max(rows, 1) + self.start = start self.onOutput = onOutput self.onClose = onClose } @@ -48,7 +72,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { } func channelActive(context: ChannelHandlerContext) { - // Request a PTY, then a shell. wantReply so we learn of failures. + // Request a PTY, then a login shell or exec. wantReply so we learn of failures. let ptyRequest = SSHChannelRequestEvent.PseudoTerminalRequest( wantReply: true, term: term, @@ -58,16 +82,60 @@ final class PTYChannelHandler: ChannelDuplexHandler { terminalPixelHeight: 0, terminalModes: SSHTerminalModes([.ECHO: 1, .ICANON: 1, .ISIG: 1]) ) - context.triggerUserOutboundEvent(ptyRequest, promise: nil) + startupState = .awaitingPTY + sendRequest(ptyRequest, context: context) + context.fireChannelActive() + } - let shellRequest = SSHChannelRequestEvent.ShellRequest(wantReply: true) - context.triggerUserOutboundEvent(shellRequest, promise: nil) + private func startProgram(context: ChannelHandlerContext) { + startupState = .awaitingProgram + switch start { + case .loginShell: + let shellRequest = SSHChannelRequestEvent.ShellRequest(wantReply: true) + sendRequest(shellRequest, context: context) + case .exec(let command): + let execRequest = SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true) + sendRequest(execRequest, context: context) + } + } - context.fireChannelActive() + private func sendRequest(_ event: Any, context: ChannelHandlerContext) { + // This promise only confirms the write. Server acceptance arrives as + // ChannelSuccessEvent / ChannelFailureEvent on the inbound pipeline. + let promise = context.eventLoop.makePromise(of: Void.self) + promise.futureResult.whenFailure { [weak self] error in + self?.errorCaught(context: context, error: error) + } + context.triggerUserOutboundEvent(event, promise: promise) + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + switch event { + case is ChannelSuccessEvent: + switch startupState { + case .awaitingPTY: startProgram(context: context) + case .awaitingProgram: startupState = .running + default: break + } + case is ChannelFailureEvent: + switch startupState { + case .awaitingPTY: errorCaught(context: context, error: StartupError.ptyRejected) + case .awaitingProgram: errorCaught(context: context, error: StartupError.programRejected) + default: break + } + case let status as SSHChannelRequestEvent.ExitStatus: + if status.exitStatus != 0 { + exitError = StartupError.remoteExit(status.exitStatus) + } + // Keep receiving trailing stderr until the server closes. + default: + context.fireUserInboundEventTriggered(event) + } } func channelInactive(context: ChannelHandlerContext) { - closeOnce.deliver(nil, to: onClose) + startupState = .closed + closeOnce.deliver(exitError, to: onClose) context.fireChannelInactive() } @@ -80,6 +148,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { } func errorCaught(context: ChannelHandlerContext, error: Error) { + startupState = .closed closeOnce.deliver(error, to: onClose) context.close(promise: nil) } diff --git a/Sources/SSH/SSHSession.swift b/Sources/SSH/SSHSession.swift index 0adb104..6c01bb6 100644 --- a/Sources/SSH/SSHSession.swift +++ b/Sources/SSH/SSHSession.swift @@ -17,6 +17,8 @@ struct SSHConnection: Identifiable { /// saved host. Not persisted; rides along so the UI can resolve persisted /// port forwards for this connection. var savedID: UUID? = nil + /// When true, the PTY execs `herdr` instead of requesting a login shell. + var attachHerdr: Bool = false } /// Drives an interactive SSH shell over a PTY using swift-nio-ssh and feeds it @@ -162,6 +164,7 @@ final class SSHSession: TerminalSession { term: self.connection.term, cols: cols, rows: rows, + start: HerdrSupport.ptyStart(attachHerdr: self.connection.attachHerdr), onOutput: { [weak self] buf in self?.deliverOutput(buf) }, @@ -250,6 +253,18 @@ final class SSHSession: TerminalSession { } } + func terminalSurfaceDidResume(_ view: TerminalSurfaceView, cols: Int, rows: Int) { + guard let childChannel, let ptyHandler else { return } + let sizes = HerdrSupport.windowChangesForForeground( + attachHerdr: connection.attachHerdr, cols: cols, rows: rows + ) + childChannel.eventLoop.execute { + for size in sizes { + ptyHandler.sendWindowChange(cols: size.cols, rows: size.rows) + } + } + } + // MARK: Helpers private static func describe(_ error: Error) -> String { diff --git a/Sources/Terminal/ActiveSession.swift b/Sources/Terminal/ActiveSession.swift index ceab5b2..f90ccd6 100644 --- a/Sources/Terminal/ActiveSession.swift +++ b/Sources/Terminal/ActiveSession.swift @@ -22,6 +22,7 @@ final class ActiveSession: ObservableObject, Identifiable { self.id = connection.savedID ?? connection.id self.connection = connection let surface = TerminalSurfaceView(ghostty: ghostty) + surface.attachHerdr = connection.attachHerdr self.surface = surface let session = SSHSession( connection: connection, diff --git a/Sources/UI/AddConnectionView.swift b/Sources/UI/AddConnectionView.swift index 60ae644..f8a30b6 100644 --- a/Sources/UI/AddConnectionView.swift +++ b/Sources/UI/AddConnectionView.swift @@ -74,6 +74,12 @@ struct AddConnectionView: View { Toggle("Save password", isOn: $connection.savePassword) } + Section { + Toggle("Attach with Herdr", isOn: $connection.attachHerdr) + } footer: { + Text("Runs herdr using your remote login shell's environment (starts or attaches to the default session). Taps are sent as mouse clicks so Herdr's tabs, panes, and menus work. Herdr must be installed on the remote host.") + } + Section("Port Forwards") { ForEach(forwardStore.forwards(for: connection.id)) { f in Button { diff --git a/Sources/UI/ConnectionListView.swift b/Sources/UI/ConnectionListView.swift index ea64b7e..40e2fd7 100644 --- a/Sources/UI/ConnectionListView.swift +++ b/Sources/UI/ConnectionListView.swift @@ -97,7 +97,8 @@ struct ConnectionListView: View { private func makeConnection(_ conn: SavedConnection, password: String) -> SSHConnection { SSHConnection( host: conn.host, port: conn.port, username: conn.username, - password: password, privateKeys: keyTexts(for: conn), savedID: conn.id) + password: password, privateKeys: keyTexts(for: conn), + savedID: conn.id, attachHerdr: conn.attachHerdr) } private func connect(_ conn: SavedConnection) { diff --git a/Tests/HerdrAutoAttachTests.swift b/Tests/HerdrAutoAttachTests.swift new file mode 100644 index 0000000..35bb28f --- /dev/null +++ b/Tests/HerdrAutoAttachTests.swift @@ -0,0 +1,203 @@ +import Foundation +import NIOCore +import NIOEmbedded +import NIOSSH +import XCTest + +final class HerdrAutoAttachTests: XCTestCase { + func testAttachWaitsForPTYAcceptanceThenExecs() throws { + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + + XCTAssertEqual(fixture.requests.events.count, 1) + let pty = try XCTUnwrap(fixture.requests.events.first as? SSHChannelRequestEvent.PseudoTerminalRequest) + XCTAssertTrue(pty.wantReply) + XCTAssertEqual(pty.term, "xterm-256color") + XCTAssertEqual(pty.terminalCharacterWidth, 80) + XCTAssertEqual(pty.terminalRowHeight, 24) + + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + XCTAssertEqual(fixture.requests.events.count, 2) + let exec = try XCTUnwrap(fixture.requests.events.last as? SSHChannelRequestEvent.ExecRequest) + XCTAssertTrue(exec.wantReply) + XCTAssertEqual(exec.command, HerdrSupport.execCommand(attachHerdr: true)) + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + XCTAssertEqual(fixture.requests.events.count, 2, "success must not start Herdr twice") + XCTAssertTrue(fixture.closes.isEmpty) + } + + func testOptionOffRequestsOnlyLoginShell() throws { + let fixture = try Fixture(attachHerdr: false) + defer { _ = try? fixture.channel.finish() } + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + XCTAssertEqual(fixture.requests.events.count, 2) + XCTAssertNotNil(fixture.requests.events.last as? SSHChannelRequestEvent.ShellRequest) + XCTAssertFalse(fixture.requests.events.contains { $0 is SSHChannelRequestEvent.ExecRequest }) + } + + func testPTYRejectionReportsErrorWithoutStartingHerdr() throws { + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelFailureEvent()) + XCTAssertEqual(fixture.requests.events.count, 1) + XCTAssertEqual(fixture.closes.count, 1) + XCTAssertNotNil(fixture.closes.first ?? nil) + XCTAssertFalse(fixture.channel.isActive) + } + + func testExecRejectionReportsErrorOnce() throws { + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelFailureEvent()) + XCTAssertEqual(fixture.closes.count, 1) + XCTAssertNotNil(fixture.closes.first ?? nil) + XCTAssertFalse(fixture.channel.isActive) + } + + func testCommandNotFoundExitIsFailureNotCleanDisconnect() throws { + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + fixture.channel.pipeline.fireUserInboundEventTriggered(SSHChannelRequestEvent.ExitStatus(exitStatus: 127)) + XCTAssertTrue(fixture.closes.isEmpty, "allow trailing stderr before delivering the exit") + var stderr = fixture.channel.allocator.buffer(capacity: 32) + stderr.writeString("herdr: command not found") + try fixture.channel.writeInbound(SSHChannelData(type: .stdErr, data: .byteBuffer(stderr))) + XCTAssertEqual(fixture.output, [stderr]) + fixture.channel.pipeline.fireChannelInactive() + XCTAssertEqual(fixture.closes.count, 1) + XCTAssertTrue((fixture.closes.first ?? nil)?.localizedDescription.contains("127") == true) + } + + func testExecWriteFailureReportsErrorOnce() throws { + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + fixture.requests.failNextRequest = true + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + XCTAssertEqual(fixture.closes.count, 1) + XCTAssertEqual((fixture.closes.first ?? nil) as? ChannelError, .operationUnsupported) + XCTAssertFalse(fixture.channel.isActive) + } + + func testSuccessfulDetachClosesCleanly() throws { + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + fixture.channel.pipeline.fireUserInboundEventTriggered(SSHChannelRequestEvent.ExitStatus(exitStatus: 0)) + fixture.channel.pipeline.fireChannelInactive() + XCTAssertEqual(fixture.closes.count, 1) + XCTAssertNil(fixture.closes.first ?? nil) + } + + func testAttachedChannelCarriesTerminalInputAndOutput() throws { + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + var input = fixture.channel.allocator.buffer(capacity: 2) + input.writeString("\u{02}c") + try fixture.channel.writeOutbound(input) + let sent = try XCTUnwrap(fixture.channel.readOutbound(as: SSHChannelData.self)) + guard case .byteBuffer(let bytes) = sent.data else { return XCTFail("expected terminal bytes") } + XCTAssertEqual(bytes, input) + var output = fixture.channel.allocator.buffer(capacity: 32) + output.writeString("Herdr session ready") + try fixture.channel.writeInbound(SSHChannelData(type: .channel, data: .byteBuffer(output))) + XCTAssertEqual(fixture.output, [output]) + } + + #if os(macOS) + func testAttachFindsHerdrFromLoginProfile() throws { + try verifyStartup(profile: ".zprofile") + } + + func testAttachFindsHerdrFromInteractiveShellConfig() throws { + try verifyStartup(profile: ".zshrc") + } + + private func verifyStartup(profile: String) throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gterm herdr \(UUID())") + defer { try? FileManager.default.removeItem(at: home) } + let bin = home.appendingPathComponent("private-bin") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + // Only shell startup configuration exposes this executable. No user's + // dotfiles, installed Herdr, credentials, or live sessions are touched. + try "export PATH=\"$HOME/private-bin:/usr/bin:/bin\"\n".write( + to: home.appendingPathComponent(profile), atomically: true, encoding: .utf8 + ) + let executable = bin.appendingPathComponent("herdr") + try "#!/bin/sh\nprintf 'HERDR_ATTACHED\\n'\n".write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + let baseline = try runRemoteCommand("herdr", home: home) + XCTAssertEqual(baseline.status, 127, "bare SSH exec must reproduce the missing-PATH failure") + + let fixture = try Fixture(attachHerdr: true) + defer { _ = try? fixture.channel.finish() } + fixture.channel.pipeline.fireUserInboundEventTriggered(ChannelSuccessEvent()) + let exec = try XCTUnwrap(fixture.requests.events.last as? SSHChannelRequestEvent.ExecRequest) + let attached = try runRemoteCommand(exec.command, home: home) + XCTAssertEqual(attached.status, 0, attached.output) + XCTAssertTrue(attached.output.contains("HERDR_ATTACHED"), attached.output) + } + + private func runRemoteCommand(_ command: String, home: URL) throws -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + // sshd invokes the user's shell with -c for an exec request. + process.arguments = ["-c", command] + process.environment = ["HOME": home.path, "ZDOTDIR": home.path, + "SHELL": "/bin/zsh", "PATH": "/usr/bin:/bin", "TERM": "xterm-256color"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + process.standardInput = FileHandle.nullDevice + let exited = expectation(description: "remote command exits") + process.terminationHandler = { _ in exited.fulfill() } + try process.run() + guard XCTWaiter.wait(for: [exited], timeout: 10) == .completed else { + process.terminate() + throw NSError(domain: "HerdrAutoAttachTests.timeout", code: 1) + } + return (process.terminationStatus, String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)) + } + #endif +} + +private final class Fixture { + let requests = RequestRecorder() + let channel: EmbeddedChannel + var closes: [Error?] = [] + var output: [ByteBuffer] = [] + + init(attachHerdr: Bool) throws { + channel = EmbeddedChannel() + let pty = PTYChannelHandler( + term: "xterm-256color", cols: 80, rows: 24, + start: HerdrSupport.ptyStart(attachHerdr: attachHerdr), + onOutput: { [weak self] in self?.output.append($0) }, + onClose: { [weak self] in self?.closes.append($0) } + ) + try channel.pipeline.addHandlers(requests, pty).wait() + try channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 22)).wait() + } +} + +private final class RequestRecorder: ChannelOutboundHandler { + typealias OutboundIn = SSHChannelData + var events: [Any] = [] + var failNextRequest = false + + func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + events.append(event) + if failNextRequest { + failNextRequest = false + promise?.fail(ChannelError.operationUnsupported) + } else { + promise?.succeed(()) + } + } +} diff --git a/Tests/HerdrSupportTests.swift b/Tests/HerdrSupportTests.swift new file mode 100644 index 0000000..df6848b --- /dev/null +++ b/Tests/HerdrSupportTests.swift @@ -0,0 +1,301 @@ +import XCTest + +final class HerdrSupportTests: XCTestCase { + func testDefaultConnectionHasHerdrOff() { + let connection = SavedConnection() + XCTAssertFalse(connection.attachHerdr) + XCTAssertEqual(HerdrSupport.ptyStart(attachHerdr: connection.attachHerdr), .loginShell) + XCTAssertNil(HerdrSupport.execCommand(attachHerdr: connection.attachHerdr)) + XCTAssertEqual( + HerdrSupport.tapDecision(attachHerdr: connection.attachHerdr, x: 8, y: 13), + .existingGestures + ) + } + + func testOptionOnMapsToHerdrAttachAndMouseLeftClick() { + var connection = SavedConnection(host: "box.example", username: "me") + connection.attachHerdr = true + + XCTAssertEqual(HerdrSupport.ptyStart(attachHerdr: connection.attachHerdr), .exec(HerdrSupport.command)) + XCTAssertEqual(HerdrSupport.execCommand(attachHerdr: connection.attachHerdr), HerdrSupport.command) + XCTAssertEqual(HerdrSupport.command, "exec \"$SHELL\" -ilc 'exec herdr'") + XCTAssertEqual( + HerdrSupport.tapDecision(attachHerdr: connection.attachHerdr, x: 42, y: 17), + .mouseLeftClick(x: 42, y: 17) + ) + } + + func testOptionOffMapsToLoginShellAndExistingTapPath() { + let connection = SavedConnection(host: "box.example", username: "me") + XCTAssertFalse(connection.attachHerdr) + + XCTAssertEqual(HerdrSupport.ptyStart(attachHerdr: connection.attachHerdr), .loginShell) + XCTAssertNil(HerdrSupport.execCommand(attachHerdr: connection.attachHerdr)) + XCTAssertEqual( + HerdrSupport.tapDecision(attachHerdr: connection.attachHerdr, x: 42, y: 17), + .existingGestures + ) + } + + func testCodecPersistReloadOffVsOn() throws { + var on = SavedConnection(name: "herdr box", host: "h.example", username: "me") + on.attachHerdr = true + var off = SavedConnection(name: "plain box", host: "p.example", username: "me") + off.attachHerdr = false + + let data = try SavedConnectionCodec.encode([on, off]) + let loaded = try SavedConnectionCodec.decode(data) + + XCTAssertEqual(loaded.count, 2) + let loadedOn = try XCTUnwrap(loaded.first { $0.id == on.id }) + let loadedOff = try XCTUnwrap(loaded.first { $0.id == off.id }) + XCTAssertTrue(loadedOn.attachHerdr) + XCTAssertFalse(loadedOff.attachHerdr) + + XCTAssertEqual(HerdrSupport.ptyStart(attachHerdr: loadedOn.attachHerdr), .exec(HerdrSupport.command)) + XCTAssertEqual(HerdrSupport.ptyStart(attachHerdr: loadedOff.attachHerdr), .loginShell) + XCTAssertEqual( + HerdrSupport.tapDecision(attachHerdr: loadedOn.attachHerdr, x: 3, y: 9), + .mouseLeftClick(x: 3, y: 9) + ) + XCTAssertEqual( + HerdrSupport.tapDecision(attachHerdr: loadedOff.attachHerdr, x: 3, y: 9), + .existingGestures + ) + } + + func testLegacyPayloadWithoutKeyDefaultsOff() throws { + let id = UUID() + let json = """ + {"id":"\(id.uuidString)","name":"old","host":"legacy.example","port":22,"username":"me","keyIDs":[],"savePassword":false} + """ + let decoded = try JSONDecoder().decode(SavedConnection.self, from: Data(json.utf8)) + XCTAssertFalse(decoded.attachHerdr) + XCTAssertEqual(decoded.host, "legacy.example") + XCTAssertEqual(HerdrSupport.ptyStart(attachHerdr: decoded.attachHerdr), .loginShell) + } + + func testConnectionStorePersistReload() throws { + let suite = "gterm.tests.connections.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("could not create suite") + } + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = ConnectionStore(defaults: defaults) + var on = SavedConnection(host: "on.example", username: "me") + on.attachHerdr = true + let off = SavedConnection(host: "off.example", username: "me") + XCTAssertFalse(off.attachHerdr) + + store.save(on, password: nil) + store.save(off, password: nil) + + let reloaded = ConnectionStore(defaults: defaults) + let loadedOn = try XCTUnwrap(reloaded.connections.first { $0.id == on.id }) + let loadedOff = try XCTUnwrap(reloaded.connections.first { $0.id == off.id }) + XCTAssertTrue(loadedOn.attachHerdr) + XCTAssertFalse(loadedOff.attachHerdr) + XCTAssertEqual(HerdrSupport.execCommand(attachHerdr: loadedOn.attachHerdr), HerdrSupport.command) + XCTAssertNil(HerdrSupport.execCommand(attachHerdr: loadedOff.attachHerdr)) + XCTAssertEqual( + HerdrSupport.tapDecision(attachHerdr: loadedOn.attachHerdr, x: 1, y: 2), + .mouseLeftClick(x: 1, y: 2) + ) + XCTAssertEqual( + HerdrSupport.tapDecision(attachHerdr: loadedOff.attachHerdr, x: 1, y: 2), + .existingGestures + ) + } + + func testConnectionEditorExposesOption() throws { + let source = try repoSource("Sources/UI/AddConnectionView.swift") + XCTAssertTrue(source.contains("Attach with Herdr"), "connection editor must expose the option") + XCTAssertTrue(source.contains("$connection.attachHerdr"), "toggle must bind the persisted flag") + } + + func testSessionWiresHerdrPTYStart() throws { + let session = try repoSource("Sources/SSH/SSHSession.swift") + XCTAssertTrue( + session.contains("HerdrSupport.ptyStart(attachHerdr: self.connection.attachHerdr)"), + "SSHSession must start the PTY via the shipped mapping" + ) + let pty = try repoSource("Sources/SSH/PTYChannelHandler.swift") + XCTAssertTrue(pty.contains("SSHChannelRequestEvent.ExecRequest"), "PTY must be able to exec herdr") + XCTAssertTrue(pty.contains("SSHChannelRequestEvent.ShellRequest"), "PTY must still request a login shell") + XCTAssertTrue(pty.contains("case .exec(let command):"), "PTY must branch on the mapping") + } + + func testKeyboardShortcutsEmptyWhenOptionOff() { + XCTAssertEqual(HerdrSupport.keyboardShortcuts(attachHerdr: false), []) + XCTAssertEqual( + HerdrSupport.keyboardShortcuts(attachHerdr: SavedConnection().attachHerdr), + [] + ) + } + + func testKeyboardShortcutsArePrefixThenActionWhenOptionOn() throws { + let shortcuts = HerdrSupport.keyboardShortcuts(attachHerdr: true) + XCTAssertFalse(shortcuts.isEmpty, "Herdr accessory row must have shortcuts when the option is on") + + let prefix = HerdrSupport.prefixStroke + XCTAssertEqual(prefix, HerdrSupport.Keystroke(character: "b", ctrl: true)) + + let prefixOnly = try XCTUnwrap(shortcuts.first { $0.id == .prefix }) + XCTAssertEqual(prefixOnly.strokes, [prefix]) + + let detach = try XCTUnwrap(shortcuts.first { $0.id == .detach }) + XCTAssertEqual(detach.strokes, [prefix, HerdrSupport.Keystroke(character: "q")]) + + let newTab = try XCTUnwrap(shortcuts.first { $0.id == .newTab }) + XCTAssertEqual(newTab.strokes, [prefix, HerdrSupport.Keystroke(character: "c")]) + + let splitDown = try XCTUnwrap(shortcuts.first { $0.id == .splitDown }) + XCTAssertEqual(splitDown.strokes, [prefix, HerdrSupport.Keystroke(character: "-")]) + + let help = try XCTUnwrap(shortcuts.first { $0.id == .help }) + XCTAssertEqual(help.strokes, [prefix, HerdrSupport.Keystroke(character: "?")]) + + // Every action except the prefix key itself is ctrl+b then one follow-up. + for shortcut in shortcuts where shortcut.id != .prefix { + XCTAssertEqual(shortcut.strokes.first, prefix, "\(shortcut.id) must start with the prefix") + XCTAssertEqual(shortcut.strokes.count, 2, "\(shortcut.id) must be prefix then one key") + } + } + + func testAccessoryBarWiresHerdrShortcuts() throws { + let accessory = try repoSource("Sources/Ghostty/AccessoryKeyboardView.swift") + XCTAssertTrue( + accessory.contains("HerdrSupport.keyboardShortcuts(attachHerdr:"), + "accessory bar must show shortcuts from the shipped mapping" + ) + XCTAssertTrue(accessory.contains("sendHerdrShortcut(shortcut)")) + XCTAssertTrue(accessory.contains("setHerdrEnabled")) + + let surface = try repoSource("Sources/Ghostty/TerminalSurfaceView.swift") + XCTAssertTrue(surface.contains("func sendHerdrShortcut")) + XCTAssertTrue(surface.contains("for stroke in shortcut.strokes")) + XCTAssertTrue(surface.contains("sendKey(key, mods: mods)")) + XCTAssertTrue(surface.contains("sendCharacter(stroke.character)")) + XCTAssertTrue(surface.contains("accessory.setHerdrEnabled(attachHerdr)")) + } + + func testPinchOffStepsFontSizeOnChangedNotEnded() { + XCTAssertEqual(HerdrSupport.pinchStep, 1.12) + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: false, scale: 1.12, phase: .changed), + .changeFontSize(increase: true) + ) + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: false, scale: 1 / 1.12, phase: .changed), + .changeFontSize(increase: false) + ) + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: false, scale: 1.12, phase: .ended), + .none + ) + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: false, scale: 1.0, phase: .changed), + .none + ) + } + + func testPinchOnMapsToHerdrPaneZoomOnEndedNotChanged() { + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: true, scale: 1.12, phase: .changed), + .none, + "must not send prefix+z on every pinch tick" + ) + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: true, scale: 1.12, phase: .ended), + .herdrPaneZoom(zoomIn: true) + ) + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: true, scale: 1 / 1.12, phase: .ended), + .herdrPaneZoom(zoomIn: false) + ) + XCTAssertEqual( + HerdrSupport.pinchAction(attachHerdr: true, scale: 1.0, phase: .ended), + .none + ) + let zoom = HerdrSupport.shortcut(.zoom) + XCTAssertEqual(zoom?.strokes, [ + HerdrSupport.prefixStroke, + HerdrSupport.Keystroke(character: "z"), + ]) + } + + func testForegroundWindowChangeBumpsThenRestoresForHerdr() { + XCTAssertEqual( + HerdrSupport.windowChangesForForeground(attachHerdr: false, cols: 80, rows: 24), + [HerdrSupport.GridSize(cols: 80, rows: 24)] + ) + XCTAssertEqual( + HerdrSupport.windowChangesForForeground(attachHerdr: true, cols: 80, rows: 24), + [ + HerdrSupport.GridSize(cols: 80, rows: 23), + HerdrSupport.GridSize(cols: 80, rows: 24), + ] + ) + XCTAssertEqual( + HerdrSupport.windowChangesForForeground(attachHerdr: true, cols: 40, rows: 1), + [ + HerdrSupport.GridSize(cols: 40, rows: 2), + HerdrSupport.GridSize(cols: 40, rows: 1), + ] + ) + } + + func testForegroundResumeWiresRefreshAndWindowChange() throws { + let surface = try repoSource("Sources/Ghostty/TerminalSurfaceView.swift") + XCTAssertTrue(surface.contains("UIApplication.didBecomeActiveNotification")) + XCTAssertTrue(surface.contains("UIApplication.willResignActiveNotification")) + XCTAssertTrue(surface.contains("ghostty_surface_set_occlusion")) + XCTAssertTrue(surface.contains("ghostty_surface_refresh")) + XCTAssertTrue(surface.contains("ghostty_surface_draw")) + XCTAssertTrue(surface.contains("terminalSurfaceDidResume")) + + let session = try repoSource("Sources/SSH/SSHSession.swift") + XCTAssertTrue( + session.contains("HerdrSupport.windowChangesForForeground("), + "resume must send the shipped window-change sequence" + ) + XCTAssertTrue(session.contains("func terminalSurfaceDidResume")) + } + + func testPinchPathWiresHerdrPaneZoom() throws { + let source = try repoSource("Sources/Ghostty/TerminalSurfaceView.swift") + XCTAssertTrue( + source.contains("HerdrSupport.pinchAction("), + "pinch handler must use the shipped decision" + ) + XCTAssertTrue(source.contains("case .herdrPaneZoom(let zoomIn):")) + XCTAssertTrue(source.contains("applyHerdrPaneZoom(zoomIn: zoomIn)")) + XCTAssertTrue(source.contains("HerdrSupport.shortcut(.zoom)")) + XCTAssertTrue(source.contains("case .changeFontSize(let increase):")) + XCTAssertTrue(source.contains("increase_font_size:1")) + XCTAssertTrue(source.contains("decrease_font_size:1")) + } + + func testTapPathIssuesLeftPressReleaseWhenHerdrOn() throws { + let source = try repoSource("Sources/Ghostty/TerminalSurfaceView+Link.swift") + XCTAssertTrue( + source.contains("HerdrSupport.tapDecision(attachHerdr: attachHerdr"), + "tap path must use the shipped tap-mode decision" + ) + XCTAssertTrue(source.contains("case .mouseLeftClick(let x, let y):")) + XCTAssertTrue(source.contains("GHOSTTY_MOUSE_PRESS")) + XCTAssertTrue(source.contains("GHOSTTY_MOUSE_RELEASE")) + XCTAssertTrue(source.contains("GHOSTTY_MOUSE_LEFT")) + XCTAssertTrue(source.contains("Ghostty.Mods.none.cMods")) + XCTAssertTrue(source.contains("case .existingGestures:")) + XCTAssertTrue(source.contains("GHOSTTY_MODS_SUPER")) + } + + private func repoSource(_ relative: String) throws -> String { + let tests = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let root = tests.deletingLastPathComponent() + return try String(contentsOf: root.appendingPathComponent(relative), encoding: .utf8) + } +} diff --git a/project.yml b/project.yml index 7d9ba3b..fd7ca46 100644 --- a/project.yml +++ b/project.yml @@ -7,6 +7,9 @@ options: groupSortPosition: top packages: + SwiftNIO: + url: https://github.com/apple/swift-nio.git + from: 2.81.0 SwiftNIOSSH: url: https://github.com/apple/swift-nio-ssh.git minorVersion: 0.13.0 @@ -120,10 +123,16 @@ targets: - path: Sources/SSH/SSHKeyParser.swift - path: Sources/SSH/KnownHosts.swift - path: Sources/SSH/ChannelCloseOnce.swift + - path: Sources/SSH/PTYChannelHandler.swift - path: Sources/SSH/SessionStateNotifier.swift - path: Sources/SSH/SSHPort.swift - path: Sources/Model/CommandHistory.swift + - path: Sources/Model/HerdrSupport.swift + - path: Sources/Model/ConnectionStore.swift + - path: Sources/Model/Keychain.swift dependencies: + - package: SwiftNIO + product: NIOEmbedded - package: OpenAI - package: SwiftAnthropic - package: SwiftNIOSSH @@ -135,3 +144,26 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: io.github.madeye.gtermTests GENERATE_INFOPLIST_FILE: YES SWIFT_VERSION: "5.0" + + # Runs real shell-startup regressions on macOS as well as SSH channel tests. + # Process is unavailable on iOS, so these tests need a host-side bundle. + gtermSSHTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Tests/HerdrAutoAttachTests.swift + - path: Sources/Model/HerdrSupport.swift + - path: Sources/Model/ConnectionStore.swift + - path: Sources/Model/Keychain.swift + - path: Sources/SSH/PTYChannelHandler.swift + - path: Sources/SSH/ChannelCloseOnce.swift + dependencies: + - package: SwiftNIOSSH + product: NIOSSH + - package: SwiftNIO + product: NIOEmbedded + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.github.madeye.gtermSSHTests + GENERATE_INFOPLIST_FILE: YES