Skip to content

Commit bc06d7f

Browse files
committed
fix(app): protect terminal right-click copy flow
1 parent 59f6195 commit bc06d7f

5 files changed

Lines changed: 497 additions & 281 deletions

File tree

Lines changed: 105 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
import {
2+
createTerminalSelectionDragController,
3+
forceTerminalSelectionModifier,
4+
suppressTerminalMouseReport,
5+
type TerminalCopyMouseEvent,
6+
type TerminalCopyMouseEventType,
7+
type TerminalMouseButtonEvent,
8+
type TerminalSelectionDragTarget
9+
} from "./terminal-copy-selection-drag.js"
10+
11+
export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js"
12+
113
export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10"
214

315
type TerminalSelectionTarget = {
@@ -11,15 +23,6 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
1123
}
1224
}
1325

14-
type TerminalMouseButtonEvent = {
15-
readonly button: number
16-
}
17-
18-
type TerminalSelectionModifierEvent = {
19-
readonly altKey: boolean
20-
readonly shiftKey: boolean
21-
}
22-
2326
type TerminalCopyClipboardData = {
2427
readonly setData: (format: string, data: string) => void
2528
}
@@ -30,35 +33,6 @@ type TerminalCopyClipboardEvent = {
3033
readonly stopPropagation: () => void
3134
}
3235

