@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@server/shared/terminal-input-mode";
|
} from "@server/shared/terminal-input-mode";
|
||||||
import {
|
import {
|
||||||
type PendingTerminalModifiers,
|
type PendingTerminalModifiers,
|
||||||
|
isAppleHandheldPlatform,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
mergeTerminalModifiers,
|
mergeTerminalModifiers,
|
||||||
normalizeDomTerminalKey,
|
normalizeDomTerminalKey,
|
||||||
@@ -84,6 +85,14 @@ const isMac =
|
|||||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
|
/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 DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||||
@@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
metaKey: event.metaKey,
|
metaKey: event.metaKey,
|
||||||
pendingModifiers: this.pendingModifiers,
|
pendingModifiers: this.pendingModifiers,
|
||||||
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||||
|
isAppleHandheld,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
|
isAppleHandheldPlatform,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
mapTerminalDataToKey,
|
mapTerminalDataToKey,
|
||||||
mergeTerminalModifiers,
|
mergeTerminalModifiers,
|
||||||
@@ -10,6 +11,13 @@ import {
|
|||||||
shouldInterceptDomTerminalKey,
|
shouldInterceptDomTerminalKey,
|
||||||
} from "./terminal-keys";
|
} 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", () => {
|
describe("terminal key helpers", () => {
|
||||||
it("normalizes supported DOM keys", () => {
|
it("normalizes supported DOM keys", () => {
|
||||||
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
|
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
|
||||||
@@ -157,6 +165,140 @@ describe("terminal key helpers", () => {
|
|||||||
).toBe(false);
|
).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", () => {
|
it("detects pending modifier state", () => {
|
||||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
||||||
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
|
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
|
||||||
|
|||||||
@@ -82,6 +82,27 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
|
|||||||
return modifiers.ctrl || modifiers.shift || modifiers.alt;
|
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: {
|
export function shouldInterceptDomTerminalKey(args: {
|
||||||
key: string;
|
key: string;
|
||||||
ctrlKey: boolean;
|
ctrlKey: boolean;
|
||||||
@@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: {
|
|||||||
metaKey: boolean;
|
metaKey: boolean;
|
||||||
pendingModifiers: PendingTerminalModifiers;
|
pendingModifiers: PendingTerminalModifiers;
|
||||||
enhancedInputActive?: boolean;
|
enhancedInputActive?: boolean;
|
||||||
|
isAppleHandheld?: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: {
|
|||||||
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
||||||
return Boolean(args.enhancedInputActive);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user