@electerm/electerm-react 5.5.56 → 5.5.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session fields that must never be settable from untrusted input: a deep link
3
+ * URL, CLI `--opts`, an MCP/AI tool call, or a shortcut file.
4
+ *
5
+ * Every entry reaches one of: child_process.spawn(), a shell, the process
6
+ * environment, or the terminal input stream.
7
+ *
8
+ * Why this is a shared module and not a local array: `proxyCommand` was added
9
+ * by a later feature and reached spawn() from a single clicked link because the
10
+ * denylists of the time were five separate copies and none of them was updated
11
+ * (follow-up to GHSA-mpm8-cx2p-626q). One list, imported everywhere, is what
12
+ * makes the guard maintainable.
13
+ *
14
+ * Why a denylist and not an allowlist: a quick connect string may legitimately
15
+ * carry any session option (encode, term, authType, agentForward, sshTunnels,
16
+ * startDirectoryRemote, ...). An allowlist would silently break every field it
17
+ * does not know about, which is a worse failure than the one it prevents. The
18
+ * cost of that choice is that a future exec-capable option must be added here.
19
+ *
20
+ * Keep in sync with the copy of this file in the other bundle - there is a unit
21
+ * test that fails when the two copies drift.
22
+ */
23
+ export default [
24
+ // spawns an external process (before any SSH authentication)
25
+ 'proxyCommand',
26
+ // run scripts after connect / override the shell binary
27
+ 'runScripts',
28
+ 'execLinux',
29
+ 'execMac',
30
+ 'execWindows',
31
+ 'execLinuxArgs',
32
+ 'execMacArgs',
33
+ 'execWindowsArgs',
34
+ // injects environment variables into the session
35
+ 'setEnv',
36
+ // injects interactive prompts / auto-answers terminal output
37
+ 'interactiveValues',
38
+ 'triggers'
39
+ ]
@@ -19,13 +19,38 @@
19
19
  * 192.168.1.100:22
20
20
  */
21
21
 
22
+ import DANGEROUS_SESSION_FIELDS from './dangerous-session-fields'
23
+
22
24
  const SUPPORTED_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'serial', 'ftp', 'http', 'https', 'electerm']
23
25
 
24
26
  /**
25
- * Deny list for opts keys - these are parsed from the URL itself
26
- * and should not be overridable via the opts JSON parameter for safety
27
+ * Deny list for opts keys.
28
+ *
29
+ * `type` and `host` are parsed from the URL itself, so `opts` must not be able
30
+ * to override them. Everything else is a field that reaches
31
+ * child_process.spawn(), a shell, the environment or the terminal input stream
32
+ * - see common/dangerous-session-fields.js.
33
+ *
34
+ * Deliberately a deny list rather than an allow list: a quick connect string
35
+ * may legitimately carry any session option (encode, term, authType,
36
+ * agentForward, sshTunnels, ...), so an allow list would silently break every
37
+ * field it does not know about.
27
38
  */
28
- const OPTS_DENY_LIST = ['type', 'host']
39
+ const OPTS_DENY_LIST = ['type', 'host', ...DANGEROUS_SESSION_FIELDS]
40
+
41
+ /**
42
+ * Return a copy of an untrusted object without the denied keys.
43
+ * @param {object} obj - untrusted object
44
+ * @param {string[]} denyList - keys to drop
45
+ * @returns {object} a new object with the denied keys removed
46
+ */
47
+ function withoutDenied (obj, denyList = OPTS_DENY_LIST) {
48
+ const out = { ...(obj || {}) }
49
+ denyList.forEach(key => {
50
+ delete out[key]
51
+ })
52
+ return out
53
+ }
29
54
 
