dsh-workspace-plus 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * dsh-workspace-plus/notice — choosing and building the click launcher.
3
+ *
4
+ * A click on a notification has to leave the host process: Windows resolves the
5
+ * toast's protocol activation by running the command line this plugin wrote under
6
+ * `HKCU\Software\Classes\<scheme>\shell\open\command`, and that command line can
7
+ * only name an executable. Three launch strategies exist, and this module is the
8
+ * one place that decides between them:
9
+ *
10
+ * 'helper' a small compiled C# program shipped as source
11
+ * (`click-host.cs`) and built on first use with the .NET
12
+ * Framework compiler that every Windows install carries.
13
+ * It is a GUI-subsystem executable, so Windows never
14
+ * creates a console or a terminal host for it — which is
15
+ * the whole point. The PowerShell launcher used to flash
16
+ * a full Windows Terminal window for seconds on machines
17
+ * where Windows Terminal is the default terminal.
18
+ * 'windowless-powershell' the PowerShell fallback, started through
19
+ * `conhost.exe --headless` so the console host that would
20
+ * otherwise be spawned by the default terminal is asked
21
+ * for no window at all.
22
+ * 'powershell' the original plain `powershell.exe -WindowStyle Hidden`
23
+ * command, kept as the last resort for a machine without
24
+ * a usable compiler or conhost.
25
+ *
26
+ * Building the helper must never be a hard dependency of the plugin: a locked-down
27
+ * or broken .NET installation may have no compiler, and notifications must keep
28
+ * working. So the build is attempted lazily, its failure is a Chinese message
29
+ * rather than an exception, and `selectLauncher` falls back instead of failing.
30
+ *
31
+ * The module also owns the exact command-line shapes. Every argument is quoted,
32
+ * because `%1` is replaced by Windows with the activated URL and a toast launch
33
+ * string may legitimately contain `&`; an unquoted argument would split it.
34
+ *
35
+ * @module dsh-workspace-plus/notice/launcher
36
+ */
37
+
38
+ import { spawn } from 'node:child_process'
39
+ import {
40
+ existsSync,
41
+ mkdirSync,
42
+ readFileSync,
43
+ renameSync,
44
+ rmSync,
45
+ statSync,
46
+ writeFileSync,
47
+ } from 'node:fs'
48
+ import { dirname, join, win32 as pathWin32 } from 'node:path'
49
+ import { fileURLToPath } from 'node:url'
50
+ import { dshHome } from './settings.js'
51
+ import { conhostPath, powershellPath, protocolCommand } from './win32.js'
52
+
53
+ /**
54
+ * Version of `click-host.cs`.
55
+ *
56
+ * The compiled executable lives in the user's DSH home rather than beside the
57
+ * source, because the plugin directory may be read-only (an installed bundle) and
58
+ * because a rebuild must not race an install. A file timestamp alone therefore
59
+ * cannot prove that an existing executable matches the shipped source (a copied
60
+ * or restored file can look newer than the source), so a sidecar file records
61
+ * this version and a mismatch forces a rebuild. Bump it whenever
62
+ * `click-host.cs` changes.
63
+ */
64
+ export const CLICK_HOST_VERSION = '1.2.0'
65
+
66
+ /** Directory of this module on disk. */
67
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
68
+
69
+ /** Absolute path of the C# source the helper is built from. */
70
+ export const CLICK_HOST_SOURCE = join(MODULE_DIR, 'click-host.cs')
71
+
72
+ /** Absolute path of the PowerShell fallback launcher. */
73
+ export const CLICK_SCRIPT = join(MODULE_DIR, 'click.ps1')
74
+
75
+ /** How long one `csc.exe` invocation may take before it is treated as hung. */
76
+ const COMPILE_TIMEOUT_MS = 30_000
77
+
78
+ /** How long the freshly built helper's `--selftest` run may take. */
79
+ const SELFTEST_TIMEOUT_MS = 10_000
80
+
81
+ /** File name of the version sidecar written next to the built executable. */
82
+ const VERSION_SUFFIX = '.version'
83
+
84
+ /**
85
+ * Absolute path of the compiled click helper inside the DSH home.
86
+ *
87
+ * The executable is per-user runtime state, not a shipped asset: it belongs with
88
+ * the settings and the toast daemon bookkeeping under `$DSH_HOME/notice`.
89
+ *
90
+ * @param env - environment snapshot (injectable for tests).
91
+ * @returns the executable path, whether or not it has been built yet.
92
+ */
93
+ export function clickHostExePath(env = process.env) {
94
+ return join(dshHome(env), 'notice', 'click-host.exe')
95
+ }
96
+
97
+ /**
98
+ * Locate the .NET Framework C# compiler shipped with Windows.
99
+ *
100
+ * The Framework 4.0 compiler is used rather than a newer Roslyn because it is
101
+ * present by default on every Windows 10/11 install and needs no SDK, package
102
+ * manager or NuGet cache. The 64-bit directory is preferred and the 32-bit one
103
+ * is the fallback for a machine where only that half of the framework is intact.
104
+ *
105
+ * @param options - environment, platform and filesystem probe overrides.
106
+ * @returns the absolute `csc.exe` path, or null when none exists.
107
+ */
108
+ export function findCsc(options = {}) {
109
+ const { env = process.env, platform = process.platform, exists = existsSync } = options
110
+ if (platform !== 'win32') return null
111
+ const root = typeof env.SystemRoot === 'string' && env.SystemRoot !== '' ? env.SystemRoot : 'C:\\Windows'
112
+ const candidates = [
113
+ pathWin32.join(root, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
114
+ pathWin32.join(root, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe'),
115
+ ]
116
+ for (const candidate of candidates) {
117
+ if (exists(candidate)) return candidate
118
+ }
119
+ return null
120
+ }
121
+
122
+ /**
123
+ * Normalize the focus argument to the helper's `0`/`1` spelling.
124
+ *
125
+ * The registry command is a string, and boolean `false` reaching it as the word
126
+ * "false" would make the helper treat it as truthy; normalizing here keeps the
127
+ * command line unambiguous.
128
+ *
129
+ * @param focus - boolean-ish focus preference.
130
+ * @returns `'1'` or `'0'`.
131
+ */
132
+ function focusFlag(focus) {
133
+ if (focus === false || focus === 0) return '0'
134
+ if (typeof focus === 'string') {
135
+ const text = focus.trim().toLowerCase()
136
+ if (text === '0' || text === 'false' || text === 'off' || text === 'no') return '0'
137
+ }
138
+ return '1'
139
+ }
140
+
141
+ /**
142
+ * The registry command line for the compiled helper.
143
+ *
144
+ * `%1` is Windows' placeholder for the activated URL and is quoted like every
145
+ * other argument: the URL carries the plugin's own query string, whose `&` would
146
+ * otherwise end the argument.
147
+ *
148
+ * @param options - executable, port, token, focus flag and protocol placeholder.
149
+ * @returns the command line to store in the registry.
150
+ */
151
+ export function clickHostCommand(options) {
152
+ const { exe, port, token, focus = true, protocolArgument = '%1' } = options
153
+ return `"${exe}" "${port}" "${token}" "${focusFlag(focus)}" "${protocolArgument}"`
154
+ }
155
+
156
+ /**
157
+ * The registry command line for the windowless PowerShell fallback.
158
+ *
159
+ * `conhost.exe --headless` runs the child console process without a console host
160
+ * window. That is the only reliable way to hide the terminal host: with Windows
161
+ * Terminal as the default terminal, `powershell.exe -WindowStyle Hidden` hides
162
+ * PowerShell's own window but the terminal host it is attached to is a separate
163
+ * window that still appears.
164
+ *
165
+ * @param options - conhost, interpreter, script, scheme, port and token.
166
+ * @returns the command line to store in the registry.
167
+ */
168
+ export function windowlessPowerShellCommand(options) {
169
+ const { conhost, powershell, script, scheme, port, token, protocolArgument = '%1' } = options
170
+ return (
171
+ `"${conhost}" --headless "${powershell}" -NoProfile -NonInteractive -ExecutionPolicy Bypass` +
172
+ ` -File "${script}" -Scheme "${scheme}" -Port ${port} -Token "${token}" "${protocolArgument}"`
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Pick the best launcher for the machine.
178
+ *
179
+ * Pure, so the preference order is testable without a Windows install: a built
180
+ * helper is always best (no console at all), conhost is next (no window), and
181
+ * plain PowerShell is the fallback that works everywhere Windows does.
182
+ *
183
+ * @param options - availability of the helper and of conhost.
184
+ * @returns one of `'helper'`, `'windowless-powershell'`, `'powershell'`.
185
+ */
186
+ export function chooseLauncherKind(options = {}) {
187
+ const { helper, conhost } = options
188
+ if (helper) return 'helper'
189
+ if (conhost) return 'windowless-powershell'
190
+ return 'powershell'
191
+ }
192
+
193
+ /**
194
+ * Run one child process hidden and wait for it, bounded by a timeout.
195
+ *
196
+ * `windowsHide: true` matters here even though both programs involved are console
197
+ * programs: `csc.exe` would otherwise flash a console window, and the selftest is
198
+ * run while the user may be clicking a notification.
199
+ *
200
+ * @param command - executable to run.
201
+ * @param args - argument list.
202
+ * @param options - timeout plus a `spawn` override for tests.
203
+ * @returns `{ ok, code, timedOut, message? }`, never a throw.
204
+ */
205
+ function runProcess(command, args, options = {}) {
206
+ const { timeout = 0, spawn: spawnChild = spawn } = options
207
+ return new Promise((resolve) => {
208
+ let child
209
+ try {
210
+ child = spawnChild(command, args, { windowsHide: true, stdio: 'ignore' })
211
+ } catch (error) {
212
+ resolve({ ok: false, code: -1, timedOut: false, message: String((error && error.message) || error) })
213
+ return
214
+ }
215
+
216
+ let settled = false
217
+ let timer = null
218
+ const finish = (result) => {
219
+ if (settled) return
220
+ settled = true
221
+ if (timer !== null) clearTimeout(timer)
222
+ resolve(result)
223
+ }
224
+
225
+ if (timeout > 0) {
226
+ timer = setTimeout(() => {
227
+ try {
228
+ child.kill()
229
+ } catch {
230
+ // The process may have exited between the timeout and the kill.
231
+ }
232
+ finish({ ok: false, code: -1, timedOut: true, message: `超时(${timeout} 毫秒)` })
233
+ }, timeout)
234
+ }
235
+
236
+ child.on('error', (error) => {
237
+ finish({ ok: false, code: -1, timedOut: false, message: String((error && error.message) || error) })
238
+ })
239
+ child.on('exit', (code) => {
240
+ finish({ ok: code === 0, code: code === null ? -1 : code, timedOut: false })
241
+ })
242
+ })
243
+ }
244
+
245
+ /**
246
+ * Whether an already built helper is current.
247
+ *
248
+ * Three independent facts have to agree, and any doubt means "rebuild": the
249
+ * executable exists, it is not older than the source (a fresh plugin install must
250
+ * not keep a stale binary), and the sidecar names the version shipped by this
251
+ * module (a source edit without a timestamp relationship still bumps the id).
252
+ *
253
+ * @param options - paths plus filesystem probe/stat overrides.
254
+ * @returns true when the existing executable can be used as-is.
255
+ */
256
+ function clickHostIsCurrent(options) {
257
+ const { exePath, source, exists, stat } = options
258
+ if (!exists(exePath)) return false
259
+ let executableStat
260
+ let sourceStat
261
+ try {
262
+ executableStat = stat(exePath)
263
+ sourceStat = stat(source)
264
+ } catch {
265
+ return false
266
+ }
267
+ if (!(executableStat.mtimeMs >= sourceStat.mtimeMs)) return false
268
+ try {
269
+ return readFileSync(`${exePath}${VERSION_SUFFIX}`, 'utf8').trim() === CLICK_HOST_VERSION
270
+ } catch {
271
+ return false
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Build the click helper if the cached executable is missing or stale.
277
+ *
278
+ * The compile is done by `csc.exe` straight from the shipped source — there is no
279
+ * build tooling in this repo, and requiring one would make the plugin unbuildable
280
+ * on a machine that merely runs the Harness. The output is written to a temporary
281
+ * name first and only renamed into place after the binary itself proved it starts
282
+ * (`--selftest`), so a half-written or broken executable can never be picked up by
283
+ * a later registration. A rebuild of an existing executable is slightly less
284
+ * atomic on Windows because rename cannot replace a file; the previous binary is
285
+ * therefore removed immediately before the rename, and any failure leaves the
286
+ * temporary file deleted rather than lying around.
287
+ *
288
+ * Never throws: the caller gets a Chinese message that ends up in the host log or
289
+ * the settings page, and the launcher selection falls back to PowerShell.
290
+ *
291
+ * @param options - paths, compiler, filesystem and process overrides for tests.
292
+ * @returns `{ ok, exe, built, message }`.
293
+ */
294
+ export async function ensureClickHost(options = {}) {
295
+ const {
296
+ env = process.env,
297
+ platform = process.platform,
298
+ source = CLICK_HOST_SOURCE,
299
+ exePath = clickHostExePath(env),
300
+ csc,
301
+ exists = existsSync,
302
+ stat = statSync,
303
+ force = false,
304
+ spawn: spawnChild,
305
+ run = runProcess,
306
+ } = options
307
+
308
+ const failed = (message) => ({ ok: false, exe: exePath, built: false, message })
309
+
310
+ try {
311
+ if (platform !== 'win32') return failed('仅 Windows 需要编译点击助手')
312
+
313
+ if (force !== true && clickHostIsCurrent({ exePath, source, exists, stat })) {
314
+ return { ok: true, exe: exePath, built: false, message: '点击助手已是最新,无需编译' }
315
+ }
316
+
317
+ const compiler = csc === undefined ? findCsc({ env, platform, exists }) : csc
318
+ if (typeof compiler !== 'string' || compiler === '' || !exists(compiler)) {
319
+ return failed('未找到 .NET Framework 4.0 的 csc.exe,无法编译点击助手')
320
+ }
321
+ if (!exists(source)) return failed(`缺少点击助手源码:${source}`)
322
+
323
+ try {
324
+ mkdirSync(dirname(exePath), { recursive: true })
325
+ } catch (error) {
326
+ return failed(`无法创建点击助手目录:${String((error && error.message) || error)}`)
327
+ }
328
+
329
+ // The temporary name stays in the target directory: a rename across
330
+ // directories is not atomic, and the DSH home may sit on another volume.
331
+ const temporary = `${exePath}.${process.pid}.${Date.now()}.tmp`
332
+ const runOptions = spawnChild === undefined ? {} : { spawn: spawnChild }
333
+ const startedAt = Date.now()
334
+
335
+ try {
336
+ // The source file must come after the options: csc.exe's command line is
337
+ // order-sensitive and rejects `/out` and `/target` once it has seen an
338
+ // input file (CS2022), so the flags are kept together in front.
339
+ const build = await run(
340
+ compiler,
341
+ ['/nologo', '/target:winexe', '/optimize+', `/out:${temporary}`, source],
342
+ { ...runOptions, timeout: COMPILE_TIMEOUT_MS },
343
+ )
344
+ if (build === null || build === undefined || build.ok !== true) {
345
+ const reason = build === null || build === undefined ? '没有返回结果' : String(build.message ?? `csc 退出码 ${build.code}`)
346
+ return failed(`点击助手编译失败:${reason}`)
347
+ }
348
+
349
+ // A binary that cannot even start is worse than no binary: the protocol
350
+ // registration would point at something that silently does nothing, and a
351
+ // later click would never learn why. The exit code is the whole check.
352
+ const selftest = await run(temporary, ['--selftest'], { ...runOptions, timeout: SELFTEST_TIMEOUT_MS })
353
+ if (selftest === null || selftest === undefined || selftest.ok !== true) {
354
+ const reason = selftest === null || selftest === undefined ? '没有返回结果' : String(selftest.message ?? `--selftest 退出码 ${selftest.code}`)
355
+ return failed(`点击助手自检失败:${reason}`)
356
+ }
357
+
358
+ try {
359
+ renameSync(temporary, exePath)
360
+ } catch {
361
+ // Windows will not rename over an existing file. Removing the old binary
362
+ // first is the only option; the window in which neither exists is one
363
+ // filesystem operation long and a click arriving in it simply falls back.
364
+ rmSync(exePath, { force: true })
365
+ renameSync(temporary, exePath)
366
+ }
367
+ } finally {
368
+ // No-op after a successful rename; on every failure path it removes the
369
+ // half-built executable so the next attempt starts clean.
370
+ rmSync(temporary, { force: true })
371
+ }
372
+
373
+ try {
374
+ writeFileSync(`${exePath}${VERSION_SUFFIX}`, `${CLICK_HOST_VERSION}\n`, 'utf8')
375
+ } catch {
376
+ // The executable itself is fine; without the sidecar the next check simply
377
+ // rebuilds it. Not worth failing the registration over.
378
+ }
379
+
380
+ const elapsed = Date.now() - startedAt
381
+ return { ok: true, exe: exePath, built: true, message: `已编译点击助手(${elapsed} 毫秒)` }
382
+ } catch (error) {
383
+ return failed(`点击助手构建失败:${String((error && error.message) || error)}`)
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Decide how a click should be launched on this machine.
389
+ *
390
+ * Always asks `ensureClickHost` first, then chooses the best command from what is
391
+ * actually available. A failed build is not a failed registration: the point of
392
+ * the fallbacks is that a machine without a usable compiler still gets clickable
393
+ * notifications, just with the older console-hosting strategy.
394
+ *
395
+ * @param options - scheme, port, token, focus preference and overrides for tests.
396
+ * @returns `{ ok, kind, command, exe, message }`.
397
+ */
398
+ export async function selectLauncher(options = {}) {
399
+ const {
400
+ scheme,
401
+ port,
402
+ token,
403
+ focus = true,
404
+ env = process.env,
405
+ platform = process.platform,
406
+ powershell = powershellPath(env),
407
+ conhost = conhostPath(env),
408
+ script = CLICK_SCRIPT,
409
+ exists = existsSync,
410
+ stat = statSync,
411
+ csc,
412
+ ensureClickHost: ensure = ensureClickHost,
413
+ } = options
414
+
415
+ const exe = clickHostExePath(env)
416
+ const failed = (message) => ({ ok: false, kind: 'powershell', command: '', exe, message })
417
+
418
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
419
+ return failed('尚未获知 Harness 监听端口,无法生成点击命令')
420
+ }
421
+
422
+ let buildMessage = ''
423
+ let helperAvailable = false
424
+ try {
425
+ const ensured = await ensure({ env, platform, source: CLICK_HOST_SOURCE, exePath: exe, csc, exists, stat })
426
+ helperAvailable = ensured !== null && ensured !== undefined && ensured.ok === true
427
+ buildMessage = String(ensured?.message ?? '')
428
+ } catch (error) {
429
+ // A broken build path must degrade, not abort the registration.
430
+ buildMessage = `点击助手不可用:${String((error && error.message) || error)}`
431
+ }
432
+
433
+ // conhost is only meaningful on Windows, and only when the file is really
434
+ // there: a command naming a missing executable would make every click do
435
+ // nothing, which is worse than a visible console window.
436
+ const conhostAvailable =
437
+ platform === 'win32' && typeof conhost === 'string' && conhost !== '' && exists(conhost)
438
+
439
+ const kind = chooseLauncherKind({ helper: helperAvailable, conhost: conhostAvailable })
440
+ const commandOptions = { port, token, focus }
441
+ let command
442
+ let message
443
+ if (kind === 'helper') {
444
+ command = clickHostCommand({ ...commandOptions, exe })
445
+ message = '点击通知将直接唤起 Harness 窗口(编译版点击助手,无控制台窗口)'
446
+ } else if (kind === 'windowless-powershell') {
447
+ command = windowlessPowerShellCommand({ ...commandOptions, conhost, powershell, script, scheme })
448
+ message = `点击助手不可用(${buildMessage === '' ? '未知原因' : buildMessage}),改用 conhost --headless 隐藏控制台的 PowerShell 启动器`
449
+ } else {
450
+ command = protocolCommand({ powershell, script, scheme, port, token })
451
+ message = `点击助手与 conhost 均不可用(${buildMessage === '' ? '未知原因' : buildMessage}),回退到普通 PowerShell 启动器(可能短暂出现控制台窗口)`
452
+ }
453
+
454
+ return { ok: true, kind, command, exe, message }
455
+ }
@@ -13,9 +13,14 @@
13
13
  * on every Windows install;
14
14
  * 2. the `dsh-notice:` URL protocol, registered under HKCU (no administrator
15
15
  * rights), which is what turns a click on a toast into a request against
16
- * this plugin's own loopback route;
16
+ * this plugin's own loopback route. The command line it stores may name the
17
+ * compiled click helper (`click-host.exe`, a GUI-subsystem program that
18
+ * never gets a console), the windowless `conhost.exe --headless` PowerShell
19
+ * launcher, or this module's plain PowerShell fallback command;
17
20
  * 3. focusing the desktop window, so a click that arrives while the window is
18
- * minimized still lands somewhere the user can see.
21
+ * minimized or hidden to the tray still lands somewhere the user can see.
22
+ * The compiled helper does this itself through the Win32 calls in
23
+ * `click-host.cs`; the PowerShell fallback still shells out to `dsh://open`.
19
24
  *
20
25
  * @module dsh-workspace-plus/notice/win32
21
26
  */
@@ -62,6 +67,25 @@ export function powershellPath(env = process.env) {
62
67
  return pathWin32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
63
68
  }
64
69
 
70
+ /**
71
+ * Absolute path of `conhost.exe`, the console host Windows itself ships.
72
+ *
73
+ * This exists for one purpose: running the PowerShell launcher without a visible
74
+ * terminal. When Windows Terminal is the default terminal, `powershell.exe
75
+ * -WindowStyle Hidden` still gets a Windows Terminal window for as long as the
76
+ * script runs, because the terminal host is a separate process from PowerShell.
77
+ * `conhost.exe --headless` starts the console program with no console host window
78
+ * at all. The System32 path is used rather than `%WINDIR%` spelling so the value
79
+ * matches what the shell would launch.
80
+ *
81
+ * @param env - environment snapshot (injectable for tests).
82
+ * @returns the absolute console host path.
83
+ */
84
+ export function conhostPath(env = process.env) {
85
+ const root = typeof env.SystemRoot === 'string' && env.SystemRoot !== '' ? env.SystemRoot : 'C:\\Windows'
86
+ return pathWin32.join(root, 'System32', 'conhost.exe')
87
+ }
88
+
65
89
  /**
66
90
  * Whether this process can show Windows toasts at all.
67
91
  *
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="dsh-sessionlog">
2
+ <!-- A log document with an arrow leaving the machine: the plugin's whole job
3
+ is handing the canonical session log to DeepSeek. -->
4
+ <defs>
5
+ <linearGradient id="page" x1="0" y1="0" x2="0" y2="1">
6
+ <stop offset="0" stop-color="#4d8dff"/>
7
+ <stop offset="1" stop-color="#2470e8"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <rect width="64" height="64" rx="14" fill="#0f1420"/>
11
+ <path d="M18 11h18l10 10v32a2 2 0 0 1-2 2H18a2 2 0 0 1-2-2V13a2 2 0 0 1 2-2Z" fill="url(#page)"/>
12
+ <path d="M36 11v8a2 2 0 0 0 2 2h8Z" fill="#8fb6ff"/>
13
+ <g fill="#0f1420" opacity=".62">
14
+ <rect x="21" y="26" width="16" height="2.6" rx="1.3"/>
15
+ <rect x="21" y="32" width="22" height="2.6" rx="1.3"/>
16
+ <rect x="21" y="38" width="12" height="2.6" rx="1.3"/>
17
+ </g>
18
+ <path d="M43 41.5h9.5m0 0-4-4m4 4-4 4" stroke="#39d98a" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
19
+ </svg>