@electerm/electerm-react 5.5.15 → 5.5.25

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.
Files changed (40) hide show
  1. package/README.md +12 -2
  2. package/client/common/constants.js +14 -0
  3. package/client/common/default-setting.js +2 -2
  4. package/client/common/parse-vv.js +313 -0
  5. package/client/common/theme-defaults.js +5 -2
  6. package/client/common/update-check.js +5 -4
  7. package/client/components/ai/ai-presets.js +13 -2
  8. package/client/components/bg/css-overwrite.jsx +16 -1
  9. package/client/components/bookmark-form/common/fields.jsx +3 -0
  10. package/client/components/bookmark-form/common/vv-file-field.jsx +137 -0
  11. package/client/components/bookmark-form/config/spice.js +1 -0
  12. package/client/components/footer/footer-entry.jsx +39 -14
  13. package/client/components/icons/ai-icon.styl +4 -1
  14. package/client/components/layout/layout.jsx +7 -1
  15. package/client/components/main/main.jsx +9 -2
  16. package/client/components/main/term-fullscreen.styl +7 -0
  17. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  18. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  19. package/client/components/quick-commands/quick-commands-box.jsx +23 -5
  20. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  21. package/client/components/setting-panel/list.jsx +2 -2
  22. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  23. package/client/components/side-panel-r/side-panel-r.jsx +45 -9
  24. package/client/components/sidebar/bookmark-select.jsx +3 -1
  25. package/client/components/sidebar/index.jsx +13 -4
  26. package/client/components/sidebar/side-panel.jsx +8 -3
  27. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  28. package/client/components/terminal/shortcut-bar.styl +14 -0
  29. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  30. package/client/components/terminal-info/terminal-info.styl +3 -0
  31. package/client/components/theme/terminal-theme-list.styl +3 -0
  32. package/client/components/theme/theme-ai-editor.jsx +2 -2
  33. package/client/components/theme/theme-form.jsx +2 -2
  34. package/client/components/theme/theme-list-item.jsx +59 -25
  35. package/client/css/mobile.styl +28 -0
  36. package/client/store/common.js +44 -0
  37. package/client/store/init-state.js +2 -1
  38. package/client/store/sidebar.js +5 -1
  39. package/client/store/store.js +9 -2
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ This is react components pack for electerm sub projects
16
16
 
17
17
  Open-sourced terminal/ssh/sftp/telnet/serialport/RDP/VNC/Spice/ftp client(Linux, Mac, Windows, Android, HarmonyOS, iOS).
18
18
 
19
- Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS, iOS, and older systems — Ubuntu 18, Windows 7, macOS 10+, and special Chinese Linux distributions such as UOS, Kylin, and LoongArch (both old-world and new-world).
19
+ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS, iOS, and older systems — Ubuntu 18, Windows 7, macOS 10+, and special Chinese Linux distributions such as UOS, Kylin, and LoongArch (both old-world and new-world), as well as RISC-V (riscv64) and PowerPC 64-bit little-endian (ppc64le) Linux.
20
20
 
21
21
  <p>
22
22
  <a href="https://electerm.org">Homepage / Downloads</a> ·
@@ -49,6 +49,16 @@ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS
49
49
 
50
50
  ----
51
51
 
52
+ <div align="center">
53
+ <a href="https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm">
54
+ <img src="https://github.com/electerm/electerm-resource/blob/master/static/images/fluxionai.png?raw=true" alt="FluxionAI" height="180" />
55
+ </a>
56
+ </div>
57
+
58
+ [Fluxion AI](https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm) — One gateway to the world's leading AI models. [Sign up here to get started](https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm).
59
+
60
+ ----
61
+
52
62
  <div align="center">
53
63
  <br />
54
64
  <br />
@@ -86,7 +96,7 @@ Thanks to APIMart for sponsoring electerm! APIMart is a low-cost API platform fo
86
96
  ## Features
87
97
 
88
98
  - Works as a terminal/file manager, ssh/sftp/ftp/telnet/serialport/RDP/VNC/Spice client