30
55
  /**
31
56
  * Default ports for each protocol
@@ -404,8 +429,22 @@ function parseQuickConnect (str) {
404
429
  if (optsStr) {
405
430
  try {
406
431
  const extraOpts = JSON.parse(optsStr)
407
- OPTS_DENY_LIST.forEach(key => delete extraOpts[key])
408
- Object.assign(opts, extraOpts)
432
+ if (
433
+ extraOpts &&
434
+ typeof extraOpts === 'object' &&
435
+ !Array.isArray(extraOpts)
436
+ ) {
437
+ Object.assign(opts, withoutDenied(extraOpts))
438
+ }
439
+ // a hop entry is spread into a sub-session (server/session-hop.js),
440
+ // so it is its own untrusted surface and needs the same filtering.
441
+ // Only the dangerous fields apply here - unlike the top level, a hop
442
+ // legitimately sets its own host and port.
443
+ if (Array.isArray(opts.connectionHoppings)) {
444
+ opts.connectionHoppings = opts.connectionHoppings
445
+ .filter(hop => hop && typeof hop === 'object' && !Array.isArray(hop))
446
+ .map(hop => withoutDenied(hop, DANGEROUS_SESSION_FIELDS))
447
+ }
409
448
  } catch (err) {
410
449
  console.error('Failed to parse opts:', err)
411
450
  }
@@ -452,5 +491,6 @@ export {
452
491
  getSupportedProtocols,
453
492
  SUPPORTED_PROTOCOLS,
454
493
  DEFAULT_PORTS,
455
- OPTS_DENY_LIST
494
+ OPTS_DENY_LIST,
495
+ DANGEROUS_SESSION_FIELDS
456
496
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Join a base directory with a filename that may come from an untrusted source
3
+ * (a remote server's directory listing, a drag payload, ...) and keep the
4
+ * result inside the base.
5
+ *
6
+ * The sanitize step is load-bearing, not cosmetic. electerm's `resolve` does no
7
+ * normalisation and three of its branches discard the base outright: a leading
8
+ * `/`, a UNC `\\`, and a Windows drive prefix. The drive branch is the easy one
9
+ * to miss because it needs no separator at all — `resolve('/uploads', 'C:x')`
10
+ * returns `'C:x'`. Stripping `:` and the separators removes every one of those
11
+ * branches, leaving only the "append to base" path.
12
+ *
13
+ * @param {String} base directory the name must stay inside
14
+ * @param {String} name untrusted file name
15
+ * @return {String} base + separator + a safe name, always inside `base`
16
+ */
17
+
18
+ import resolve from './resolve.js'
19
+ import sanitizeFilename from './sanitize-filename.js'
20
+
21
+ // A single path component: no separator, and not `.` or `..`.
22
+ const SINGLE_COMPONENT = /^(?!\.\.?$)[^/\\]+$/
23
+
24
+ export default function safeJoin (base, name) {
25
+ const safeName = sanitizeFilename(name)
26
+ // sanitizeFilename already satisfies this, so the fallback is unreachable in
27
+ // practice: it is here so that a future change to its character list degrades
28
+ // to a neutral name instead of escaping the base. Deliberately not a throw —
29
+ // a bad entry in a remote listing must not take down the whole drop. It also
30
+ // stays silent on purpose: no legitimate name contains a separator, so the
31
+ // only names that can reach the fallback are hostile ones.
32
+ return resolve(base, SINGLE_COMPONENT.test(safeName) ? safeName : 'unnamed')
33
+ }
@@ -14,6 +14,14 @@
14
14
  * - Reject reserved Windows device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
15
15
  * - Limit filename length to 255 bytes (common filesystem limit)
16
16
  * - Fallback to 'unnamed' if result is empty
