Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Repository Guidelines

## Project Structure & Module Organization

`gterm` is an iOS SwiftUI SSH terminal app backed by Ghostty's rendering engine. Source lives under `Sources/`:

- `Sources/App/`: app entry point.
- `Sources/UI/`: SwiftUI screens and connection/key management views.
- `Sources/Ghostty/`: Swift bridge and terminal surface/input integration.
- `Sources/SSH/`: `swift-nio-ssh` transport, PTY channel, known-host handling, and key parsing.
- `Sources/Terminal/` and `Sources/Model/`: terminal protocol abstractions, persistence, and Keychain helpers.

`project.yml` is the XcodeGen source of truth. `gterm.xcodeproj`, `Info.plist`, and `GhosttyKit.xcframework` are generated and ignored. `scripts/build-ghostty-xcframework.sh` builds the Ghostty engine from a sibling `../ghostty` checkout.

## Build, Test, and Development Commands

- `./scripts/build-ghostty-xcframework.sh`: build and copy `GhosttyKit.xcframework`. Requires patched Homebrew `zig@0.15`; override with `ZIG=...` or `GHOSTTY_DIR=...`.
- `xcodegen generate`: regenerate `gterm.xcodeproj` and generated app metadata from `project.yml`.
- `xcodebuild -project gterm.xcodeproj -scheme gterm -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build`: compile the app for simulator without signing.
- Open `gterm.xcodeproj` in Xcode for simulator/device runs after regeneration.

## Coding Style & Naming Conventions

Use Swift 5 language mode, four-space indentation, and existing Swift API naming. Types use `UpperCamelCase`; methods, properties, and enum cases use `lowerCamelCase`. Keep UI code in `Sources/UI`, SSH/networking code in `Sources/SSH`, and C/Ghostty interop isolated in `Sources/Ghostty`. Prefer small, explicit types and avoid blocking callbacks from Ghostty or NIO threads; hop to the appropriate event loop or main thread.

## Testing Guidelines

Three test targets exist, all defined in `project.yml`: `gtermTests` (logic-only, compiles `Sources/LLM` + `Tests/`, run with `xcodebuild -scheme gtermTests -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' test`), `gtermSSHTests` (macOS), and `gtermUITests` (XCUITest, `UITests/`). Run the UI suite offscreen with `python3 scripts/test-ipad-ui.py` (needs `paramiko`; `--device-type NAME` targets another simulator such as `"iPhone 17 Pro Max"` or the iPhone Duo). Every change should at least pass the simulator build command above. For behavior touching SSH auth, host-key trust, terminal input, resize, or Keychain persistence, also do a manual simulator/device smoke test. Name test files after the feature under test, for example `SSHKeyParserTests.swift`.

## Commit & Pull Request Guidelines

Recent commits use concise, imperative subjects with the `gterm:` prefix, for example `gterm: SSH private-key management`. Keep commits focused and mention generated artifacts only when the source file that produces them changed. PRs should describe the user-visible behavior, list build/manual verification, link related issues, and include screenshots or short recordings for UI changes.

## Security & Configuration Tips

Do not commit secrets, private keys, provisioning material, or local config. `.gitignore` already excludes `*.p8`, `local.properties`, `secrets.xcconfig`, generated build output, and `GhosttyKit.xcframework`. Store SSH passwords and imported keys through the app's Keychain paths, not in source fixtures or logs.
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ Terminal resize → passthru resize callback → SSHSession → WindowChangeRequ

Passthru callbacks fire on ghostty's IO thread — always hop to the NIO event loop (SSH) or main thread (UI) and never block.

### Layout selection (iPad vs iPhone vs iPhone Duo)

`RootView.usesWorkspace` picks the split-view workspace when the idiom is
`.pad` or the window is regular in **both** size classes (the iPhone Duo's
inner display); everything else gets the phone tabs. Do not use
`horizontalSizeClass == .regular` alone: Max-class iPhones are regular-width
in landscape and must keep tabs. Never reintroduce `UIScreen.main`; use
`TerminalSurfaceView.displayScale`, which follows the display the view is on.

### C interop