89
- - Support Window 7+(X64/ARM64), HarmonyOS,Android, iOS, Mac OS 10.15+(x64/arm64), Linux(x64/arm64/Loong64 new world & old world), even old Linux with glibc 2.17+ like UOS/Kylin/Ubuntu 18.04 etc
99
+ - Support Window 7+(X64/ARM64), HarmonyOS,Android, iOS, Mac OS 10.15+(x64/arm64), Linux(x64/arm64/riscv64/ppc64le/Loong64 new world & old world), even old Linux with glibc 2.17+ like UOS/Kylin/Ubuntu 18.04 etc
90
100
  - Global hotkey to toggle window visibility (similar to guake, default is `ctrl + 2`)
91
101
  - 🇺🇸 🇨🇳 🇧🇷 🇷🇺 🇪🇸 🇫🇷 🇹🇷 🇭🇰 🇯🇵 🇸🇦 🇩🇪 🇰🇷 🇮🇩 🇵🇱 Multi-language support([electerm-locales](https://github.com/electerm/electerm-locales), contributions/fixes welcome)
92
102
  - Double click to directly edit (small) remote files.
@@ -2,6 +2,7 @@
2
2
  * constants
3
3
  */
4
4
  import { isMacJs } from './platform.js'
5
+ import { defaultThemeId, defaultThemeLightId } from './theme-defaults.js'
5
6
  import logoPath1Ref from '@electerm/electerm-resource/res/imgs/electerm-round-128x128.png'
6
7
  import logoPath2Ref from '@electerm/electerm-resource/res/imgs/electerm.png'
7
8
  import logoPath3Ref from '@electerm/electerm-resource/res/imgs/electerm-watermark.png'
@@ -156,6 +157,7 @@ export const leftSideBarOpenKey = 'left-side-bar-open'
156
157
  export const pinnedQuickCommandBarKey = 'pinned-quick-command-bar'
157
158
  export const leftSidePanelWidthKey = 'left-sidebar-width'
158
159
  export const rightSidebarWidthKey = 'right-sidebar-width'
160
+ export const rightPanelPinnedKey = 'right-panel-pinned'
159
161
  export const addPanelWidthLsKey = 'addPanelWidth'
160
162
  export const sftpDefaultSortSettingKey = 'sftp-default-sort'
161
163
  export const qmSortByFrequencyKey = 'qm-sort-by-frequency'
@@ -241,6 +243,18 @@ export const settingShortcutsId = 'setting-shortcuts'
241
243
  export const settingAiId = 'setting-ai'
242
244
  export const settingCommonId = 'setting-common'
243
245
  export const settingPasswordsId = 'setting-passwords'
246
+ // Built in items the user is not allowed to delete: the two settings tabs,
247
+ // the default bookmark group and the two built in terminal themes.
248
+ // Keep this an explicit id list. A prefix test like `id.startsWith('default')`
249
+ // would also cover user facing seeds (default quick commands, default local
250
+ // terminal bookmarks) that the user must be able to remove.
251
+ export const undeletableIds = new Set([
252
+ settingSyncId,
253
+ settingCommonId,
254
+ defaultBookmarkGroupId,
255
+ defaultThemeId,
256
+ defaultThemeLightId
257
+ ])
244
258
  // settings that only apply to the desktop (electron) app,
245
259
  // hidden when window.et.isWebApp is true
246
260
  export const webAppHiddenSettings = [
@@ -78,8 +78,8 @@ export default {
78
78
  hideIP: false,
79
79
  dataSyncSelected: 'all',
80
80
  nameAI: '',
81
- baseURLAI: 'https://api.atlascloud.ai/v1',
82
- modelAI: 'deepseek-chat',
81
+ baseURLAI: 'https://fluxionai.world/v1',
82
+ modelAI: 'deepseek-v4-flash-0731',
83
83
  roleAI: '终端专家,提供不同系统下命令,简要解释用法,用markdown格式',
84
84
  apiPathAI: '/chat/completions',
85
85
  proxyAI: '',
@@ -0,0 +1,313 @@
1
+ /**
2
+ * .vv connection file parser (virt-viewer / remote-viewer)
3
+ *
4
+ * A .vv file is an INI document with a mandatory [virt-viewer] group and a
5
+ * mandatory `type` key. Everything SPICE-related lives directly in
6
+ * [virt-viewer] -- there is NO [spice] group. An optional [ovirt] group
7
+ * carries oVirt REST integration data.
8
+ *
9
+ * Behaviour is deliberately matched against:
10
+ * - src/virt-viewer-file.c (gitlab.com/virt-viewer/virt-viewer)
11
+ * - remote-viewer(1), "CONNECTION FILE" section
12
+ *
13
+ * Rejection rules copied from virt_viewer_file_new():
14
+ * no [virt-viewer] group -> invalid file
15
+ * no `type` key -> invalid file
16
+ * unknown keys/groups -> kept, never an error (the format is
17
+ * extensible; the convention is an `x-` prefix)
18
+ *
19
+ * Keep in sync with src/client/common/parse-vv.js -- the two differ only in
20
+ * the export statement (see sanitize-filename.js for the same pattern).
21
+ */
22
+
23
+ const MAIN_GROUP = 'virt-viewer'
24
+ const OVIRT_GROUP = 'ovirt'
25
+
26
+ /**
27
+ * .vv key -> bookmark field understood by
28
+ * src/client/components/bookmark-form/config/spice.js
29
+ */
30
+ const FIELD_MAP = {
31
+ host: 'host',
32
+ port: 'port',
33
+ password: 'password',
34
+ title: 'title',
35
+ proxy: 'proxy'
36
+ }
37
+
38
+ /**
39
+ * Keys the .vv format defines but electerm cannot honour for a spice session.
40
+ * Each becomes a user-visible note instead of being dropped silently.
41
+ */
42
+ const UNSUPPORTED = {
43
+ 'tls-port': 'TLS is not supported (electerm makes a plain TCP connection)',
44
+ 'tls-ciphers': 'TLS is not supported',
45
+ ca: 'TLS certificate verification is not supported',
46
+ 'host-subject': 'TLS certificate verification is not supported',
47
+ 'unix-path': 'Unix-socket transport is not supported',
48
+ username: 'spice sessions authenticate with the password only',
49
+ 'disable-channels': 'spice channel selection is not supported',
50
+ 'secure-channels': 'spice channel selection is not supported',
51
+ 'color-depth': 'colour depth is negotiated with the server',
52
+ 'disable-effects': 'remote desktop-effect control is not supported',
53
+ 'enable-smartcard': 'smartcard redirection is not supported',
54
+ 'enable-usbredir': 'USB redirection is not supported',
55
+ 'enable-usb-autoshare': 'USB redirection is not supported',
56
+ 'usb-filter': 'USB redirection is not supported',
57
+ 'usb-redirect-on-connect': 'USB redirection is not supported',
58
+ fullscreen: 'fullscreen is a session-time toggle',
59
+ version: 'client version requirements are not enforced',
60
+ versions: 'client version requirements are not enforced',
61
+ 'newer-version-url': 'client version requirements are not enforced',
62
+ 'toggle-fullscreen': 'hotkey rebinding is not supported',
63
+ 'release-cursor': 'hotkey rebinding is not supported',
64
+ 'secure-attention': 'hotkey rebinding is not supported',
65
+ 'zoom-in': 'hotkey rebinding is not supported',
66
+ 'zoom-out': 'hotkey rebinding is not supported',
67
+ 'zoom-reset': 'hotkey rebinding is not supported',
68
+ 'smartcard-insert': 'hotkey rebinding is not supported',
69
+ 'smartcard-remove': 'hotkey rebinding is not supported',
70
+ 'usb-device-reset': 'hotkey rebinding is not supported'
71
+ }
72
+
73
+ const BOOL_TRUE = new Set(['1', 'true', 'yes', 'on'])
74
+
75
+ function stripBom (text) {
76
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
77
+ }
78
+
79
+ function isComment (line) {
80
+ return line.startsWith('#') || line.startsWith(';')
81
+ }
82
+
83
+ /**
84
+ * Parse INI text into { groupName: { key: value } }.
85
+ *
86
+ * GKeyFile itself is case-sensitive; group and key names are lowercased on
87
+ * purpose, because hand-written .vv files are inconsistent about casing and
88
+ * the alternative is a confusing "host not set" for a file that clearly
89
+ * sets `Host=`.
90
+ *
91
+ * A trailing backslash continues the value on the next line (GKeyFile rule).
92
+ *
93
+ * @param {string} text
94
+ * @returns {Object} groups
95
+ */
96
+ function parseIni (text) {
97
+ const groups = {}
98
+ let group = null
99
+ const lines = stripBom(text).split(/\r\n|\r|\n/)
100
+
101
+ for (let i = 0; i < lines.length; i++) {
102
+ let line = lines[i]
103
+
104
+ while (/\\[ \t]*$/.test(line) && i + 1 < lines.length) {
105
+ line = line.replace(/\\[ \t]*$/, '') + lines[++i]
106
+ }
107
+
108
+ const trimmed = line.trim()
109
+ if (!trimmed || isComment(trimmed)) {
110
+ continue
111
+ }
112
+
113
+ const groupMatch = trimmed.match(/^\[(.+)\]$/)
114
+ if (groupMatch) {
115
+ group = groupMatch[1].trim().toLowerCase()
116
+ if (!groups[group]) {
117
+ groups[group] = {}
118
+ }
119
+ continue
120
+ }
121
+
122
+ const eq = line.indexOf('=')
123
+ if (eq < 0 || !group) {
124
+ continue
125
+ }
126
+
127
+ const key = line.slice(0, eq).trim().toLowerCase()
128
+ // Only leading whitespace is stripped. A trailing space is significant
129
+ // for values such as passwords, so it is preserved.
130
+ const value = line.slice(eq + 1).replace(/^[ \t]+/, '')
131
+ if (key) {
132
+ groups[group][key] = value
133
+ }
134
+ }
135
+
136
+ return groups
137
+ }
138
+
139
+ /**
140
+ * Parse the contents of a .vv file.
141
+ *
142
+ * @param {string} text - raw contents of a .vv file
143
+ * @returns {{
144
+ * ok: boolean, error: string|null, type: string|null,
145
+ * fields: Object, applied: Array, ignored: Array,
146
+ * unknown: Array, warnings: string[], deleteThisFile: boolean,
147
+ * groups: Object
148
+ * }}
149
+ */
150
+ function parseVv (text) {
151
+ const result = {
152
+ ok: false,
153
+ error: null,
154
+ type: null,
155
+ fields: {},
156
+ applied: [],
157
+ ignored: [],
158
+ unknown: [],
159
+ warnings: [],
160
+ deleteThisFile: false,
161
+ groups: {}
162
+ }
163
+
164
+ if (typeof text !== 'string' || !text.trim()) {
165
+ result.error = 'empty file'
166
+ return result
167
+ }
168
+
169
+ let groups
170
+ try {
171
+ groups = parseIni(text)
172
+ } catch (e) {
173
+ result.error = 'cannot parse file: ' + e.message
174
+ return result
175
+ }
176
+ result.groups = groups
177
+
178
+ const main = groups[MAIN_GROUP]
179
+ if (!main) {
180
+ result.error = 'invalid .vv file: missing [' + MAIN_GROUP + '] group'
181
+ return result
182
+ }
183
+
184
+ const type = (main.type || '').trim().toLowerCase()
185
+ if (!type) {
186
+ result.error = 'invalid .vv file: missing "type" key in [' + MAIN_GROUP + ']'
187
+ return result
188
+ }
189
+ result.type = type
190
+ if (type !== 'spice') {
191
+ result.error = 'unsupported session type "' + type + '": spice only'
192
+ return result
193
+ }
194
+
195
+ Object.keys(FIELD_MAP).forEach(key => {
196
+ if (!(key in main)) {
197
+ return
198
+ }
199
+ const raw = main[key]
200
+ const field = FIELD_MAP[key]
201
+
202
+ if (key === 'port') {
203
+ const port = parseInt(raw.trim(), 10)
204
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
205
+ result.warnings.push('ignoring invalid port "' + raw.trim() + '"')
206
+ return
207
+ }
208
+ result.fields.port = port
209
+ } else if (key === 'password') {
210
+ result.fields.password = raw
211
+ } else {
212
+ const value = raw.trim()
213
+ if (!value) {
214
+ result.warnings.push('ignoring empty "' + key + '"')
215
+ return
216
+ }
217
+ result.fields[field] = value
218
+ }
219
+ result.applied.push({ key, value: raw, field })
220
+ })
221
+
222
+ if (!result.fields.host) {
223
+ result.error = 'no usable host: [virt-viewer] has no "host" key'
224
+ return result
225
+ }
226
+
227
+ // A file that only offers a TLS port has nothing we can dial, because the
228
+ // spice transport here is plain TCP (see src/app/server/spice-proxy.js).
229
+ if (main['tls-port'] && !('port' in main)) {
230
+ result.warnings.push(
231
+ 'this file defines only tls-port, and TLS is not supported here, ' +
232
+ 'so there is no plain port to connect to'
233
+ )
234
+ }
235
+
236
+ // delete-this-file=1 makes remote-viewer unlink the file after loading it
237
+ // (unless VIRT_VIEWER_KEEP_FILE is set). We never delete the user's file;
238
+ // surface it instead so the caller can decide.
239
+ if ('delete-this-file' in main) {
240
+ result.deleteThisFile = BOOL_TRUE.has(
241
+ main['delete-this-file'].trim().toLowerCase()
242
+ )
243
+ if (result.deleteThisFile) {
244
+ result.warnings.push(
245
+ 'the file is marked delete-this-file=1; it was NOT deleted'
246
+ )
247
+ }
248
+ }
249
+
250
+ Object.keys(main).forEach(key => {
251
+ if (key === 'type' || key === 'delete-this-file' || key in FIELD_MAP) {
252
+ return
253
+ }
254
+ const value = main[key]
255
+ if (key in UNSUPPORTED) {
256
+ result.ignored.push({ key, value, reason: UNSUPPORTED[key] })
257
+ } else {
258
+ result.unknown.push({ key, value })
259
+ }
260
+ })
261
+
262
+ if (groups[OVIRT_GROUP]) {
263
+ result.ignored.push({
264
+ key: '[' + OVIRT_GROUP + ']',
265
+ value: Object.keys(groups[OVIRT_GROUP]).join(', '),
266
+ reason: 'oVirt REST integration is not supported'
267
+ })
268
+ }
269
+
270
+ if (result.ignored.length) {
271
+ result.warnings.push(
272
+ result.ignored.length +
273
+ ' option(s) in this file are not supported and were skipped'
274
+ )
275
+ }
276
+
277
+ result.ok = true
278
+ return result
279
+ }
280
+
281
+ /**
282
+ * Turn a parse result into a one-line summary for a UI hint.
283
+ *
284
+ * @param {Object} parsed - result of parseVv
285
+ * @returns {string}
286
+ */
287
+ function describeVv (parsed) {
288
+ if (!parsed || !parsed.ok) {
289
+ return (parsed && parsed.error) || 'invalid .vv file'
290
+ }
291
+ const { host, port, title, proxy } = parsed.fields
292
+ const bits = ['spice://' + host + (port ? ':' + port : '')]
293
+ if (title) {
294
+ bits.push('"' + title + '"')
295
+ }
296
+ if (proxy) {
297
+ bits.push('via ' + proxy)
298
+ }
299
+ if (parsed.ignored.length) {
300
+ bits.push(parsed.ignored.length + ' option(s) skipped')
301
+ }
302
+ return bits.join(' ')
303
+ }
304
+
305
+ export {
306
+ parseVv,
307
+ parseIni,
308
+ describeVv,
309
+ MAIN_GROUP,
310
+ OVIRT_GROUP,
311
+ FIELD_MAP,
312
+ UNSUPPORTED
313
+ }
@@ -101,9 +101,12 @@ const defaultThemeDarkTerminal = () => {
101
101
  }
102
102
  }
103
103
 
104
+ export const defaultThemeId = 'default'
105
+ export const defaultThemeLightId = 'defaultLight'
106
+
104
107
  export function defaultTheme () {
105
108
  return {
106
- id: 'default',
109
+ id: defaultThemeId,
107
110
  name: 'default',
108
111
  themeConfig: defaultThemeDarkTerminal(),
109
112
  uiThemeConfig: defaultThemeDark()
@@ -112,7 +115,7 @@ export function defaultTheme () {
112
115
 
113
116
  export function defaultThemeLight () {
114
117
  return {
115
- id: 'defaultLight',
118
+ id: defaultThemeLightId,
116
119
  name: 'default light',
117
120
  themeConfig: defaultThemeLightTerminal(),
118
121
  uiThemeConfig: defaultThemeLightFunc()
@@ -23,7 +23,7 @@ async function fetchData (url, options) {
23
23
 
24
24
  function getInfo (url) {
25
25
  const n = Date.now()
26
- const tail = url.includes('?') ? '' : '?_=' + n
26
+ const tail = url.includes('?') ? '&_=' + n : '?_=' + n
27
27
  return fetchData(url + tail, {
28
28
  action: 'get-update-info',
29
29
  headers: {
@@ -87,16 +87,17 @@ export function getDownloadUrl (browserDownloadUrl, mirror) {
87
87
  }
88
88
 
89
89
  export async function getLatestReleaseInfo () {
90
- let url = `${baseUpdateCheckUrls[0]}/data/electerm-github-release.json`
90
+ const { installSrc } = window.store
91
+ const src = installSrc ? '?src=' + encodeURIComponent(installSrc) : ''
92
+ let url = `${baseUpdateCheckUrls[0]}/data/electerm-github-release.json${src}`
91
93
  let res = await getInfo(url)
92
94
  if (!res?.release?.body) {
93
- url = `${baseUpdateCheckUrls[1]}/data/electerm-github-release.json`
95
+ url = `${baseUpdateCheckUrls[1]}/data/electerm-github-release.json${src}`
94
96
  res = await getInfo(url)
95
97
  }
96
98
  if (!res || !res.release) {
97
99
  return undefined
98
100
  }
99
- const { installSrc } = window.store
100
101
  const asset = (res.release.assets || []).find(r => r.name.includes(installSrc))
101
102
  return {
102
103
  body: res.release.body,
@@ -1,4 +1,13 @@
1
1
  export const defaultAIPresets = [
2
+ {
3
+ id: 'fluxionai',
4
+ nameAI: 'FluxionAI',
5
+ baseURLAI: 'https://fluxionai.world/v1',
6
+ apiPathAI: '/chat/completions',
7
+ modelAI: 'deepseek-v4-flash-0731',
8
+ authHeaderNameAI: 'Authorization: Bearer',
9
+ siteUrl: 'https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm'
10
+ },
2
11
  {
3
12
  id: 'atlascloud',
4
13
  nameAI: 'AtlasCloud',
@@ -166,8 +175,10 @@ export const defaultAIPresets = [
166
175
 
167
176
  export function getAIPresets () {
168
177
  const presets = [...defaultAIPresets]
169
- if (window.et?.defaultAIPreset) {
170
- presets.unshift(window.et.defaultAIPreset)
178
+ const customPreset = window.et?.defaultAIPreset
179
+ if (customPreset) {
180
+ // keep the first preset (sponsor) on top, insert after it
181
+ presets.splice(1, 0, customPreset)
171
182
  }
172
183
  return presets
173
184
  }
@@ -93,10 +93,25 @@ async function createStyleForTab (tab, props) {
93
93
  'background-image: none'
94
94
  )
95
95
  }
96
- } else if (st !== 'none') {
96
+ } else if (st === 'none') {
97
+ // The [🚫] opt-out must be spelled out: this rule has a higher specificity
98
+ // than the global one, but an empty declaration block would still let the
99
+ // built-in default bg painted by createGlobalStyle show through.
100
+ styles.push('background-image: none')
101
+ } else {
97
102
  styles.push(
98
103
  `background-image: ${st}`,
99
104
  'background-position: center',
105
+ // createGlobalStyle's built-in default bg rule carries an auto-fit
106
+ // `background-size` (so the 766px wide watermark is not cropped on
107
+ // narrow panes), and for the active tab it targets this very same
108
+ // ::before. `background-size` is not reset by a rule that only sets
109
+ // `background-image`, so it would leak onto the user's image and scale
110
+ // it down to at most 766px, centred — a big image that used to fill the
111
+ // pane would no longer cover it. Stating the initial value keeps user
112
+ // images at their natural size, which is what they rendered at before
113
+ // the auto-fit was introduced.
114
+ 'background-size: auto',
100
115
  `filter: ${createFilterStyle(props, tab)}`
101
116
  )
102
117
  }
@@ -20,6 +20,7 @@ import TriggersField from '../../triggers/triggers-field.jsx'
20
20
  import ProfileItem from './profile-item.jsx'
21
21
  import renderRunScripts from './run-scripts.jsx'
22
22
  import SerialPathSelector from './serial-path-selector.jsx'
23
+ import VvFileField from './vv-file-field.jsx'
23
24
  import SshHostSelector from './ssh-host-selector.jsx'
24
25
  import SshAuthTypeSelector from './ssh-auth-type-selector.jsx'
25
26
  import SshAuthSelector from './ssh-auth-selector.jsx'
@@ -150,6 +151,8 @@ export function renderFormItem (item, formItemLayout, form, ctxProps, index) {
150
151
  )
151
152
  case 'proxy':
152
153
  return <ProxyField key={name} bookmarks={ctxProps.bookmarks} />
154
+ case 'vvFile':
155
+ return <VvFileField key={name} form={form} />
153
156
  case 'x11':
154
157
  return <X11Field key={name} form={form} />
155
158
  case 'sshTunnels':
@@ -0,0 +1,137 @@
1
+ /**
2
+ * bookmark form - load a .vv (virt-viewer) connection file
3
+ *
4
+ * Registered as field type 'vvFile' in ./fields.jsx and used by
5
+ * ../config/spice.js. Fills host/port/password/title/proxy from the file and
6
+ * reports anything it could not honour instead of dropping it silently.
7
+ */
8
+ import { useState } from 'react'
9
+ import {
10
+ Form,
11
+ Button,
12
+ Alert
13
+ } from 'antd'
14
+ import { FileAddOutlined } from '@ant-design/icons'
15
+ import fs from '../../../common/fs'
16
+ import { tailFormItemLayout } from '../../../common/form-layout'
17
+ import { parseVv, describeVv } from '../../../common/parse-vv'
18
+
19
+ const FormItem = Form.Item
20
+
21
+ /**
22
+ * window.translate echoes an unknown key back capitalised, so an unmapped
23
+ * 'loadVvFile' would render as "LoadVvFile". The key IS in
24
+ * ~/dev/electerm-locales, but the app consumes the published
25
+ * @electerm/electerm-locales, so it stays unknown until that ships and the dep
26
+ * is bumped. Fall back to the literal, which is what en_us resolves to anyway
27
+ * (capitalizeFirstLetter('load .vv file')), so nothing changes visually when
28
+ * the translation does land.
29
+ */
30
+ function vvLabel () {
31
+ const t = window.translate('loadVvFile')
32
+ return t === 'LoadVvFile' ? 'Load .vv file' : t
33
+ }
34
+
35
+ /**
36
+ * Open the native file dialog, filtered to .vv files.
37
+ * window.api.openDialog is dialog.showOpenDialogSync in the main process
38
+ * (see src/app/lib/ipc.js), so the full Electron options object is accepted.
39
+ * @returns {Promise<string|null>} selected path, or null when cancelled
40
+ */
41
+ async function pickVvFile () {
42
+ const files = await window.api.openDialog({
43
+ title: 'Choose a .vv connection file',
44
+ message: 'Choose a .vv connection file',
45
+ properties: [
46
+ 'openFile',
47
+ 'showHiddenFiles',
48
+ 'noResolveAliases',
49
+ 'treatPackageAsDirectory',
50
+ 'dontAddToRecent'
51
+ ],
52
+ filters: [
53
+ { name: 'Virt Viewer connection file', extensions: ['vv'] },
54
+ { name: 'All files', extensions: ['*'] }
55
+ ]
56
+ }).catch(() => false)
57
+ if (!files || !files.length) {
58
+ return null
59
+ }
60
+ return files[0]
61
+ }
62
+
63
+ export default function VvFileField ({ form }) {
64
+ const [hint, setHint] = useState(null)
65
+ const [loading, setLoading] = useState(false)
66
+
67
+ const load = async () => {
68
+ setLoading(true)
69
+ setHint(null)
70
+ try {
71
+ const filePath = await pickVvFile()
72
+ if (!filePath) {
73
+ return
74
+ }
75
+ // fs.readFile resolves the file as a utf8 string (src/app/lib/fs.js)
76
+ const text = await fs.readFile(filePath)
77
+ const parsed = parseVv(text)
78
+
79
+ if (!parsed.ok) {
80
+ setHint({ type: 'error', text: describeVv(parsed) })
81
+ return
82
+ }
83
+
84
+ // Only push the keys the file actually defined, so loading a .vv on top
85
+ // of a partly filled form does not wipe the other fields.
86
+ form.setFieldsValue(parsed.fields)
87
+
88
+ const notes = parsed.warnings.slice()
89
+ if (parsed.ignored.length) {
90
+ notes.push(
91
+ 'skipped: ' + parsed.ignored.map(d => d.key).join(', ')
92
+ )
93
+ }
94
+ if (parsed.unknown.length) {
95
+ notes.push(
96
+ 'unrecognised: ' + parsed.unknown.map(d => d.key).join(', ')
97
+ )
98
+ }
99
+
100
+ const summary = describeVv(parsed)
101
+ setHint({
102
+ type: notes.length ? 'warning' : 'success',
103
+ text: summary + (notes.length ? ' -- ' + notes.join('; ') : '')
104
+ })
105
+ } catch (err) {
106
+ setHint({ type: 'error', text: 'cannot read file: ' + err.message })
107
+ } finally {
108
+ setLoading(false)
109
+ }
110
+ }
111
+
112
+ return (
113
+ <FormItem
114
+ {...tailFormItemLayout}
115
+ >
116
+ <Button
117
+ icon={<FileAddOutlined />}
118
+ loading={loading}
119
+ onClick={load}
120
+ >
121
+ {vvLabel()}
122
+ </Button>
123
+ {
124
+ hint && (
125
+ <Alert
126
+ className='mg1t'
127
+ type={hint.type}
128
+ title={hint.text}
129
+ showIcon
130
+ closable
131
+ onClose={() => setHint(null)}
132
+ />
133
+ )
134
+ }
135
+ </FormItem>
136
+ )
137
+ }
@@ -26,6 +26,7 @@ const spiceConfig = {
26
26
  fields: [
27
27
  commonFields.category,
28
28
  commonFields.colorTitle,
29
+ { type: 'vvFile', name: '__vv__', label: '' },
29
30
  { type: 'input', name: 'host', label: () => e('host'), rules: [{ required: true, message: e('host') + ' required' }] },
30
31
  commonFields.port,
31
32
  { type: 'switch', name: 'viewOnly', label: () => e('viewOnly'), valuePropName: 'checked' },