17
+ *
18
+ * Note for callers using this to build a path: the output is guaranteed to
19
+ * contain no path separator (`/` or `\`) and never to be `.` or `..`, because
20
+ * all of those are in ILLEGAL_CHARS or stripped as trailing dots. That is what
21
+ * makes `resolve(dir, sanitizeFilename(name))` stay inside `dir` — and it is
22
+ * load-bearing for path containment (CWE-22), not just cosmetics. Prefer
23
+ * `safeJoin(base, name)` when the name is untrusted: it depends on this
24
+ * property and asserts it.
17
25
  */
18
26
 
19
27
  // Characters illegal on Windows (and problematic on many systems)
@@ -1,5 +1,5 @@
1
1
  import { Component } from 'react'
2
- import resolve from '../../common/resolve'
2
+ import safeJoin from '../../common/safe-join'
3
3
  import { typeMap } from '../../common/constants'
4
4
  import { refsStatic } from '../common/ref'
5
5
  import Remote2RemoteHandler from './remote2remote-handler'
@@ -35,7 +35,10 @@ export default class Remote2RemoteHandlers extends Component {
35
35
  createHandler = ({ fromFile, targetPathBase, targetTab }) => {
36
36
  const handler = new Remote2RemoteHandler({
37
37
  fromFile,
38
- toPath: resolve(targetPathBase, fromFile.name),
38
+ // `fromFile.name` comes straight off the source server's READDIR, so it
39
+ // is attacker-controlled. It is written to the target host as the victim
40
+ // below, so it must not be able to leave `targetPathBase`.
41
+ toPath: safeJoin(targetPathBase, fromFile.name),
39
42
  sourceHost: fromFile.host,
40
43
  sourceTabId: fromFile.tabId,
41
44
  title: fromFile.title,
@@ -58,7 +61,9 @@ export default class Remote2RemoteHandlers extends Component {
58
61
  }
59
62
 
60
63
  onRemote2RemoteDrop = ({ fromFiles, toFile, targetTab }) => {
61
- const targetPathBase = resolve(toFile.path, toFile.name)
64
+ // `toFile.name` is an entry from the target panel's listing. The victim
65
+ // chose the panel, but the name is still server-supplied.
66
+ const targetPathBase = safeJoin(toFile.path, toFile.name)
62
67
  const targetHost = targetTab?.host
63
68
  let handled = false
64
69
  for (const fromFile of fromFiles) {
@@ -569,6 +569,7 @@ export default class SettingTerminal extends Component {
569
569
  [
570
570
  'cursorBlink',
571
571
  'rightClickSelectsWord',
572
+ 'mouseEventsRequireAlt',
572
573
  'pasteWhenContextMenu',
573
574
  'copyWhenSelect',
574
575
  'disableConfirmForLargeClipboardContent',
@@ -27,6 +27,7 @@ export const initMixin = {
27
27
  allowProposedApi: true,
28
28
  scrollback: config.scrollback,
29
29
  rightClickSelectsWord: config.rightClickSelectsWord || false,
30
+ mouseEventsRequireAlt: config.mouseEventsRequireAlt || false,
30
31
  fontFamily: tab.fontFamily || config.fontFamily,
31
32
  theme: tc,
32
33
  allowTransparency: true,
@@ -79,13 +79,21 @@ function getShInlineIntegration () {
79
79
  return [
80
80
  'if [ -z "$ELECTERM_SHELL_INTEGRATION" ]',
81
81
  'then export ELECTERM_SHELL_INTEGRATION=1',
82
- '__e_esc() { printf \'%s\' "$1" | sed \'s/\\\\/\\\\\\\\/g; s/;/\\\\x3b/g\'; }',
83
82
  // sh/dash/ash re-evaluate $(...) inside PS1 every time the prompt is
84
83
  // shown, but only if the $() stays literal — so PS1 must be assigned
85
84
  // single-quoted. printf interprets \033/\a, unlike PS1 backslash
86
85
  // escapes which dash does not expand.
87
- '__e_ps1() { printf \'\\033]633;P;Cwd=%s\\a\\033]633;A\\a\' "$(__e_esc "$PWD")"; }',
88
- 'PS1=\'$(__e_ps1)\'"${PS1:-# }"',
86
+ //
87
+ // The prompt is deliberately self-contained: it must never call a shell
88
+ // function. On OpenWrt /etc/profile does `export PS1=...`, so PS1 is an
89
+ // environment variable and every child shell inherits it — while shell
90
+ // functions are not inherited. A `PS1='$(__e_ps1)'...` prompt therefore
91
+ // dies with "ash: __e_ps1: not found" on every prompt of any nested shell
92
+ // (ash, su, login, ...), and the exported ELECTERM_SHELL_INTEGRATION
93
+ // guard stops the child from ever defining the missing function.
94
+ // Inlining keeps it working there too, and printf is an ash builtin so
95
+ // this is cheaper than the old function + sed pair.
96
+ 'PS1=\'$(printf "\\033]633;P;Cwd=%s\\a\\033]633;A\\a" "$PWD")\'"${PS1:-# }"',
89
97
  'fi'
90
98
  ].join('; ')
91
99
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * press-and-hold auto-repeat for the touch shortcut bar.
3
+ *
4
+ * Why: the bar's buttons sent their key from `click` alone, so a touch user
5
+ * had to tap over and over for anything that repeats — holding Bksp to erase
6
+ * a word, holding ↓ to walk back through the scrollback. A physical key
7
+ * repeats while it is held, so the bar does the same: press, wait out the
8
+ * initial delay, then send the key at a steady interval until the finger (or
9
+ * mouse button) lifts.
10
+ *
11
+ * Deliberately free of React and of the DOM — shortcut-bar.jsx owns the
12
+ * wiring, this owns the timing, so the timing can be unit-tested on its own
13
+ * (src/test/unit-ci/shortcut-bar-press.spec.js).
14
+ *
15
+ * Two movement tolerances, because the two phases want opposite things:
16
+ * - before the repeat begins, ANY movement means the finger is scrolling
17
+ * the bar rather than pressing a key, so a tight tolerance cancels —
18
+ * a press that slides off must never send a repeat;
19
+ * - once the key is repeating, a held finger always drifts a little, so it
20
+ * has to travel much further before it counts as a scroll. With one
21
+ * tolerance, holding Bksp for two seconds would either stop halfway (too
22
+ * tight) or keep erasing while the bar slides sideways (too loose).
23
+ *
24
+ * Only one press is ever in flight. A second pointerdown takes over (a second
25
+ * finger on another key is a deliberate change of target, and two repeating
26
+ * keys at once helps nobody), and any event from a different pointerId is
27
+ * ignored, so a stray move from a finger already lifted cannot stop or
28
+ * corrupt the live repeat.
29
+ *
30
+ * `up()` / `cancel()` report whether the gesture had become a long press, and
31
+ * both report the same value — a release can be caught by the component's own
32
+ * handler or by the window-level safety net (the pointer may be released
33
+ * anywhere, even outside the window), so the answer must not depend on which
34
+ * one runs first. The caller uses it to swallow the `click` the browser
35
+ * synthesizes after a long press, which would otherwise send one extra key.
36
+ */
37
+
38
+ export const longPressDelay = 400
39
+
40
+ // ~16 sends/sec once repeating: fast enough to clear a long line or scroll
41
+ // back through history, slow enough that the remote end can keep up.
42
+ export const repeatInterval = 60
43
+
44
+ // pre-repeat: a press that moves this much is a scroll, not a tap
45
+ export const moveTolerance = 10
46
+
47
+ // while repeating: how far a held finger may drift before it is a scroll
48
+ export const repeatMoveTolerance = 32
49
+
50
+ export function createKeyRepeat (opts = {}) {
51
+ const {
52
+ send = () => {},
53
+ onRepeatChange = () => {},
54
+ delay = longPressDelay,
55
+ interval = repeatInterval,
56
+ tolerance = moveTolerance,
57
+ repeatTolerance = repeatMoveTolerance
58
+ } = opts
59
+
60
+ let holdTimer = null
61
+ let tickTimer = null
62
+ let payload = null
63
+ let startX = 0
64
+ let startY = 0
65
+ let pointerId = null
66
+ let inFlight = false
67
+ let repeating = false
68
+ // sticky for the whole gesture: set when the long press begins, cleared only
69
+ // by the next press, so it survives any release path
70
+ let longFired = false
71
+
72
+ function stopTimers () {
73
+ if (holdTimer) {
74
+ clearTimeout(holdTimer)
75
+ holdTimer = null
76
+ }
77
+ if (tickTimer) {
78
+ clearInterval(tickTimer)
79
+ tickTimer = null
80
+ }
81
+ }
82
+
83
+ function setRepeating (on) {
84
+ if (repeating === on) {
85
+ return
86
+ }
87
+ repeating = on
88
+ onRepeatChange(on ? payload : null)
89
+ }
90
+
91
+ // leave the gesture, keeping `longFired` for the caller's click decision
92
+ function release () {
93
+ stopTimers()
94
+ if (inFlight) {
95
+ setRepeating(false)
96
+ }
97
+ inFlight = false
98
+ payload = null
99
+ pointerId = null
100
+ return longFired
101
+ }
102
+
103
+ function beginRepeat () {
104
+ holdTimer = null
105
+ longFired = true
106
+ setRepeating(true)
107
+ // send the first repeat immediately: holding a key should not wait out
108
+ // the delay twice
109
+ send(payload)
110
+ tickTimer = setInterval(() => send(payload), interval)
111
+ }
112
+
113
+ // an event only counts while it belongs to the press in flight. Events
114
+ // synthesized in tests (or by a browser that omits pointerId) always match.
115
+ function owns (ev) {
116
+ return inFlight &&
117
+ (pointerId === null || !ev || ev.pointerId == null || ev.pointerId === pointerId)
118
+ }
119
+
120
+ return {
121
+ get repeating () {
122
+ return repeating
123
+ },
124
+
125
+ get longFired () {
126
+ return longFired
127
+ },
128
+
129
+ /**
130
+ * A press starts. `p` is whatever should be handed back to `send` — the
131
+ * bar passes the whole button so it can also read the id for its visual
132
+ * state. A second press cancels the first.
133
+ */
134
+ down (p, ev) {
135
+ release()
136
+ payload = p
137
+ pointerId = ev && ev.pointerId != null ? ev.pointerId : null
138
+ startX = ev ? ev.clientX || 0 : 0
139
+ startY = ev ? ev.clientY || 0 : 0
140
+ longFired = false
141
+ inFlight = true
142
+ holdTimer = setTimeout(beginRepeat, delay)
143
+ },
144
+
145
+ move (ev) {
146
+ if (!owns(ev)) {
147
+ return
148
+ }
149
+ const x = ev ? ev.clientX || 0 : 0
150
+ const y = ev ? ev.clientY || 0 : 0
151
+ const dx = x - startX
152
+ const dy = y - startY
153
+ const moved = Math.sqrt(dx * dx + dy * dy)
154
+ if (repeating) {
155
+ if (moved > repeatTolerance) {
156
+ release()
157
+ }
158
+ } else if (moved > tolerance) {
159
+ // the press turned into a scroll of the bar: drop it before it can
160
+ // fire, and let the scroll happen
161
+ release()
162
+ }
163
+ },
164
+
165
+ /** the pointer lifted. Returns true if this gesture was a long press. */
166
+ up (ev) {
167
+ if (!owns(ev)) {
168
+ return false
169
+ }
170
+ return release()
171
+ },
172
+
173
+ /**
174
+ * The gesture was aborted (pointercancel, a second finger, the bar being
175
+ * hidden, unmount). Never sends anything, so a scroll that turns into a
176
+ * cancel cannot leak a key.
177
+ */
178
+ cancel (ev) {
179
+ if (ev && !owns(ev)) {
180
+ return false
181
+ }
182
+ const wasLong = inFlight ? longFired : false
183
+ release()
184
+ return wasLong
185
+ },
186
+
187
+ dispose () {
188
+ release()
189
+ longFired = false
190
+ }
191
+ }
192
+ }
@@ -10,6 +10,10 @@
10
10
  * - first (fixed) button collapses the bar
11
11
  * - second (fixed) button opens the edit modal (shortcut-bar-edit.jsx)
12
12
  * - tapping a shortcut sends it to the active terminal via runQuickCommand
13
+ * - pressing AND holding one repeats it, like a physical key: a tap has to
14
+ * send exactly one key, but holding Bksp to erase a word or ↓ to walk
15
+ * back through the scrollback is impossible without auto-repeat. The
16
+ * timing lives in shortcut-bar-press.js
13
17
  * - while the system input panel overlays the page without resizing the
14
18
  * layout viewport (iOS / HarmonyOS do this, Android resizes instead), the
15
19
  * bar lifts itself above the panel — see the visualViewport effect below
@@ -27,6 +31,7 @@ import { shortcutBarHeight } from '../../common/constants'
27
31
  import ShortcutBarEdit from './shortcut-bar-edit'
28
32
  import { KEYBOARD_MIN } from './shortcut-bar-entry'
29
33
  import { candidates, loadActive, saveActive, ESC } from './shortcut-bar-defs'
34
+ import { createKeyRepeat } from './shortcut-bar-press'
30
35
  import './shortcut-bar.styl'
31
36
 
32
37
  const e = window.translate
@@ -47,15 +52,57 @@ const appCursorMap = {
47
52
  [ESC + '[F']: ESC + 'OF' // End
48
53
  }
49
54
 
50
- function sendToTerminal (data) {
55
+ function activeTerm () {
51
56
  const { store } = window
52
- const term = refs.get('term-' + store.activeTabId)
57
+ return refs.get('term-' + store.activeTabId)
58
+ }
59
+
60
+ function sendToTerminal (data) {
61
+ const term = activeTerm()
53
62
  if (term && typeof term.runQuickCommand === 'function') {
54
63
  const resolved = resolveCursorMode(data, term)
55
64
  term.runQuickCommand(resolved, true)
56
65
  }
57
66
  }
58
67
 
68
+ // Put focus back on the terminal's helper textarea, which is the element that
69
+ // owns the soft keyboard.
70
+ //
71
+ // A press on a bar button focuses that button instead, and the OS then
72
+ // dismisses the input panel — which also drops the bar, since it rides above
73
+ // the panel, so the hold visibly breaks.
74
+ //
75
+ // Preventing the default on mousedown covers the mouse path, but Chromium only
76
+ // synthesizes those compatibility mouse events after touchend, so on touch the
77
+ // theft happens after any reclaim made during pointerdown. Hence this is also
78
+ // driven from the focusin listener in the effect below, which is timing
79
+ // independent. Called from pointerdown it still helps: it puts focus back
80
+ // before the platform has any chance to act on the button.
81
+ //
82
+ // The call is synchronous inside the user gesture, which is what a
83
+ // keyboard-showing focus() requires, and it is idempotent.
84
+ let reclaiming = false
85
+
86
+ function reclaimTerminalFocus () {
87
+ // focus() re-raises focusin, so guard against recursing: a press that
88
+ // repeatedly reclaims would otherwise spin.
89
+ if (reclaiming) {
90
+ return
91
+ }
92
+ const term = activeTerm()
93
+ if (!term || !term.term || typeof term.term.focus !== 'function') {
94
+ return
95
+ }
96
+ reclaiming = true
97
+ try {
98
+ term.term.focus()
99
+ } finally {
100
+ // released synchronously, once the focus() call has returned — the
101
+ // focusin it triggers has already been dispatched by then
102
+ reclaiming = false
103
+ }
104
+ }
105
+
59
106
  // honor the active terminal's DECCKM state for the cursor / Home / End keys.
60
107
  // custom combos can be multi-key sequences (Ctrl+A then ↑), so match the
61
108
  // trailing key too — not only the whole payload.
@@ -177,13 +224,142 @@ function ShortcutBar (props) {
177
224
  saveActive(next)
178
225
  }
179
226
 
227
+ // ---- press-and-hold auto-repeat ----
228
+ // A tap is a plain click, so it stays one key. On top of that, a press that
229
+ // is held repeats its key (see shortcut-bar-press.js) — the only way to hold
230
+ // Bksp or an arrow key without a physical keyboard.
231
+ const repeatRef = useRef(null)
232
+ // id of the button currently repeating, so only it shows the pressed state
233
+ const [repeatingId, setRepeatingId] = useState(null)
234
+ // a long press has already sent its key(s); the click the browser fires on
235
+ // release must not add one more. Set by the repeat, cleared once consumed.
236
+ const swallowClickRef = useRef(false)
237
+
238
+ if (!repeatRef.current) {
239
+ repeatRef.current = createKeyRepeat({
240
+ send: b => {
241
+ if (b) {
242
+ sendToTerminal(b.data)
243
+ }
244
+ },
245
+ onRepeatChange: b => setRepeatingId(b ? b.id : null)
246
+ })
247
+ }
248
+
249
+ // The gesture can end anywhere — the finger can slide off the bar, lift
250
+ // outside the window, or the system can steal it (a callout, an incoming
251
+ // call). A window-level release is the only net that catches all of those;
252
+ // the per-button handler below is what keeps the common case responsive.
253
+ useEffect(() => {
254
+ function onGlobalRelease (ev) {
255
+ if (repeatRef.current.up(ev)) {
256
+ swallowClickRef.current = true
257
+ }
258
+ }
259
+ function onGlobalCancel (ev) {
260
+ // a cancelled gesture is not a long press: nothing was sent that the
261
+ // user needs to "not double", so no click has to be swallowed
262
+ repeatRef.current.cancel(ev)
263
+ }
264
+ // Catch-all for focus theft, and the reliable half of the fix.
265
+ //
266
+ // Reclaiming focus at pointerdown is too early to be trustworthy: Chromium
267
+ // only synthesizes the compatibility mouse events (mousedown/mouseup/click)
268
+ // AFTER touchend, so on the touch path the button takes focus *after* the
269
+ // press has already reclaimed it. Timing the reclaim per-event cannot cover
270
+ // that; reacting to focus itself can, and it covers every path — mouse,
271
+ // touch, the compatibility mousedown, and the context-menu focus grab.
272
+ //
273
+ // focusin rather than focus: it bubbles, so one listener on the window sees
274
+ // the bar's buttons from outside the portal.
275
+ //
276
+ // The guard is what keeps this from looping: only focus landing inside this
277
+ // bar is reclaimed, and not when it is the reclaim's own doing.
278
+ function onFocusIn (ev) {
279
+ const el = ev.target
280
+ if (!el || !el.closest || !el.closest('.shortcut-bar')) {
281
+ return
282
+ }
283
+ if (reclaiming) {
284
+ return
285
+ }
286
+ reclaimTerminalFocus()
287
+ }
288
+ window.addEventListener('pointerup', onGlobalRelease)
289
+ window.addEventListener('pointercancel', onGlobalCancel)
290
+ window.addEventListener('focusin', onFocusIn)
291
+ return () => {
292
+ window.removeEventListener('pointerup', onGlobalRelease)
293
+ window.removeEventListener('pointercancel', onGlobalCancel)
294
+ window.removeEventListener('focusin', onFocusIn)
295
+ // unmount (or the bar going away) must not leave a timer running
296
+ repeatRef.current.dispose()
297
+ }
298
+ }, [])
299
+
300
+ // the bar hiding mid-hold is a cancel, not a release: the keys already sent
301
+ // stand, and no stale click may be swallowed by the next one
302
+ useEffect(() => {
303
+ if (!store.shortcutBarVisible) {
304
+ repeatRef.current.cancel()
305
+ swallowClickRef.current = false
306
+ }
307
+ }, [store.shortcutBarVisible])
308
+
309
+ function handleClick (b) {
310
+ if (swallowClickRef.current) {
311
+ swallowClickRef.current = false
312
+ return
313
+ }
314
+ sendToTerminal(b.data)
315
+ }
316
+
317
+ function handlePointerDown (ev, b) {
318
+ // a right / middle click is not a key press
319
+ if (ev.button != null && ev.button > 0) {
320
+ return
321
+ }
322
+ // The flag only has to survive from the release to the click the browser
323
+ // synthesizes right after it. A release that lands OFF the button
324
+ // synthesizes no click at all, so without this the flag would still be
325
+ // armed on the next press and eat a legitimate key. Any new press starts
326
+ // strictly after the previous click, so clearing here is safe.
327
+ swallowClickRef.current = false
328
+ // Only a mouse: capturing a touch pointer makes the browser scroll the
329
+ // button row no more, and dragging the row by a button is exactly how the
330
+ // bar is scrolled. Touch instead relies on pointercancel (fired when the
331
+ // browser takes over for a scroll) plus the move tolerance below.
332
+ if (ev.pointerType === 'mouse') {
333
+ try {
334
+ ev.currentTarget.setPointerCapture(ev.pointerId)
335
+ } catch (e) {
336
+ // capture is an optimisation for cancel-on-slide-off; without it the
337
+ // hold still stops on pointerup, just via the window-level net
338
+ }
339
+ }
340
+ // before anything can steal it — see reclaimTerminalFocus()
341
+ reclaimTerminalFocus()
342
+ repeatRef.current.down(b, ev)
343
+ }
344
+
345
+ function handlePointerUp (ev) {
346
+ if (repeatRef.current.up(ev)) {
347
+ swallowClickRef.current = true
348
+ }
349
+ }
350
+
180
351
  function renderButton (b, i) {
181
352
  return (
182
353
  <button
183
354
  type='button'
184
355
  key={b.id + '-' + i}
185
- className='shortcut-bar-btn'
186
- onClick={() => sendToTerminal(b.data)}
356
+ className={
357
+ 'shortcut-bar-btn' + (repeatingId === b.id ? ' repeating' : '')
358
+ }
359
+ onPointerDown={ev => handlePointerDown(ev, b)}
360
+ onPointerMove={ev => repeatRef.current.move(ev)}
361
+ onPointerUp={handlePointerUp}
362
+ onClick={() => handleClick(b)}
187
363
  >
188
364
  {b.label}
189
365
  </button>
@@ -199,6 +375,25 @@ function ShortcutBar (props) {
199
375
  className='shortcut-bar'
200
376
  role='toolbar'
201
377
  style={kbOffset ? { bottom: kbOffset + 'px' } : undefined}
378
+ // Keep the press from moving DOM focus in the first place.
379
+ //
380
+ // A button is focusable, and on a real press the browser focuses it
381
+ // (measured: focus lands on .shortcut-bar-btn, not on the terminal's
382
+ // helper textarea). That helper textarea is what owns the soft keyboard,
383
+ // so on Android Chrome focus moving to a non-editable element makes the
384
+ // OS dismiss the input panel — and the bar, which rides above the
385
+ // panel, drops with it, so the hold visibly breaks.
386
+ //
387
+ // This is the preventive half (it still delivers the click, also
388
+ // measured) and it covers the mouse path plus the compatibility mousedown
389
+ // on the container, so the two icon buttons get it too. It cannot cover
390
+ // the touch path on its own — those compatibility events are synthesized
391
+ // after touchend — which is why the focusin listener above exists as the
392
+ // reliable half.
393
+ //
394
+ // pointerdown is deliberately NOT prevented: that would stop the browser
395
+ // scrolling the button row.
396
+ onMouseDown={e => e.preventDefault()}
202
397
  >
203
398
  <div className='shortcut-bar-fixed'>
204
399
  <button
@@ -17,6 +17,41 @@
17
17
  user-select none
18
18
  -webkit-user-select none
19
19
 
20
+ // A held button now means "keep sending this key", so the platform must not
21
+ // treat the hold as its own gesture:
22
+ // - the iOS callout (Copy/Look Up) and Android's text-selection handles
23
+ // would swallow it — same reason .term-wrap suppresses them;
24
+ // - touch-action keeps the hold from turning into a scroll of the page; the
25
+ // bar's own horizontal scroll lives on .shortcut-bar-scroll, which is a
26
+ // separate element, so this only affects the button itself.
27
+ .shortcut-bar-btn
28
+ -webkit-touch-callout none
29
+ touch-action manipulation
30
+ flex-shrink 0
31
+ height 32px
32
+ min-width 32px
33
+ padding 0 10px
34
+ display inline-flex
35
+ align-items center
36
+ justify-content center
37
+ border-radius 6px
38
+ border 1px solid var(--main-lighter)
39
+ background var(--main-light)
40
+ color var(--text)
41
+ font-size 13px
42
+ cursor pointer
43
+ white-space nowrap
44
+ &:active
45
+ background var(--primary)
46
+ color var(--primary-contrast)
47
+ &:hover
48
+ color var(--text-light)
49
+ // held down and repeating: keep the :active look for as long as the key is
50
+ // being re-sent, so the user can see the auto-repeat is running
51
+ &.repeating
52
+ background var(--primary)
53
+ color var(--primary-contrast)
54
+
20
55
  .shortcut-bar-fixed
21
56
  display flex
22
57
  align-items stretch
@@ -33,6 +68,8 @@
33
68
  color var(--text)
34
69
  cursor pointer
35
70
  font-size 16px
71
+ -webkit-touch-callout none
72
+ touch-action manipulation
36
73
  &:active
37
74
  background var(--main-lighter)
38
75
  &:hover
@@ -48,30 +85,12 @@
48
85
  overflow-y hidden
49
86
  scrollbar-width none
50
87
  -webkit-overflow-scrolling touch
88
+ // a press that turns into a drag is a scroll of the button row, which is
89
+ // exactly what this container does — the hold itself is handled in JS
90
+ touch-action pan-x
51
91
  &::-webkit-scrollbar
52
92
  display none
53
93
 
54
- .shortcut-bar-btn
55
- flex-shrink 0
56
- height 32px
57
- min-width 32px
58
- padding 0 10px
59
- display inline-flex
60
- align-items center
61
- justify-content center
62
- border-radius 6px
63
- border 1px solid var(--main-lighter)
64
- background var(--main-light)
65
- color var(--text)
66
- font-size 13px
67
- cursor pointer
68
- white-space nowrap
69
- &:active
70
- background var(--primary)
71
- color var(--primary-contrast)
72
- &:hover
73
- color var(--text-light)
74
-
75
94
  // while the bar is visible, lift the footer + reserve space so nothing is
76
95
  // covered (terminal area is shrunk by layout.jsx via store.shortcutBarVisible;
77
96
  // --shortcut-bar-kb-offset raises both bar + footer above a page-overlaying
@@ -103,6 +103,10 @@ class Term extends Component {
103
103
  name: 'rightClickSelectsWord',
104
104
  type: 'glob'
105
105
  },
106
+ {
107
+ name: 'mouseEventsRequireAlt',
108
+ type: 'glob'
109
+ },
106
110
  {
107
111
  name: 'fontSize',
108
112
  type: 'glob_local'
@@ -17,23 +17,16 @@ import {
17
17
  validateBookmarkData
18
18
  } from '../components/bookmark-form/fix-bookmark-default'
19
19
  import newTerm from '../common/new-terminal'
20
+ import dangerousSessionFields from '../common/dangerous-session-fields'
20
21
 
21
22
  // Dangerous props that must not be accepted from MCP/AI tool calls.
22
- // Mirrors the blocklist in src/client/store/tab.js (dangerousTabProps).
23
- // These allow arbitrary command execution if set (e.g. execLinux/execLinuxArgs
24
- // override the shell binary, setEnv injects environment variables, runScripts
25
- // executes scripts, interactiveValues injects interactive prompts).
26
- const dangerousTabProps = [
27
- 'execLinux',
28
- 'execMac',
29
- 'execWindows',
30
- 'execWindowsArgs',
31
- 'execMacArgs',
32
- 'execLinuxArgs',
33
- 'setEnv',
34
- 'runScripts',
35
- 'interactiveValues'
36
- ]
23
+ // Shared with src/client/store/tab.js and the quick connect parser so the
24
+ // copies cannot drift apart. These allow arbitrary command execution if set
25
+ // (execLinux/execLinuxArgs override the shell binary, setEnv injects
26
+ // environment variables, runScripts executes scripts, interactiveValues
27
+ // injects interactive prompts, triggers auto-answer terminal output,
28
+ // proxyCommand is spawned before any SSH authentication).
29
+ const dangerousTabProps = dangerousSessionFields
37
30
 
38
31
  // Tab/session UI state keys that must never be set by MCP/AI callers.
39
32
  // A crafted `batch` (missing, string, or out-of-range) used to crash the
@@ -819,6 +819,7 @@ export default (Store) => {
819
819
  const configSyncKeys = [
820
820
  'keepaliveInterval',
821
821
  'rightClickSelectsWord',
822
+ 'mouseEventsRequireAlt',
822
823
  'pasteWhenContextMenu',
823
824
  'ctrlOrMetaOpenTerminalLink',
824
825
  'hotkey',
@@ -18,6 +18,7 @@ import uid from '../common/uid'
18
18
  import newTerm, { updateCount } from '../common/new-terminal.js'
19
19
  import { action } from 'manate'
20
20
  import { shouldCaptureTerminalReloadState } from '../components/terminal/ssh-reload-state.js'
21
+ import dangerousSessionFields from '../common/dangerous-session-fields'
21
22
 
22
23
  function captureSshSessionState (tab, config) {
23
24
  return shouldCaptureTerminalReloadState(tab, config)
@@ -415,23 +416,9 @@ export default Store => {
415
416
  store.updateHistory(newTab)
416
417
  }
417
418
 
418
- // Dangerous props that should not be accepted from IPC
419
- const dangerousTabProps = [
420
- 'execLinux',
421
- 'execMac',
422
- 'execWindows',
423
- 'execWindowsArgs',
424
- 'execMacArgs',
425
- 'execLinuxArgs',
426
- 'setEnv',
427
- 'runScripts',
428
- 'interactiveValues',
429
- 'triggers'
430
- ]
431
-
432
419
  Store.prototype.ipcOpenTab = function (parsed) {
433
420
  const safeTab = Object.fromEntries(
434
- Object.entries(parsed).filter(([key]) => !dangerousTabProps.includes(key))
421
+ Object.entries(parsed).filter(([key]) => !dangerousSessionFields.includes(key))
435
422
  )
436
423
  return window.store.addTab(safeTab)
437
424
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "5.5.56",
3
+ "version": "5.5.66",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",