diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 1feff96e3..b844c6125 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -15,6 +15,7 @@ import { } from "@server/shared/terminal-input-mode"; import { type PendingTerminalModifiers, + isAppleHandheldPlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, @@ -84,6 +85,14 @@ const isMac = (/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") || /Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? "")); +const isAppleHandheld = + typeof navigator !== "undefined" && + isAppleHandheldPlatform({ + userAgent: navigator.userAgent, + platform: (navigator as Navigator & { platform?: string }).platform, + maxTouchPoints: navigator.maxTouchPoints, + }); + const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; @@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime { metaKey: event.metaKey, pendingModifiers: this.pendingModifiers, enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(), + isAppleHandheld, }) ) { return true; diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 07dff7b1c..5d39d55cf 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { hasPendingTerminalModifiers, + isAppleHandheldPlatform, isTerminalModifierDomKey, mapTerminalDataToKey, mergeTerminalModifiers, @@ -10,6 +11,13 @@ import { shouldInterceptDomTerminalKey, } from "./terminal-keys"; +const IPAD_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15"; +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const IPHONE_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; + describe("terminal key helpers", () => { it("normalizes supported DOM keys", () => { expect(normalizeDomTerminalKey("Esc")).toBe("Escape"); @@ -157,6 +165,140 @@ describe("terminal key helpers", () => { ).toBe(false); }); + it("intercepts plain Ctrl+C on iPad so xterm's keyCode-13 quirk never reaches the PTY (#1049)", () => { + // See COMPAT(xterm-ipad-ctrl-c) in terminal-keys.ts. + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(true); + // Uppercase variant in case Caps Lock is on. + expect( + shouldInterceptDomTerminalKey({ + key: "C", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(true); + }); + + it("does not intercept other Ctrl+letter combos on iPad (xterm handles them correctly)", () => { + for (const key of ["b", "d", "z", "a", "r", "l"]) { + expect( + shouldInterceptDomTerminalKey({ + key, + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + } + }); + + it("does not intercept Ctrl+C on real macOS / Windows / Linux", () => { + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: false, + }), + ).toBe(false); + }); + + it("does not intercept Cmd+C on iPad (Cmd-based shortcuts stay with the OS)", () => { + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: true, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + }); + + it("does not intercept Ctrl+Shift+C / Ctrl+Alt+C on iPad", () => { + // Only bare Ctrl+C is affected by the WebKit quirk; modified variants stay with xterm. + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: true, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + }); + + it("detects iPad masquerading as macOS via maxTouchPoints", () => { + expect( + isAppleHandheldPlatform({ userAgent: IPAD_UA, platform: "MacIntel", maxTouchPoints: 5 }), + ).toBe(true); + }); + + it("detects iPhone/iPod by UA", () => { + expect( + isAppleHandheldPlatform({ userAgent: IPHONE_UA, platform: "iPhone", maxTouchPoints: 5 }), + ).toBe(true); + }); + + it("does not flag real macOS desktop as a handheld", () => { + expect( + isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 0 }), + ).toBe(false); + }); + + it("does not flag macOS when maxTouchPoints == 1 (some trackpad contexts)", () => { + expect( + isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 1 }), + ).toBe(false); + }); + + it("tolerates null/undefined navigator-style inputs", () => { + expect(isAppleHandheldPlatform({ userAgent: null, platform: null, maxTouchPoints: null })).toBe( + false, + ); + expect( + isAppleHandheldPlatform({ + userAgent: undefined, + platform: undefined, + maxTouchPoints: undefined, + }), + ).toBe(false); + }); + it("detects pending modifier state", () => { expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false); expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index 4a70cb312..a25d4e08a 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -82,6 +82,27 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers) return modifiers.ctrl || modifiers.shift || modifiers.alt; } +interface AppleHandheldDetectionInput { + userAgent: string | null | undefined; + platform: string | null | undefined; + maxTouchPoints: number | null | undefined; +} + +// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish +// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS. +export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean { + const userAgent = input.userAgent ?? ""; + const platform = input.platform ?? ""; + const touchPoints = input.maxTouchPoints ?? 0; + if (/iPad|iPhone|iPod/.test(userAgent)) { + return true; + } + if (/Mac/i.test(platform) && touchPoints > 1) { + return true; + } + return false; +} + export function shouldInterceptDomTerminalKey(args: { key: string; ctrlKey: boolean; @@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: { metaKey: boolean; pendingModifiers: PendingTerminalModifiers; enhancedInputActive?: boolean; + isAppleHandheld?: boolean; }): boolean { if (hasPendingTerminalModifiers(args.pendingModifiers)) { return true; @@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: { if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) { return Boolean(args.enhancedInputActive); } + // COMPAT(xterm-ipad-ctrl-c): WebKit sends keyCode=13 for hardware-kbd Ctrl+C on iPad, so + // xterm.js emits \r instead of \x03. Upstream: xtermjs/xterm.js#5721, targeting xterm.js 7.0.0. + // Drop this block and the isAppleHandheld plumbing once @xterm/xterm is bumped past it. + if ( + args.isAppleHandheld && + args.ctrlKey && + !args.metaKey && + !args.altKey && + !args.shiftKey && + (args.key === "c" || args.key === "C") + ) { + return true; + } return false; }