@electerm/electerm-react 3.15.90 → 3.15.105

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.
@@ -241,7 +241,6 @@ export const termLSPrefix = 'term:sess:'
241
241
  export const batchInputLsKey = 'batch-inputs'
242
242
  export const rendererTypes = {
243
243
  dom: 'dom',
244
- canvas: 'canvas',
245
244
  webGL: 'webGL'
246
245
  }
247
246
  export const downloadUpgradeTimeout = 20000
@@ -28,7 +28,7 @@ export default {
28
28
  terminalBackgroundTextSize: 48,
29
29
  terminalBackgroundTextColor: '#ffffff',
30
30
  terminalBackgroundTextFontFamily: 'Maple Mono',
31
- rendererType: 'canvas',
31
+ rendererType: 'dom',
32
32
  terminalType: 'xterm-256color',
33
33
  keepaliveCountMax: 10,
34
34
  keyword2FA: 'verification code,otp,one-time,two-factor,2fa,totp,authenticator,duo,yubikey,security code,mfa,passcode',
@@ -48,6 +48,7 @@ export default {
48
48
  sftpPathFollowSsh: false,
49
49
  keepaliveInterval: 10000,
50
50
  backspaceMode: '^?',
51
+ shiftEnterMode: '\\n',
51
52
  showHiddenFilesOnSftpStart: true,
52
53
  terminalInfos: [
53
54
  'uptime',
@@ -9,7 +9,8 @@
9
9
  * Rules applied:
10
10
  * - Remove control characters (0x00-0x1F)
11
11
  * - Replace reserved characters: < > : " / \ | ? * with _
12
- * - Remove leading/trailing dots and spaces (Windows restriction)
12
+ * - Strip trailing dots and spaces (Windows restriction: "file.", "file ")
13
+ * - Strip leading spaces only (NOT leading dots — they mean hidden file)
13
14
  * - Reject reserved Windows device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
14
15
  * - Limit filename length to 255 bytes (common filesystem limit)
15
16
  * - Fallback to 'unnamed' if result is empty
@@ -19,8 +20,12 @@
19
20
  // eslint-disable-next-line no-control-regex
20
21
  const ILLEGAL_CHARS = /[<>:"/\\|?\x00-\x1f]/g
21
22
 
22
- // Leading/trailing dots and spaces are problematic on Windows
23
- const LEADING_TRAILING = /^[.\s]+|[.\s]+$/g
23
+ // Trailing dots and spaces = problematic on Windows (e.g. "file." → "file")
24
+ // Leading dots are PRESERVED — they mean "hidden file" on Unix and work on modern Windows
25
+ const TRAILING_DOTS_SPACES = /[.\s]+$/g
26
+
27
+ // Leading spaces only — Windows can't handle filenames starting with space
28
+ const LEADING_SPACES = /^\s+/
24
29
 
25
30
  // Reserved Windows device names (case-insensitive)
26
31
  const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i
@@ -37,8 +42,10 @@ export default function sanitizeFilename (name) {
37
42
  let safe = name
38
43
  // Replace illegal characters
39
44
  .replace(ILLEGAL_CHARS, REPLACEMENT_CHAR)
40
- // Strip leading/trailing dots and spaces
41
- .replace(LEADING_TRAILING, '')
45
+ // Strip trailing dots and spaces (Windows restriction)
46
+ .replace(TRAILING_DOTS_SPACES, '')
47
+ // Strip leading spaces only (not dots — they mean hidden file)
48
+ .replace(LEADING_SPACES, '')
42
49
 
43
50
  // Handle reserved Windows device names by appending underscore
44
51
  if (RESERVED_NAMES.test(safe)) {
@@ -9,7 +9,36 @@ import './notification.styl'
9
9
  const notifications = []
10
10
  const NOTIFICATION_EVENT = 'notification-update'
11
11
 
12
+ function getTextFromReactChildren (children) {
13
+ if (typeof children === 'string' || typeof children === 'number') {
14
+ return String(children)
15
+ }
16
+ if (React.isValidElement(children)) {
17
+ return getTextFromReactChildren(children.props.children)
18
+ }
19
+ if (Array.isArray(children)) {
20
+ return children.map(getTextFromReactChildren).join('\n')
21
+ }
22
+ return ''
23
+ }
24
+
12
25
  function addNotification (notif) {
26
+ notif.descriptions = notif.description ? [notif.description] : []
27
+ if (notif.type === 'error') {
28
+ const messageText = getTextFromReactChildren(notif.message)
29
+ const existingIndex = notifications.findIndex(n =>
30
+ n.type === 'error' &&
31
+ getTextFromReactChildren(n.message) === messageText
32
+ )
33
+ if (existingIndex > -1) {
34
+ const existing = notifications[existingIndex]
35
+ existing.count = (existing.count || 1) + 1
36
+ existing.descriptions.push(notif.description)
37
+ window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT))
38
+ return
39
+ }
40
+ notif.count = 1
41
+ }
13
42
  notifications.push(notif)
14
43
  window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT))
15
44
  }
@@ -60,9 +89,10 @@ export function NotificationContainer () {
60
89
  <NotificationItem
61
90
  key={notif.key}
62
91
  message={notif.message || notif.type}
63
- description={notif.description}
92
+ descriptions={notif.descriptions}
64
93
  type={notif.type}
65
94
  duration={notif.duration}
95
+ count={notif.count || 1}
66
96
  onClose={() => removeNotification(notif.key)}
67
97
  />
68
98
  ))}
@@ -70,20 +100,7 @@ export function NotificationContainer () {
70
100
  )
71
101
  }
72
102
 
73
- function getTextFromReactChildren (children) {
74
- if (typeof children === 'string' || typeof children === 'number') {
75
- return String(children)
76
- }
77
- if (React.isValidElement(children)) {
78
- return getTextFromReactChildren(children.props.children)
79
- }
80
- if (Array.isArray(children)) {
81
- return children.map(getTextFromReactChildren).join('\n')
82
- }
83
- return ''
84
- }
85
-
86
- function NotificationItem ({ message, description, type, onClose, duration = 18.5 }) {
103
+ function NotificationItem ({ message, descriptions = [], type, onClose, duration = 18.5, count = 1 }) {
87
104
  const timeoutRef = useRef(null)
88
105
 
89
106
  useEffect(() => {
@@ -96,7 +113,7 @@ function NotificationItem ({ message, description, type, onClose, duration = 18.
96
113
  timeoutRef.current = null
97
114
  }
98
115
  }
99
- }, [])
116
+ }, [count])
100
117
 
