@lievclouds/dsh-cortex 0.4.4 → 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.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -0
@@ -0,0 +1,664 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { FormEvent } from 'react'
3
+ import {
4
+ Boxes,
5
+ FolderPlus,
6
+ Loader2,
7
+ PackageSearch,
8
+ RefreshCw,
9
+ Search,
10
+ ShieldCheck,
11
+ X
12
+ } from 'lucide-react'
13
+ import { toast } from 'sonner'
14
+ import { Button } from '@renderer/components/ui/button'
15
+ import { Input } from '@renderer/components/ui/input'
16
+ import { Skeleton } from '@renderer/components/ui/skeleton'
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectItem,
21
+ SelectTrigger,
22
+ SelectValue
23
+ } from '@renderer/components/ui/select'
24
+ import { EmptyState } from '@renderer/components/common/EmptyState'
25
+ import { MiniappCard } from '@renderer/components/miniapp/MiniappCard'
26
+ import type { MiniappCardAction } from '@renderer/components/miniapp/MiniappCard'
27
+ import { MiniappDetail } from '@renderer/components/miniapp/MiniappDetail'
28
+ import {
29
+ MINIAPP_STATUSES,
30
+ analyzeMiniapp,
31
+ importMiniappAssets,
32
+ listMiniappRoots,
33
+ listMiniapps,
34
+ openMiniappDir,
35
+ scanMiniapps,
36
+ setMiniappRoots,
37
+ toMiniappErrorMessage,
38
+ unpackMiniapp,
39
+ type MiniappListResponse,
40
+ type MiniappRootsResponse,
41
+ type MiniappRow,
42
+ type MiniappScanRoot,
43
+ type MiniappStatus
44
+ } from '@renderer/lib/miniappApi'
45
+ import { useDebouncedValue } from '@renderer/hooks/useDebouncedValue'
46
+ import { useI18n } from '@renderer/i18n'
47
+ import { cn } from '@renderer/lib/utils'
48
+
49
+ interface MiniappPageProps {
50
+ dataVersion: number
51
+ }
52
+
53
+ const EMPTY_LIST: MiniappListResponse = {
54
+ miniapps: [],
55
+ total: 0,
56
+ appCount: 0,
57
+ customRoots: [],
58
+ workspaceRoot: '',
59
+ lastScanAt: null
60
+ }
61
+
62
+ /**
63
+ * Mini-program (wxapkg) reverse-engineering workbench.
64
+ *
65
+ * The page only reads the host routes under `/cortex-api/cortex/miniapps`; the
66
+ * host owns the WeChat cache read-only contract. Every destructive-looking
67
+ * action here (unpack/analyze/import/open) is explicit and disabled while a
68
+ * request is in flight, and the detail inspector opens on browse.
69
+ */
70
+ export function MiniappPage({ dataVersion }: MiniappPageProps) {
71
+ const { t } = useI18n()
72
+ const [data, setData] = useState<MiniappListResponse | null>(null)
73
+ const [loading, setLoading] = useState(true)
74
+ const [error, setError] = useState<string | null>(null)
75
+ const [search, setSearch] = useState('')
76
+ const debouncedSearch = useDebouncedValue(search.trim(), 180)
77
+ const [status, setStatus] = useState<MiniappStatus | 'all'>('all')
78
+ const [showExtras, setShowExtras] = useState(false)
79
+ const [retrySeq, setRetrySeq] = useState(0)
80
+ const [refreshSeq, setRefreshSeq] = useState(0)
81
+ const [scanPending, setScanPending] = useState(false)
82
+ const [scanError, setScanError] = useState<string | null>(null)
83
+ const [roots, setRoots] = useState<MiniappRootsResponse | null>(null)
84
+ const [scanRoots, setScanRoots] = useState<MiniappScanRoot[] | null>(null)
85
+ const [addRootOpen, setAddRootOpen] = useState(false)
86
+ const [addRootValue, setAddRootValue] = useState('')
87
+ const [addRootPending, setAddRootPending] = useState(false)
88
+ const [selectedId, setSelectedId] = useState<string | null>(null)
89
+ const [busy, setBusy] = useState<{ id: string; action: MiniappCardAction } | null>(null)
90
+ const searchInputRef = useRef<HTMLInputElement>(null)
91
+
92
+ const filtersActive = search.trim().length > 0 || status !== 'all'
93
+
94
+ const load = useCallback(
95
+ async (silent: boolean) => {
96
+ if (!silent) setLoading(true)
97
+ try {
98
+ const next = await listMiniapps({
99
+ search: debouncedSearch,
100
+ status: status === 'all' ? '' : status
101
+ })
102
+ setData(next)
103
+ setError(null)
104
+ } catch (cause) {
105
+ setError(toMiniappErrorMessage(cause))
106
+ } finally {
107
+ setLoading(false)
108
+ }
109
+ },
110
+ [debouncedSearch, status]
111
+ )
112
+
113
+ useEffect(() => {
114
+ void load(data !== null)
115
+ }, [load, dataVersion, retrySeq, refreshSeq])
116
+
117
+ useEffect(() => {
118
+ let alive = true
119
+ listMiniappRoots().then(
120
+ (next) => {
121
+ if (alive) setRoots(next)
122
+ },
123
+ () => {
124
+ /* root metadata is informational; the scan still works without it */
125
+ }
126
+ )
127
+ return () => {
128
+ alive = false
129
+ }
130
+ }, [])
131
+
132
+ useEffect(() => {
133
+ const onKeyDown = (event: KeyboardEvent): void => {
134
+ if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return
135
+ const target = event.target as HTMLElement | null
136
+ if (
137
+ target &&
138
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
139
+ ) {
140
+ return
141
+ }
142
+ event.preventDefault()
143
+ searchInputRef.current?.focus()
144
+ }
145
+ window.addEventListener('keydown', onKeyDown)
146
+ return () => window.removeEventListener('keydown', onKeyDown)
147
+ }, [])
148
+
149
+ const replaceRow = (row: MiniappRow): void => {
150
+ setData((prev) => {
151
+ if (prev === null) return prev
152
+ return {
153
+ ...prev,
154
+ miniapps: prev.miniapps.map((item) => (item.id === row.id ? { ...item, ...row } : item))
155
+ }
156
+ })
157
+ }
158
+
159
+ const handleScan = async (customRoots?: string[]): Promise<void> => {
160
+ if (scanPending) return
161
+ setScanPending(true)
162
+ setScanError(null)
163
+ try {
164
+ const result = await scanMiniapps(customRoots)
165
+ setScanRoots(Array.isArray(result.roots) ? result.roots : null)
166
+ const scannedRows = result.miniapps ?? []
167
+ setData((prev) => {
168
+ const base = prev ?? EMPTY_LIST
169
+ return {
170
+ ...base,
171
+ miniapps: scannedRows,
172
+ total: result.totalSources ?? scannedRows.length,
173
+ appCount: new Set(scannedRows.map((row) => row.appid)).size,
174
+ customRoots: customRoots ?? base.customRoots,
175
+ lastScanAt: result.scannedAt
176
+ }
177
+ })
178
+ setError(null)
179
+ toast.success(
180
+ t('miniapp.scan.done', {
181
+ total: result.totalPackages,
182
+ added: result.added,
183
+ updated: result.updated,
184
+ unchanged: result.unchanged
185
+ })
186
+ )
187
+ if (result.errors.length > 0) {
188
+ toast.warning(t('miniapp.scan.errors', { count: result.errors.length }))
189
+ }
190
+ } catch (cause) {
191
+ const message = toMiniappErrorMessage(cause)
192
+ setScanError(message)
193
+ toast.error(t('miniapp.scan.failed', { error: message }))
194
+ } finally {
195
+ setScanPending(false)
196
+ }
197
+ }
198
+
199
+ const handleAddRoot = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
200
+ event.preventDefault()
201
+ const directory = addRootValue.trim()
202
+ if (directory.length === 0 || addRootPending) return
203
+ setAddRootPending(true)
204
+ try {
205
+ const next = await setMiniappRoots([directory], 'append')
206
+ setRoots(next)
207
+ setAddRootValue('')
208
+ setAddRootOpen(false)
209
+ toast.success(t('miniapp.toast.rootAdded', { path: directory }))
210
+ await handleScan(next.customRoots)
211
+ } catch (cause) {
212
+ toast.error(t('miniapp.toast.rootFailed', { error: toMiniappErrorMessage(cause) }))
213
+ } finally {
214
+ setAddRootPending(false)
215
+ }
216
+ }
217
+
218
+ const handleUnpack = async (item: MiniappRow): Promise<void> => {
219
+ if (busy !== null) return
220
+ setBusy({ id: item.id, action: 'unpack' })
221
+ try {
222
+ const result = await unpackMiniapp(item.id)
223
+ replaceRow({ ...item, ...result.miniapp })
224
+ if (result.ok) {
225
+ toast.success(t('miniapp.toast.unpacked', { appid: item.appid }))
226
+ } else if (result.status === 'unsupported') {
227
+ toast.error(t('miniapp.toast.unpackUnsupported', { error: result.error }))
228
+ } else {
229
+ toast.error(t('miniapp.toast.unpackFailed', { error: result.error }))
230
+ }
231
+ } catch (cause) {
232
+ toast.error(t('miniapp.toast.unpackFailed', { error: toMiniappErrorMessage(cause) }))
233
+ } finally {
234
+ setBusy(null)
235
+ }
236
+ }
237
+
238
+ const handleAnalyze = async (item: MiniappRow): Promise<void> => {
239
+ if (busy !== null) return
240
+ setBusy({ id: item.id, action: 'analyze' })
241
+ try {
242
+ const result = await analyzeMiniapp(item.id)
243
+ replaceRow({ ...item, has_analysis: true })
244
+ toast.success(
245
+ t('miniapp.toast.analyzed', {
246
+ pages: result.analysis?.pages.length ?? 0,
247
+ endpoints: result.counts.endpoints,
248
+ secrets: result.counts.secrets
249
+ })
250
+ )
251
+ setSelectedId(item.id)
252
+ } catch (cause) {
253
+ toast.error(t('miniapp.toast.analyzeFailed', { error: toMiniappErrorMessage(cause) }))
254
+ } finally {
255
+ setBusy(null)
256
+ }
257
+ }
258
+
259
+ const handleImport = async (item: MiniappRow): Promise<void> => {
260
+ if (busy !== null) return
261
+ setBusy({ id: item.id, action: 'import' })
262
+ try {
263
+ const result = await importMiniappAssets(item.id, {})
264
+ replaceRow({ ...item, has_analysis: true })
265
+ if (result.ok) {
266
+ toast.success(t('miniapp.toast.imported', { ...result.totals }))
267
+ } else {
268
+ toast.warning(t('miniapp.toast.importWarnings', { warnings: result.warnings.join('; ') }))
269
+ }
270
+ } catch (cause) {
271
+ toast.error(t('miniapp.toast.importFailed', { error: toMiniappErrorMessage(cause) }))
272
+ } finally {
273
+ setBusy(null)
274
+ }
275
+ }
276
+
277
+ const handleOpenDir = async (item: MiniappRow): Promise<void> => {
278
+ if (busy !== null) return
279
+ setBusy({ id: item.id, action: 'open' })
280
+ try {
281
+ const result = await openMiniappDir(item.id)
282
+ toast.success(t('miniapp.toast.opened', { path: result.path }))
283
+ } catch (cause) {
284
+ toast.error(t('miniapp.toast.openFailed', { error: toMiniappErrorMessage(cause) }))
285
+ } finally {
286
+ setBusy(null)
287
+ }
288
+ }
289
+
290
+ const resetFilters = (): void => {
291
+ setSearch('')
292
+ setStatus('all')
293
+ }
294
+
295
+ const workspaceRoot = data?.workspaceRoot ?? ''
296
+ const customRootCount = roots?.customRoots?.length ?? data?.customRoots?.length ?? 0
297
+ const extraCount =
298
+ data === null
299
+ ? 0
300
+ : data.miniapps.filter((row) => row.kind === 'plugin' || row.kind === 'library').length
301
+ const visibleMiniapps =
302
+ data === null
303
+ ? []
304
+ : data.miniapps.filter(
305
+ (row) => showExtras || (row.kind !== 'plugin' && row.kind !== 'library')
306
+ )
307
+ const visibleCountLabel = t('miniapp.page.count', {
308
+ count: visibleMiniapps.length,
309
+ apps: new Set(visibleMiniapps.map((row) => row.appid)).size
310
+ })
311
+ const headerCountLabel =
312
+ data === null
313
+ ? '—'
314
+ : !showExtras && extraCount > 0
315
+ ? t('miniapp.page.countVisible', {
316
+ visible: visibleMiniapps.length,
317
+ total: data.total,
318
+ apps: data.appCount
319
+ })
320
+ : t('miniapp.page.count', { count: data.total, apps: data.appCount })
321
+
322
+ return (
323
+ <section
324
+ data-testid="miniapps-page"
325
+ className="mx-auto w-full max-w-[1440px] px-8 py-5"
326
+ >
327
+ <div className="flex flex-wrap items-start justify-between gap-4">
328
+ <div className="min-w-0">
329
+ <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
330
+ {t('miniapp.page.eyebrow')}
331
+ </p>
332
+ <div className="mt-1 flex items-baseline gap-2.5">
333
+ <h1 className="text-[18px] font-medium tracking-tight text-foreground">
334
+ {t('nav.miniapps')}
335
+ </h1>
336
+ <span className="font-mono text-[11px] tabular-nums text-foreground/70">
337
+ {headerCountLabel}
338
+ </span>
339
+ </div>
340
+ </div>
341
+ </div>
342
+
343
+ <div className="mt-4 flex flex-wrap items-start gap-3 rounded-lg border border-border bg-sidebar px-4 py-3">
344
+ <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-background text-emerald-700">
345
+ <ShieldCheck aria-hidden="true" className="h-3.5 w-3.5" strokeWidth={1.75} />
346
+ </span>
347
+ <div className="min-w-0 flex-1">
348
+ <p className="text-[12px] font-medium text-foreground">{t('miniapp.banner.title')}</p>
349
+ <p className="mt-1 break-all font-mono text-[10px] text-muted-foreground/80">
350
+ {workspaceRoot.length > 0
351
+ ? t('miniapp.banner.workspace', { path: workspaceRoot })
352
+ : null}
353
+ {customRootCount > 0
354
+ ? `${workspaceRoot.length > 0 ? ' · ' : ''}${t('miniapp.banner.roots', {
355
+ count: customRootCount
356
+ })}`
357
+ : null}
358
+ </p>
359
+ </div>
360
+ </div>
361
+
362
+ {scanRoots !== null && scanRoots.length > 0 ? (
363
+ <div className="mt-2 flex flex-col gap-0.5 text-[10.5px] leading-relaxed text-muted-foreground">
364
+ <p className="break-all font-mono text-[10px] text-muted-foreground/80">
365
+ {t('miniapp.page.rootsSummary', {
366
+ summary: scanRoots
367
+ .map(
368
+ (root) =>
369
+ `${root.path.split(/[\\/]/).filter(Boolean).pop() ?? root.kind} ${
370
+ root.exists ? (root.filesFound ?? 0) : '—'
371
+ }`
372
+ )
373
+ .join(' · ')
374
+ })}
375
+ </p>
376
+ </div>
377
+ ) : null}
378
+
379
+ <div className="mt-4 flex flex-wrap items-center gap-2">
380
+ <Button
381
+ variant="primary"
382
+ size="sm"
383
+ data-testid="miniapps-scan"
384
+ disabled={scanPending}
385
+ onClick={() => void handleScan()}
386
+ className="gap-1.5"
387
+ >
388
+ {scanPending ? (
389
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
390
+ ) : (
391
+ <PackageSearch aria-hidden="true" className="h-3.5 w-3.5" />
392
+ )}
393
+ {scanPending ? t('miniapp.action.scanning') : t('miniapp.action.scan')}
394
+ </Button>
395
+ <Button
396
+ variant="outline"
397
+ size="sm"
398
+ data-testid="miniapps-refresh"
399
+ aria-label={t('common.refresh')}
400
+ disabled={loading && data === null}
401
+ onClick={() => setRefreshSeq((value) => value + 1)}
402
+ className="gap-1.5"
403
+ >
404
+ <RefreshCw aria-hidden="true" className="h-3.5 w-3.5" />
405
+ {t('common.refresh')}
406
+ </Button>
407
+ <Button
408
+ variant="outline"
409
+ size="sm"
410
+ data-testid="miniapps-add-root-toggle"
411
+ aria-expanded={addRootOpen}
412
+ onClick={() => setAddRootOpen((value) => !value)}
413
+ className="gap-1.5"
414
+ >
415
+ <FolderPlus aria-hidden="true" className="h-3.5 w-3.5" />
416
+ {t('miniapp.action.addRoot')}
417
+ </Button>
418
+
419
+ <div className="ml-auto flex flex-wrap items-center gap-2">
420
+ <Select
421
+ value={status}
422
+ onValueChange={(value) => setStatus(value as MiniappStatus | 'all')}
423
+ >
424
+ <SelectTrigger
425
+ data-testid="miniapps-status-filter"
426
+ aria-label={t('miniapp.filter.statusAria')}
427
+ className="h-8 w-[132px] text-[11px]"
428
+ >
429
+ <SelectValue />
430
+ </SelectTrigger>
431
+ <SelectContent>
432
+ <SelectItem value="all">{t('miniapp.filter.all')}</SelectItem>
433
+ {MINIAPP_STATUSES.map((value) => (
434
+ <SelectItem key={value} value={value}>
435
+ {t(`miniapp.status.${value}`)}
436
+ </SelectItem>
437
+ ))}
438
+ </SelectContent>
439
+ </Select>
440
+
441
+ <Button
442
+ variant={showExtras ? 'subtle' : 'outline'}
443
+ size="sm"
444
+ data-testid="miniapps-show-extras"
445
+ aria-pressed={showExtras}
446
+ onClick={() => setShowExtras((value) => !value)}
447
+ className="gap-1.5"
448
+ >
449
+ <Boxes aria-hidden="true" className="h-3.5 w-3.5" />
450
+ {t(showExtras ? 'miniapp.filter.hideExtras' : 'miniapp.filter.showExtras')}
451
+ {extraCount > 0 ? (
452
+ <span className="font-mono text-[10px] tabular-nums">({extraCount})</span>
453
+ ) : null}
454
+ </Button>
455
+
456
+ <div className="relative w-full max-w-[320px] sm:w-[280px]">
457
+ <Search
458
+ aria-hidden="true"
459
+ className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
460
+ />
461
+ <Input
462
+ ref={searchInputRef}
463
+ data-testid="miniapps-search"
464
+ aria-label={t('miniapp.search.ariaLabel')}
465
+ value={search}
466
+ onChange={(event) => setSearch(event.target.value)}
467
+ placeholder={t('miniapp.search.placeholder')}
468
+ className="dshc-flat-input pl-9 pr-9"
469
+ />
470
+ {search.length > 0 ? (
471
+ <button
472
+ type="button"
473
+ aria-label={t('miniapp.search.clear')}
474
+ onClick={() => setSearch('')}
475
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
476
+ >
477
+ <X aria-hidden="true" className="h-3.5 w-3.5" />
478
+ </button>
479
+ ) : null}
480
+ </div>
481
+ </div>
482
+ </div>
483
+
484
+ {addRootOpen ? (
485
+ <form
486
+ onSubmit={(event) => void handleAddRoot(event)}
487
+ className="mt-3 flex flex-wrap items-start gap-2 rounded-lg border border-border bg-background px-3 py-2.5"
488
+ >
489
+ <div className="min-w-[240px] flex-1">
490
+ <Input
491
+ autoFocus
492
+ data-testid="miniapps-add-root-input"
493
+ aria-label={t('miniapp.action.addRoot')}
494
+ value={addRootValue}
495
+ onChange={(event) => setAddRootValue(event.target.value)}
496
+ placeholder={t('miniapp.addRoot.placeholder')}
497
+ className="dshc-flat-input h-8 font-mono text-[11.5px]"
498
+ />
499
+ <p className="mt-1 text-[10.5px] leading-relaxed text-muted-foreground">
500
+ {t('miniapp.addRoot.hint')}
501
+ </p>
502
+ </div>
503
+ <Button
504
+ type="submit"
505
+ variant="primary"
506
+ size="sm"
507
+ data-testid="miniapps-add-root-submit"
508
+ disabled={addRootPending || addRootValue.trim().length === 0}
509
+ className="gap-1.5"
510
+ >
511
+ {addRootPending ? (
512
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
513
+ ) : (
514
+ <FolderPlus aria-hidden="true" className="h-3.5 w-3.5" />
515
+ )}
516
+ {t('miniapp.action.addRootSubmit')}
517
+ </Button>
518
+ <Button
519
+ type="button"
520
+ variant="ghost"
521
+ size="sm"
522
+ onClick={() => {
523
+ setAddRootOpen(false)
524
+ setAddRootValue('')
525
+ }}
526
+ >
527
+ {t('miniapp.action.addRootCancel')}
528
+ </Button>
529
+ </form>
530
+ ) : null}
531
+
532
+ {scanError !== null ? (
533
+ <p
534
+ role="alert"
535
+ className="mt-3 break-all rounded-md border border-red-200 bg-red-50 px-3 py-2 text-[11.5px] text-red-700"
536
+ >
537
+ {t('miniapp.scan.failed', { error: scanError })}
538
+ </p>
539
+ ) : null}
540
+
541
+ <div className="mt-3 overflow-hidden rounded-lg border border-border bg-background">
542
+ <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-1.5">
543
+ <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
544
+ {t('nav.miniapps')}
545
+ </span>
546
+ <span className="font-mono text-[10.5px] tabular-nums text-muted-foreground">
547
+ {data === null
548
+ ? t('miniapp.page.loading')
549
+ : filtersActive
550
+ ? t('miniapp.page.count', {
551
+ count: visibleMiniapps.length,
552
+ apps: new Set(visibleMiniapps.map((row) => row.appid)).size
553
+ })
554
+ : visibleCountLabel}
555
+ {!showExtras && extraCount > 0
556
+ ? ` · ${t('miniapp.page.hiddenExtras', { count: extraCount })}`
557
+ : ''}
558
+ </span>
559
+ </div>
560
+
561
+ {error !== null ? (
562
+ <div role="alert" data-testid="miniapps-error" className="flex flex-wrap items-start gap-3 px-4 py-6">
563
+ <div className="min-w-0 flex-1">
564
+ <p className="text-[12.5px] font-medium text-red-700">{t('miniapp.error.title')}</p>
565
+ <p className="mt-1 break-all font-mono text-[11px] leading-relaxed text-red-700/80">
566
+ {error}
567
+ </p>
568
+ </div>
569
+ <Button
570
+ variant="outline"
571
+ size="sm"
572
+ onClick={() => {
573
+ setData(null)
574
+ setRetrySeq((value) => value + 1)
575
+ }}
576
+ >
577
+ {t('common.retry')}
578
+ </Button>
579
+ </div>
580
+ ) : loading && data === null ? (
581
+ <div className="flex flex-col gap-2 p-4">
582
+ <Skeleton className="h-16 w-full" />
583
+ <Skeleton className="h-16 w-full" />
584
+ <Skeleton className="h-16 w-full" />
585
+ </div>
586
+ ) : data === null || data.miniapps.length === 0 ? (
587
+ <EmptyState
588
+ icon={PackageSearch}
589
+ title={filtersActive ? t('miniapp.emptyFiltered.title') : t('miniapp.empty.title')}
590
+ description={
591
+ filtersActive
592
+ ? t('miniapp.emptyFiltered.description')
593
+ : t('miniapp.empty.description')
594
+ }
595
+ >
596
+ {filtersActive ? (
597
+ <Button variant="outline" size="sm" onClick={resetFilters}>
598
+ {t('miniapp.emptyFiltered.reset')}
599
+ </Button>
600
+ ) : (
601
+ <Button
602
+ variant="primary"
603
+ size="sm"
604
+ disabled={scanPending}
605
+ onClick={() => void handleScan()}
606
+ className={cn('gap-1.5')}
607
+ >
608
+ {scanPending ? (
609
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
610
+ ) : (
611
+ <PackageSearch aria-hidden="true" className="h-3.5 w-3.5" />
612
+ )}
613
+ {t('miniapp.empty.cta')}
614
+ </Button>
615
+ )}
616
+ </EmptyState>
617
+ ) : visibleMiniapps.length === 0 && filtersActive ? (
618
+ <EmptyState
619
+ icon={Search}
620
+ title={t('miniapp.emptyFiltered.title')}
621
+ description={t('miniapp.emptyFiltered.description')}
622
+ >
623
+ <Button variant="outline" size="sm" onClick={resetFilters}>
624
+ {t('miniapp.emptyFiltered.reset')}
625
+ </Button>
626
+ </EmptyState>
627
+ ) : visibleMiniapps.length === 0 ? (
628
+ <EmptyState
629
+ icon={Boxes}
630
+ title={t('miniapp.emptyExtras.title')}
631
+ description={t('miniapp.emptyExtras.description')}
632
+ >
633
+ <Button variant="outline" size="sm" onClick={() => setShowExtras(true)}>
634
+ {t('miniapp.emptyExtras.cta')}
635
+ </Button>
636
+ </EmptyState>
637
+ ) : (
638
+ <ul>
639
+ {visibleMiniapps.map((item) => (
640
+ <MiniappCard
641
+ key={item.id}
642
+ item={item}
643
+ busy={busy !== null && busy.id === item.id}
644
+ busyAction={busy !== null && busy.id === item.id ? busy.action : null}
645
+ onOpen={(row) => setSelectedId(row.id)}
646
+ onUnpack={(row) => void handleUnpack(row)}
647
+ onAnalyze={(row) => void handleAnalyze(row)}
648
+ onImport={(row) => void handleImport(row)}
649
+ onOpenDir={(row) => void handleOpenDir(row)}
650
+ />
651
+ ))}
652
+ </ul>
653
+ )}
654
+ </div>
655
+
656
+ <MiniappDetail
657
+ miniappId={selectedId}
658
+ onClose={() => setSelectedId(null)}
659
+ onChanged={() => setRefreshSeq((value) => value + 1)}
660
+ />
661
+ </section>
662
+ )
663
+ }
664
+
@@ -159,11 +159,10 @@ export interface CortexApi extends PocApi {
159
159
  deleteVulnerability(id: string): Promise<DeleteVulnResult>
160
160
  deleteVulnerabilities(ids: string[]): Promise<DeleteVulnsResult>
161
161
  /**
162
- * Export one or more vulnerability reports as Markdown. `outputDir` is an
163
- * absolute directory chosen by the user; omitted/empty means the Host default
164
- * (`~/.cortex/exports/<timestamp>/`).
162
+ * Export one or more vulnerability reports. Options choose the output
163
+ * directory (absolute, `~` allowed) and the format (`md` default, `docx`, `pdf`).
165
164
  */
166
- exportVulnerabilities(ids: string[], outputDir?: string): Promise<ExportReportsResult>
165
+ exportVulnerabilities(ids: string[], options?: ExportReportOptions): Promise<ExportReportsResult>
167
166
  listAssets(filters?: AssetFilters): Promise<AssetListItem[]>
168
167
  getAssetDetail(hostPort: string): Promise<AssetContext | null>
169
168
  getAssetRelatedVulnerabilities(hostPort: string): Promise<VulnerabilityListItem[]>
@@ -1018,6 +1018,15 @@ export interface DeleteVulnsResult {
1018
1018
  deleted: number
1019
1019
  }
1020
1020
 
1021
+ export type ExportReportFormat = 'md' | 'docx' | 'pdf'
1022
+
1023
+ export interface ExportReportOptions {
1024
+ /** Absolute output directory chosen by the user; empty means the default. */
1025
+ outputDir?: string
1026
+ /** Markdown (default), Word or PDF. */
1027
+ format?: ExportReportFormat
1028
+ }
1029
+
1021
1030
  export interface ExportReportsResult {
1022
1031
  cancelled: boolean
1023
1032
  /** File names (or absolute paths for desktop transports) of exported reports. */