GhosttyKit is a static Zig library linked with `-ObjC -lstdc++`. The `Ghostty/` module uses `Unmanaged`, C function-pointer callbacks, and raw struct access against `ghostty.h`. The project uses **Swift 5 language mode** because this low-level interop is substantially simpler than under Swift 6 strict concurrency.
Expand All @@ -117,4 +126,5 @@ GhosttyKit is a static Zig library linked with `-ObjC -lstdc++`. The `Ghostty/`
- Team: from `DEVELOPMENT_TEAM` in the git-ignored `.env` (see `env.example`); App Store Connect only accepts builds signed by the current team there — the old `SK4GFF6AHN` team is stale
- Version/build: `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.yml`
- Dependencies: swift-nio-ssh, swift-crypto, OpenAI (MacPaw), SwiftAnthropic
- iPhone Duo: full edge-to-edge use of the inner display needs a build against the iOS 27.1 SDK (Xcode 27.1+); the deployment target stays 17.0
- CI: `.github/workflows/build.yml` — builds the engine on macOS-15, caches `~/.cache/zig`
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ connection via a custom **passthru IO backend** added to libghostty. See
Keychain, device-only); each host can select one or more keys to try
- ⏳ Encrypted (passphrase) keys, RSA, richer settings (font/theme)

## iPad and Magic Keyboard
## iPad, iPhone Duo, and Magic Keyboard

On iPad, Hosts stays in a collapsible sidebar beside the terminal. Use the
Sessions menu to switch connections without disconnecting them. Keys, AI,
and Settings open in sheets; narrow windows collapse to one column.

iPhone Duo gets the same sidebar workspace on its inner display, where the
window is regular in both size classes. The outer display and Split View use
the iPhone tab layout. Sessions stay connected across folding; the terminal
surface simply moves between the two layouts. Full edge-to-edge use of the
inner display requires a build against the iOS 27.1 SDK.

Magic Keyboard supports two-finger scrollback, pointer drag selection, and
Command-click to open terminal links. The keyboard button hides the software
keyboard while keeping physical keyboard input focused on the terminal.
Expand Down Expand Up @@ -56,6 +62,10 @@ starts the loopback SSH fixture, and runs all UI tests. It removes its device
and stops its fixture on completion or interruption. Existing simulators are
left alone. Logs and the `.xcresult` bundle (including screenshots) remain in
the printed temporary directory. Use `--derived-data PATH` to reuse a build cache.
`--device-type NAME` picks another simulator: `"iPhone 17 Pro Max"` runs the
phone layout tests (the iPad window test skips itself), and the iPhone Duo
simulator can be targeted the same way once Xcode 27.1 is installed.
`--only-testing gtermUITests/PhoneLayoutTests` narrows the run.

