@lenorin/dsh-tauri-launcher 1.0.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/cordis.patch.yml +7 -0
  4. package/launcher/README.md +56 -0
  5. package/launcher/bin/dsh-launcher.exe +0 -0
  6. package/launcher/build.ps1 +44 -0
  7. package/launcher/src-tauri/Cargo.toml +20 -0
  8. package/launcher/src-tauri/build.rs +3 -0
  9. package/launcher/src-tauri/capabilities/default.json +9 -0
  10. package/launcher/src-tauri/icons/128x128.png +0 -0
  11. package/launcher/src-tauri/icons/128x128@2x.png +0 -0
  12. package/launcher/src-tauri/icons/32x32.png +0 -0
  13. package/launcher/src-tauri/icons/Square107x107Logo.png +0 -0
  14. package/launcher/src-tauri/icons/Square142x142Logo.png +0 -0
  15. package/launcher/src-tauri/icons/Square150x150Logo.png +0 -0
  16. package/launcher/src-tauri/icons/Square284x284Logo.png +0 -0
  17. package/launcher/src-tauri/icons/Square30x30Logo.png +0 -0
  18. package/launcher/src-tauri/icons/Square310x310Logo.png +0 -0
  19. package/launcher/src-tauri/icons/Square44x44Logo.png +0 -0
  20. package/launcher/src-tauri/icons/Square71x71Logo.png +0 -0
  21. package/launcher/src-tauri/icons/Square89x89Logo.png +0 -0
  22. package/launcher/src-tauri/icons/StoreLogo.png +0 -0
  23. package/launcher/src-tauri/icons/icon.icns +0 -0
  24. package/launcher/src-tauri/icons/icon.ico +0 -0
  25. package/launcher/src-tauri/icons/icon.png +0 -0
  26. package/launcher/src-tauri/src/dsh.rs +286 -0
  27. package/launcher/src-tauri/src/lib.rs +727 -0
  28. package/launcher/src-tauri/src/main.rs +6 -0
  29. package/launcher/src-tauri/tauri.conf.json +38 -0
  30. package/launcher/ui/index.html +64 -0
  31. package/launcher/ui/main.js +122 -0
  32. package/launcher/ui/settings.css +157 -0
  33. package/launcher/ui/settings.html +71 -0
  34. package/launcher/ui/settings.js +83 -0
  35. package/launcher/ui/styles.css +212 -0
  36. package/lib/client.js +241 -0
  37. package/lib/index.js +445 -0
  38. package/package.json +28 -0