101
118
  const handleMouseEnter = () => {
102
119
  if (timeoutRef.current) {
@@ -118,6 +135,9 @@ function NotificationItem ({ message, description, type, onClose, duration = 18.
118
135
  }
119
136
 
120
137
  const className = classnames('notification', type)
138
+ const titleText = getTextFromReactChildren(message)
139
+ const title = count > 1 ? `(${count}) ${titleText}` : message
140
+ const validDescriptions = descriptions.filter(Boolean)
121
141
 
122
142
  return (
123
143
  <div
@@ -128,18 +148,20 @@ function NotificationItem ({ message, description, type, onClose, duration = 18.
128
148
  <div className='notification-content'>
129
149
  <div className='notification-message'>
130
150
  <div className='notification-icon'>{messageIcons[type]}</div>
131
- <div className='notification-title' title={message}>{message}</div>
151
+ <div className='notification-title' title={titleText}>{title}</div>
132
152
  <CopyOutlined
133
153
  className='notification-copy-icon'
134
154
  onClick={(e) => handleCopy(message, e)}
135
155
  />
136
156
  </div>
137
- {description && (
157
+ {validDescriptions.length > 0 && (
138
158
  <div className='notification-description'>
139
- {description}
159
+ {validDescriptions.map((desc, i) => (
160
+ <div key={i}>{desc}</div>
161
+ ))}
140
162
  <CopyOutlined
141
163
  className='notification-copy-icon'
142
- onClick={(e) => handleCopy(description, e)}
164
+ onClick={(e) => handleCopy(validDescriptions, e)}
143
165
  />
144
166
  </div>
145
167
  )}
@@ -0,0 +1,5 @@
1
+ export default function Placeholder () {
2
+ return (
3
+ <div id='placeholder' className='placeholder-wrap' />
4
+ )
5
+ }
@@ -1,13 +1,17 @@
1
1
  import React from 'react'
2
2
  import InputNumberConfirm from './input-number-confirm'
3
3
  import {
4
- Space
4
+ Space,
5
+ Tooltip
5
6
  } from 'antd'
6
7
  import {
7
8
  MinusCircleOutlined,
8
- PlusCircleOutlined
9
+ PlusCircleOutlined,
10
+ AimOutlined
9
11
  } from '@ant-design/icons'
10
12
 
13
+ const e = window.translate
14
+
11
15
  export default function ZoomControl (props) {
12
16
  const {
13
17
  value,
@@ -33,6 +37,23 @@ export default function ZoomControl (props) {
33
37
  onChange(next)
34
38
  }
35
39
 
40
+ const handleReset = () => {
41
+ onChange(1)
42
+ }
43
+
44
+ const isDefault = Math.round(value * 100) === 100
45
+
46
+ const resetIcon = isDefault
47
+ ? null
48
+ : (
49
+ <Tooltip title={e('resetzoom')}>
50
+ <AimOutlined
51
+ onClick={handleReset}
52
+ className='pointer font16 mg1r'
53
+ />
54
+ </Tooltip>
55
+ )
56
+
36
57
  return (
37
58
  <InputNumberConfirm
38
59
  value={Math.round(value * 100)}
@@ -43,6 +64,7 @@ export default function ZoomControl (props) {
43
64
  suffix='%'
44
65
  addonBefore={
45
66
  <Space.Compact>
67
+ {resetIcon}
46
68
  <PlusCircleOutlined
47
69
  onClick={handleAdd}
48
70
  className='mg1r pointer font16'
@@ -31,6 +31,13 @@
31
31
  .terminal-footer-info
32
32
  margin-right 10px
33
33
  line-height 39px
34
+ .terminal-footer-history
35
+ display flex
36
+ align-items center
37
+ .terminal-footer-qm
38
+ // Q is now an icon-only button (5px internal padding); reduce left
39
+ // margin so the history icon stays roughly equidistant from AI and Q
40
+ margin-left 3px
34
41
  .batch-input-outer
35
42
  .bi-full
36
43
  display none
@@ -169,6 +169,7 @@ export default auto(function Index (props) {
169
169
  'sidebarPanelTab',
170
170
  'openWidgetsModal'
171
171
  ]),
172
+ zoom: config.zoom,
172
173
  fileTransfers: copiedTransfer,
173
174
  transferHistory: copiedHistory,
174
175
  upgradeInfo,
@@ -2,6 +2,8 @@
2
2
  * two column layout, left column fixed with, right column auto width
3
3
  */
4
4
 
5
+ import Placeholder from '../common/placeholder'
6
+
5
7
  export default function SettingCol (props) {
6
8
  return (
7
9
  <div className='setting-col'>
@@ -11,7 +13,12 @@ export default function SettingCol (props) {
11
13
  <div
12
14
  className='setting-row setting-row-right'
13
15
  >
14
- {props.children[1]}
16
+ <div className='setting-col-content'>
17
+ {props.children[1]}
18
+ </div>
19
+ <div className='setting-col-placeholder'>
20
+ <Placeholder />
21
+ </div>
15
22
  </div>
16
23
  </div>
17
24
  )
@@ -552,6 +552,10 @@ export default class SettingTerminal extends Component {
552
552
  {
553
553
  this.renderText('terminalWordSeparator', e('terminalWordSeparator'))
554
554
  }
555
+ <div className='pd1b'>Shift+Enter</div>
556
+ {
557
+ this.renderText('shiftEnterMode', 'Text to send on Shift+Enter. Use \\n for newline')
558
+ }
555
559
  {
556
560
  this.renderCursorStyleSelect()
557
561
  }
@@ -38,6 +38,20 @@
38
38
  bottom 0
39
39
  top 129px
40
40
  padding 20px
41
+ display flex
42
+ flex-direction column
43
+ overflow hidden
44
+ min-height 400px
45
+ .setting-col-content
46
+ flex 1
47
+ overflow-y auto
48
+ min-height 0
49
+ .setting-col-placeholder
50
+ flex-shrink 0
51
+ display flex
52
+ justify-content flex-end
53
+ .carbonads-wrap
54
+ margin 8px 0 0
41
55
  .setting-tabs-bookmarks
42
56
  .setting-row-left
43
57
  width 440px
@@ -75,6 +89,10 @@
75
89
  bottom auto
76
90
  top auto
77
91
  padding 10px
92
+ position static
93
+ overflow visible
94
+ .setting-col-content
95
+ overflow visible
78
96
  .setting-tabs-bookmarks
79
97
  .setting-row-left
80
98
  width auto
@@ -5,6 +5,30 @@ import {
5
5
  } from '../../common/constants'
6
6
  import keyControlPressed from '../../common/key-control-pressed.js'
7
7
 
8
+ function processEscapeSequences (text) {
9
+ let result = ''
10
+ for (let i = 0; i < text.length; i++) {
11
+ if (text[i] === '\\' && i + 1 < text.length) {
12
+ const next = text[i + 1]
13
+ if (next === 'n') {
14
+ result += '\n'
15
+ } else if (next === 't') {
16
+ result += '\t'
17
+ } else if (next === 'r') {
18
+ result += '\r'
19
+ } else if (next === '\\') {
20
+ result += '\\'
21
+ } else {
22
+ result += '\\' + next
23
+ }
24
+ i++
25
+ } else {
26
+ result += text[i]
27
+ }
28
+ }
29
+ return result
30
+ }
31
+
8
32
  function sendInputData (ctx, data) {
9
33
  if (!data) return
10
34
  if (ctx.attachAddon && ctx.attachAddon._sendData) {
@@ -170,6 +194,21 @@ export function shortcutExtend (Cls) {
170
194
  this.socket.send(char)
171
195
  this.term.scrollToBottom()
172
196
  return false
197
+ } else if (
198
+ this.term &&
199
+ key === 'Enter' &&
200
+ type === 'keydown' &&
201
+ shiftKey &&
202
+ !ctrlKey &&
203
+ !altKey &&
204
+ !metaKey
205
+ ) {
206
+ event.preventDefault()
207
+ event.stopPropagation()
208
+ const shiftEnterText = processEscapeSequences(this.props.config.shiftEnterMode || '\\n')
209
+ this.socket.send(shiftEnterText)
210
+ this.term.scrollToBottom()
211
+ return false
173
212
  } else if (
174
213
  this.term &&
175
214
  this.term.buffer.active.type === 'alternate'
@@ -7,7 +7,8 @@ import {
7
7
  SettingOutlined,
8
8
  UpCircleOutlined,
9
9
  AppstoreOutlined,
10
- ThunderboltOutlined
10
+ ThunderboltOutlined,
11
+ AimOutlined
11
12
  } from '@ant-design/icons'
12
13
  import { Tooltip, Popover } from 'antd'
13
14
  import SideBarPanel from './sidebar-panel'
@@ -42,7 +43,8 @@ export default function Sidebar (props) {
42
43
  showModal,
43
44
  showInfoModal,
44
45
  sidebarPanelTab,
45
- openWidgetsModal
46
+ openWidgetsModal,
47
+ zoom
46
48
  } = props
47
49
 
48
50
  const { store } = window
@@ -85,6 +87,10 @@ export default function Sidebar (props) {
85
87
  window.store.upgradeInfo.showUpgradeModal = true
86
88
  }
87
89
 
90
+ const handleZoomReset = () => {
91
+ store.onZoomReset()
92
+ }
93
+
88
94
  const {
89
95
  onNewSsh,
90
96
  openSetting,
@@ -203,6 +209,20 @@ export default function Sidebar (props) {
203
209
  onClick={openAbout}
204
210
  />
205
211
  </SideIcon>
212
+ {
213
+ Math.round((zoom ?? 1) * 100) !== 100
214
+ ? (
215
+ <SideIcon
216
+ title={e('resetzoom')}
217
+ >
218
+ <AimOutlined
219
+ className='iblock font16 control-icon zoom-reset-icon'
220
+ onClick={handleZoomReset}
221
+ />
222
+ </SideIcon>
223
+ )
224
+ : null
225
+ }
206
226
  {
207
227
  !checkingRemoteVersion && !showUpgradeModal && shouldUpgrade
208
228
  ? (
@@ -4,16 +4,17 @@ import {
4
4
  HighlightOutlined,
5
5
  HomeOutlined,
6
6
  UserOutlined,
7
- WarningOutlined,
8
7
  InfoCircleOutlined,
9
8
  AlignLeftOutlined,
10
9
  BugOutlined,
11
- HeartOutlined
10
+ HeartOutlined,
11
+ CloudOutlined
12
12
  } from '@ant-design/icons'
13
13
  import { Tabs, Button } from 'antd'
14
14
  import Modal from '../common/modal'
15
15
  import Link from '../common/external-link'
16
16
  import LogoElem from '../common/logo-elem'
17
+ import Placeholder from '../common/placeholder'
17
18
  import RunningTime from './app-running-time'
18
19
  import { auto } from 'manate/react'
19
20
  import { useState } from 'react'
@@ -136,10 +137,10 @@ export default auto(function InfoModal (props) {
136
137
  url: bugReportLink
137
138
  },
138
139
  releases: releaseLink,
139
- privacyNoticeLink,
140
140
  sponsorLink,
141
141
  knownIssuesLink
142
142
  } = packInfo
143
+ const electermOnline = 'https://cloud.electerm.org'
143
144
  const link = releaseLink.replace('/releases', '')
144
145
  const { versions } = window.pre
145
146
  const deps = {
@@ -215,9 +216,9 @@ export default auto(function InfoModal (props) {
215
216
  </Link>
216
217
  </p>
217
218
  <p className='mg1b'>
218
- <WarningOutlined /> <b className='mg1r'>{e('privacyNotice')} ➾</b>
219
- <Link to={privacyNoticeLink} className='mg1l'>
220
- {privacyNoticeLink}
219
+ <CloudOutlined /> <b className='mg1r'>electerm Online ➾</b>
220
+ <Link to={electermOnline} className='mg1l'>
221
+ {electermOnline}
221
222
  </Link>
222
223
  </p>
223
224
  <p className='mg1b'>
@@ -230,6 +231,7 @@ export default auto(function InfoModal (props) {
230
231
  <InfoCircleOutlined /> <b className='mg1r'>{window.store.installSrc}</b>
231
232
  </p>
232
233
  {renderCheckUpdate()}
234
+ <Placeholder />
233
235
  </>
234
236
  )
235
237
  },
@@ -118,6 +118,10 @@
118
118
  color var(--text-dark)
119
119
  &:hover
120
120
  color var(--text-light)
121
+ .zoom-reset-icon
122
+ color var(--primary)
123
+ &:hover
124
+ color var(--text-light)
121
125
 
122
126
  .btns .upgrade-icon
123
127
  color var(--success)
@@ -63,14 +63,14 @@ export default class TermSearch extends PureComponent {
63
63
  }
64
64
  }
65
65
 
66
- prev = () => {
66
+ prev = (searchTerm) => {
67
67
  const {
68
68
  activeTabId,
69
69
  termSearchOptions
70
70
  } = this.props
71
71
  refs.get('term-' + activeTabId)
72
72
  ?.searchPrev(
73
- this.props.termSearch,
73
+ searchTerm ?? this.props.termSearch,
74
74
  copy(termSearchOptions)
75
75
  )
76
76
  }
@@ -0,0 +1,133 @@
1
+ const ESC = '\x1b'
2
+ const ST = '\x1b\\'
3
+
4
+ function toHexByte (value) {
5
+ return value.toString(16).padStart(2, '0')
6
+ }
7
+
8
+ function clampByte (value) {
9
+ return Math.max(0, Math.min(255, Math.round(value)))
10
+ }
11
+
12
+ function parseChannel (value) {
13
+ const trimmed = value.trim()
14
+ if (!trimmed) {
15
+ return null
16
+ }
17
+ if (trimmed.endsWith('%')) {
18
+ const percent = Number(trimmed.slice(0, -1))
19
+ return Number.isFinite(percent)
20
+ ? clampByte(percent * 255 / 100)
21
+ : null
22
+ }
23
+ const num = Number(trimmed)
24
+ return Number.isFinite(num)
25
+ ? clampByte(num)
26
+ : null
27
+ }
28
+
29
+ function parseAlpha (value) {
30
+ if (!value) {
31
+ return 1
32
+ }
33
+ const trimmed = value.trim()
34
+ if (trimmed.endsWith('%')) {
35
+ const percent = Number(trimmed.slice(0, -1))
36
+ return Number.isFinite(percent) ? percent / 100 : null
37
+ }
38
+ const num = Number(trimmed)
39
+ return Number.isFinite(num) ? num : null
40
+ }
41
+
42
+ function parseHexColor (color) {
43
+ const match = color.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i)
44
+ if (!match) {
45
+ return null
46
+ }
47
+ const raw = match[1]
48
+ const expanded = raw.length <= 4
49
+ ? raw.split('').map(char => char + char).join('')
50
+ : raw
51
+ const alpha = expanded.length === 8
52
+ ? parseInt(expanded.slice(6, 8), 16) / 255
53
+ : 1
54
+ if (alpha <= 0) {
55
+ return null
56
+ }
57
+ return [
58
+ parseInt(expanded.slice(0, 2), 16),
59
+ parseInt(expanded.slice(2, 4), 16),
60
+ parseInt(expanded.slice(4, 6), 16)
61
+ ]
62
+ }
63
+
64
+ function parseRgbColor (color) {
65
+ const match = color.match(/^rgba?\((.*)\)$/i)
66
+ if (!match) {
67
+ return null
68
+ }
69
+ const body = match[1].trim()
70
+ const [channelsPart, slashAlpha] = body.split('/').map(part => part.trim())
71
+ const parts = channelsPart.includes(',')
72
+ ? channelsPart.split(',').map(part => part.trim())
73
+ : channelsPart.split(/\s+/).filter(Boolean)
74
+ if (parts.length < 3) {
75
+ return null
76
+ }
77
+ const channels = parts.slice(0, 3).map(parseChannel)
78
+ if (channels.some(channel => channel === null)) {
79
+ return null
80
+ }
81
+ const alpha = parseAlpha(slashAlpha || parts[3])
82
+ if (alpha === null || alpha <= 0) {
83
+ return null
84
+ }
85
+ return channels
86
+ }
87
+
88
+ export function parseColorToRgb (color) {
89
+ if (typeof color !== 'string') {
90
+ return null
91
+ }
92
+ const trimmed = color.trim()
93
+ if (!trimmed || trimmed.toLowerCase() === 'transparent') {
94
+ return null
95
+ }
96
+ return parseHexColor(trimmed) || parseRgbColor(trimmed)
97
+ }
98
+
99
+ export function colorToOscRgb (color) {
100
+ const rgb = parseColorToRgb(color)
101
+ if (!rgb) {
102
+ return ''
103
+ }
104
+ return `rgb:${rgb.map(toHexByte).join('/')}`
105
+ }
106
+
107
+ export function buildOscColorResponse (identifier, color, fallbackColor) {
108
+ const oscColor = colorToOscRgb(color) || colorToOscRgb(fallbackColor)
109
+ return oscColor
110
+ ? `${ESC}]${identifier};${oscColor}${ST}`
111
+ : ''
112
+ }
113
+
114
+ export function handleTerminalColorQuery (terminal, identifier, color, fallbackColor, data) {
115
+ if (typeof data !== 'string' || data.trim() !== '?') {
116
+ return false
117
+ }
118
+ const response = buildOscColorResponse(identifier, color, fallbackColor)
119
+ if (!response || typeof terminal?.input !== 'function') {
120
+ return false
121
+ }
122
+ terminal.input(response, false)
123
+ return true
124
+ }
125
+
126
+ export function createRendererThemeConfig (themeConfig = {}, rendererType, visibleBackground) {
127
+ return {
128
+ ...themeConfig,
129
+ background: rendererType === 'webGL' && colorToOscRgb(visibleBackground)
130
+ ? visibleBackground
131
+ : 'rgba(0,0,0,0)'
132
+ }
133
+ }
@@ -4,7 +4,8 @@ import clone from '../../common/to-simple-obj.js'
4
4
  import resolve from '../../common/resolve.js'
5
5
  import {
6
6
  Spin,
7
- Dropdown
7
+ Dropdown,
8
+ Button
8
9
  } from 'antd'
9
10
  import message from '../common/message'
10
11
  import { notification } from '../common/notification'
@@ -40,6 +41,9 @@ import { getFolderFromFilePath } from '../sftp/file-read.js'
40
41
  import { CommandTrackerAddon } from './command-tracker-addon.js'
41
42
  import { Osc52Addon } from './osc52-addon.js'
42
43
  import AIIcon from '../icons/ai-icon.jsx'
44
+ import {
45
+ AimOutlined
46
+ } from '@ant-design/icons'
43
47
  import {
44
48
  getShellIntegrationCommand,
45
49
  detectRemoteShell,
@@ -57,13 +61,16 @@ import {
57
61
  loadTerminal,
58
62
  loadFitAddon,
59
63
  loadWebLinksAddon,
60
- loadCanvasAddon,
61
64
  loadWebglAddon,
62
65
  loadSearchAddon,
63
66
  loadLigaturesAddon,
64
67
  loadUnicode11Addon,
65
68
  loadImageAddon
66
69
  } from './xterm-loader.js'
70
+ import {
71
+ createRendererThemeConfig,
72
+ handleTerminalColorQuery
73
+ } from './terminal-color-query.mjs'
67
74
 
68
75
  const e = window.translate
69
76
 
@@ -87,7 +94,8 @@ class Term extends Component {
87
94
  reconnectCountdown: null,
88
95
  terminalError: null,
89
96
  dropFileModalVisible: false,
90
- droppedFiles: []
97
+ droppedFiles: [],
98
+ fontSizeChanged: false
91
99
  }
92
100
  this.id = `term-${this.props.tab.id}`
93
101
  refs.add(this.id, this)
@@ -141,10 +149,8 @@ class Term extends Component {
141
149
  prevProps.themeConfig
142
150
  )
143
151
  if (themeChanged && this.term) {
144
- this.term.options.theme = {
145
- ...deepCopy(this.props.themeConfig),
146
- background: 'rgba(0,0,0,0)'
147
- }
152
+ this.term.options.theme = this.getRendererThemeConfig(this.props.themeConfig)
153
+ this.registerTerminalColorQueryHandlers(this.term, this.props.themeConfig)
148
154
  }
149
155
  }
150
156
 
@@ -156,6 +162,7 @@ class Term extends Component {
156
162
  if (this.term) {
157
163
  this.term.parent = null
158
164
  }
165
+ this.disposeTerminalColorQueryHandlers()
159
166
  Object.keys(this.timers).forEach(k => {
160
167
  clearTimeout(this.timers[k])
161
168
  this.timers[k] = null
@@ -228,6 +235,10 @@ class Term extends Component {
228
235
  if (['fontFamily', 'fontSize'].includes(name)) {
229
236
  this.onResize()
230
237
  }
238
+ if (name === 'fontSize') {
239
+ this.originalFontSize = curr
240
+ this.setState({ fontSizeChanged: false })
241
+ }
231
242
  }
232
243
  }
233
244
 
@@ -289,6 +300,23 @@ class Term extends Component {
289
300
  }
290
301
  term.options.fontSize = term.options.fontSize + v
291
302
  window.store.triggerResize()
303
+ if (this.originalFontSize == null) {
304
+ this.originalFontSize = term.options.fontSize - v
305
+ }
306
+ this.setState({
307
+ fontSizeChanged: term.options.fontSize !== this.originalFontSize
308
+ })
309
+ }
310
+
311
+ handleResetFontSize = () => {
312
+ const { term } = this
313
+ if (!term || this.originalFontSize == null) {
314
+ return
315
+ }
316
+ term.options.fontSize = this.originalFontSize
317
+ window.store.triggerResize()
318
+ this.setState({ fontSizeChanged: false })
319
+ term.focus()
292
320
  }
293
321
 
294
322
  isActiveTerminal = () => {
@@ -451,13 +479,19 @@ class Term extends Component {
451
479
  return
452
480
  }
453
481
 
482
+ const hasMultiFiles = filePaths.length > 1
483
+
454
484
  if (isSshTerminal) {
455
485
  const behavior = this.props.config.dragDropBehavior || 'ask'
456
- if (behavior === 'ask') {
486
+ // multi-file drops always paste paths, skip the action modal
487
+ if (behavior === 'ask' && !hasMultiFiles) {
457
488
  this.setState({
458
489
  dropFileModalVisible: true,
459
490
  droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
460
491
  })
492
+ } else if (hasMultiFiles) {
493
+ const filesAll = filePaths.map(path => `"${path}"`).join(' ')
494
+ this.attachAddon._sendData(filesAll)
461
495
  } else {
462
496
  this.handleDropFileAction(behavior, filePaths.map(path => ({ path, isRemote: false })))
463
497
  }
@@ -465,10 +499,16 @@ class Term extends Component {
465
499
  }
466
500
 
467
501
  if (isSerialTerminal) {
468
- this.setState({
469
- dropFileModalVisible: true,
470
- droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
471
- })
502
+ // multi-file drops always paste paths, skip the action modal
503
+ if (hasMultiFiles) {
504
+ const filesAll = filePaths.map(path => `"${path}"`).join(' ')
505
+ this.attachAddon._sendData(filesAll)
506
+ } else {
507
+ this.setState({
508
+ dropFileModalVisible: true,
509
+ droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
510
+ })
511
+ }
472
512
  return
473
513
  }
474
514
 
@@ -606,7 +646,7 @@ class Term extends Component {
606
646
  this.term.clear()
607
647
  this.term.focus()
608
648
  if (shouldClear) {
609
- this.searchAddon._linesCache = undefined
649
+ this.searchAddon._lineCache.clear()
610
650
  this.timers.clearSearchTimer = setTimeout(() => {
611
651
  refsStatic.get('term-search')?.next()
612
652
  }, 100)
@@ -658,8 +698,8 @@ class Term extends Component {
658
698
  }
659
699
 
660
700
  updateSearchResults = (resultIndex) => {
661
- const matches = this.searchAddon._highlightDecorations.map((highlight, i) => {
662
- return highlight.match.row
701
+ const matches = this.searchAddon._resultTracker.searchResults.map((result, i) => {
702
+ return result.row
663
703
  })
664
704
 
665
705
  this.setState({
@@ -1066,26 +1106,70 @@ class Term extends Component {
1066
1106
  }
1067
1107
 
1068
1108
  loadRenderer = async (term, config) => {
1069
- if (config.rendererType === rendererTypes.canvas) {
1070
- const CanvasAddon = await loadCanvasAddon()
1071
- term.loadAddon(new CanvasAddon())
1072
- } else if (config.rendererType === rendererTypes.webGL) {
1109
+ // xterm 6.x: only the built-in DOM renderer and the WebGL addon exist
1110
+ // (the canvas renderer addon was removed in 6.x). 'dom' = no addon loaded
1111
+ // (built-in DOM renderer). Legacy 'canvas' settings fall back to DOM.
1112
+ if (config.rendererType === rendererTypes.webGL) {
1073
1113
  try {
1074
1114
  const WebglAddon = await loadWebglAddon()
1075
1115
  term.loadAddon(new WebglAddon())
1076
1116
  } catch (e) {
1077
- console.error('render with webgl failed, fallback to canvas')
1117
+ console.error('render with webgl failed, fallback to dom renderer')
1078
1118
  console.error(e)
1079
- const CanvasAddon = await loadCanvasAddon()
1080
- term.loadAddon(new CanvasAddon())
1119
+ // built-in DOM renderer is used (no addon loaded)
1081
1120
  }
1082
1121
  }
1083
1122
  }
1084
1123
 
1124
+ terminalColorQueryDisposables = []
1125
+
1126
+ disposeTerminalColorQueryHandlers = () => {
1127
+ this.terminalColorQueryDisposables.forEach(disposable => disposable?.dispose?.())
1128
+ this.terminalColorQueryDisposables.length = 0
1129
+ }
1130
+
1131
+ getVisibleTerminalBackground = () => {
1132
+ const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
1133
+ const dom = this.domRef.current
1134
+ const cssMain = dom && window.getComputedStyle
1135
+ ? window.getComputedStyle(dom).getPropertyValue('--main').trim()
1136
+ : ''
1137
+ return uiThemeConfig.main || cssMain || this.props.themeConfig.background
1138
+ }
1139
+
1140
+ getVisibleTerminalForeground = () => {
1141
+ const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
1142
+ return uiThemeConfig.text
1143
+ }
1144
+
1145
+ registerTerminalColorQueryHandlers = (term, themeConfig = {}) => {
1146
+ this.disposeTerminalColorQueryHandlers()
1147
+ if (!term?.parser?.registerOscHandler) {
1148
+ return
1149
+ }
1150
+ const background = this.getVisibleTerminalBackground()
1151
+ const foregroundFallback = this.getVisibleTerminalForeground()
1152
+ this.terminalColorQueryDisposables.push(
1153
+ term.parser.registerOscHandler(10, data => {
1154
+ return handleTerminalColorQuery(term, 10, themeConfig.foreground, foregroundFallback, data)
1155
+ }),
1156
+ term.parser.registerOscHandler(11, data => {
1157
+ return handleTerminalColorQuery(term, 11, background, themeConfig.background, data)
1158
+ })
1159
+ )
1160
+ }
1161
+
1162
+ getRendererThemeConfig = (themeConfig = this.props.themeConfig) => {
1163
+ return createRendererThemeConfig(
1164
+ deepCopy(themeConfig),
1165
+ this.props.config.rendererType,
1166
+ this.getVisibleTerminalBackground()
1167
+ )
1168
+ }
1169
+
1085
1170
  initTerminal = async () => {
1086
1171
  const { themeConfig, tab = {}, config = {} } = this.props
1087
- const tc = deepCopy(themeConfig)
1088
- tc.background = 'rgba(0,0,0,0)'
1172
+ const tc = this.getRendererThemeConfig(themeConfig)
1089
1173
  const Terminal = await loadTerminal()
1090
1174
  const term = new Terminal({
1091
1175
  allowProposedApi: true,
@@ -1104,6 +1188,7 @@ class Term extends Component {
1104
1188
  term.parent = this
1105
1189
  term.onSelectionChange(this.onSelection)
1106
1190
  term.open(this.domRef.current, true)
1191
+ this.registerTerminalColorQueryHandlers(term, themeConfig)
1107
1192
  await this.loadRenderer(term, config)
1108
1193
 
1109
1194
  const FitAddon = await loadFitAddon()
@@ -1324,7 +1409,7 @@ class Term extends Component {
1324
1409
  }
1325
1410
 
1326
1411
  openNormalBuffer = () => {
1327
- const normal = this.term.buffer._normal
1412
+ const normal = this.term.buffer.normal
1328
1413
  const len = normal.length
1329
1414
  const lines = new Array(len).fill('').map((x, i) => {
1330
1415
  return normal.getLine(i).translateToString(false)
@@ -1486,13 +1571,12 @@ class Term extends Component {
1486
1571
  socket.onopen = async () => {
1487
1572
  await this.initAttachAddon()
1488
1573
  this.runInitScript()
1489
- term._initialized = true
1490
1574
  }
1491
1575
  // term.onRrefresh(this.onRefresh)
1492
1576
  term.onResize(this.onResizeTerminal)
1493
- if (pick(term, 'buffer._onBufferChange._listeners')) {
1494
- term.buffer._onBufferChange._listeners.push(this.onBufferChange)
1495
- }
1577
+ // xterm 6.x exposes buffer change as a public event (IBufferNamespace.onBufferChange).
1578
+ // Previously this reached into the private _onBufferChange._listeners array.
1579
+ term.buffer.onBufferChange(this.onBufferChange)
1496
1580
  const WebLinksAddon = await loadWebLinksAddon()
1497
1581
  term.loadAddon(new WebLinksAddon(this.webLinkHandler))
1498
1582
  term.focus()
@@ -1662,6 +1746,23 @@ class Term extends Component {
1662
1746
  Object.assign(window.store.terminalInfoProps, infoProps)
1663
1747
  }
1664
1748
 
1749
+ renderResetFontSizeButton = () => {
1750
+ if (!this.state.fontSizeChanged) {
1751
+ return null
1752
+ }
1753
+ const txt = `${e('reset')} ${e('fontSize')}`
1754
+ return (
1755
+ <Button
1756
+ className='terminal-fontsize-reset'
1757
+ onClick={this.handleResetFontSize}
1758
+ type='default'
1759
+ size='small'
1760
+ title={txt}
1761
+ icon={<AimOutlined />}
1762
+ />
1763
+ )
1764
+ }
1765
+
1665
1766
  // getPwd = async () => {
1666
1767
  // const { sessionId, config } = this.props
1667
1768
  // const { pid } = this.state
@@ -1760,6 +1861,7 @@ class Term extends Component {
1760
1861
  <ReconnectOverlay
1761
1862
  countdown={this.state.reconnectCountdown}
1762
1863
  />
1864
+ {this.renderResetFontSizeButton()}
1763
1865
  <DropFileModal
1764
1866
  visible={this.state.dropFileModalVisible}
1765
1867
  files={this.state.droppedFiles}
@@ -32,6 +32,10 @@
32
32
  margin-left 5px
33
33
 
34
34
  #container
35
+ .xterm
36
+ background transparent
37
+ .xterm-viewport
38
+ background-color transparent !important
35
39
  .xterm-screen::before
36
40
  content ''
37
41
  position absolute
@@ -167,3 +171,8 @@
167
171
  z-index 100
168
172
  background var(--main)
169
173
  max-width calc(100% - 24px)
174
+
175
+ .terminal-fontsize-reset
176
+ position absolute
177
+ left 8px
178
+ bottom 4px
@@ -37,13 +37,6 @@ export async function loadWebLinksAddon () {
37
37
  return window.xtermAddons.WebLinksAddon
38
38
  }
39
39
 
40
- export async function loadCanvasAddon () {
41
- if (window.xtermAddons.CanvasAddon) return window.xtermAddons.CanvasAddon
42
- const mod = await import('@xterm/addon-canvas')
43
- window.xtermAddons.CanvasAddon = mod.CanvasAddon
44
- return window.xtermAddons.CanvasAddon
45
- }
46
-
47
40
  export async function loadWebglAddon () {
48
41
  if (window.xtermAddons.WebglAddon) return window.xtermAddons.WebglAddon
49
42
  const mod = await import('@xterm/addon-webgl')
@@ -95,10 +88,6 @@ export function getWebLinksAddon () {
95
88
  return window.xtermAddons.WebLinksAddon
96
89
  }
97
90
 
98
- export function getCanvasAddon () {
99
- return window.xtermAddons.CanvasAddon
100
- }
101
-
102
91
  export function getWebglAddon () {
103
92
  return window.xtermAddons.WebglAddon
104
93
  }
@@ -4,12 +4,38 @@ function isTopLevelGroup (group) {
4
4
  return !group?.level || group.level < 2
5
5
  }
6
6
 
7
+ function bookmarkCompare (a, b, bookmarksMap, sortTitle, sortHost) {
8
+ const itemA = bookmarksMap.get(a)
9
+ const itemB = bookmarksMap.get(b)
10
+ if (!itemA || !itemB) {
11
+ return 0
12
+ }
13
+
14
+ if (sortTitle) {
15
+ const cmp = (itemA.title || '').localeCompare(itemB.title || '')
16
+ if (cmp !== 0) {
17
+ return sortTitle === 'asc' ? cmp : -cmp
18
+ }
19
+ }
20
+
21
+ if (sortHost) {
22
+ const cmp = (itemA.host || '').localeCompare(itemB.host || '')
23
+ if (cmp !== 0) {
24
+ return sortHost === 'asc' ? cmp : -cmp
25
+ }
26
+ }
27
+
28
+ return 0
29
+ }
30
+
7
31
  export function buildVisibleTreeRows ({
8
32
  bookmarkGroups,
9
33
  bookmarkGroupTree,
10
34
  bookmarksMap,
11
35
  expandedKeys,
12
- keyword
36
+ keyword,
37
+ sortTitle,
38
+ sortHost
13
39
  }) {
14
40
  const groupTree = bookmarkGroupTree || {}
15
41
  const rows = []
@@ -77,7 +103,12 @@ export function buildVisibleTreeRows ({
77
103
  visitGroup(groupTree[groupId], nextParentId, depth + 1)
78
104
  }
79
105
 
80
- for (const bookmarkId of group.bookmarkIds || []) {
106
+ const bookmarkIds = group.bookmarkIds || []
107
+ const sortedIds = (sortTitle || sortHost)
108
+ ? [...bookmarkIds].sort((a, b) => bookmarkCompare(a, b, bookmarksMap, sortTitle, sortHost))
109
+ : bookmarkIds
110
+
111
+ for (const bookmarkId of sortedIds) {
81
112
  const item = bookmarksMap.get(bookmarkId)
82
113
  if (!item || (lowerKeyword && !bookmarkMatches(bookmarkId))) {
83
114
  continue
@@ -44,7 +44,8 @@ export default class ItemListTree extends Component {
44
44
  categoryTitle: '',
45
45
  categoryColor: '',
46
46
  categoryId: '',
47
- searchSelectedRowKey: ''
47
+ searchSelectedRowKey: '',
48
+ sort: null
48
49
  }
49
50
  this.listRef = React.createRef()
50
51
  }
@@ -109,6 +110,10 @@ export default class ItemListTree extends Component {
109
110
  })
110
111
  }
111
112
 
113
+ handleSortChange = sort => {
114
+ this.setState({ sort })
115
+ }
116
+
112
117
  handleKeyDown = (e) => {
113
118
  const { keyword } = this.state
114
119
  if (!keyword) return
@@ -176,12 +181,15 @@ export default class ItemListTree extends Component {
176
181
  }
177
182
 
178
183
  getVisibleTreeData = () => {
184
+ const sort = this.state.sort
179
185
  return buildVisibleTreeRows({
180
186
  bookmarkGroups: this.props.bookmarkGroups,
181
187
  bookmarkGroupTree: this.props.bookmarkGroupTree,
182
188
  bookmarksMap: this.props.bookmarksMap,
183
189
  expandedKeys: this.props.expandedKeys,
184
- keyword: this.state.keyword
190
+ keyword: this.state.keyword,
191
+ sortTitle: sort?.field === 'title' ? sort.dir : null,
192
+ sortHost: sort?.field === 'host' ? sort.dir : null
185
193
  })
186
194
  }
187
195
 
@@ -414,6 +422,8 @@ export default class ItemListTree extends Component {
414
422
  keyword={this.state.keyword}
415
423
  autoFocus={this.props.autoFocus}
416
424
  onKeyDown={this.handleKeyDown}
425
+ sort={this.state.sort}
426
+ onSortChange={this.handleSortChange}
417
427
  />
418
428
  </div>
419
429
  )
@@ -101,6 +101,42 @@
101
101
  min-height 0
102
102
  position relative
103
103
 
104
+ .tree-sort-wrap
105
+ position relative
106
+ .tree-sort-dropdown
107
+ display none
108
+ position absolute
109
+ right 0
110
+ top 100%
111
+ margin-top 4px
112
+ background var(--main-dark)
113
+ border 1px solid var(--main)
114
+ border-radius 4px
115
+ padding 4px
116
+ z-index 100
117
+ box-shadow 0 2px 8px rgba(0,0,0,.1)
118
+ &.open
119
+ .tree-sort-dropdown
120
+ display block
121
+
122
+ .tree-sort-trigger
123
+ cursor pointer
124
+ &:hover
125
+ color var(--primary)
126
+ &.active
127
+ color var(--primary)
128
+
129
+ .tree-sort-popover-item
130
+ padding 4px 8px
131
+ border-radius 3px
132
+ cursor pointer
133
+ white-space nowrap
134
+ font-size 12px
135
+ &:hover
136
+ background var(--hover-bg, rgba(0,0,0,.04))
137
+ &.active
138
+ color var(--primary)
139
+
104
140
  .tree-list-virtual-spacer
105
141
  position relative
106
142
  width 100%
@@ -1,10 +1,33 @@
1
- import React, { useState, memo } from 'react'
1
+ import React, { useState, memo, useCallback, useEffect, useRef } from 'react'
2
2
  import { debounce } from 'lodash-es'
3
+ import { Space, Button } from 'antd'
4
+ import { SortAscendingOutlined, CaretUpOutlined, CaretDownOutlined } from '@ant-design/icons'
3
5
  import Search from '../common/search'
4
6
  import runIdle from '../../common/run-idle'
5
7
 
6
- export default memo(function TreeSearchComponent ({ onSearch, keyword, autoFocus, onKeyDown }) {
8
+ function cycleSort (currentField, currentDir, clickedField) {
9
+ if (currentField === clickedField) {
10
+ if (currentDir === 'asc') {
11
+ return { field: clickedField, dir: 'desc' }
12
+ }
13
+ if (currentDir === 'desc') {
14
+ return null
15
+ }
16
+ }
17
+ return { field: clickedField, dir: 'asc' }
18
+ }
19
+
20
+ export default memo(function TreeSearchComponent ({
21
+ onSearch,
22
+ keyword,
23
+ autoFocus,
24
+ onKeyDown,
25
+ sort,
26
+ onSortChange
27
+ }) {
7
28
  const [searchTerm, setSearchTerm] = useState(keyword)
29
+ const [open, setOpen] = useState(false)
30
+ const wrapRef = useRef(null)
8
31
 
9
32
  const performSearch = debounce((term) => {
10
33
  runIdle(() => {
@@ -24,13 +47,68 @@ export default memo(function TreeSearchComponent ({ onSearch, keyword, autoFocus
24
47
  }
25
48
  }
26
49
 
50
+ const handleSortClick = useCallback((field) => {
51
+ const next = cycleSort(sort?.field, sort?.dir, field)
52
+ onSortChange(next)
53
+ }, [sort, onSortChange])
54
+
55
+ const handleTriggerClick = () => {
56
+ setOpen(v => !v)
57
+ }
58
+
59
+ const handleItemClick = (field) => {
60
+ handleSortClick(field)
61
+ }
62
+
63
+ const isSortActive = !!sort?.field
64
+
65
+ useEffect(() => {
66
+ if (!open) return
67
+ const onMouseDown = (e) => {
68
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
69
+ setOpen(false)
70
+ }
71
+ }
72
+ document.addEventListener('mousedown', onMouseDown)
73
+ return () => document.removeEventListener('mousedown', onMouseDown)
74
+ }, [open])
75
+
27
76
  return (
28
- <Search
29
- onChange={handleChange}
30
- onKeyDown={handleKeyDown}
31
- value={searchTerm}
32
- allowClear
33
- autoFocus={autoFocus}
34
- />
77
+ <div className={`tree-sort-wrap iblock${open ? ' open' : ''}`} ref={wrapRef}>
78
+ <Space.Compact className='width-100'>
79
+ <Search
80
+ onChange={handleChange}
81
+ onKeyDown={handleKeyDown}
82
+ value={searchTerm}
83
+ allowClear
84
+ autoFocus={autoFocus}
85
+ />
86
+ <Button
87
+ className={`tree-sort-trigger pointer${isSortActive ? ' active' : ''}`}
88
+ onClick={handleTriggerClick}
89
+ icon={<SortAscendingOutlined />}
90
+ />
91
+ </Space.Compact>
92
+ <div className='tree-sort-dropdown'>
93
+ {['title', 'host'].map(field => {
94
+ const isActive = sort?.field === field
95
+ const isAsc = isActive && sort?.dir === 'asc'
96
+ const isDesc = isActive && sort?.dir === 'desc'
97
+ const cap = window.translate(field)
98
+ return (
99
+ <div
100
+ key={field}
101
+ className={`tree-sort-popover-item${isActive ? ' active' : ''}`}
102
+ onClick={() => handleItemClick(field)}
103
+ >
104
+ {isDesc
105
+ ? <CaretDownOutlined />
106
+ : <CaretUpOutlined className={isAsc ? '' : 'hidden'} />}
107
+ {cap}
108
+ </div>
109
+ )
110
+ })}
111
+ </div>
112
+ </div>
35
113
  )
36
114
  })
@@ -367,7 +367,8 @@ export default class VncSession extends PureComponent {
367
367
  'desktopname',
368
368
  'capabilities',
369
369
  'screens',
370
- 'desktopsize'
370
+ 'desktopsize',
371
+ 'serververification'
371
372
  ]
372
373
  for (const event of events) {
373
374
  rfb.addEventListener(event, this[`on${window.capitalizeFirstLetter(event)}`])
@@ -400,6 +401,14 @@ export default class VncSession extends PureComponent {
400
401
  message.error('Security Failure: ' + event.detail?.reason)
401
402
  }
402
403
 
404
+ onSerververification = (event) => {
405
+ // RA2ne (RealVNC) authentication requires the client to approve
406
+ // the server's public key before the handshake can proceed.
407
+ // Auto-approve since the user explicitly initiated the connection.
408
+ console.debug('[VNC-CLIENT] Server verification event, approving server:', event.detail?.type)
409
+ this.rfb?.approveServer()
410
+ }
411
+
403
412
  onOk = (res) => {
404
413
  this.setState({
405
414
  showConfirm: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.15.90",
3
+ "version": "3.15.105",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",