@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.
- package/client/common/dangerous-session-fields.js +39 -0
- package/client/common/parse-quick-connect.js +46 -6
- package/client/common/safe-join.js +33 -0
- package/client/common/sanitize-filename.js +8 -0
- package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
- package/client/components/setting-panel/setting-terminal.jsx +1 -0
- package/client/components/terminal/mixins/term-init.js +1 -0
- package/client/components/terminal/shell.js +11 -3
- package/client/components/terminal/shortcut-bar-press.js +192 -0
- package/client/components/terminal/shortcut-bar.jsx +199 -4
- package/client/components/terminal/shortcut-bar.styl +40 -21
- package/client/components/terminal/terminal.jsx +4 -0
- package/client/store/mcp-handler.js +8 -15
- package/client/store/sync.js +1 -0
- package/client/store/tab.js +2 -15
- package/package.json +1 -1
|
@@ -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
|
|
26
|
-
*
|
|
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
|
-
|
|
408
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
|
55
|
+
function activeTerm () {
|
|
51
56
|
const { store } = window
|
|
52
|
-
|
|
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=
|
|
186
|
-
|
|
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
|
|
@@ -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
|
-
//
|
|
23
|
-
// These allow arbitrary command execution if set
|
|
24
|
-
// override the shell binary, setEnv injects
|
|
25
|
-
// executes scripts, interactiveValues
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
package/client/store/sync.js
CHANGED
package/client/store/tab.js
CHANGED
|
@@ -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]) => !
|
|
421
|
+
Object.entries(parsed).filter(([key]) => !dangerousSessionFields.includes(key))
|
|
435
422
|
)
|
|
436
423
|
return window.store.addTab(safeTab)
|
|
437
424
|
}
|