From 4d0c88525ca6d51531eb330da372b669cba79167 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 15 Sep 2026 14:42:23 +0800 Subject: [PATCH 1/2] gterm: add iPad split-view workspace and Magic Keyboard support On iPad, Hosts becomes a collapsible NavigationSplitView sidebar next to the terminal, with a Sessions menu to switch live connections without disconnecting. Keys, AI, and Settings open as sheets. Narrow windows fall back to a single column. Magic Keyboard: Command shortcuts (copy/paste/select all, font size, next/previous session, back to hosts) via UIKeyCommand; pointer drag selection, click, and hover routed to ghostty mouse events; two-finger scrollback. Hiding the software keyboard now keeps first-responder focus by swapping in a zero-height inputView so physical keys keep working. Add a gtermUITests target with an offscreen iPad runner (scripts/test-ipad-ui.py) and a loopback paramiko SSH echo fixture (scripts/ipad-ssh-fixture.py) that verifies key routing and resize. Claude-Session: https://claude.ai/code/session_011AGnzfZpK9Jvx13DcS97DZ --- README.md | 43 ++++- .../TerminalSurfaceView+Hardware.swift | 51 ++++++ .../Ghostty/TerminalSurfaceView+Link.swift | 1 + .../Ghostty/TerminalSurfaceView+Pointer.swift | 53 ++++++ .../Ghostty/TerminalSurfaceView+Scroll.swift | 3 + .../TerminalSurfaceView+Selection.swift | 1 + Sources/Ghostty/TerminalSurfaceView.swift | 25 ++- Sources/UI/ConnectionListView.swift | 144 +++++++++------- Sources/UI/RootView.swift | 158 ++++++++++++++--- Sources/UI/TerminalScreen.swift | 34 +++- Sources/UI/TerminalView.swift | 34 +++- UITests/iPadWorkspaceTests.swift | 163 ++++++++++++++++++ project.yml | 15 ++ scripts/ipad-ssh-fixture.py | 72 ++++++++ scripts/test-ipad-ui.py | 92 ++++++++++ 15 files changed, 794 insertions(+), 95 deletions(-) create mode 100644 Sources/Ghostty/TerminalSurfaceView+Pointer.swift create mode 100644 UITests/iPadWorkspaceTests.swift create mode 100644 scripts/ipad-ssh-fixture.py create mode 100644 scripts/test-ipad-ui.py 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..e787069 --- /dev/null +++ b/UITests/iPadWorkspaceTests.swift @@ -0,0 +1,163 @@ +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 = app.windows.firstMatch.frame + let corner = app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.995, dy: 0.99)) + let target = app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + corner.click(forDuration: 0.2, thenDragTo: target) + capture("iPad narrow window") + XCTAssertLessThan(app.windows.firstMatch.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) + XCTAssertGreaterThan(app.windows.firstMatch.frame.width, originalFrame.width * 0.8) + } + + private func maximizeWindow(_ app: XCUIApplication) { + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + if app.windows.firstMatch.frame.width < UIScreen.main.bounds.width * 0.9 { + // iPadOS 26 retains window sizes across app launches. Double-tap + // the window's top edge to restore full size before wide tests. + app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.005)).doubleTap() + } + } + + 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) From 39551a4b48d2d121bd75954b2ffc6e888056a165 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Tue, 15 Sep 2026 15:07:18 +0800 Subject: [PATCH 2/2] gterm: target the main window in iPad resize UI test The app owns several windows (keyboard, text effects) and app.windows.firstMatch sometimes returned one whose frame is reported in portrait, so the restored-width assertion read 834 instead of 1210. Pick the window containing the Hosts sidebar and wait for two consecutive wide reads since the zoom animates. Claude-Session: https://claude.ai/code/session_011AGnzfZpK9Jvx13DcS97DZ --- UITests/iPadWorkspaceTests.swift | 43 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/UITests/iPadWorkspaceTests.swift b/UITests/iPadWorkspaceTests.swift index e787069..99a082a 100644 --- a/UITests/iPadWorkspaceTests.swift +++ b/UITests/iPadWorkspaceTests.swift @@ -92,12 +92,12 @@ final class iPadWorkspaceTests: XCTestCase { app.launch() maximizeWindow(app) XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) - let originalFrame = app.windows.firstMatch.frame - let corner = app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.995, dy: 0.99)) - let target = app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + 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(app.windows.firstMatch.frame.width, originalFrame.width * 0.8) + 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)) @@ -106,16 +106,43 @@ final class iPadWorkspaceTests: XCTestCase { app.typeKey("w", modifierFlags: .command) XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 5)) maximizeWindow(app) - XCTAssertGreaterThan(app.windows.firstMatch.frame.width, originalFrame.width * 0.8) + 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.windows.firstMatch.waitForExistence(timeout: 10)) - if app.windows.firstMatch.frame.width < UIScreen.main.bounds.width * 0.9 { + 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. - app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.005)).doubleTap() + 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) {