diff --git a/README.md b/README.md index 84c56b9..162d54d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,48 @@ 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 + +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. + +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. +Hold Command to discover these terminal shortcuts: + +| Shortcut | Action | +| --- | --- | +| Command-C / V / A | Copy selection / paste / select all | +| Command-= / - / 0 | Increase / decrease / reset font size | +| Command-Shift-] / [ | Next / previous session | +| Command-W | Return to Hosts, keeping the connection alive | + +Control and Option combinations continue to go to the remote terminal, +including Control-C, Control-D, and Option word navigation. Right-click a +host to connect, edit, disconnect, or delete it. + +Run the iPad UI tests offscreen (requires `paramiko` in your Python environment +and an installed iOS 26+ simulator runtime): + +```sh +python3 scripts/test-ipad-ui.py +``` + +The runner creates a disposable iPad, boots it without opening Simulator.app, +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. + +Port 62222 must be free for the fixture. The keyboard test exercises an SSH +connection, keyboard dispatch with the software keyboard hidden, session +switching, and rotation. The fixture reports input as hex: Control-C is `03`, +Control-D is `04`, and Option-X is `1b78`. Command shortcuts should not appear +in the SSH input. This is an SSH echo fixture, not a remote shell. Physical +trackpad feel and Magic Keyboard hardware still require a device smoke test. + ## Building Requirements: macOS, Xcode 26+, and the patched Homebrew zig: @@ -113,4 +155,3 @@ Bundled / dependency components keep their own licenses: the [ghostty](https://github.com/madeye/ghostty) engine is MIT; swift-nio-ssh, swift-crypto, and swift-nio are Apache-2.0; the OpenAI and SwiftAnthropic SDKs are MIT. - diff --git a/Sources/Ghostty/TerminalSurfaceView+Hardware.swift b/Sources/Ghostty/TerminalSurfaceView+Hardware.swift index e72808b..aeba0c8 100644 --- a/Sources/Ghostty/TerminalSurfaceView+Hardware.swift +++ b/Sources/Ghostty/TerminalSurfaceView+Hardware.swift @@ -10,6 +10,38 @@ import GhosttyKit /// (mode-aware), while plain printable input is left to `UIKeyInput.insertText` /// so the layout-correct character is produced and we never double-send. extension TerminalSurfaceView { + override var keyCommands: [UIKeyCommand]? { + let definitions: [(String, UIKeyModifierFlags, String)] = [ + ("c", .command, "Copy"), ("v", .command, "Paste"), + ("a", .command, "Select All"), + ("=", .command, "Increase Font Size"), + ("-", .command, "Decrease Font Size"), + ("0", .command, "Reset Font Size"), + ("w", .command, "Back to Hosts"), + ("]", [.command, .shift], "Next Session"), + ("[", [.command, .shift], "Previous Session") + ] + return definitions.map { input, modifiers, title in + let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: #selector(handleAppCommand(_:))) + command.discoverabilityTitle = title + command.wantsPriorityOverSystemBehavior = true + return command + } + } + + @objc private func handleAppCommand(_ command: UIKeyCommand) { + switch command.input { + case "c": _ = performBindingAction("copy_to_clipboard") + case "v": _ = performBindingAction("paste_from_clipboard") + case "a": _ = performBindingAction("select_all") + case "=": _ = performBindingAction("increase_font_size:1") + case "-": _ = performBindingAction("decrease_font_size:1") + case "0": _ = performBindingAction("reset_font_size") + case let input?: onKeyboardShortcut?(input) + default: break + } + } + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { let unhandled = presses.filter { !handleHardwarePress($0, action: .press) } if !unhandled.isEmpty { super.pressesBegan(unhandled, with: event) } @@ -28,6 +60,25 @@ extension TerminalSurfaceView { private func handleHardwarePress(_ press: UIPress, action: Ghostty.KeyAction) -> Bool { guard let uiKey = press.key else { return false } + // UIKit normally dispatches priority key commands before presses. + // Handle the fallback here as well, consuming it exactly once rather + // than letting Ghostty interpret an app shortcut. Shifted bracket + // characters vary by keyboard layout; their physical usages are stable. + let commandModifiers = uiKey.modifierFlags.intersection([.command, .control, .alternate, .shift]) + let commandInput: String + switch uiKey.keyCode { + case .keyboardOpenBracket: commandInput = "[" + case .keyboardCloseBracket: commandInput = "]" + default: commandInput = uiKey.charactersIgnoringModifiers.lowercased() + } + if let command = keyCommands?.first(where: { + $0.input == commandInput && $0.modifierFlags == commandModifiers + }) { + handleAppCommand(command) + clearStickyMods() + return true + } + var mods = Ghostty.Mods.fromHardware(uiKey.modifierFlags) mods.formUnion(stickyMods) diff --git a/Sources/Ghostty/TerminalSurfaceView+Link.swift b/Sources/Ghostty/TerminalSurfaceView+Link.swift index f025450..91f3824 100644 --- a/Sources/Ghostty/TerminalSurfaceView+Link.swift +++ b/Sources/Ghostty/TerminalSurfaceView+Link.swift @@ -15,6 +15,7 @@ import GhosttyKit extension TerminalSurfaceView { func setupLinkTapGesture() { let tap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTap(_:))) + tap.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)] tap.numberOfTapsRequired = 1 tap.numberOfTouchesRequired = 1 addGestureRecognizer(tap) diff --git a/Sources/Ghostty/TerminalSurfaceView+Pointer.swift b/Sources/Ghostty/TerminalSurfaceView+Pointer.swift new file mode 100644 index 0000000..e6c9067 --- /dev/null +++ b/Sources/Ghostty/TerminalSurfaceView+Pointer.swift @@ -0,0 +1,53 @@ +import UIKit +import GhosttyKit + +extension TerminalSurfaceView { + func setupPointerGestures() { + let pointerTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)] + let drag = UIPanGestureRecognizer(target: self, action: #selector(pointerDrag(_:))) + drag.allowedTouchTypes = pointerTypes + drag.allowedScrollTypesMask = [] + addGestureRecognizer(drag) + + let click = UITapGestureRecognizer(target: self, action: #selector(pointerClick(_:))) + click.allowedTouchTypes = pointerTypes + click.require(toFail: drag) + addGestureRecognizer(click) + addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(pointerHover(_:)))) + } + + @objc private func pointerHover(_ gesture: UIHoverGestureRecognizer) { + guard let surface = ghosttySurface else { return } + let point = gesture.location(in: self) + let mods = Ghostty.Mods.fromHardware(gesture.modifierFlags).cMods + ghostty_surface_mouse_pos(surface, Double(point.x), Double(point.y), mods) + } + + @objc private func pointerClick(_ gesture: UITapGestureRecognizer) { + guard gesture.state == .ended, let surface = ghosttySurface else { return } + _ = becomeFirstResponder() + let point = gesture.location(in: self) + let mods = Ghostty.Mods.fromHardware(gesture.modifierFlags).cMods + ghostty_surface_mouse_pos(surface, Double(point.x), Double(point.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) + } + + @objc private func pointerDrag(_ gesture: UIPanGestureRecognizer) { + guard let surface = ghosttySurface else { return } + let point = gesture.location(in: self) + let mods = Ghostty.Mods.fromHardware(gesture.modifierFlags).cMods + if gesture.state == .began { + _ = becomeFirstResponder() + // Recognition starts after the pointer has moved. Anchor the + // selection at the original press rather than the threshold point. + let translation = gesture.translation(in: self) + ghostty_surface_mouse_pos(surface, Double(point.x - translation.x), Double(point.y - translation.y), mods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) + } + ghostty_surface_mouse_pos(surface, Double(point.x), Double(point.y), mods) + if [.ended, .cancelled, .failed].contains(gesture.state) { + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + } + } +} diff --git a/Sources/Ghostty/TerminalSurfaceView+Scroll.swift b/Sources/Ghostty/TerminalSurfaceView+Scroll.swift index 606670e..5088ba6 100644 --- a/Sources/Ghostty/TerminalSurfaceView+Scroll.swift +++ b/Sources/Ghostty/TerminalSurfaceView+Scroll.swift @@ -13,7 +13,10 @@ import GhosttyKit extension TerminalSurfaceView { func setupScrollGesture() { let pan = UIPanGestureRecognizer(target: self, action: #selector(handleScrollPan(_:))) + pan.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)] pan.maximumNumberOfTouches = 1 + // Magic Keyboard two-finger scrolling and mouse wheels. + pan.allowedScrollTypesMask = .all addGestureRecognizer(pan) scrollPan = pan } diff --git a/Sources/Ghostty/TerminalSurfaceView+Selection.swift b/Sources/Ghostty/TerminalSurfaceView+Selection.swift index 21685bc..2ecc535 100644 --- a/Sources/Ghostty/TerminalSurfaceView+Selection.swift +++ b/Sources/Ghostty/TerminalSurfaceView+Selection.swift @@ -20,6 +20,7 @@ extension TerminalSurfaceView { let longPress = UILongPressGestureRecognizer( target: self, action: #selector(handleSelectionLongPress(_:)) ) + longPress.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)] longPress.minimumPressDuration = 0.4 addGestureRecognizer(longPress) } diff --git a/Sources/Ghostty/TerminalSurfaceView.swift b/Sources/Ghostty/TerminalSurfaceView.swift index bfc2667..61093d3 100644 --- a/Sources/Ghostty/TerminalSurfaceView.swift +++ b/Sources/Ghostty/TerminalSurfaceView.swift @@ -29,6 +29,11 @@ extension TerminalSurfaceViewDelegate { /// CAMetalLayer) driven by the passthru IO backend. Input is sent via the /// ghostty key/text C API; received bytes are pushed in via `receive(_:)`. final class TerminalSurfaceView: UIView { + var onKeyboardShortcut: ((String) -> Void)? + private var softwareKeyboardHidden = false + private lazy var hiddenKeyboardView = UIView(frame: .zero) + override var inputView: UIView? { softwareKeyboardHidden ? hiddenKeyboardView : nil } + weak var delegate: TerminalSurfaceViewDelegate? private weak var ghostty: Ghostty.App? @@ -43,6 +48,10 @@ final class TerminalSurfaceView: UIView { // Non-zero initial frame so the Metal layer has a drawable size. super.init(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + isAccessibilityElement = true + accessibilityLabel = "Terminal" + accessibilityIdentifier = "terminalSurface" + accessibilityTraits = .allowsDirectInteraction backgroundColor = .black isOpaque = true contentMode = .redraw @@ -104,6 +113,7 @@ final class TerminalSurfaceView: UIView { } self.surface = surface setupSelectionGestures() + setupPointerGestures() setupScrollGesture() setupLinkTapGesture() } @@ -258,6 +268,10 @@ final class TerminalSurfaceView: UIView { /// iPadOS keyboard hide key, or the accessory bar's collapse button). The /// single-tap gesture calls this so tapping the terminal expands it again. func showKeyboardIfNeeded() { + if softwareKeyboardHidden { + softwareKeyboardHidden = false + reloadInputViews() + } if !isFirstResponder { _ = becomeFirstResponder() } else if !keyboardVisible { @@ -272,12 +286,15 @@ final class TerminalSurfaceView: UIView { /// the whole screen to the terminal. Tapping the terminal or the status-bar /// keyboard button expands it again. func collapseKeyboard() { - _ = resignFirstResponder() + // Keep focus so Magic Keyboard input continues with the software + // keyboard hidden. A zero-height input view suppresses only that UI. + softwareKeyboardHidden = true + reloadInputViews() } /// Toggle the on-screen keyboard: collapse when visible, expand otherwise. func toggleKeyboard() { - if isFirstResponder && keyboardVisible { + if isFirstResponder && keyboardVisible && !softwareKeyboardHidden { collapseKeyboard() } else { showKeyboardIfNeeded() @@ -288,7 +305,7 @@ final class TerminalSurfaceView: UIView { /// Perform a libghostty keybind action by name (e.g. "increase_font_size:1"). @discardableResult - private func performBindingAction(_ action: String) -> Bool { + func performBindingAction(_ action: String) -> Bool { guard let surface else { return false } let len = action.utf8.count return action.withCString { ghostty_surface_binding_action(surface, $0, UInt(len)) } @@ -334,7 +351,7 @@ final class TerminalSurfaceView: UIView { lazy var accessory = AccessoryKeyboardView(target: self) - override var inputAccessoryView: UIView? { accessory } + override var inputAccessoryView: UIView? { softwareKeyboardHidden ? nil : accessory } /// LLM autocompletion engine (lazy: only built when first needed). lazy var completionEngine = CompletionEngine() diff --git a/Sources/UI/ConnectionListView.swift b/Sources/UI/ConnectionListView.swift index 40e2fd7..9f573e6 100644 --- a/Sources/UI/ConnectionListView.swift +++ b/Sources/UI/ConnectionListView.swift @@ -10,6 +10,8 @@ struct ConnectionListView: View { @ObservedObject var keyStore: KeyStore @ObservedObject var forwardStore: PortForwardStore @ObservedObject var sessions: SessionManager + var usesNavigationStack = true + var selectedSessionID: UUID? = nil let onConnect: (SSHConnection) -> Void @State private var editing: SavedConnection? @@ -17,79 +19,105 @@ struct ConnectionListView: View { @State private var promptPassword = "" var body: some View { - NavigationStack { - List { - if store.connections.isEmpty { - ContentUnavailableView( - "No Connections", - systemImage: "terminal", - description: Text("Tap + to add an SSH host.") - ) - } - ForEach(store.connections) { conn in - Button { connect(conn) } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(conn.title).font(.headline) - Text(conn.subtitle).font(.subheadline).foregroundStyle(.secondary) - } - Spacer() - if let session = sessions.session(for: conn.id) { - SessionBadge(session: session) - } + Group { + if usesNavigationStack { + NavigationStack { listContent } + } else { + // NavigationSplitView provides the sidebar's navigation stack. + listContent + } + } + } + + private var listContent: some View { + List { + if store.connections.isEmpty { + ContentUnavailableView( + "No Connections", + systemImage: "terminal", + description: Text("Tap + to add an SSH host.") + ) + } + ForEach(store.connections) { conn in + Button { connect(conn) } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(conn.title).font(.headline) + Text(conn.subtitle).font(.subheadline).foregroundStyle(.secondary) } - } - .swipeActions(edge: .leading) { + Spacer() if let session = sessions.session(for: conn.id) { - Button { sessions.disconnect(session) } label: { - Label("Disconnect", systemImage: "bolt.slash") - }.tint(.orange) + SessionBadge(session: session) } } - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - store.delete(conn) - forwardStore.deleteForwards(for: conn.id) - } label: { - Label("Delete", systemImage: "trash") + } + .listRowBackground(selectedSessionID == conn.id ? Color.accentColor.opacity(0.15) : nil) + .contextMenu { + Button { connect(conn) } label: { Label("Connect", systemImage: "terminal") } + Button { editing = conn } label: { Label("Edit", systemImage: "pencil") } + if let session = sessions.session(for: conn.id) { + Button { sessions.disconnect(session) } label: { + Label("Disconnect", systemImage: "bolt.slash") } - Button { editing = conn } label: { - Label("Edit", systemImage: "pencil") - }.tint(.blue) } + Button(role: .destructive) { + store.delete(conn) + forwardStore.deleteForwards(for: conn.id) + } label: { Label("Delete", systemImage: "trash") } } - } - .navigationTitle("Hosts") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { editing = SavedConnection() } label: { - Image(systemName: "plus") + .swipeActions(edge: .leading) { + if let session = sessions.session(for: conn.id) { + Button { sessions.disconnect(session) } label: { + Label("Disconnect", systemImage: "bolt.slash") + }.tint(.orange) } } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + store.delete(conn) + forwardStore.deleteForwards(for: conn.id) + } label: { + Label("Delete", systemImage: "trash") + } + Button { editing = conn } label: { + Label("Edit", systemImage: "pencil") + }.tint(.blue) + } } - .sheet(item: $editing) { conn in - AddConnectionView(store: store, keyStore: keyStore, forwardStore: forwardStore, connection: conn) - } - .alert( - "Password", - isPresented: Binding( - get: { passwordPromptFor != nil }, - set: { if !$0 { passwordPromptFor = nil } } - ), - presenting: passwordPromptFor - ) { conn in - SecureField("password", text: $promptPassword) - Button("Connect") { - onConnect(makeConnection(conn, password: promptPassword)) - promptPassword = "" + } + .navigationTitle("Hosts") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { editing = SavedConnection() } label: { + Image(systemName: "plus") } - Button("Cancel", role: .cancel) { promptPassword = "" } - } message: { conn in - Text("Enter the password for \(conn.username)@\(conn.host).") + .accessibilityLabel("Add host") + .keyboardShortcut("n", modifiers: .command) } } + .sheet(item: $editing) { conn in + AddConnectionView(store: store, keyStore: keyStore, forwardStore: forwardStore, connection: conn) + } + .alert( + "Password", + isPresented: Binding( + get: { passwordPromptFor != nil }, + set: { if !$0 { passwordPromptFor = nil } } + ), + presenting: passwordPromptFor + ) { conn in + SecureField("password", text: $promptPassword) + Button("Connect") { + onConnect(makeConnection(conn, password: promptPassword)) + promptPassword = "" + } + Button("Cancel", role: .cancel) { promptPassword = "" } + } message: { conn in + Text("Enter the password for \(conn.username)@\(conn.host).") + } } + private func keyTexts(for conn: SavedConnection) -> [String] { conn.keyIDs.compactMap { keyStore.text(for: $0) } } diff --git a/Sources/UI/RootView.swift b/Sources/UI/RootView.swift index b653e31..e26d63a 100644 --- a/Sources/UI/RootView.swift +++ b/Sources/UI/RootView.swift @@ -7,47 +7,161 @@ struct RootView: View { @StateObject private var forwards = PortForwardStore() @StateObject private var sessions = SessionManager() @State private var activeSession: ActiveSession? + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var preferredColumn: NavigationSplitViewColumn = .sidebar + @State private var utility: Utility? + @State private var welcomeAfterUtility = false + + private enum Utility: String, Identifiable { + case keys, ai, settings + var id: String { rawValue } + } /// Whether the user has seen the Get Started screen (persisted). @AppStorage("hasSeenWelcome") private var hasSeenWelcome = false @State private var showWelcome = false var body: some View { - TabView { - ConnectionListView( - store: connections, keyStore: keys, forwardStore: forwards, sessions: sessions - ) { connection in - let connectionForwards = connection.savedID.map { forwards.forwards(for: $0) } ?? [] - activeSession = sessions.open(connection, ghostty: ghostty, forwards: connectionForwards) + Group { + if UIDevice.current.userInterfaceIdiom == .pad { + iPadWorkspace + } else { + phoneTabs + .fullScreenCover(item: $activeSession) { session in + terminal(session) + } + } + } + .fullScreenCover(isPresented: $showWelcome) { + OnboardingView { + showWelcome = false + hasSeenWelcome = true } - .tabItem { Label("Hosts", systemImage: "server.rack") } + } + .onAppear { + if !hasSeenWelcome { showWelcome = true } + } + } + private var hosts: some View { + ConnectionListView( + store: connections, keyStore: keys, forwardStore: forwards, sessions: sessions, + usesNavigationStack: UIDevice.current.userInterfaceIdiom != .pad, + selectedSessionID: activeSession?.id + ) { connection in + let connectionForwards = connection.savedID.map { forwards.forwards(for: $0) } ?? [] + activeSession = sessions.open(connection, ghostty: ghostty, forwards: connectionForwards) + preferredColumn = .detail + } + } + + private var phoneTabs: some View { + TabView { + hosts.tabItem { Label("Hosts", systemImage: "server.rack") } KeyListView(store: keys, connections: connections) .tabItem { Label("Keys", systemImage: "key.fill") } - AISettingsView() .tabItem { Label("AI", systemImage: "sparkles") } - SettingsView(showWelcome: $showWelcome) .tabItem { Label("Settings", systemImage: "gearshape") } } - .fullScreenCover(item: $activeSession) { session in - TerminalScreen(session: session, forwardStore: forwards) { - // Detach only: a live session keeps running in the background - // so the user can come back to it from the Hosts list. - sessions.pruneIfDead(session) - activeSession = nil + } + + private var iPadWorkspace: some View { + NavigationSplitView(columnVisibility: $columnVisibility, preferredCompactColumn: $preferredColumn) { + hosts + .safeAreaInset(edge: .bottom) { + HStack { + Button { utility = .keys } label: { Label("Keys", systemImage: "key") } + Spacer() + Button { utility = .ai } label: { Label("AI", systemImage: "sparkles") } + Spacer() + Button { utility = .settings } label: { + Image(systemName: "gearshape") + } + .accessibilityLabel("Settings") + } + .padding() + .background(.bar) + } + .navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 400) + } detail: { + if let session = activeSession { + terminal(session) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + ForEach(sessions.sessions) { candidate in + Button { + activeSession = candidate + } label: { + Label( + "\(candidate.connection.username)@\(candidate.connection.host)", + systemImage: candidate.id == session.id ? "checkmark" : "terminal" + ) + } + } + } label: { + Label("Sessions", systemImage: "rectangle.stack") + } + } + } + } else { + ContentUnavailableView( + "Select a Host", systemImage: "terminal", + description: Text("Open an SSH connection from the sidebar. Your sessions stay connected when you switch hosts.") + ) + .navigationTitle("Terminal") } - .environmentObject(ghostty) } - .fullScreenCover(isPresented: $showWelcome) { - OnboardingView { - showWelcome = false - hasSeenWelcome = true + .navigationSplitViewStyle(.balanced) + .sheet(item: $utility, onDismiss: { + if welcomeAfterUtility { + welcomeAfterUtility = false + showWelcome = true + } + }) { item in + Group { + switch item { + case .keys: KeyListView(store: keys, connections: connections) + case .ai: AISettingsView() + case .settings: + SettingsView(showWelcome: Binding( + get: { showWelcome }, + set: { requested in + if requested { + welcomeAfterUtility = true + utility = nil + } + } + )) + } + } + .safeAreaInset(edge: .bottom) { + Button("Done") { utility = nil } + .frame(maxWidth: .infinity).padding().background(.bar) } } - .onAppear { - if !hasSeenWelcome { showWelcome = true } + } + + private func terminal(_ session: ActiveSession) -> some View { + TerminalScreen(session: session, forwardStore: forwards, onSwitchSession: { offset in + let candidates = sessions.sessions + guard let index = candidates.firstIndex(where: { $0.id == session.id }), !candidates.isEmpty else { return } + activeSession = candidates[(index + offset + candidates.count) % candidates.count] + }) { + sessions.pruneIfDead(session) + if activeSession?.id == session.id { + activeSession = nil + preferredColumn = .sidebar + columnVisibility = .all + } } + // A representable must be recreated when switching sessions: its + // UIView belongs to that session and cannot be replaced in updateUIView. + .id(session.id) + .environmentObject(ghostty) } + } diff --git a/Sources/UI/TerminalScreen.swift b/Sources/UI/TerminalScreen.swift index 810efcb..c1977fc 100644 --- a/Sources/UI/TerminalScreen.swift +++ b/Sources/UI/TerminalScreen.swift @@ -19,6 +19,7 @@ struct TappedURL: Identifiable { struct TerminalScreen: View { @ObservedObject var session: ActiveSession @ObservedObject var forwardStore: PortForwardStore + var onSwitchSession: (Int) -> Void = { _ in } let onClose: () -> Void @State private var showingAICommands = false @@ -38,10 +39,18 @@ struct TerminalScreen: View { var body: some View { VStack(spacing: 0) { statusBar - TerminalView(surface: session.surface) - } - .onAppear { - session.surface.onOpenURL = { url in linkURL = TappedURL(url: url) } + TerminalView( + surface: session.surface, + onOpenURL: { linkURL = TappedURL(url: $0) }, + onKeyboardShortcut: { input in + switch input { + case "w": onClose() + case "]": onSwitchSession(1) + case "[": onSwitchSession(-1) + default: break + } + } + ) } .sheet(isPresented: $showingAICommands) { AICommandSheet( @@ -133,7 +142,9 @@ struct TerminalScreen: View { Button(action: onClose) { Image(systemName: "chevron.left") .font(.body.weight(.semibold)) + .frame(minWidth: 36, minHeight: 36) } + .accessibilityLabel("Back to hosts") Text("\(connection.username)@\(connection.host)") .font(.subheadline.weight(.medium)) .lineLimit(1) @@ -152,6 +163,10 @@ struct TerminalScreen: View { .accessibilityLabel("Port forwards") .disabled(session.state != .connected) statusIndicator + .accessibilityElement(children: .ignore) + .accessibilityLabel("Connection status") + .accessibilityValue(statusDescription) + .accessibilityIdentifier("connectionStatus") } .padding(.horizontal, 14) .padding(.vertical, 8) @@ -178,6 +193,17 @@ struct TerminalScreen: View { } } + private var statusDescription: String { + switch session.state { + case .idle: return "Idle" + case .connecting: return "Connecting" + case .authenticating: return "Authenticating" + case .connected: return "Connected" + case .closed: return "Disconnected" + case .failed(let message): return "Failed: \(message)" + } + } + private var label: String { switch session.state { case .connecting: return "connecting…" diff --git a/Sources/UI/TerminalView.swift b/Sources/UI/TerminalView.swift index 63347c3..c05165d 100644 --- a/Sources/UI/TerminalView.swift +++ b/Sources/UI/TerminalView.swift @@ -1,15 +1,37 @@ import SwiftUI -/// SwiftUI wrapper that hosts a `TerminalSurfaceView` owned by an -/// `ActiveSession`. The session and surface outlive this view: dismissing the -/// screen detaches the surface but leaves the SSH connection running, and -/// presenting it again reattaches the same surface with its scrollback. +/// Hosts a session-owned surface without tying the SSH connection to a screen. struct TerminalView: UIViewRepresentable { let surface: TerminalSurfaceView + let onOpenURL: (URL) -> Void + let onKeyboardShortcut: (String) -> Void + + final class Coordinator { + var onOpenURL: ((URL) -> Void)? + var onKeyboardShortcut: ((String) -> Void)? + } + + func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> TerminalSurfaceView { - surface + updateHandlers(context.coordinator) + return surface } - func updateUIView(_ uiView: TerminalSurfaceView, context: Context) {} + func updateUIView(_ uiView: TerminalSurfaceView, context: Context) { + updateHandlers(context.coordinator) + } + + private func updateHandlers(_ coordinator: Coordinator) { + coordinator.onOpenURL = onOpenURL + coordinator.onKeyboardShortcut = onKeyboardShortcut + // SwiftUI can call onDisappear during split-view navigation while the + // surface is still visible. Bind to the representable's lifetime instead. + // Weak captures also prevent a retained session surface from retaining + // its old screen and the session manager after detaching. + surface.onOpenURL = { [weak coordinator] in coordinator?.onOpenURL?($0) } + surface.onKeyboardShortcut = { [weak coordinator] input in + coordinator?.onKeyboardShortcut?(input) + } + } } diff --git a/UITests/iPadWorkspaceTests.swift b/UITests/iPadWorkspaceTests.swift new file mode 100644 index 0000000..99a082a --- /dev/null +++ b/UITests/iPadWorkspaceTests.swift @@ -0,0 +1,190 @@ +import XCTest +import UIKit + +final class iPadWorkspaceTests: XCTestCase { + func testWorkspaceNavigationAndRotation() throws { + let app = XCUIApplication() + app.launchArguments = ["-hasSeenWelcome", "YES"] + XCUIDevice.shared.orientation = .landscapeLeft + app.launch() + maximizeWindow(app) + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Select a Host"].exists) + capture("iPad landscape 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)) + + app.buttons["Add host"].tap() + XCTAssertTrue(app.textFields["host"].waitForExistence(timeout: 5)) + capture("iPad host editor") + app.buttons["Cancel"].tap() + + XCUIDevice.shared.orientation = .portrait + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 5)) + capture("iPad portrait workspace") + app.buttons["Settings"].tap() + app.buttons["Get Started Guide"].tap() + XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5)) + app.buttons["Done"].tap() + XCTAssertTrue(app.staticTexts["Select a Host"].waitForExistence(timeout: 5)) + XCUIDevice.shared.orientation = .landscapeLeft + } + + func testMagicKeyboardCommandsKeepFocusWhenSoftwareKeyboardIsHidden() throws { + let app = XCUIApplication() + app.launchArguments = ["-hasSeenWelcome", "YES"] + XCUIDevice.shared.orientation = .landscapeLeft + app.launch() + maximizeWindow(app) + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + connectTestHost(app, username: "test") + XCTAssertTrue(app.buttons["Toggle keyboard"].waitForExistence(timeout: 5)) + app.buttons["Toggle keyboard"].tap() + let status = app.descendants(matching: .any)["connectionStatus"] + let connected = expectation(for: NSPredicate(format: "value == 'Connected'"), evaluatedWith: status) + wait(for: [connected], timeout: 5) + // The simulator's first synthetic key enables hardware input and + // may omit modifiers; prime that transition with an unmodified key. + app.typeKey("x", modifierFlags: []) + app.typeKey("c", modifierFlags: .control) + app.typeKey("d", modifierFlags: .control) + app.typeKey("x", modifierFlags: .option) + app.typeKey("=", modifierFlags: .command) + app.typeKey("0", modifierFlags: .command) + let terminal = app.descendants(matching: .any)["terminalSurface"] + XCTAssertTrue(terminal.waitForExistence(timeout: 5)) + terminal.hover() + terminal.scroll(byDeltaX: 0, deltaY: -300) + capture("Magic Keyboard scrolled history") + let selectionStart = terminal.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.3)) + let selectionEnd = terminal.coordinate(withNormalizedOffset: CGVector(dx: 0.45, dy: 0.3)) + selectionStart.click(forDuration: 0.1, thenDragTo: selectionEnd) + capture("Magic Keyboard pointer selection") + app.typeKey("c", modifierFlags: .command) + app.typeKey("v", modifierFlags: .command) + app.typeKey("a", modifierFlags: .command) + app.typeKey("c", modifierFlags: .command) + app.typeKey("v", modifierFlags: .command) + capture("Magic Keyboard terminal with software keyboard hidden") + XCUIDevice.shared.orientation = .portrait + XCTAssertTrue(app.buttons["Toggle keyboard"].waitForExistence(timeout: 5)) + app.typeKey("w", modifierFlags: .command) + XCTAssertTrue(app.staticTexts["Select a Host"].waitForExistence(timeout: 5)) + XCUIDevice.shared.orientation = .landscapeLeft + connectTestHost(app, username: "test2") + XCTAssertTrue(app.staticTexts["test2@127.0.0.1"].waitForExistence(timeout: 5)) + app.typeKey("x", modifierFlags: []) + app.typeKey("[", modifierFlags: [.command, .shift]) + XCTAssertTrue(app.staticTexts["test@127.0.0.1"].waitForExistence(timeout: 5)) + app.typeKey("]", modifierFlags: [.command, .shift]) + XCTAssertTrue(app.staticTexts["test2@127.0.0.1"].waitForExistence(timeout: 5)) + app.typeKey("w", modifierFlags: .command) + XCUIDevice.shared.orientation = .landscapeLeft + } + + func testWindowResizingKeepsHostsReachable() throws { + let app = XCUIApplication() + app.launchArguments = ["-hasSeenWelcome", "YES"] + XCUIDevice.shared.orientation = .landscapeLeft + app.launch() + maximizeWindow(app) + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + let originalFrame = mainWindow(app).frame + let corner = mainWindow(app).coordinate(withNormalizedOffset: CGVector(dx: 0.995, dy: 0.99)) + let target = mainWindow(app).coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + corner.click(forDuration: 0.2, thenDragTo: target) + capture("iPad narrow window") + XCTAssertLessThan(mainWindow(app).frame.width, originalFrame.width * 0.8) + XCTAssertTrue(app.buttons["Add host"].exists) + connectTestHost(app, username: "test") + XCTAssertTrue(app.buttons["Toggle keyboard"].waitForExistence(timeout: 5)) + capture("iPad narrow terminal") + app.typeKey("x", modifierFlags: []) + app.typeKey("w", modifierFlags: .command) + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 5)) + maximizeWindow(app) + capture("iPad restored window") + XCTAssertTrue(waitForWindowWidth(app, atLeast: originalFrame.width * 0.8), + "window width \(mainWindow(app).frame.width) after restoring from \(originalFrame.width)") + } + + /// The app owns several windows (keyboard, text effects) whose frames can + /// be reported in a different orientation. Target the one hosting Hosts. + private func mainWindow(_ app: XCUIApplication) -> XCUIElement { + app.windows.containing(.button, identifier: "Add host").firstMatch + } + + private func maximizeWindow(_ app: XCUIApplication) { + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + let wide = UIScreen.main.bounds.width * 0.9 + if mainWindow(app).frame.width < wide { + // iPadOS 26 retains window sizes across app launches. Double-tap + // the window's top edge to restore full size before wide tests. + mainWindow(app).coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.005)).doubleTap() + waitForWindowWidth(app, atLeast: wide) + } + } + + /// The zoom animates, so require two consecutive wide reads. + @discardableResult + private func waitForWindowWidth(_ app: XCUIApplication, atLeast minWidth: CGFloat, timeout: TimeInterval = 5) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + var wideReads = 0 + while Date() < deadline { + if mainWindow(app).frame.width >= minWidth { + wideReads += 1 + if wideReads >= 2 { return true } + } else { + wideReads = 0 + } + Thread.sleep(forTimeInterval: 0.25) + } + return mainWindow(app).frame.width >= minWidth + } + + private 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) + } + + private func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/project.yml b/project.yml index 9fef54e..3f519a0 100644 --- a/project.yml +++ b/project.yml @@ -167,3 +167,18 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: io.github.madeye.gtermSSHTests GENERATE_INFOPLIST_FILE: YES + + gtermUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "17.0" + sources: + - path: UITests + dependencies: + - target: gterm + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.github.madeye.gtermUITests + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: gterm + TARGETED_DEVICE_FAMILY: "1,2" diff --git a/scripts/ipad-ssh-fixture.py b/scripts/ipad-ssh-fixture.py new file mode 100644 index 0000000..374a33e --- /dev/null +++ b/scripts/ipad-ssh-fixture.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Loopback-only SSH echo fixture for gtermUITests. Requires paramiko. + +Accepts only synthetic test/test2 users with password 'test'. Reports received +bytes and PTY sizes so hardware-key routing and resize can be verified without +contacting a real host. This is an echo channel, not a shell. +""" +import socket +import threading + +import paramiko + + +class Server(paramiko.ServerInterface): + def check_auth_password(self, username, password): + if username in ("test", "test2") and password == "test": + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def get_allowed_auths(self, username): + return "password" + + def check_channel_request(self, kind, channel_id): + if kind == "session": + return paramiko.OPEN_SUCCEEDED + return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + + def check_channel_pty_request(self, channel, term, width, height, *args): + print("PTY", width, height, flush=True) + return True + + def check_channel_window_change_request(self, channel, width, height, *args): + print("RESIZE", width, height, flush=True) + return True + + def check_channel_shell_request(self, channel): + return True + + def check_channel_env_request(self, *args): + return True + + +def serve_client(sock, host_key): + transport = paramiko.Transport(sock) + try: + transport.add_server_key(host_key) + transport.start_server(server=Server()) + channel = transport.accept(20) + if channel is None: + return + for index in range(120): + channel.sendall(f"Fixture line {index:03}: Magic Keyboard selection and scrolling test\r\n".encode()) + channel.sendall(b"\r\ngterm Magic Keyboard SSH fixture\r\n$ ") + while data := channel.recv(4096): + print("INPUT", data.hex(), flush=True) + channel.sendall(data) + except (EOFError, OSError, paramiko.SSHException) as error: + print(type(error).__name__, flush=True) + finally: + transport.close() + + +if __name__ == "__main__": + host_key = paramiko.ECDSAKey.generate(bits=256) + with socket.socket() as listener: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 62222)) + listener.listen() + print("READY on 127.0.0.1:62222", flush=True) + while True: + client, _ = listener.accept() + threading.Thread(target=serve_client, args=(client, host_key), daemon=True).start() diff --git a/scripts/test-ipad-ui.py b/scripts/test-ipad-ui.py new file mode 100644 index 0000000..21602d8 --- /dev/null +++ b/scripts/test-ipad-ui.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Run the iPad UI suite on a disposable simulator without opening Simulator.app.""" +import argparse +import json +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time + + +ROOT = Path(__file__).resolve().parent.parent + + +def run(*args, **kwargs): + return subprocess.run(args, cwd=ROOT, check=True, **kwargs) + + +def interrupted(signum, frame): + raise KeyboardInterrupt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--derived-data", default=str(ROOT / "build/ui-tests")) + args = parser.parse_args() + # Fail before allocating a device if the fixture dependency is missing. + run(sys.executable, "-c", "import paramiko") + runtimes = json.loads(run("xcrun", "simctl", "list", "runtimes", "-j", + capture_output=True, text=True).stdout)["runtimes"] + runtimes = [r for r in runtimes if r["isAvailable"] and r.get("platform") == "iOS" + and int(r["version"].split(".")[0]) >= 26] + if not runtimes: + parser.error("Install an iOS 26 or newer simulator runtime in Xcode.") + runtime = max(runtimes, key=lambda r: tuple(map(int, r["version"].split(".")))) + device_type = next(d["identifier"] for d in runtime["supportedDeviceTypes"] + if d["name"] == "iPad Pro 11-inch (M5)") + artifacts = Path(tempfile.mkdtemp(prefix="gterm-ui-tests-")) + print(f"Test logs and screenshots: {artifacts}", flush=True) + device = None + fixture = None + signal.signal(signal.SIGTERM, interrupted) + try: + with (artifacts / "ssh.log").open("w") as fixture_log: + fixture = subprocess.Popen([sys.executable, str(ROOT / "scripts/ipad-ssh-fixture.py")], + stdout=fixture_log, stderr=subprocess.STDOUT) + for _ in range(100): + if fixture.poll() is not None: + raise RuntimeError(f"SSH fixture failed; see {artifacts / 'ssh.log'}") + if "READY on" in (artifacts / "ssh.log").read_text(): + break + time.sleep(0.1) + else: + raise RuntimeError("SSH fixture did not become ready within 10 seconds") + device = run("xcrun", "simctl", "create", artifacts.name, device_type, + runtime["identifier"], capture_output=True, text=True).stdout.strip() + print(f"Offscreen iPad: {device} ({runtime['name']})", flush=True) + # simctl boots the device services without launching the Simulator UI. + run("xcrun", "simctl", "boot", device) + run("xcrun", "simctl", "bootstatus", device, "-b") + run("xcodegen", "generate") + with (artifacts / "xcodebuild.log").open("w") as build_log: + run("xcodebuild", "-project", "gterm.xcodeproj", "-scheme", "gtermUITests", + "-destination", f"platform=iOS Simulator,id={device}", + "-parallel-testing-enabled", "NO", "-derivedDataPath", args.derived_data, + "-resultBundlePath", str(artifacts / "Tests.xcresult"), + "CODE_SIGNING_ALLOWED=NO", "test", + stdout=build_log, stderr=subprocess.STDOUT) + print("iPad UI tests passed.", flush=True) + finally: + if fixture is not None and fixture.poll() is None: + fixture.terminate() + try: + fixture.wait(timeout=5) + except subprocess.TimeoutExpired: + fixture.kill() + fixture.wait() + if device: + subprocess.run(["xcrun", "simctl", "shutdown", device], check=False) + subprocess.run(["xcrun", "simctl", "delete", device], check=False) + print(f"Results retained at {artifacts}", flush=True) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(130) + except (RuntimeError, subprocess.CalledProcessError) as error: + print(error, file=sys.stderr) + sys.exit(1)