Port 62222 must be free for the fixture. The keyboard test exercises an SSH
connection, keyboard dispatch with the software keyboard hidden, session
Expand Down
2 changes: 1 addition & 1 deletion Sources/Ghostty/TerminalSurfaceView+Scroll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ extension TerminalSurfaceView {
// Convert points to surface pixels and feed as a precision scroll so
// the content follows the finger. Positive y scrolls toward older
// lines, which is what dragging the content downward should do.
let scale = window?.screen.scale ?? UIScreen.main.scale
let scale = displayScale
let yOffset = Double(delta * scale)
ghostty_surface_mouse_scroll(surface, 0, yOffset, Self.precisionScrollMods)
default:
Expand Down
21 changes: 19 additions & 2 deletions Sources/Ghostty/TerminalSurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ extension TerminalSurfaceViewDelegate {
/// ghostty key/text C API; received bytes are pushed in via `receive(_:)`.
final class TerminalSurfaceView: UIView {
var onKeyboardShortcut: ((String) -> Void)?
/// The representable currently hosting this surface; see TerminalView.
weak var host: AnyObject?
private var softwareKeyboardHidden = false
private lazy var hiddenKeyboardView = UIView(frame: .zero)
override var inputView: UIView? { softwareKeyboardHidden ? hiddenKeyboardView : nil }
Expand All @@ -56,6 +58,12 @@ final class TerminalSurfaceView: UIView {
isOpaque = true
contentMode = .redraw

// Moving between displays with different scales (iPhone Duo) must
// re-sync the surface's pixel size.
registerForTraitChanges([UITraitDisplayScale.self]) { (view: TerminalSurfaceView, _) in
view.setNeedsLayout()
}

// Pinch: font size normally; Herdr pane zoom when attachHerdr is on.
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
addGestureRecognizer(pinch)
Expand Down Expand Up @@ -93,7 +101,7 @@ final class TerminalSurfaceView: UIView {
cfg.platform_tag = GHOSTTY_PLATFORM_IOS
cfg.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(uiview: selfPtr))
cfg.userdata = selfPtr
cfg.scale_factor = Double(window?.screen.scale ?? UIScreen.main.scale)
cfg.scale_factor = Double(displayScale)
cfg.font_size = 0
cfg.io_backend = GHOSTTY_IO_BACKEND_PASSTHRU
cfg.pty_write_cb = { ud, ptr, len in
Expand Down Expand Up @@ -229,9 +237,18 @@ final class TerminalSurfaceView: UIView {
CATransaction.commit()
}

/// Scale of the display currently showing this view. `UIScreen.main` is
/// deprecated and wrong on multi-display devices (iPhone Duo inner/outer);
/// `displayScale` is 0 when unspecified, hence the fallback.
var displayScale: CGFloat {
if let scale = window?.windowScene?.screen.scale, scale > 0 { return scale }
let trait = traitCollection.displayScale
return trait > 0 ? trait : 2
}

private func syncSize() {
guard let surface else { return }
let scale = window?.screen.scale ?? UIScreen.main.scale
let scale = displayScale
ghostty_surface_set_content_scale(surface, Double(scale), Double(scale))
let size = bounds.size
ghostty_surface_set_size(
Expand Down
32 changes: 28 additions & 4 deletions Sources/UI/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ struct RootView: View {
@StateObject private var keys = KeyStore()
@StateObject private var forwards = PortForwardStore()
@StateObject private var sessions = SessionManager()
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var activeSession: ActiveSession?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var preferredColumn: NavigationSplitViewColumn = .sidebar
Expand All @@ -21,17 +23,39 @@ struct RootView: View {
@AppStorage("hasSeenWelcome") private var hasSeenWelcome = false
@State private var showWelcome = false

/// Wide layouts use the split-view workspace: iPad (any window width; the
/// split view collapses itself when narrow) and phones whose window is
/// regular in both axes, i.e. the iPhone Duo's inner display. Deliberately
/// not `horizontalSizeClass == .regular` alone: Max-class iPhones are
/// regular-width in landscape and must keep the tab layout. Read here at
/// the window root; NavigationSplitView overrides size classes per column.
private var usesWorkspace: Bool {
#if DEBUG
if UserDefaults.standard.bool(forKey: "gtermForceWorkspaceLayout") { return true }
#endif
return UIDevice.current.userInterfaceIdiom == .pad
|| (horizontalSizeClass == .regular && verticalSizeClass == .regular)
}

var body: some View {
Group {
if UIDevice.current.userInterfaceIdiom == .pad {
iPadWorkspace
if usesWorkspace {
workspace
} else {
phoneTabs
.fullScreenCover(item: $activeSession) { session in
terminal(session)
}
}
}
.onChange(of: usesWorkspace) { _, wide in
// Folding an iPhone Duo tears the workspace's utility sheet down
// without onDismiss; clear it so unfolding doesn't re-present it.
if !wide {
utility = nil
welcomeAfterUtility = false
}
}
.fullScreenCover(isPresented: $showWelcome) {
OnboardingView {
showWelcome = false
Expand All @@ -46,7 +70,7 @@ struct RootView: View {
private var hosts: some View {
ConnectionListView(
store: connections, keyStore: keys, forwardStore: forwards, sessions: sessions,
usesNavigationStack: UIDevice.current.userInterfaceIdiom != .pad,
usesNavigationStack: !usesWorkspace,
selectedSessionID: activeSession?.id
) { connection in
let connectionForwards = connection.savedID.map { forwards.forwards(for: $0) } ?? []
Expand All @@ -67,7 +91,7 @@ struct RootView: View {
}
}

private var iPadWorkspace: some View {
private var workspace: some View {
NavigationSplitView(columnVisibility: $columnVisibility, preferredCompactColumn: $preferredColumn) {
hosts
.safeAreaInset(edge: .bottom) {
Expand Down
5 changes: 5 additions & 0 deletions Sources/UI/TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ struct TerminalView: UIViewRepresentable {
func makeCoordinator() -> Coordinator { Coordinator() }

func makeUIView(context: Context) -> TerminalSurfaceView {
// The session-owned surface is reparented when the layout swaps (iPad
// session switch, iPhone Duo fold). Only its newest host may rebind
// handlers, whatever order the old and new representables update in.
surface.host = context.coordinator
updateHandlers(context.coordinator)
return surface
}
Expand All @@ -23,6 +27,7 @@ struct TerminalView: UIViewRepresentable {
}

private func updateHandlers(_ coordinator: Coordinator) {
guard surface.host === coordinator else { return }
coordinator.onOpenURL = onOpenURL
coordinator.onKeyboardShortcut = onKeyboardShortcut
// SwiftUI can call onDisappear during split-view navigation while the
Expand Down
50 changes: 50 additions & 0 deletions UITests/PhoneLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import XCTest
import UIKit

/// Phone-idiom layout rules. Run on an iPhone 17 Pro Max simulator, whose
/// landscape window is regular-width but compact-height: the size-class rule
/// in RootView must keep tabs there and reserve the workspace for windows that
/// are regular in both axes (the iPhone Duo's inner display).
final class PhoneLayoutTests: XCTestCase {
override func setUpWithError() throws {
try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .phone)
}

func testRegularWidthLandscapeKeepsTabs() throws {
let app = XCUIApplication()
app.launchArguments = ["-hasSeenWelcome", "YES"]
XCUIDevice.shared.orientation = .landscapeLeft
app.launch()
XCTAssertTrue(app.tabBars.buttons["Hosts"].waitForExistence(timeout: 10))
XCTAssertFalse(app.staticTexts["Select a Host"].exists)
capture("iPhone landscape tabs")
XCUIDevice.shared.orientation = .portrait
XCTAssertTrue(app.tabBars.buttons["Hosts"].waitForExistence(timeout: 5))
}

/// Exercises the workspace branch on the phone idiom ahead of the iPhone
/// Duo simulator: sidebar, utility sheet, connect, and Command-W back.
func testForcedWorkspaceLayoutOnPhone() throws {
let app = XCUIApplication()
app.launchArguments = ["-hasSeenWelcome", "YES", "-gtermForceWorkspaceLayout", "YES"]
XCUIDevice.shared.orientation = .landscapeLeft
app.launch()
XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Select a Host"].exists)
XCTAssertFalse(app.tabBars.buttons["Hosts"].exists)
capture("iPhone forced workspace")

app.buttons["Keys"].tap()
XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5))
app.buttons["Done"].tap()
XCTAssertTrue(app.staticTexts["Select a Host"].waitForExistence(timeout: 5))

connectTestHost(app, username: "test")
XCTAssertTrue(app.buttons["Toggle keyboard"].waitForExistence(timeout: 5))
capture("iPhone forced workspace terminal")
app.typeKey("x", modifierFlags: [])
app.typeKey("w", modifierFlags: .command)
XCTAssertTrue(app.staticTexts["Select a Host"].waitForExistence(timeout: 5))
XCUIDevice.shared.orientation = .portrait
}
}
47 changes: 47 additions & 0 deletions UITests/TestSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import XCTest

/// Helpers shared by the iPad and iPhone UI suites.
extension XCTestCase {
func connectTestHost(_ app: XCUIApplication, username: String) {
app.buttons["Add host"].tap()
let name = "Keyboard test \(UUID().uuidString.prefix(6))"
for (field, value) in [("name (optional)", name), ("host", "127.0.0.1"), ("username", username)] {
app.textFields[field].tap()
app.textFields[field].typeText(value)
}
app.textFields["port"].tap()
app.textFields["port"].typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 2) + "62222")
// Dismiss iPadOS's floating number-pad popover before tapping Save.
app.textFields["name (optional)"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(app.buttons["Save"].isEnabled)
app.buttons["Save"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let host = app.staticTexts[name]
for _ in 0..<12 {
if host.isHittable && host.frame.maxY < app.buttons["Keys"].frame.minY - 12 { break }
app.collectionViews.firstMatch.swipeUp()
}
XCTAssertTrue(host.waitForExistence(timeout: 5))
host.tap()
XCTAssertTrue(app.alerts["Password"].waitForExistence(timeout: 5))
app.alerts.secureTextFields.firstMatch.typeText("test")
app.alerts.buttons["Connect"].tap()
if app.alerts["Unknown Host"].waitForExistence(timeout: 3) {
app.alerts.buttons["Trust"].tap()
} else if app.alerts["Host Key Changed"].exists {
// The loopback fixture generates an ephemeral key on every run.
app.alerts.buttons["Accept New Key"].tap()
}
// A fresh simulator may still be opening the terminal after the host
// header appears. Wait for SSH readiness before synthesizing keys.
let status = app.descendants(matching: .any)["connectionStatus"]
let connected = expectation(for: NSPredicate(format: "value == 'Connected'"), evaluatedWith: status)
wait(for: [connected], timeout: 10)
}

func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
Loading
Loading