t3-code-android-nightly/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Julius Marminge cc41482df0
feat(auth): allow passive terminal observation (#9791)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-10-06 20:30:13 -07:00

760 lines
23 KiB
Swift

import ExpoModulesCore
import Foundation
import GhosttyKit
import QuartzCore
import UIKit
private enum GhosttyRuntime {
private static let lock = NSLock()
private static var initialized = false
static func ensureInitialized() -> Bool {
lock.lock()
defer { lock.unlock() }
if initialized {
return true
}
let result = ghostty_init(0, nil)
initialized = result == GHOSTTY_SUCCESS
return initialized
}
}
/// Encodes hardware-keyboard combos that UITextField never surfaces through its
/// text-editing delegate (control combos, Escape, Tab, arrow keys) into the byte
/// sequences a terminal expects.
///
/// Capture uses UIKeyCommand with `wantsPriorityOverSystemBehavior` rather than
/// `pressesBegan`: while a text field is first responder, iPadOS routes hardware key
/// events through the text-input system, which can consume presses before they reach
/// responder press callbacks. Registered key commands are matched deterministically
/// before that happens.
private enum TerminalHardwareKeyEncoder {
/// Characters that produce a control byte when combined with Ctrl.
private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? "
static func makeKeyCommands(action: Selector) -> [UIKeyCommand] {
var commands: [UIKeyCommand] = []
let specialInputs = [
UIKeyCommand.inputEscape,
UIKeyCommand.inputUpArrow,
UIKeyCommand.inputDownArrow,
UIKeyCommand.inputLeftArrow,
UIKeyCommand.inputRightArrow,
"\t",
]
for input in specialInputs {
commands.append(makeCommand(input: input, modifierFlags: [], action: action))
}
commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action))
for character in controlInputs {
commands.append(makeCommand(input: String(character), modifierFlags: .control, action: action))
commands.append(
makeCommand(input: String(character), modifierFlags: [.control, .shift], action: action)
)
}
return commands
}
private static func makeCommand(
input: String,
modifierFlags: UIKeyModifierFlags,
action: Selector
) -> UIKeyCommand {
let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action)
command.wantsPriorityOverSystemBehavior = true
return command
}
static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? {
switch input {
case UIKeyCommand.inputEscape:
return "\u{1B}"
case UIKeyCommand.inputUpArrow:
return "\u{1B}[A"
case UIKeyCommand.inputDownArrow:
return "\u{1B}[B"
case UIKeyCommand.inputRightArrow:
return "\u{1B}[C"
case UIKeyCommand.inputLeftArrow:
return "\u{1B}[D"
case "\t":
return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t"
default:
break
}
guard modifiers.contains(.control) else { return nil }
guard let scalar = input.lowercased().unicodeScalars.first else { return nil }
return controlSequence(for: scalar)
}
private static func controlSequence(for scalar: Unicode.Scalar) -> String? {
switch scalar {
case "a"..."z":
// Ctrl+A..Z -> 0x01..0x1A (Ctrl+C = ETX, Ctrl+Z = SUB, ...).
return UnicodeScalar(scalar.value - 96).map(String.init)
case " ", "@":
return "\u{00}"
case "[":
return "\u{1B}"
case "\\":
return "\u{1C}"
case "]":
return "\u{1D}"
case "^":
return "\u{1E}"
case "_", "-":
return "\u{1F}"
case "?":
return "\u{7F}"
default:
return nil
}
}
}
private enum TerminalInputSequence {
/// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J,
/// which raw-mode TUIs may interpret as the literal J key.
static let carriageReturn = "\r"
static func normalizingReturn(_ input: String) -> String {
switch input {
case "\n", "\r\n":
return carriageReturn
default:
return input
}
}
}
private final class TerminalInputField: UITextField {
var onDeleteBackward: (() -> Void)?
var onInsert: ((String) -> Void)?
private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands(
action: #selector(handleHardwareKeyCommand(_:))
)
override var keyCommands: [UIKeyCommand]? {
Self.hardwareKeyCommands
}
override func deleteBackward() {
onDeleteBackward?()
super.deleteBackward()
}
@objc
private func handleHardwareKeyCommand(_ command: UIKeyCommand) {
guard let input = command.input else { return }
guard let sequence = TerminalHardwareKeyEncoder.sequence(
input: input,
modifiers: command.modifierFlags
) else { return }
onInsert?(sequence)
}
}
private enum TerminalAppearanceScheme: String {
case light
case dark
init(value: String) {
self = TerminalAppearanceScheme(rawValue: value) ?? .dark
}
var ghosttyColorScheme: ghostty_color_scheme_e {
switch self {
case .light:
return GHOSTTY_COLOR_SCHEME_LIGHT
case .dark:
return GHOSTTY_COLOR_SCHEME_DARK
}
}
}
private extension UIColor {
convenience init(hexString: String) {
let sanitized = hexString.replacingOccurrences(of: "#", with: "")
let value = Int(sanitized, radix: 16) ?? 0
self.init(
red: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: 1
)
}
}
public final class T3TerminalView: ExpoView, UITextFieldDelegate {
private static let minimumVerticalScrollStepPoints: CGFloat = 18
private static let verticalScrollStepMultiplier: CGFloat = 1.15
private let terminalViewport = UIView()
private let inputField = TerminalInputField()
private let focusTapGesture = UITapGestureRecognizer()
private let scrollPanGesture = UIPanGestureRecognizer()
private var lastViewportSize: CGSize = .zero
private var lastContentScale: CGFloat = 0
private var lastReportedGrid: (cols: Int, rows: Int)?
private var lastAppliedBuffer = ""
private var pendingVerticalScrollPoints: CGFloat = 0
private var app: ghostty_app_t?
private var surface: ghostty_surface_t?
private var isCreatingSurface = false
private var surfaceCreationFailed = false
private var appearance = TerminalAppearanceScheme.dark
private var backgroundColorValue = UIColor(hexString: "#24292e")
let onInput = EventDispatcher()
let onResize = EventDispatcher()
let onCapture = EventDispatcher()
var captureRequest: Double = 0 {
didSet {
guard captureRequest > 0, captureRequest != oldValue else { return }
guard let surface else { onCapture(["text": ""]); return }
let selection = ghostty_selection_s(
top_left: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0),
bottom_right: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0),
rectangle: false)
var captured = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &captured) else { onCapture(["text": ""]); return }
defer { ghostty_surface_free_text(surface, &captured) }
let text = captured.text.flatMap { String(bytes: UnsafeBufferPointer(start: UnsafeRawPointer($0).assumingMemoryBound(to: UInt8.self), count: Int(captured.text_len)), encoding: .utf8) } ?? ""
// Android joins snapshot rows with "\n" and trims each row's trailing whitespace, so do
// the same here: identical terminal content must capture identically on both platforms.
let normalized = text.split(separator: "\n", omittingEmptySubsequences: false)
.map { row -> String in
var line = String(row)
// Kotlin's trimEnd only strips ASCII whitespace; match it exactly.
while let last = line.last, last.isASCII && last.isWhitespace { line.removeLast() }
return line
}
.joined(separator: "\n")
onCapture(["text": normalized])
}
}
var terminalKey: String = "" {
didSet {
accessibilityIdentifier = "t3-terminal-\(terminalKey)"
if oldValue != terminalKey {
resetSurface()
}
}
}
var initialBuffer: String = "" {
didSet {
applyRemoteBuffer(initialBuffer)
}
}
var fontSize: CGFloat = 10 {
didSet {
guard oldValue != fontSize else { return }
inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
refreshSurface()
}
}
var focusRequest: Double = 0 {
didSet {
guard oldValue != focusRequest else { return }
DispatchQueue.main.async { [weak self] in
self?.requestKeyboardFocus()
}
}
}
var readOnly = false {
didSet {
if readOnly {
inputField.resignFirstResponder()
}
}
}
var autoFocus = true {
didSet {
guard oldValue != autoFocus else { return }
if autoFocus {
requestKeyboardFocus()
} else {
inputField.resignFirstResponder()
}
}
}
var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
didSet {
guard oldValue != appearanceScheme else { return }
appearance = TerminalAppearanceScheme(value: appearanceScheme)
refreshSurface()
}
}
var themeConfig: String = "" {
didSet {
guard oldValue != themeConfig else { return }
refreshSurface()
}
}
var backgroundColorHex: String = "#24292e" {
didSet {
backgroundColorValue = UIColor(hexString: backgroundColorHex)
applyTheme()
}
}
var foregroundColorHex: String = "#d1d5da"
var mutedForegroundColorHex: String = "#959da5"
public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
applyTheme()
clipsToBounds = true
// The real scale arrives with the window; layoutSubviews corrects it.
contentScaleFactor = traitCollection.displayScale
terminalViewport.clipsToBounds = true
terminalViewport.contentScaleFactor = contentScaleFactor
terminalViewport.translatesAutoresizingMaskIntoConstraints = false
terminalViewport.isUserInteractionEnabled = true
inputField.delegate = self
inputField.backgroundColor = UIColor.clear
inputField.textColor = UIColor.clear
inputField.tintColor = UIColor.clear
inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
inputField.placeholder = ""
inputField.autocorrectionType = .no
inputField.autocapitalizationType = .none
inputField.spellCheckingType = .no
inputField.smartDashesType = .no
inputField.smartQuotesType = .no
inputField.returnKeyType = .send
inputField.keyboardType = .asciiCapable
inputField.enablesReturnKeyAutomatically = false
inputField.translatesAutoresizingMaskIntoConstraints = false
inputField.alpha = 0.02
inputField.isAccessibilityElement = false
inputField.accessibilityElementsHidden = true
inputField.addTarget(self, action: #selector(handleInputEditingDidBegin), for: .editingDidBegin)
inputField.onDeleteBackward = { [weak self] in
self?.emitInput("\u{7F}")
}
inputField.onInsert = { [weak self] data in
self?.emitInput(data)
}
focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
terminalViewport.addGestureRecognizer(focusTapGesture)
scrollPanGesture.addTarget(self, action: #selector(handleViewportPan(_:)))
scrollPanGesture.maximumNumberOfTouches = 1
scrollPanGesture.cancelsTouchesInView = false
terminalViewport.addGestureRecognizer(scrollPanGesture)
addSubview(terminalViewport)
addSubview(inputField)
NSLayoutConstraint.activate([
terminalViewport.leadingAnchor.constraint(equalTo: leadingAnchor),
terminalViewport.trailingAnchor.constraint(equalTo: trailingAnchor),
terminalViewport.topAnchor.constraint(equalTo: topAnchor),
terminalViewport.bottomAnchor.constraint(equalTo: bottomAnchor),
inputField.trailingAnchor.constraint(equalTo: trailingAnchor),
inputField.topAnchor.constraint(equalTo: bottomAnchor, constant: 8),
inputField.widthAnchor.constraint(equalToConstant: 1),
inputField.heightAnchor.constraint(equalToConstant: 1),
])
}
deinit {
destroySurface()
}
public override func layoutSubviews() {
super.layoutSubviews()
updateContentScale()
let viewportSize = terminalViewport.bounds.size
if surface == nil {
createSurfaceIfPossible()
}
guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else {
return
}
lastViewportSize = viewportSize
lastContentScale = contentScaleFactor
resizeSurface()
}
public override func didMoveToWindow() {
super.didMoveToWindow()
guard window != nil, autoFocus else { return }
DispatchQueue.main.async { [weak self] in
self?.requestKeyboardFocus()
}
}
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if !string.isEmpty {
// Some software keyboards deliver Return through this delegate instead of
// textFieldShouldReturn, so normalize that path too.
emitInput(TerminalInputSequence.normalizingReturn(string))
return false
}
emitInput("\u{7F}")
return false
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
emitInput(TerminalInputSequence.carriageReturn)
textField.text = ""
return false
}
@objc
private func handleViewportTap() {
requestKeyboardFocus()
}
@objc
private func handleViewportPan(_ gesture: UIPanGestureRecognizer) {
guard let surface else { return }
let location = gesture.location(in: terminalViewport)
ghostty_surface_mouse_pos(
surface,
Double(location.x * contentScaleFactor),
Double(location.y * contentScaleFactor),
GHOSTTY_MODS_NONE
)
switch gesture.state {
case .began:
pendingVerticalScrollPoints = 0
gesture.setTranslation(.zero, in: terminalViewport)
case .changed:
let translation = gesture.translation(in: terminalViewport)
let stepSize = max(
fontSize * Self.verticalScrollStepMultiplier,
Self.minimumVerticalScrollStepPoints
)
let totalVerticalPoints = pendingVerticalScrollPoints + translation.y
let verticalSteps = Int(totalVerticalPoints / stepSize)
pendingVerticalScrollPoints = totalVerticalPoints - (CGFloat(verticalSteps) * stepSize)
guard verticalSteps != 0 else {
gesture.setTranslation(.zero, in: terminalViewport)
return
}
ghostty_surface_mouse_scroll(surface, 0, Double(verticalSteps), 0)
redrawSurface()
gesture.setTranslation(.zero, in: terminalViewport)
default:
pendingVerticalScrollPoints = 0
gesture.setTranslation(.zero, in: terminalViewport)
}
}
@objc
private func handleInputEditingDidBegin() {
textInputModeDidChange()
}
private func createSurfaceIfPossible() {
guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return }
guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return }
guard GhosttyRuntime.ensureInitialized() else {
surfaceCreationFailed = true
return
}
isCreatingSurface = true
defer { isCreatingSurface = false }
var runtimeConfig = ghostty_runtime_config_s(
userdata: Unmanaged.passUnretained(self).toOpaque(),
supports_selection_clipboard: false,
wakeup_cb: { _ in },
action_cb: { _, _, _ in false },
read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED },
confirm_read_clipboard_cb: { _, _, _, _ in },
write_clipboard_cb: { _, _, _, _, _ in },
close_surface_cb: { _, _ in }
)
guard let config = ghostty_config_new() else {
surfaceCreationFailed = true
return
}
loadThemeConfig(into: config)
ghostty_config_finalize(config)
defer { ghostty_config_free(config) }
guard let createdApp = ghostty_app_new(&runtimeConfig, config) else {
surfaceCreationFailed = true
return
}
var surfaceConfig = ghostty_surface_config_new()
surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS
surfaceConfig.platform.ios.uiview = Unmanaged.passUnretained(terminalViewport).toOpaque()
surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
surfaceConfig.scale_factor = Double(contentScaleFactor)
surfaceConfig.font_size = Float(fontSize)
surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
surfaceConfig.use_custom_io = true
guard let createdSurface = ghostty_surface_new(createdApp, &surfaceConfig) else {
ghostty_app_free(createdApp)
surfaceCreationFailed = true
return
}
app = createdApp
surface = createdSurface
ghostty_app_set_color_scheme(createdApp, appearance.ghosttyColorScheme)
ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme)
setupWriteCallback()
resizeSurface()
feedBuffer(initialBuffer)
}
private func resetSurface() {
destroySurface()
lastAppliedBuffer = ""
lastViewportSize = .zero
lastContentScale = 0
lastReportedGrid = nil
surfaceCreationFailed = false
setNeedsLayout()
}
private func refreshSurface() {
resetSurface()
createSurfaceIfPossible()
}
private func destroySurface() {
if let surface {
ghostty_surface_set_write_callback(surface, nil, nil)
ghostty_surface_free(surface)
}
if let app {
ghostty_app_free(app)
}
surface = nil
app = nil
}
private func applyRemoteBuffer(_ buffer: String) {
guard surface != nil else {
createSurfaceIfPossible()
return
}
if buffer.isEmpty {
feedData(Data("\u{1B}[3J\u{1B}[H\u{1B}[2J".utf8))
lastAppliedBuffer = ""
return
}
if buffer.hasPrefix(lastAppliedBuffer) {
let suffix = String(buffer.dropFirst(lastAppliedBuffer.count))
feedData(Data(suffix.utf8))
lastAppliedBuffer = buffer
return
}
resetSurface()
createSurfaceIfPossible()
}
private func feedBuffer(_ buffer: String) {
guard !buffer.isEmpty else { return }
feedData(Data(buffer.utf8))
lastAppliedBuffer = buffer
}
private func feedData(_ data: Data) {
guard let surface, !data.isEmpty else { return }
data.withUnsafeBytes { buffer in
guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return
}
ghostty_surface_feed_data(surface, pointer, buffer.count)
}
redrawSurface()
}
private func setupWriteCallback() {
guard let surface else { return }
let userdata = Unmanaged.passUnretained(self).toOpaque()
ghostty_surface_set_write_callback(surface, { userdata, data, len in
guard let userdata, let data, len > 0 else { return }
let view = Unmanaged<T3TerminalView>.fromOpaque(userdata).takeUnretainedValue()
let bytes = Data(bytes: data, count: len)
guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }
DispatchQueue.main.async {
view.emitInput(input)
}
}, userdata)
}
private func resizeSurface() {
guard let surface else {
emitEstimatedResize()
return
}
let scale = contentScaleFactor
let width = UInt32(max(floor(terminalViewport.bounds.width * scale), 1))
let height = UInt32(max(floor(terminalViewport.bounds.height * scale), 1))
terminalViewport.contentScaleFactor = scale
ghostty_surface_set_content_scale(surface, Double(scale), Double(scale))
ghostty_surface_set_size(surface, width, height)
ghostty_surface_set_occlusion(surface, window != nil)
configureIOSurfaceLayers()
redrawSurface()
emitGhosttyResize()
}
private func redrawSurface() {
guard let surface else { return }
ghostty_surface_refresh(surface)
ghostty_surface_draw(surface)
markIOSurfaceLayersForDisplay()
emitGhosttyResize()
}
private func emitGhosttyResize() {
guard let surface else {
emitEstimatedResize()
return
}
let size = ghostty_surface_size(surface)
let cols = max(1, Int(size.columns))
let rows = max(1, Int(size.rows))
emitResize(cols: cols, rows: rows)
}
private func emitEstimatedResize() {
guard bounds.width > 0, bounds.height > 0 else { return }
let cellWidth = max(fontSize * 0.62, 1)
let cellHeight = max(fontSize * 1.35, 1)
let cols = max(20, min(400, Int(bounds.width / cellWidth)))
let terminalHeight = max(bounds.height, 0)
let rows = max(5, min(200, Int(terminalHeight / cellHeight)))
emitResize(cols: cols, rows: rows)
}
private func emitResize(cols: Int, rows: Int) {
guard lastReportedGrid?.cols != cols || lastReportedGrid?.rows != rows else {
return
}
lastReportedGrid = (cols, rows)
onResize([
"cols": cols,
"rows": rows,
])
}
private func updateContentScale() {
// The trait collection follows the scene the view is in, which matters once
// iPhone apps run resizable and on external displays; UIScreen.main does not.
let scale = traitCollection.displayScale
if contentScaleFactor != scale {
contentScaleFactor = scale
}
}
private func requestKeyboardFocus() {
guard window != nil, !readOnly else { return }
inputField.becomeFirstResponder()
textInputModeDidChange()
}
private func emitInput(_ data: String) {
guard !readOnly, !data.isEmpty else { return }
onInput(["data": data])
}
private func textInputModeDidChange() {
guard let app else { return }
ghostty_app_keyboard_changed(app)
}
private func configureIOSurfaceLayers() {
let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size)
CATransaction.begin()
CATransaction.setDisableActions(true)
terminalViewport.layer.sublayers?.forEach { sublayer in
sublayer.frame = targetBounds
sublayer.contentsScale = contentScaleFactor
}
CATransaction.commit()
}
private func markIOSurfaceLayersForDisplay() {
terminalViewport.layer.setNeedsDisplay()
terminalViewport.layer.sublayers?.forEach { layer in
layer.setNeedsDisplay()
}
}
private func applyTheme() {
backgroundColor = backgroundColorValue
terminalViewport.backgroundColor = backgroundColorValue
}
private func loadThemeConfig(into config: ghostty_config_t) {
guard let path = writeThemeConfigFile() else { return }
path.withCString { cString in
ghostty_config_load_file(config, cString)
}
}
private func writeThemeConfigFile() -> String? {
guard !themeConfig.isEmpty else { return nil }
let configContents = themeConfig
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty")
do {
if let existing = try? String(contentsOf: url, encoding: .utf8), existing == configContents {
return url.path
}
try configContents.write(to: url, atomically: true, encoding: .utf8)
return url.path
} catch {
return nil
}
}
}