package/lib/index.js ADDED
@@ -0,0 +1,445 @@
1
+ /**
2
+ * dsh-tauri-launcher — host half.
3
+ *
4
+ * 在 DSH Web 设置中启动/退出本机 DeepSeek Harness Tauri 桌面应用,并联动
5
+ * 桌面快捷方式(开启自动创建、关闭自动删除、可手动补建)。与桌面应用的
6
+ * 协作协议(launcher exe 同目录):
7
+ * - `.dsh-heartbeat`:桌面应用每秒写入的 Unix 时间戳,freshSecs 秒内新鲜视为运行中;
8
+ * - `.dsh-quit`:内容 `1` 且 60 秒内新鲜 → 桌面应用仅退出自身(消费时自删)。
9
+ *
10
+ * 浏览器侧通过本插件注册的 /api/dsh-tauri-launcher/* 路由通信(仅回环)。
11
+ *
12
+ * 可调参数全部走行配置(见 apply 的 config 处理与 README「配置」章节);
13
+ * 内置候选目录只是默认值,供未配置时自动探测。
14
+ */
15
+
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ export const name = 'desktop-launcher'
19
+
20
+ /** 路由注册与文件/进程操作所需的主机服务(声明后加载器会等待就绪再 apply)。 */
21
+ export const inject = ['webServer', 'fs', 'subprocess']
22
+
23
+ const STDIO = { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }
24
+
25
+ /** 本包随附的预编译桌面应用 exe 目录(无论安装到哪个 profile 都能定位)。 */
26
+ const PACKAGE_BIN = fileURLToPath(new URL('../launcher/bin', import.meta.url))
27
+
28
+ /** 未配置 launcherDirs 时的默认候选目录(包内 exe 优先,本机常见布局兜底,可被行配置覆盖)。 */
29
+ const DEFAULT_DIRS = [
30
+ PACKAGE_BIN,
31
+ 'F:\\DeepSeek Harness\\DeepSeek Harness Tauri\\dsh-launcher\\src-tauri\\target\\release',
32
+ 'F:\\DeepSeek Harness\\DeepSeek Harness Tauri\\dsh-launcher\\src-tauri\\target\\debug',
33
+ ]
34
+
35
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
36
+ const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'"
37
+
38
+ function writeJson(res, status, body) {
39
+ const payload = JSON.stringify(body)
40
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
41
+ res.end(payload)
42
+ }
43
+
44
+ async function readJsonBody(req) {
45
+ const chunks = []
46
+ for await (const chunk of req) chunks.push(chunk)
47
+ try {
48
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
49
+ return typeof parsed === 'object' && parsed !== null ? parsed : {}
50
+ } catch {
51
+ return {}
52
+ }
53
+ }
54
+
55
+ /** 仅允许本机回环请求(与 dsh-ssh 相同的信任边界)。 */
56
+ function isLoopback(req) {
57
+ const address = String(req.socket.remoteAddress ?? '')
58
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
59
+ }
60
+
61
+ export function apply(ctx, config) {
62
+ // 注入的服务由加载器保证在 apply 前就绪(不能在 apply 时用 ctx.get 提前
63
+ // 捕获未就绪的服务,否则会永久拿到 undefined)。
64
+ const fs = ctx.fs
65
+ const subprocess = ctx.subprocess
66
+ const sandboxPolicy = ctx.get('sandboxPolicy')
67
+
68
+ const resolved = {
69
+ /** 直接指定桌面应用 exe 路径;空则按 launcherDirs/内置候选自动探测。 */
70
+ launcherExe: config && typeof config.launcherExe === 'string' ? config.launcherExe : '',
71
+ /** 候选 exe 目录列表;为空时使用内置默认候选。 */
72
+ launcherDirs: Array.isArray(config && config.launcherDirs) ? config.launcherDirs.filter((d) => typeof d === 'string') : [],
73
+ /** 心跳“新鲜窗口”(秒)。桌面应用每秒写心跳,默认 4 秒即 4 个周期的余量。 */
74
+ freshSecs: config && typeof config.freshSecs === 'number' && config.freshSecs > 1 ? config.freshSecs : 4,
75
+ /** 桌面快捷方式的文件名。 */
76
+ shortcutName: config && typeof config.shortcutName === 'string' && config.shortcutName !== '' ? config.shortcutName : 'DeepSeek Harness.lnk',
77
+ }
78
+
79
+ let lastFsError = ''
80
+ let lastMarkerError = ''
81
+ let spawnHandle = null
82
+ let shortcutCache = { t: 0, value: false }
83
+
84
+ function baseDirs() {
85
+ const list = resolved.launcherDirs.slice()
86
+ if (resolved.launcherExe) {
87
+ const slash = resolved.launcherExe.lastIndexOf('\\')
88
+ if (slash > 0) list.push(resolved.launcherExe.slice(0, slash))
89
+ }
90
+ for (const dir of DEFAULT_DIRS) list.push(dir)
91
+ const root = sandboxPolicy && sandboxPolicy.workspaceRoot
92
+ if (root) {
93
+ const r = String(root).replace(/[\\/]+$/, '')
94
+ list.push(r + '\\dsh-launcher\\src-tauri\\target\\release')
95
+ list.push(r + '\\dsh-launcher\\src-tauri\\target\\debug')
96
+ }
97
+ return [...new Set(list)]
98
+ }
99
+
100
+ async function exeExists(dir) {
101
+ if (!fs) return false
102
+ try {
103
+ const target = await fs.resolve(dir + '\\dsh-launcher.exe')
104
+ const info = await fs.stat(target)
105
+ return Boolean(info && info.type === 'file')
106
+ } catch (error) {
107
+ lastFsError = String((error && error.message) || error)
108
+ return false
109
+ }
110
+ }
111
+
112
+ async function exeDirs() {
113
+ const dirs = []
114
+ for (const dir of baseDirs()) {
115
+ if (await exeExists(dir)) dirs.push(dir)
116
+ }
117
+ return dirs
118
+ }
119
+
120
+ async function fileText(dir, name) {
121
+ if (!fs) return null
122
+ try {
123
+ const target = await fs.resolve(dir + '\\' + name)
124
+ const info = await fs.stat(target)
125
+ if (!info) return null
126
+ const text = await fs.readText(target)
127
+ return String(text).trim()
128
+ } catch (error) {
129
+ lastFsError = String((error && error.message) || error)
130
+ return null
131
+ }
132
+ }
133
+
134
+ async function hbState(dir) {
135
+ const text = await fileText(dir, '.dsh-heartbeat')
136
+ if (text === null || text === '') return null
137
+ const stamp = Number(text)
138
+ if (!Number.isFinite(stamp)) return null
139
+ const age = Math.abs(Date.now() / 1000 - stamp)
140
+ return age < resolved.freshSecs
141
+ }
142
+
143
+ async function isRunning() {
144
+ const dirs = await exeDirs()
145
+ const states = []
146
+ for (const dir of dirs) states.push(await hbState(dir))
147
+ if (states.includes(true)) return true
148
+ if (states.length === 0 || states.every((s) => s === null)) return null
149
+ return false
150
+ }
151
+
152
+ async function waitFresh(seconds) {
153
+ let sawFile = false
154
+ for (let i = 0; i < seconds * 2; i++) {
155
+ const state = await isRunning()
156
+ if (state === true) return true
157
+ if (state === false) sawFile = true
158
+ await sleep(500)
159
+ }
160
+ return sawFile ? false : null
161
+ }
162
+
163
+ async function waitGone(seconds) {
164
+ for (let i = 0; i < seconds * 2; i++) {
165
+ const state = await isRunning()
166
+ if (state === false) return true
167
+ if (state === null) return null
168
+ await sleep(500)
169
+ }
170
+ return false
171
+ }
172
+
173
+ async function pickExe() {
174
+ if (resolved.launcherExe) {
175
+ try {
176
+ const target = await fs.resolve(resolved.launcherExe)
177
+ const info = await fs.stat(target)
178
+ if (info && info.type === 'file') return resolved.launcherExe
179
+ } catch (error) {
180
+ lastFsError = String((error && error.message) || error)
181
+ }
182
+ }
183
+ const dirs = await exeDirs()
184
+ if (dirs.length > 0) return dirs[0] + '\\dsh-launcher.exe'
185
+ return null
186
+ }
187
+
188
+ async function runPowerShell(script, cwd) {
189
+ if (!subprocess) throw new Error('subprocess 服务不可用')
190
+ const handle = subprocess.spawn({
191
+ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
192
+ cwd: cwd || (await pickExe() ? await pickExe() : ''),
193
+ stdio: STDIO,
194
+ graceMs: 3000,
195
+ })
196
+ if (handle && handle.done) await handle.done.catch(() => {})
197
+ }
198
+
199
+ async function runPowerShellExitCode(script, cwd) {
200
+ if (!subprocess) return null
201
+ try {
202
+ const handle = subprocess.spawn({
203
+ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
204
+ cwd: cwd || process.cwd(),
205
+ stdio: STDIO,
206
+ graceMs: 3000,
207
+ })
208
+ const outcome = handle && handle.done ? await handle.done.catch(() => null) : null
209
+ return outcome && typeof outcome.exitCode === 'number' ? outcome.exitCode : null
210
+ } catch (error) {
211
+ lastMarkerError = String((error && error.message) || error)
212
+ return null
213
+ }
214
+ }
215
+
216
+ async function quitScriptFor(value) {
217
+ const exe = await pickExe()
218
+ if (!exe) return null
219
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
220
+ return {
221
+ dir,
222
+ script: "Set-Content -LiteralPath '" + dir + "\\.dsh-quit' -Value '" + value + "' -NoNewline",
223
+ }
224
+ }
225
+
226
+ async function writeQuitMarker(value) {
227
+ const job = await quitScriptFor(value)
228
+ if (!job) {
229
+ lastMarkerError = 'writeQuitMarker: no exe dir'
230
+ return
231
+ }
232
+ try {
233
+ await runPowerShell(job.script, job.dir)
234
+ lastMarkerError = ''
235
+ } catch (error) {
236
+ lastMarkerError = String((error && error.message) || error)
237
+ }
238
+ }
239
+
240
+ function writeQuitMarkerNoWait(value) {
241
+ void quitScriptFor(value).then((job) => {
242
+ if (!job) return
243
+ try {
244
+ subprocess.spawn({
245
+ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', job.script],
246
+ cwd: job.dir,
247
+ stdio: STDIO,
248
+ graceMs: 3000,
249
+ })
250
+ } catch (error) {
251
+ lastMarkerError = String((error && error.message) || error)
252
+ }
253
+ })
254
+ }
255
+
256
+ async function quitFileGone() {
257
+ const exe = await pickExe()
258
+ if (!exe) return true
259
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
260
+ return (await fileText(dir, '.dsh-quit')) === null
261
+ }
262
+
263
+ async function quitMarkerIsOne() {
264
+ const exe = await pickExe()
265
+ if (!exe) return false
266
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
267
+ return (await fileText(dir, '.dsh-quit')) === '1'
268
+ }
269
+
270
+ function shortcutExistsScript() {
271
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + "; if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }"
272
+ }
273
+
274
+ function shortcutCreateScript(exe) {
275
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + "; " +
276
+ "$exe=" + psQuote(exe) + "; " +
277
+ '$ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut($lnk); ' +
278
+ '$sc.TargetPath=$exe; $sc.WorkingDirectory=Split-Path $exe; ' +
279
+ "$sc.IconLocation=($exe + ',0'); $sc.Description=" + psQuote('DeepSeek Harness 桌面启动器') + '; ' +
280
+ '$sc.Save(); if (Test-Path -LiteralPath $lnk) { exit 0 } else { exit 1 }'
281
+ }
282
+
283
+ function shortcutDeleteScript() {
284
+ return "$desktop=[Environment]::GetFolderPath('Desktop'); $lnk=Join-Path $desktop " + psQuote(resolved.shortcutName) + '; ' +
285
+ 'Remove-Item -LiteralPath $lnk -Force -ErrorAction SilentlyContinue; ' +
286
+ 'if (Test-Path -LiteralPath $lnk) { exit 1 } else { exit 0 }'
287
+ }
288
+
289
+ async function shortcutExists() {
290
+ const now = Date.now()
291
+ if (now - shortcutCache.t < 5000) return shortcutCache.value
292
+ const code = await runPowerShellExitCode(shortcutExistsScript(), process.cwd())
293
+ shortcutCache = { t: Date.now(), value: code === 0 }
294
+ return shortcutCache.value
295
+ }
296
+
297
+ async function ensureShortcut() {
298
+ if (await shortcutExists()) return true
299
+ const exe = await pickExe()
300
+ if (!exe) return false
301
+ const code = await runPowerShellExitCode(shortcutCreateScript(exe), process.cwd())
302
+ shortcutCache = { t: Date.now(), value: code === 0 }
303
+ return code === 0
304
+ }
305
+
306
+ async function removeShortcut() {
307
+ if (!(await shortcutExists())) return true
308
+ const code = await runPowerShellExitCode(shortcutDeleteScript(), process.cwd())
309
+ shortcutCache = { t: Date.now(), value: code !== 0 }
310
+ return code === 0
311
+ }
312
+
313
+ async function buildDiag() {
314
+ const lines = []
315
+ lines.push('services: fs=' + !!fs + ' subprocess=' + !!subprocess + ' sandboxPolicy=' + !!sandboxPolicy)
316
+ lines.push('workspaceRoot: ' + (sandboxPolicy ? sandboxPolicy.workspaceRoot : '(none)'))
317
+ lines.push('freshWindowSecs: ' + resolved.freshSecs)
318
+ lines.push('launcherExe: ' + (resolved.launcherExe || '(auto)'))
319
+ lines.push('shortcut: ' + (await shortcutExists()))
320
+ const dirs = await exeDirs()
321
+ lines.push('exeDirs: ' + JSON.stringify(dirs))
322
+ const exe = await pickExe()
323
+ lines.push('pickExe: ' + (exe || '(null)'))
324
+ if (lastFsError) lines.push('fsError: ' + lastFsError)
325
+ lines.push('markerWrite: ' + (lastMarkerError || 'ok'))
326
+ for (const dir of dirs) {
327
+ lines.push('heartbeat(' + dir.split('\\').slice(-2).join('\\') + '): ' + String(await hbState(dir)))
328
+ }
329
+ if (dirs.length === 0) lines.push('heartbeat: (no exe dirs)')
330
+ return lines.join('\n')
331
+ }
332
+
333
+ async function getState() {
334
+ const running = await isRunning()
335
+ const exe = await pickExe()
336
+ const shortcut = await shortcutExists()
337
+ const diag = await buildDiag()
338
+ return { ok: true, desktop: running, shortcut, exe: exe || null, diag }
339
+ }
340
+
341
+ async function setDesktop(enabled) {
342
+ if (enabled) {
343
+ if (await quitMarkerIsOne()) await writeQuitMarker('0')
344
+ const exe = await pickExe()
345
+ if (!exe) {
346
+ return { ok: false, error: '未找到桌面应用可执行文件(可通过行配置 launcherExe 或 launcherDirs 指定)。', diag: await buildDiag() }
347
+ }
348
+ if (!subprocess) {
349
+ return { ok: false, error: 'subprocess 服务不可用,无法启动桌面应用。', diag: await buildDiag() }
350
+ }
351
+ try {
352
+ const dir = exe.slice(0, exe.lastIndexOf('\\'))
353
+ spawnHandle = subprocess.spawn({ argv: [exe], cwd: dir, stdio: STDIO, graceMs: 3000 })
354
+ } catch (error) {
355
+ return { ok: false, error: '启动桌面应用失败:' + String((error && error.message) || error), diag: await buildDiag() }
356
+ }
357
+ const state = await waitFresh(20)
358
+ if (state === true) {
359
+ if (!(await ensureShortcut())) lastMarkerError = 'shortcut sync failed (create)'
360
+ return { ok: true, desktop: true, shortcut: await shortcutExists(), diag: await buildDiag() }
361
+ }
362
+ if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: await buildDiag() }
363
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
364
+ }
365
+
366
+ const owned = spawnHandle
367
+ spawnHandle = null
368
+ if (owned) {
369
+ try { owned.terminate() } catch {}
370
+ }
371
+ await writeQuitMarker('1')
372
+ let confirmed = false
373
+ for (let i = 0; i < 12; i++) {
374
+ if (await quitFileGone()) { confirmed = true; break }
375
+ if ((await isRunning()) === false) { confirmed = true; break }
376
+ await sleep(500)
377
+ }
378
+ if (confirmed) {
379
+ if (owned && owned.done) await Promise.race([owned.done.catch(() => {}), sleep(3000)])
380
+ writeQuitMarkerNoWait('0')
381
+ if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
382
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
383
+ }
384
+ writeQuitMarkerNoWait('0')
385
+ const state = await waitGone(20)
386
+ if (state === true) {
387
+ if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
388
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
389
+ }
390
+ if (state === null) return { ok: true, desktop: null, shortcut: await shortcutExists(), diag: await buildDiag() }
391
+ try {
392
+ const exe = await pickExe()
393
+ const dir = exe ? exe.slice(0, exe.lastIndexOf('\\')) : process.cwd()
394
+ await runPowerShell('Stop-Process -Name dsh-launcher -Force -ErrorAction SilentlyContinue', dir)
395
+ } catch (error) {
396
+ lastMarkerError = String((error && error.message) || error)
397
+ }
398
+ const state2 = await waitGone(8)
399
+ if (state2 === true) {
400
+ if (!(await removeShortcut())) lastMarkerError = 'shortcut sync failed (delete)'
401
+ return { ok: true, desktop: false, shortcut: await shortcutExists(), diag: await buildDiag() }
402
+ }
403
+ return { ok: false, error: '桌面应用仍在运行(退出请求与强制结束均未生效)。', shortcut: await shortcutExists(), diag: await buildDiag() }
404
+ }
405
+
406
+ async function setShortcut() {
407
+ const ok = await ensureShortcut()
408
+ if (ok) return { ok: true, shortcut: true, diag: await buildDiag() }
409
+ return { ok: false, error: '创建桌面快捷方式失败。', shortcut: await shortcutExists(), diag: await buildDiag() }
410
+ }
411
+
412
+ ctx.effect(() => {
413
+ const routes = [
414
+ {
415
+ kind: 'exact',
416
+ path: '/api/dsh-tauri-launcher/state',
417
+ handler: async (req, res) => {
418
+ if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
419
+ writeJson(res, 200, await getState())
420
+ },
421
+ },
422
+ {
423
+ kind: 'exact',
424
+ path: '/api/dsh-tauri-launcher/set-desktop',
425
+ handler: async (req, res) => {
426
+ if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
427
+ const body = await readJsonBody(req)
428
+ writeJson(res, 200, await setDesktop(Boolean(body.enabled)))
429
+ },
430
+ },
431
+ {
432
+ kind: 'exact',
433
+ path: '/api/dsh-tauri-launcher/set-shortcut',
434
+ handler: async (req, res) => {
435
+ if (!isLoopback(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
436
+ writeJson(res, 200, await setShortcut())
437
+ },
438
+ },
439
+ ]
440
+ const disposers = routes.map((route) => ctx.webServer.register(route))
441
+ return () => {
442
+ for (const dispose of disposers) dispose()
443
+ }
444
+ }, 'dsh-tauri-launcher: routes')
445
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@lenorin/dsh-tauri-launcher",
3
+ "version": "1.0.0",
4
+ "description": "DSH Web 插件:基于 Tauri 2 的 DeepSeek Harness 桌面启动器,自动联动桌面快捷方式,exe 路径与探测窗口可配置。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "launcher",
16
+ "README.md"
17
+ ],
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "inject": [],
24
+ "platform": "web"
25
+ }
26
+ },
27
+ "license": "MIT"
28
+ }