33-
type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent & {
34-
readonly buttons?: number | undefined
35-
readonly clientX?: number | undefined
36-
readonly clientY?: number | undefined
37-
readonly ctrlKey?: boolean | undefined
38-
readonly detail?: number | undefined
39-
readonly metaKey?: boolean | undefined
40-
readonly preventDefault?: (() => void) | undefined
41-
readonly screenX?: number | undefined
42-
readonly screenY?: number | undefined
43-
readonly stopImmediatePropagation?: (() => void) | undefined
44-
readonly stopPropagation?: (() => void) | undefined
45-
}
46-
47-
type TerminalSelectionDragEventType = "mousemove" | "mouseup"
48-
type TerminalCopyMouseEventType = "mousedown" | TerminalSelectionDragEventType
49-
50-
type TerminalSelectionDragListenerRegistration = (
51-
type: TerminalSelectionDragEventType,
52-
listener: (event: TerminalCopyMouseEvent) => void,
53-
options: true
54-
) => void
55-
56-
type TerminalSelectionDragTarget = {
57-
readonly addEventListener: TerminalSelectionDragListenerRegistration
58-
readonly dispatchEvent?: ((event: Event) => boolean) | undefined
59-
readonly removeEventListener: TerminalSelectionDragListenerRegistration
60-
}
61-
6236
type TerminalCopyListenerRegistration = {
6337
(type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void
6438
(type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void
@@ -75,22 +49,9 @@ type TerminalCopyInteractionArgs = {
7549
readonly terminal: TerminalCopyInteractionTerminal
7650
}
7751

78-
type TerminalSelectionDragController = {
79-
readonly dispose: () => void
80-
readonly start: () => void
81-
}
82-
8352
const primaryMouseButton = 0
8453
const secondaryMouseButton = 2
85-
86-
const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"])
87-
88-
const currentNavigatorPlatform = (): string => {
89-
if (typeof navigator === "undefined") {
90-
return ""
91-
}
92-
return navigator.platform
93-
}
54+
const terminalSelectionContextSnapshotTtlMs = 10_000
9455

9556
const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton
9657

@@ -109,18 +70,6 @@ export const shouldForceTerminalSelectionContext = (
10970
terminal: TerminalCopyInteractionTerminal
11071
): boolean => isSecondaryMouseButton(event) && terminal.hasSelection()
11172

112-
const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent =>
113-
macPlatformNames.has(platform) ? "altKey" : "shiftKey"
114-
115-
export const forceTerminalSelectionModifier = (
116-
event: TerminalSelectionModifierEvent,
117-
platform: string = currentNavigatorPlatform()
118-
): boolean =>
119-
Reflect.defineProperty(event, terminalSelectionModifier(platform), {
120-
configurable: true,
121-
value: true
122-
})
123-
12473
export const writeTerminalSelectionToClipboardData = (
12574
terminal: TerminalSelectionTarget,
12675
clipboardData: TerminalCopyClipboardData | null
@@ -136,182 +85,131 @@ export const writeTerminalSelectionToClipboardData = (
13685
return true
13786
}
13887

139-
const resolveTerminalSelectionDragTarget = (
140-
host: TerminalCopyInteractionHost
141-
): TerminalSelectionDragTarget => host.ownerDocument ?? host
142-
143-
const optionalNumber = (value: number | undefined): number => value ?? 0
144-
145-
const optionalBoolean = (value: boolean | undefined): boolean => value ?? false
146-
147-
const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventInit => {
148-
const selectionModifier = terminalSelectionModifier(currentNavigatorPlatform())
149-
return {
150-
altKey: selectionModifier === "altKey" ? true : event.altKey,
151-
bubbles: true,
152-
button: event.button,
153-
buttons: 0,
154-
cancelable: true,
155-
clientX: optionalNumber(event.clientX),
156-
clientY: optionalNumber(event.clientY),
157-
ctrlKey: optionalBoolean(event.ctrlKey),
158-
detail: optionalNumber(event.detail),
159-
metaKey: optionalBoolean(event.metaKey),
160-
screenX: optionalNumber(event.screenX),
161-
screenY: optionalNumber(event.screenY),
162-
shiftKey: selectionModifier === "shiftKey" ? true : event.shiftKey
163-
}
164-
}
165-
166-
const defineMouseEventProperty = (
167-
event: Event,
168-
property: string,
169-
value: boolean | number
170-
): void => {
171-
Reflect.defineProperty(event, property, {
172-
configurable: true,
173-
value
174-
})
175-
}
88+
class TerminalSelectionContextSnapshot {
89+
private selection = ""
90+
private timer: ReturnType<typeof setTimeout> | null = null
17691

177-
const copyMouseEventInitProperties = (
178-
event: Event,
179-
init: MouseEventInit
180-
): void => {
181-
defineMouseEventProperty(event, "altKey", optionalBoolean(init.altKey))
182-
defineMouseEventProperty(event, "button", optionalNumber(init.button))
183-
defineMouseEventProperty(event, "buttons", optionalNumber(init.buttons))
184-
defineMouseEventProperty(event, "clientX", optionalNumber(init.clientX))
185-
defineMouseEventProperty(event, "clientY", optionalNumber(init.clientY))
186-
defineMouseEventProperty(event, "ctrlKey", optionalBoolean(init.ctrlKey))
187-
defineMouseEventProperty(event, "detail", optionalNumber(init.detail))
188-
defineMouseEventProperty(event, "metaKey", optionalBoolean(init.metaKey))
189-
defineMouseEventProperty(event, "screenX", optionalNumber(init.screenX))
190-
defineMouseEventProperty(event, "screenY", optionalNumber(init.screenY))
191-
defineMouseEventProperty(event, "shiftKey", optionalBoolean(init.shiftKey))
192-
}
92+
constructor(private readonly terminal: TerminalSelectionTarget) {}
19393

194-
const createForcedTerminalMouseUpEvent = (
195-
sourceEvent: TerminalCopyMouseEvent
196-
): Event => {
197-
const init = forcedTerminalMouseUpInit(sourceEvent)
198-
const event = typeof MouseEvent === "function"
199-
? new MouseEvent("mouseup", init)
200-
: new Event("mouseup", { bubbles: true, cancelable: true })
201-
copyMouseEventInitProperties(event, init)
202-
return event
203-
}
94+
readonly clear = (): void => {
95+
this.selection = ""
96+
if (this.timer !== null) {
97+
clearTimeout(this.timer)
98+
this.timer = null
99+
}
100+
}
204101

205-
const suppressOriginalTerminalMouseUp = (event: TerminalCopyMouseEvent): void => {
206-
event.preventDefault?.()
207-
event.stopPropagation?.()
208-
event.stopImmediatePropagation?.()
209-
}
102+
readonly has = (): boolean => this.selection.length > 0
210103

211-
const suppressTerminalMouseReport = (event: TerminalCopyMouseEvent): void => {
212-
event.stopPropagation?.()
213-
event.stopImmediatePropagation?.()
214-
}
104+
readonly refresh = (): boolean => {
105+
const selection = this.terminal.getSelection()
106+
if (selection.length === 0) {
107+
this.clear()
108+
return false
109+
}
110+
this.selection = selection
111+
if (this.timer !== null) {
112+
clearTimeout(this.timer)
113+
}
114+
this.timer = setTimeout(this.clear, terminalSelectionContextSnapshotTtlMs)
115+
return true
116+
}
215117

216-
const replayForcedTerminalMouseUp = (
217-
target: TerminalSelectionDragTarget,
218-
event: TerminalCopyMouseEvent
219-
): void => {
220-
target.dispatchEvent?.(createForcedTerminalMouseUpEvent(event))
118+
readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => {
119+
if (clipboardData === null || this.selection.length === 0) {
120+
return false
121+
}
122+
clipboardData.setData("text/plain", this.selection)
123+
return true
124+
}
221125
}
222126

223-
const createTerminalSelectionDragController = (
224-
host: TerminalCopyInteractionHost
225-
): TerminalSelectionDragController => {
226-
let forcedSelectionDrag = false
227-
let selectionDragTarget: TerminalSelectionDragTarget | null = null
127+
class TerminalCopyInteractionController {
128+
private readonly selectionContext: TerminalSelectionContextSnapshot
129+
private readonly selectionDrag: ReturnType<typeof createTerminalSelectionDragController>
228130

229-
const clearSelectionDrag = (): void => {
230-
if (selectionDragTarget === null) {
231-
forcedSelectionDrag = false
232-
return
233-
}
234-
selectionDragTarget.removeEventListener("mousemove", onMouseMove, true)
235-
selectionDragTarget.removeEventListener("mouseup", onMouseUp, true)
236-
selectionDragTarget = null
237-
forcedSelectionDrag = false
131+
constructor(private readonly args: TerminalCopyInteractionArgs) {
132+
this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal)
133+
this.selectionDrag = createTerminalSelectionDragController(args.host)
238134
}
239135

240-
const onMouseMove = (event: TerminalCopyMouseEvent): void => {
241-
if (!forcedSelectionDrag) {
242-
return
243-
}
244-
forceTerminalSelectionModifier(event)
136+
readonly attach = (): { readonly dispose: () => void } => {
137+
this.args.host.addEventListener("mousedown", this.onMouseDown, true)
138+
this.args.host.addEventListener("mouseup", this.onMouseUp, true)
139+
this.args.host.addEventListener("contextmenu", this.onContextMenu, true)
140+
this.args.host.addEventListener("copy", this.onCopy, true)
141+
return { dispose: this.dispose }
245142
}
246143

247-
const onMouseUp = (event: TerminalCopyMouseEvent): void => {
248-
if (!forcedSelectionDrag) {
249-
return
144+
private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean =>
145+
isSecondaryMouseButton(event) && (this.selectionContext.has() || this.args.terminal.hasSelection())
146+
147+
private readonly onSelectionContextMouseEvent = (event: TerminalCopyMouseEvent): boolean => {
148+
if (!this.shouldProtectSelectionContext(event)) {
149+
return false
250150
}
251-
const target = selectionDragTarget
252151
forceTerminalSelectionModifier(event)
253-
if (target?.dispatchEvent !== undefined) {
254-
// CHANGE: replay a clean document mouseup for xterm selection finalization.
255-
// WHY: xterm's mouse-report mouseup treats the original release as pty input,
256-
// which triggers onUserInput and clears the just-created selection.
257-
suppressOriginalTerminalMouseUp(event)
258-
clearSelectionDrag()
259-
replayForcedTerminalMouseUp(target, event)
260-
return
152+
if (this.args.terminal.hasSelection()) {
153+
this.selectionContext.refresh()
261154
}
262-
clearSelectionDrag()
263-
}
264-
265-
const startSelectionDrag = (): void => {
266-
clearSelectionDrag()
267-
forcedSelectionDrag = true
268-
selectionDragTarget = resolveTerminalSelectionDragTarget(host)
269-
selectionDragTarget.addEventListener("mousemove", onMouseMove, true)
270-
selectionDragTarget.addEventListener("mouseup", onMouseUp, true)
155+
return true
271156
}
272157

273-
return {
274-
dispose: clearSelectionDrag,
275-
start: startSelectionDrag
276-
}
277-
}
278-
279-
export const attachTerminalCopyInteraction = (
280-
args: TerminalCopyInteractionArgs
281-
): { readonly dispose: () => void } => {
282-
const selectionDrag = createTerminalSelectionDragController(args.host)
283-
284-
const onMouseDown = (event: TerminalCopyMouseEvent): void => {
285-
const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, args.terminal)
286-
const forceSelectionContext = shouldForceTerminalSelectionContext(event, args.terminal)
158+
private readonly onMouseDown = (event: TerminalCopyMouseEvent): void => {
159+
if (isPrimaryMouseButton(event)) {
160+
this.selectionContext.clear()
161+
}
162+
const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal)
163+
const forceSelectionContext = shouldForceTerminalSelectionContext(event, this.args.terminal)
287164
if (!forceBrowserSelection && !forceSelectionContext) {
165+
if (isSecondaryMouseButton(event)) {
166+
this.selectionContext.clear()
167+
}
288168
return
289169
}
290170
forceTerminalSelectionModifier(event)
291171
if (forceSelectionContext) {
172+
this.selectionContext.refresh()
292173
suppressTerminalMouseReport(event)
293174
return
294175
}
295176
if (forceBrowserSelection) {
296-
selectionDrag.start()
177+
this.selectionDrag.start()
297178
}
298179
}
299-
const onCopy = (event: TerminalCopyClipboardEvent): void => {
300-
if (!writeTerminalSelectionToClipboardData(args.terminal, event.clipboardData)) {
180+
181+
private readonly onMouseUp = (event: TerminalCopyMouseEvent): void => {
182+
if (!this.onSelectionContextMouseEvent(event)) {
301183
return
302184
}
303-
event.preventDefault()
304-
event.stopPropagation()
185+
suppressTerminalMouseReport(event)
305186
}
306187

307-
args.host.addEventListener("mousedown", onMouseDown, true)
308-
args.host.addEventListener("copy", onCopy, true)
188+
private readonly onContextMenu = (event: TerminalCopyMouseEvent): void => {
189+
this.onSelectionContextMouseEvent(event)
190+
}
309191

310-
return {
311-
dispose: () => {
312-
selectionDrag.dispose()
313-
args.host.removeEventListener("mousedown", onMouseDown, true)
314-
args.host.removeEventListener("copy", onCopy, true)
192+
private readonly onCopy = (event: TerminalCopyClipboardEvent): void => {
193+
const wroteSelection = writeTerminalSelectionToClipboardData(this.args.terminal, event.clipboardData)
194+
const wroteSnapshot = wroteSelection ? false : this.selectionContext.writeToClipboardData(event.clipboardData)
195+
if (!wroteSelection && !wroteSnapshot) {
196+
return
315197
}
198+
this.selectionContext.clear()
199+
event.preventDefault()
200+
event.stopPropagation()
201+
}
202+
203+
private readonly dispose = (): void => {
204+
this.selectionDrag.dispose()
205+
this.selectionContext.clear()
206+
this.args.host.removeEventListener("mousedown", this.onMouseDown, true)
207+
this.args.host.removeEventListener("mouseup", this.onMouseUp, true)
208+
this.args.host.removeEventListener("contextmenu", this.onContextMenu, true)
209+
this.args.host.removeEventListener("copy", this.onCopy, true)
316210
}
317211
}
212+
213+
export const attachTerminalCopyInteraction = (
214+
args: TerminalCopyInteractionArgs
215+
): { readonly dispose: () => void } => new TerminalCopyInteractionController(args).attach()

0 commit comments

Comments
 (0)