mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 日报新增模板市场,支持社区模板安装与使用
This commit is contained in:
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
reportTemplateTest: resolve('src/main/report-template-test-entry.ts'),
|
||||
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
|
||||
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
# TraceMemo 日报模板示例
|
||||
|
||||
此目录是可安装的最小日报模板源文件,使用全部虚构数据进行预览。运行 `node scripts/build-report-template-example.cjs` 会生成 `examples/report-template-basic.zip`。
|
||||
|
||||
模板只能调整已有日报模块的 HTML/CSS 排版。作者不能加入 JavaScript、事件属性、外部网络资源、嵌套页面、数据库访问、AI Prompt 或新的业务分析。
|
||||
|
||||
占位符分为三类:普通文本(会被 HTML 转义)、应用生成的 HTML 片段(只能作为元素内容使用)、受限样式类(只能放进 `class` 属性,并由应用输出合法 token)。`*_MORE_NOTE` 是 HTML 片段,不是样式类。
|
||||
|
||||
允许的标签和属性由 `src/shared/report-template-package.ts` 统一定义;图片资源只能是包内 `assets/` 下的 PNG/JPEG/WebP。缺少可选模块时应用输出空字符串和 `*_EMPTY_CLASS`,模板应允许该模块隐藏。
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"protocolVersion": "1.0",
|
||||
"kind": "daily-report",
|
||||
"id": "community.github.example.basic-feed",
|
||||
"name": "基础信息流",
|
||||
"author": { "name": "example" },
|
||||
"templateVersion": "1.0.0",
|
||||
"interfaceVersion": "1",
|
||||
"entry": "template.html",
|
||||
"preview": "preview.png",
|
||||
"capture": { "width": 430, "maxWidth": 430, "maxHeight": 20000 },
|
||||
"license": { "spdx": "MIT" },
|
||||
"platform": "mobile"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{REPORT_TITLE}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; width: 430px; background: #f3f5f7; color: #18202a; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.report { padding: 18px 14px 32px; }
|
||||
.hero, .section { margin-top: 10px; padding: 16px; border-radius: 14px; background: #fff; }
|
||||
.hero { margin-top: 0; border-top: 4px solid #1769aa; }
|
||||
h1 { margin: 0 0 8px; font-size: 23px; }
|
||||
.meta, .muted { color: #64748b; font-size: 12px; line-height: 1.5; }
|
||||
.section-title { margin: 0 0 8px; font-size: 14px; }
|
||||
.topics-grid, .important-list { display: grid; gap: 8px; }
|
||||
.topic-card, .important-card { padding: 10px; border: 1px solid #dbe4ee; border-radius: 10px; }
|
||||
.empty-section { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="report {{REPORT_MODE_CLASS}}">
|
||||
<section class="hero {{REPORT_MODE_CLASS}}">
|
||||
<h1>{{REPORT_TITLE}}</h1>
|
||||
<div class="meta">{{REPORT_DATE}} · {{DATE_RANGE}}</div>
|
||||
<p>{{HERO_SUMMARY}}</p>
|
||||
<div class="muted">{{HERO_STATUS_LINE}}</div>
|
||||
</section>
|
||||
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||
<h2 class="section-title">今日话题</h2>
|
||||
<div class="topics-grid">{{TOPIC_CARDS}}</div>
|
||||
{{TOPICS_MORE_NOTE}}
|
||||
</section>
|
||||
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||
<h2 class="section-title">重要消息</h2>
|
||||
<div class="important-list">{{IMPORTANT_MESSAGES}}</div>
|
||||
</section>
|
||||
<footer class="muted">{{FOOTER_NOTE}}</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -91,6 +91,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-tree": "^3.0.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
@@ -98,11 +99,13 @@
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"parse5": "^8.0.0",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sherpa-onnx-node": "1.13.3",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"unzipper": "^0.12.0",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+39
-5
@@ -46,6 +46,7 @@ specifiers:
|
||||
class-variance-authority: ^0.7.1
|
||||
clsx: ^2.1.1
|
||||
cross-env: ^10.1.0
|
||||
css-tree: ^3.0.1
|
||||
electron: ^43.0.0
|
||||
electron-builder: ^26.0.12
|
||||
electron-updater: ^6.6.2
|
||||
@@ -61,6 +62,7 @@ specifiers:
|
||||
jsonrepair: ^3.15.0
|
||||
koffi: ^3.1.0
|
||||
openai: ^6.10.0
|
||||
parse5: ^8.0.0
|
||||
pinyin-pro: ^3.26.0
|
||||
postcss: ^8.5.26
|
||||
prettier: ^3.7.4
|
||||
@@ -74,6 +76,7 @@ specifiers:
|
||||
tailwindcss: 3.4.17
|
||||
tailwindcss-animate: ^1.0.7
|
||||
typescript: ^5.9.3
|
||||
unzipper: ^0.12.0
|
||||
vite: ^7.2.6
|
||||
vitest: ^4.1.10
|
||||
wechat-emojis: ^1.0.2
|
||||
@@ -102,6 +105,7 @@ dependencies:
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cross-env: 10.1.0
|
||||
css-tree: 3.2.1
|
||||
electron-updater: 6.8.9
|
||||
ffmpeg-static: 5.3.0
|
||||
fs-extra: 11.3.2
|
||||
@@ -109,11 +113,13 @@ dependencies:
|
||||
jsonrepair: 3.15.0
|
||||
koffi: 3.1.0
|
||||
openai: 6.10.0
|
||||
parse5: 8.0.1
|
||||
pinyin-pro: 3.29.3
|
||||
qrcode: 1.5.4
|
||||
sherpa-onnx-node: 1.13.3
|
||||
silk-wasm: 3.7.1
|
||||
tailwind-merge: 3.6.0
|
||||
unzipper: 0.12.5
|
||||
wechat-emojis: 1.0.2
|
||||
|
||||
devDependencies:
|
||||
@@ -3900,6 +3906,10 @@ packages:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
dev: true
|
||||
|
||||
/bluebird/3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
dev: false
|
||||
|
||||
/brace-expansion/1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
dependencies:
|
||||
@@ -4352,7 +4362,6 @@ packages:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
dev: true
|
||||
|
||||
/css.escape/1.5.1:
|
||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||
@@ -4572,6 +4581,12 @@ packages:
|
||||
gopd: 1.2.0
|
||||
dev: true
|
||||
|
||||
/duplexer2/0.1.4:
|
||||
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
dev: false
|
||||
|
||||
/eastasianwidth/0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
dev: true
|
||||
@@ -4699,7 +4714,6 @@ packages:
|
||||
/entities/8.0.0:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
dev: true
|
||||
|
||||
/env-paths/2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
@@ -5303,6 +5317,15 @@ packages:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
/fs-extra/11.3.1:
|
||||
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
|
||||
engines: {node: '>=14.14'}
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
dev: false
|
||||
|
||||
/fs-extra/11.3.2:
|
||||
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
||||
engines: {node: '>=14.14'}
|
||||
@@ -6328,7 +6351,6 @@ packages:
|
||||
|
||||
/mdn-data/2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
dev: true
|
||||
|
||||
/merge2/1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
@@ -6554,6 +6576,10 @@ packages:
|
||||
semver: 7.7.3
|
||||
dev: true
|
||||
|
||||
/node-int64/0.4.0:
|
||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||
dev: false
|
||||
|
||||
/node-releases/2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
dev: true
|
||||
@@ -6773,7 +6799,6 @@ packages:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
dev: true
|
||||
|
||||
/path-exists/4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
@@ -7665,7 +7690,6 @@ packages:
|
||||
/source-map-js/1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/source-map-support/0.5.21:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
@@ -8210,6 +8234,16 @@ packages:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
/unzipper/0.12.5:
|
||||
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
|
||||
dependencies:
|
||||
bluebird: 3.7.2
|
||||
duplexer2: 0.1.4
|
||||
fs-extra: 11.3.1
|
||||
graceful-fs: 4.2.11
|
||||
node-int64: 0.4.0
|
||||
dev: false
|
||||
|
||||
/update-browserslist-db/1.2.2_browserslist@4.28.1:
|
||||
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
|
||||
hasBin: true
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { ZipArchive } = require('archiver')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const source = path.join(root, 'examples', 'report-template-basic')
|
||||
const outputPath = path.join(root, 'examples', 'report-template-basic.zip')
|
||||
const output = fs.createWriteStream(outputPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } })
|
||||
output.on('close', () => console.log(`wrote ${outputPath} (${archive.pointer()} bytes)`))
|
||||
archive.on('error', (error) => { throw error })
|
||||
archive.pipe(output)
|
||||
archive.file(path.join(source, 'manifest.json'), { name: 'manifest.json' })
|
||||
archive.file(path.join(source, 'template.html'), { name: 'template.html' })
|
||||
// 使用 1x1 PNG 作为虚构预览占位图,避免引入真实用户媒体。
|
||||
archive.append(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), { name: 'preview.png' })
|
||||
archive.finalize()
|
||||
@@ -1,7 +1,10 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import crypto from 'node:crypto'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
@@ -15,6 +18,8 @@ import {
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
import { getReportTemplate } from '../shared/report-templates'
|
||||
import type { ReportTemplateRef } from '../shared/report-template-package'
|
||||
import { reportTemplateService, validateReportTemplateHtml } from './report-template-service'
|
||||
|
||||
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
||||
v1: 'mobile_daily_report_v1.html',
|
||||
@@ -33,6 +38,40 @@ const templatePath = (templateId?: string): string => {
|
||||
return found
|
||||
}
|
||||
|
||||
const resolveTemplateSelection = async (templateId?: string, templateRef?: ReportTemplateRef) => {
|
||||
if (!templateRef) {
|
||||
const definition = getReportTemplate(templateId)
|
||||
return {
|
||||
definition,
|
||||
entryPath: templatePath(templateId),
|
||||
captureMaxHeight: 20000,
|
||||
source: 'builtin' as const
|
||||
}
|
||||
}
|
||||
const installed = await reportTemplateService.resolve(templateRef)
|
||||
return {
|
||||
definition: {
|
||||
id: installed.id,
|
||||
order: 999,
|
||||
platform: 'default' as const,
|
||||
label: installed.name,
|
||||
name: installed.name,
|
||||
tagline: `${installed.author} · ${installed.version}`,
|
||||
fileLabel: installed.name,
|
||||
cssClass: `template-external-${installed.id.replace(/[^a-z0-9-]/gi, '-')}`,
|
||||
resourceFile: installed.entryPath,
|
||||
captureWidth: installed.capture.width,
|
||||
maxCaptureWidth: installed.capture.maxWidth
|
||||
},
|
||||
entryPath: installed.entryPath,
|
||||
captureMaxHeight: installed.capture.maxHeight,
|
||||
source: 'installed' as const
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTemplate = async (request: GroupReportExportRequest) =>
|
||||
resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
|
||||
const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -148,6 +187,52 @@ const heatClass = (heat: ReportHeat): string => {
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const addReportCsp = (html: string, allowFileImages = true): string =>
|
||||
html.replace(
|
||||
/<head(\s[^>]*)?>/i,
|
||||
(head) =>
|
||||
`${head}<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:${allowFileImages ? ' file:' : ''}; style-src 'unsafe-inline'; font-src 'none'; base-uri 'none'; form-action 'none'">`
|
||||
)
|
||||
|
||||
const inlineTemplateAssets = async (html: string, templateRoot: string): Promise<string> => {
|
||||
const matches = [...html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)]
|
||||
let result = html
|
||||
for (const match of matches) {
|
||||
const relative = match[2]
|
||||
const assetPath = path.resolve(templateRoot, relative)
|
||||
if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) continue
|
||||
const extension = path.extname(assetPath).toLowerCase()
|
||||
const mime =
|
||||
extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||
const data = (await fs.readFile(assetPath)).toString('base64')
|
||||
result = result.replace(match[0], `src="data:${mime};base64,${data}"`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const recordBlockedTemplateRequest = (
|
||||
details: Electron.OnBeforeRequestListenerDetails,
|
||||
reason: string
|
||||
): void => {
|
||||
// 仅测试入口设置该路径,用于保留运行时拦截证据;生产默认不记录请求明细。
|
||||
const logPath = process.env.TRACEMEMO_TEMPLATE_SECURITY_LOG
|
||||
if (!logPath) return
|
||||
try {
|
||||
appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({
|
||||
url: details.url,
|
||||
method: details.method,
|
||||
resourceType: details.resourceType,
|
||||
reason
|
||||
})}\n`,
|
||||
'utf8'
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[GroupReport] failed to record template security block:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sectionMeta = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
@@ -170,7 +255,8 @@ const overflowNote = (
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const template = getReportTemplate(request.templateId)
|
||||
const resolvedTemplate = await resolveTemplate(request)
|
||||
const template = resolvedTemplate.definition
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||
@@ -237,7 +323,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
}
|
||||
}
|
||||
if (!imageUrl) return ''
|
||||
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
return `<div class="topic-inline-image"><img src="${escapeHtml(imageUrl)}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
})()
|
||||
: ''
|
||||
}
|
||||
@@ -349,7 +435,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||
.map(
|
||||
(item) => `<div class="vision-card">
|
||||
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
||||
<img class="vision-image" src="${escapeHtml(item.imageUrl)}" alt="AI 识别的图片">
|
||||
<div class="vision-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||
@@ -441,7 +527,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
unresolvedCount: report.unresolved.length
|
||||
}
|
||||
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||
if (resolvedTemplate.source === 'installed') {
|
||||
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
TEMPLATE_LABEL: escapeHtml(template.label),
|
||||
@@ -547,14 +637,19 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||
return html
|
||||
return addReportCsp(html, resolvedTemplate.source === 'builtin')
|
||||
}
|
||||
|
||||
const renderReportSnapshotHtml = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(request.templateId)
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
const template = resolvedTemplate.definition
|
||||
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||
if (resolvedTemplate.source === 'installed') {
|
||||
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||
}
|
||||
const values = {
|
||||
...request.snapshot.values,
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
@@ -565,7 +660,7 @@ const renderReportSnapshotHtml = async (
|
||||
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
return html.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
|
||||
return addReportCsp(html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''), resolvedTemplate.source === 'builtin')
|
||||
}
|
||||
|
||||
export const extractGroupReportRenderSnapshot = async (
|
||||
@@ -777,20 +872,61 @@ export const extractGroupReportRenderSnapshot = async (
|
||||
const captureFullPage = async (
|
||||
htmlPath: string,
|
||||
pngPath: string,
|
||||
templateId?: string
|
||||
templateId?: string,
|
||||
templateOverride?: {
|
||||
captureWidth: number
|
||||
maxCaptureWidth: number
|
||||
maxCaptureHeight?: number
|
||||
}
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(templateId)
|
||||
const template = templateOverride || getReportTemplate(templateId)
|
||||
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
|
||||
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
|
||||
const maxCaptureHeight = templateOverride?.maxCaptureHeight || 20000
|
||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||
const reportSessionPartition = `report-template-${crypto.randomUUID()}`
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: captureWidth,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
partition: reportSessionPartition
|
||||
}
|
||||
})
|
||||
const allowedDocumentPath = path.resolve(htmlPath)
|
||||
const requestHandler = (
|
||||
details: Electron.OnBeforeRequestListenerDetails,
|
||||
callback: (response: { cancel: boolean }) => void
|
||||
): void => {
|
||||
if (details.url.startsWith('file:')) {
|
||||
try {
|
||||
const requestedPath = path.resolve(fileURLToPath(details.url))
|
||||
if (requestedPath === allowedDocumentPath) {
|
||||
callback({ cancel: false })
|
||||
} else {
|
||||
recordBlockedTemplateRequest(details, 'file-path-not-allowed')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
} catch {
|
||||
recordBlockedTemplateRequest(details, 'invalid-file-url')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
recordBlockedTemplateRequest(details, 'scheme-not-allowed')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
reportWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*', 'file://*/*'] },
|
||||
requestHandler
|
||||
)
|
||||
reportWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
reportWindow.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||
|
||||
try {
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
@@ -808,7 +944,7 @@ const captureFullPage = async (
|
||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||
})`)) as { width: number; height: number }
|
||||
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
|
||||
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
||||
const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height)))
|
||||
reportWindow.setContentSize(width, height)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||
@@ -819,6 +955,7 @@ const captureFullPage = async (
|
||||
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
||||
return `data:image/png;base64,${png.toString('base64')}`
|
||||
} finally {
|
||||
reportWindow.webContents.session.webRequest.onBeforeRequest(null)
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
@@ -830,10 +967,14 @@ export const exportGroupReport = async (
|
||||
// === enrich 在 render 之前:从群成员快照反推真头像 ===
|
||||
await enrichAvatarsFromGroup(request.metadata)
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
const outputDir =
|
||||
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel =
|
||||
request.templateId === 'v1'
|
||||
const resolvedTemplate = await resolveTemplate(request)
|
||||
const templateLabel = request.templateRef
|
||||
? resolvedTemplate.definition.fileLabel
|
||||
: request.templateId === 'v1'
|
||||
? '经典版'
|
||||
: request.templateId === 'v2'
|
||||
? '丰富版'
|
||||
@@ -846,7 +987,15 @@ export const exportGroupReport = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
@@ -877,9 +1026,12 @@ export const exportGroupReportSnapshot = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
try {
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
const outputDir =
|
||||
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel = getReportTemplate(request.templateId).fileLabel
|
||||
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
const templateLabel = resolvedTemplate.definition.fileLabel
|
||||
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
@@ -888,7 +1040,15 @@ export const exportGroupReportSnapshot = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -334,6 +334,11 @@ const routes: Record<string, RouteHandler> = {
|
||||
if (!request?.report || !request?.metadata) {
|
||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||
}
|
||||
if (request.templateRef !== undefined) {
|
||||
return sendError(res, 400, 'HTTP API 暂不支持外部日报模板,请使用内置 templateId', {
|
||||
code: 'external_template_unsupported'
|
||||
})
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
},
|
||||
|
||||
+4
-5
@@ -32,7 +32,6 @@ import {
|
||||
type DecodedImage
|
||||
} from './image-decrypt-service'
|
||||
import {
|
||||
exportGroupReport,
|
||||
exportGroupReportSnapshot,
|
||||
extractGroupReportRenderSnapshot
|
||||
} from './group-report-service'
|
||||
@@ -43,8 +42,9 @@ import {
|
||||
saveGeneratedReport,
|
||||
updateGeneratedReportTemplate
|
||||
} from './report-history-service'
|
||||
import { reportTemplateService } from './report-template-service'
|
||||
import { registerReportTemplateIpc } from './report-template-ipc'
|
||||
import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type {
|
||||
@@ -667,6 +667,8 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
|
||||
await reportTemplateService.recover()
|
||||
registerReportTemplateIpc()
|
||||
createWindow()
|
||||
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||
console.log('[WCDB4] async bootstrap complete')
|
||||
@@ -1281,9 +1283,6 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'report:exportSnapshot',
|
||||
async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { reportTemplateService } from './report-template-service'
|
||||
import { reportTemplateMarketService } from './report-template-market-service'
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
|
||||
let registered = false
|
||||
|
||||
export function registerReportTemplateIpc(): void {
|
||||
if (registered) return
|
||||
registered = true
|
||||
ipcMain.handle('report-template:list', () => reportTemplateService.list())
|
||||
ipcMain.handle('report-template:install', async (_, packagePath: string) => {
|
||||
try {
|
||||
return { success: true, template: await reportTemplateService.install(packagePath) }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
code: error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : 'install_failed',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('report-template:uninstall', async (_, id: string, version: string) => {
|
||||
try {
|
||||
await reportTemplateService.uninstall(id, version)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
code: error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : 'uninstall_failed',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('report-template-market:list', () => reportTemplateMarketService.listCatalog())
|
||||
ipcMain.handle('report-template-market:install', (_, id: string, version: string) =>
|
||||
reportTemplateMarketService.installFromCatalog(String(id || ''), String(version || ''))
|
||||
)
|
||||
ipcMain.handle('report:export', (_, request: GroupReportExportRequest) => exportGroupReport(request))
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
REPORT_TEMPLATE_INTERFACE_VERSION,
|
||||
REPORT_TEMPLATE_LIMITS,
|
||||
ReportTemplateError,
|
||||
type ReportTemplateOperationResult
|
||||
} from '../shared/report-template-package'
|
||||
import {
|
||||
REPORT_TEMPLATE_CATALOG_URL,
|
||||
type ReportTemplateCatalog,
|
||||
type ReportTemplateCatalogEntry,
|
||||
type ReportTemplateCatalogInstallResult,
|
||||
type ReportTemplateCatalogResult
|
||||
} from '../shared/report-template-market'
|
||||
import { reportTemplateService } from './report-template-service'
|
||||
|
||||
const CATALOG_MAX_BYTES = 2 * 1024 * 1024
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
const SAFE_ID = /^community\.github\.[a-z0-9][a-z0-9-]{0,38}\.[a-z0-9][a-z0-9-]{0,63}$/
|
||||
const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
|
||||
const SHA256 = /^[a-f0-9]{64}$/
|
||||
const COMMIT = /^[a-f0-9]{40}$/
|
||||
const RAW_HOST = 'raw.githubusercontent.com'
|
||||
const RAW_REPOSITORY_PREFIX = '/Wxw-Gu/TraceMemo-Templates/'
|
||||
|
||||
const operationError = (fallbackCode: string, error: unknown): ReportTemplateOperationResult => ({
|
||||
success: false,
|
||||
code: error instanceof ReportTemplateError ? error.code : fallbackCode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
|
||||
const assertAllowedRemoteUrl = (value: unknown, label: string): URL => {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(String(value || ''))
|
||||
} catch {
|
||||
throw new ReportTemplateError('invalid_catalog', `${label} URL 无效`)
|
||||
}
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
parsed.hostname !== RAW_HOST ||
|
||||
parsed.port !== '' ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash ||
|
||||
!parsed.pathname.startsWith(RAW_REPOSITORY_PREFIX)
|
||||
) {
|
||||
throw new ReportTemplateError('invalid_catalog', `${label} 只允许 GitHub raw HTTPS 地址`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const assertAllowedPackageUrl = (value: unknown, label: string): URL => {
|
||||
const parsed = assertAllowedRemoteUrl(value, label)
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
if (segments.length < 4 || !COMMIT.test(segments[2])) {
|
||||
throw new ReportTemplateError('invalid_catalog', `${label} 必须固定到模板仓库 commit`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const remoteCommit = (value: string): string | undefined => {
|
||||
try {
|
||||
return new URL(value).pathname.split('/').filter(Boolean)[2]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBytes = async (url: URL, maxBytes: number): Promise<Buffer> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json, application/zip, image/png', 'User-Agent': 'TraceMemo' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
redirect: 'error'
|
||||
})
|
||||
if (!response.ok) throw new ReportTemplateError('catalog_fetch_failed', `远端请求失败:HTTP ${response.status}`)
|
||||
const contentLength = Number(response.headers.get('content-length') || 0)
|
||||
if (contentLength > maxBytes) throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制')
|
||||
if (!response.body) {
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
if (bytes.length > maxBytes) throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制')
|
||||
return bytes
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel()
|
||||
throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制')
|
||||
}
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return Buffer.concat(chunks, total)
|
||||
}
|
||||
|
||||
const validateCatalogEntry = (value: unknown): ReportTemplateCatalogEntry => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ReportTemplateError('invalid_catalog', '目录条目必须是对象')
|
||||
}
|
||||
const entry = value as Record<string, unknown>
|
||||
const id = String(entry.id || '')
|
||||
const version = String(entry.version || '')
|
||||
const interfaceVersion = String(entry.interfaceVersion || '')
|
||||
const sizeBytes = Number(entry.sizeBytes)
|
||||
if (!SAFE_ID.test(id) || !SAFE_VERSION.test(version)) {
|
||||
throw new ReportTemplateError('invalid_catalog', '目录模板 ID 或版本无效')
|
||||
}
|
||||
if (interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) {
|
||||
throw new ReportTemplateError('unsupported_interface', `模板接口版本不兼容:${interfaceVersion}`)
|
||||
}
|
||||
if (entry.status !== 'published' || !Number.isSafeInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > REPORT_TEMPLATE_LIMITS.maxCompressedBytes) {
|
||||
throw new ReportTemplateError('invalid_catalog', `${id}@${version} 目录状态或大小无效`)
|
||||
}
|
||||
if (typeof entry.name !== 'string' || !entry.name.trim() || typeof entry.description !== 'string' || typeof entry.author !== 'string') {
|
||||
throw new ReportTemplateError('invalid_catalog', `${id}@${version} 目录名称或作者无效`)
|
||||
}
|
||||
if (!SHA256.test(String(entry.sha256 || ''))) throw new ReportTemplateError('invalid_catalog', `${id}@${version} SHA-256 无效`)
|
||||
const download = assertAllowedPackageUrl(entry.download, 'download')
|
||||
const preview = entry.preview === undefined || entry.preview === null ? undefined : assertAllowedPackageUrl(entry.preview, 'preview')
|
||||
const platform = entry.platform === 'mobile' || entry.platform === 'desktop' || entry.platform === 'default' ? entry.platform : undefined
|
||||
const tags = Array.isArray(entry.tags) && entry.tags.every((tag) => typeof tag === 'string') ? entry.tags : []
|
||||
return {
|
||||
id,
|
||||
version,
|
||||
interfaceVersion,
|
||||
name: entry.name.trim(),
|
||||
description: entry.description,
|
||||
author: entry.author,
|
||||
...(platform ? { platform } : {}),
|
||||
tags,
|
||||
license: typeof entry.license === 'string' ? entry.license : '',
|
||||
minAppVersion: typeof entry.minAppVersion === 'string' ? entry.minAppVersion : null,
|
||||
download: download.toString(),
|
||||
sizeBytes,
|
||||
sha256: String(entry.sha256),
|
||||
...(preview ? { preview: preview.toString() } : {}),
|
||||
publishedAt: typeof entry.publishedAt === 'string' ? entry.publishedAt : null,
|
||||
status: 'published'
|
||||
}
|
||||
}
|
||||
|
||||
const parseCatalog = (bytes: Buffer): ReportTemplateCatalog => {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'))
|
||||
} catch {
|
||||
throw new ReportTemplateError('invalid_catalog', '远端模板目录不是有效 JSON')
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ReportTemplateError('invalid_catalog', '远端模板目录格式无效')
|
||||
}
|
||||
const raw = value as Record<string, unknown>
|
||||
if (raw.schemaVersion !== '1' || raw.status !== 'published' || !Array.isArray(raw.templates) || raw.templates.length > 100) {
|
||||
throw new ReportTemplateError('invalid_catalog', '远端模板目录版本或状态无效')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const templates = raw.templates.map(validateCatalogEntry)
|
||||
for (const entry of templates) {
|
||||
const key = `${entry.id}@${entry.version}`
|
||||
if (seen.has(key)) throw new ReportTemplateError('invalid_catalog', `目录包含重复模板:${key}`)
|
||||
seen.add(key)
|
||||
}
|
||||
const source = raw.source && typeof raw.source === 'object' && !Array.isArray(raw.source)
|
||||
? raw.source as Record<string, unknown>
|
||||
: undefined
|
||||
if (
|
||||
!source ||
|
||||
source.repository !== 'Wxw-Gu/TraceMemo-Templates' ||
|
||||
!COMMIT.test(String(source.commit || ''))
|
||||
) {
|
||||
throw new ReportTemplateError('invalid_catalog', '目录 source.repository 或 source.commit 无效')
|
||||
}
|
||||
for (const entry of templates) {
|
||||
if (
|
||||
remoteCommit(entry.download) !== source.commit ||
|
||||
(entry.preview !== undefined && remoteCommit(entry.preview) !== source.commit)
|
||||
) {
|
||||
throw new ReportTemplateError('invalid_catalog', `${entry.id}@${entry.version} 未固定到目录 source.commit`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
schemaVersion: '1',
|
||||
...(typeof raw.generatedAt === 'string' ? { generatedAt: raw.generatedAt } : {}),
|
||||
...(source ? { source: { ...(typeof source.repository === 'string' ? { repository: source.repository } : {}), ...(typeof source.commit === 'string' ? { commit: source.commit } : {}) } } : {}),
|
||||
status: 'published',
|
||||
templates
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCatalog = async (): Promise<ReportTemplateCatalog> => {
|
||||
const url = assertAllowedRemoteUrl(REPORT_TEMPLATE_CATALOG_URL, 'catalog')
|
||||
return parseCatalog(await fetchBytes(url, CATALOG_MAX_BYTES))
|
||||
}
|
||||
|
||||
export class ReportTemplateMarketService {
|
||||
async listCatalog(): Promise<ReportTemplateCatalogResult> {
|
||||
try {
|
||||
return { success: true, catalog: await fetchCatalog() }
|
||||
} catch (error) {
|
||||
return operationError('catalog_fetch_failed', error) as ReportTemplateCatalogResult
|
||||
}
|
||||
}
|
||||
|
||||
async installFromCatalog(id: string, version: string): Promise<ReportTemplateCatalogInstallResult> {
|
||||
let temporaryDirectory: string | undefined
|
||||
try {
|
||||
const catalog = await fetchCatalog()
|
||||
const entry = catalog.templates.find((candidate) => candidate.id === id && candidate.version === version)
|
||||
if (!entry) return { success: false, code: 'catalog_template_not_found', error: `远端目录不存在:${id}@${version}` }
|
||||
const bytes = await fetchBytes(assertAllowedPackageUrl(entry.download, 'download'), REPORT_TEMPLATE_LIMITS.maxCompressedBytes)
|
||||
const digest = crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
if (bytes.length !== entry.sizeBytes || digest !== entry.sha256) {
|
||||
return { success: false, code: 'download_integrity_failed', error: `模板包校验失败:${id}@${version}`, catalogEntry: entry }
|
||||
}
|
||||
temporaryDirectory = await fs.mkdtemp(path.join(app.getPath('temp'), 'tracememo-template-market-'))
|
||||
const packagePath = path.join(temporaryDirectory, `${id}-${version}.zip`)
|
||||
await fs.writeFile(packagePath, bytes, { flag: 'wx' })
|
||||
const template = await reportTemplateService.install(packagePath, {
|
||||
id: entry.id,
|
||||
version: entry.version
|
||||
})
|
||||
return { success: true, template, catalogEntry: entry }
|
||||
} catch (error) {
|
||||
return { ...(operationError('market_install_failed', error) as ReportTemplateCatalogInstallResult) }
|
||||
} finally {
|
||||
if (temporaryDirectory) await fs.rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reportTemplateMarketService = new ReportTemplateMarketService()
|
||||
@@ -0,0 +1,403 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Open } from 'unzipper'
|
||||
import { parseFragment } from 'parse5'
|
||||
import * as csstree from 'css-tree'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
DEFAULT_REPORT_TEMPLATE,
|
||||
REPORT_TEMPLATES,
|
||||
type ReportTemplateDefinition
|
||||
} from '../shared/report-templates'
|
||||
import {
|
||||
REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS,
|
||||
REPORT_TEMPLATE_ALLOWED_ATTRS,
|
||||
REPORT_TEMPLATE_ALLOWED_TAGS,
|
||||
REPORT_TEMPLATE_INTERFACE_VERSION,
|
||||
REPORT_TEMPLATE_LIMITS,
|
||||
REPORT_TEMPLATE_PACKAGE_PROTOCOL_VERSION,
|
||||
REPORT_TEMPLATE_PLACEHOLDERS,
|
||||
type InstalledReportTemplate,
|
||||
type ReportTemplateManifest,
|
||||
type ReportTemplateRef,
|
||||
ReportTemplateError
|
||||
} from '../shared/report-template-package'
|
||||
|
||||
const INDEX_FILE = 'index.json'
|
||||
const INSTALLED_DIR = 'installed'
|
||||
const STAGING_DIR = 'staging'
|
||||
const SAFE_ID = /^community\.github\.[a-z0-9][a-z0-9-]{0,38}\.[a-z0-9][a-z0-9-]{0,63}$/
|
||||
const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
|
||||
const SAFE_RELATIVE_PATH = /^[^\\/][^:]*$/
|
||||
|
||||
interface TemplateIndex {
|
||||
version: 1
|
||||
templates: InstalledReportTemplate[]
|
||||
}
|
||||
|
||||
interface EntryInfo {
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
size: number
|
||||
entry: Awaited<ReturnType<typeof Open.file>>['files'][number]
|
||||
}
|
||||
|
||||
function rootPath(): string {
|
||||
return path.join(app.getPath('userData'), 'report-templates')
|
||||
}
|
||||
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate)
|
||||
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function validateRelativePath(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !value || !SAFE_RELATIVE_PATH.test(value)) {
|
||||
throw new ReportTemplateError('invalid_path', `${label} 必须是包内相对路径`)
|
||||
}
|
||||
const normalized = path.posix.normalize(value.replace(/\\/g, '/'))
|
||||
if (normalized === '.' || normalized.startsWith('../') || normalized.includes('/../') || path.posix.isAbsolute(normalized)) {
|
||||
throw new ReportTemplateError('invalid_path', `${label} 不能越出模板包目录`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown): ReportTemplateManifest {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ReportTemplateError('invalid_manifest', 'manifest.json 必须是对象')
|
||||
}
|
||||
const manifest = value as Record<string, unknown>
|
||||
const author = manifest.author as Record<string, unknown> | undefined
|
||||
const capture = manifest.capture as Record<string, unknown> | undefined
|
||||
const license = manifest.license as Record<string, unknown> | undefined
|
||||
if (manifest.protocolVersion !== REPORT_TEMPLATE_PACKAGE_PROTOCOL_VERSION) {
|
||||
throw new ReportTemplateError('unsupported_protocol', '模板包协议版本不受支持')
|
||||
}
|
||||
if (manifest.kind !== 'daily-report' || typeof manifest.id !== 'string' || !SAFE_ID.test(manifest.id)) {
|
||||
throw new ReportTemplateError('invalid_manifest', '模板类型或 ID 无效')
|
||||
}
|
||||
if (typeof manifest.name !== 'string' || !manifest.name.trim() || !author || typeof author.name !== 'string') {
|
||||
throw new ReportTemplateError('invalid_manifest', '模板名称和作者不能为空')
|
||||
}
|
||||
if (typeof manifest.templateVersion !== 'string' || !SAFE_VERSION.test(manifest.templateVersion)) {
|
||||
throw new ReportTemplateError('invalid_version', 'templateVersion 必须是 semver')
|
||||
}
|
||||
if (manifest.interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) {
|
||||
throw new ReportTemplateError('unsupported_interface', '占位符接口版本不受支持')
|
||||
}
|
||||
const entry = validateRelativePath(manifest.entry, 'entry')
|
||||
const preview = manifest.preview === undefined ? undefined : validateRelativePath(manifest.preview, 'preview')
|
||||
const captureWidth = capture?.width
|
||||
const captureMaxWidth = capture?.maxWidth
|
||||
const captureMaxHeight = capture?.maxHeight
|
||||
if (!capture || !Number.isInteger(captureWidth) || !Number.isInteger(captureMaxWidth) || !Number.isInteger(captureMaxHeight)) {
|
||||
throw new ReportTemplateError('invalid_manifest', 'capture 尺寸无效')
|
||||
}
|
||||
if ((captureWidth as number) < 320 || (captureWidth as number) > 1920 || (captureMaxWidth as number) < (captureWidth as number) || (captureMaxWidth as number) > 1920 || (captureMaxHeight as number) < 800 || (captureMaxHeight as number) > 20000) {
|
||||
throw new ReportTemplateError('invalid_manifest', 'capture 尺寸超出允许范围')
|
||||
}
|
||||
if (!license || typeof license.spdx !== 'string' || !license.spdx.trim()) {
|
||||
throw new ReportTemplateError('invalid_manifest', '必须声明 license.spdx')
|
||||
}
|
||||
return {
|
||||
protocolVersion: manifest.protocolVersion,
|
||||
kind: 'daily-report',
|
||||
id: manifest.id,
|
||||
name: manifest.name.trim(),
|
||||
author: { name: author.name.trim(), ...(typeof author.homepage === 'string' ? { homepage: author.homepage } : {}) },
|
||||
templateVersion: manifest.templateVersion,
|
||||
interfaceVersion: manifest.interfaceVersion,
|
||||
entry,
|
||||
...(preview ? { preview } : {}),
|
||||
capture: { width: captureWidth as number, maxWidth: captureMaxWidth as number, maxHeight: captureMaxHeight as number },
|
||||
license: { spdx: license.spdx.trim(), ...(typeof license.notice === 'string' ? { notice: license.notice } : {}) },
|
||||
...(typeof manifest.minAppVersion === 'string' ? { minAppVersion: manifest.minAppVersion } : {}),
|
||||
...(manifest.platform === 'mobile' || manifest.platform === 'desktop' || manifest.platform === 'default' ? { platform: manifest.platform } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function validateCss(css: string, fileName: string): void {
|
||||
if (Buffer.byteLength(css, 'utf8') > REPORT_TEMPLATE_LIMITS.maxCssBytes) {
|
||||
throw new ReportTemplateError('file_too_large', `${fileName} 超过 CSS 大小限制`)
|
||||
}
|
||||
let ast: csstree.CssNode
|
||||
try {
|
||||
ast = csstree.parse(css, { positions: false })
|
||||
} catch (error) {
|
||||
throw new ReportTemplateError('invalid_css', `${fileName} CSS 解析失败:${String(error)}`)
|
||||
}
|
||||
csstree.walk(ast, (node) => {
|
||||
if (node.type === 'Atrule' && node.name.toLowerCase() === 'import') {
|
||||
throw new ReportTemplateError('unsafe_css', `${fileName} 不允许 @import`)
|
||||
}
|
||||
if (node.type === 'Url' || (node.type === 'Function' && node.name.toLowerCase() === 'url')) {
|
||||
const raw = csstree.generate(node)
|
||||
if (/^(?:url\()?\s*(?:https?:|file:|data:|javascript:)/i.test(raw.trim())) {
|
||||
throw new ReportTemplateError('unsafe_url', `${fileName} 包含危险资源 URL`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function validateReportTemplateHtml(html: string, fileName = 'template.html'): void {
|
||||
if (Buffer.byteLength(html, 'utf8') > REPORT_TEMPLATE_LIMITS.maxHtmlBytes) {
|
||||
throw new ReportTemplateError('file_too_large', `${fileName} 超过 HTML 大小限制`)
|
||||
}
|
||||
const placeholders = [...html.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)].map((match) => match[1])
|
||||
for (const key of placeholders) {
|
||||
if (!REPORT_TEMPLATE_PLACEHOLDERS[key]) throw new ReportTemplateError('unknown_placeholder', `不支持占位符 {{${key}}}`)
|
||||
}
|
||||
const fragment = parseFragment(html)
|
||||
const visit = (node: { nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }): void => {
|
||||
const tag = node.nodeName.toLowerCase()
|
||||
if (tag === '#text' || tag === '#comment' || tag === '#document-fragment') {
|
||||
for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child)
|
||||
return
|
||||
}
|
||||
if (!REPORT_TEMPLATE_ALLOWED_TAGS.has(tag)) throw new ReportTemplateError('unsafe_html', `${fileName} 不允许标签 <${tag}>`)
|
||||
for (const attr of node.attrs || []) {
|
||||
const name = attr.name.toLowerCase()
|
||||
if (!REPORT_TEMPLATE_ALLOWED_ATTRS.has(name) || name.startsWith('on')) throw new ReportTemplateError('unsafe_html', `${fileName} 不允许属性 ${attr.name}`)
|
||||
if (/\{\{[A-Z0-9_]+\}\}/.test(attr.value)) {
|
||||
const keys = [...attr.value.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)].map((match) => match[1])
|
||||
if (name !== 'class' || keys.some((key) => REPORT_TEMPLATE_PLACEHOLDERS[key] !== 'class')) {
|
||||
throw new ReportTemplateError('invalid_placeholder_context', `占位符不能出现在 ${name} 属性中`)
|
||||
}
|
||||
}
|
||||
if (name === 'src') {
|
||||
const assetPath = path.posix.normalize(attr.value.replace(/\\/g, '/'))
|
||||
if (!assetPath.startsWith('assets/') || !REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS.has(path.posix.extname(assetPath).toLowerCase())) {
|
||||
throw new ReportTemplateError('unsafe_url', `${fileName} 只允许 assets/ 下的图片资源`)
|
||||
}
|
||||
}
|
||||
if (['href', 'action', 'poster'].includes(name)) throw new ReportTemplateError('unsafe_url', `${fileName} 不允许 URL 属性`)
|
||||
}
|
||||
if (tag === 'style') {
|
||||
const text = (node.childNodes || []).map((child) => (child as { value?: string }).value || '').join('')
|
||||
if (/\{\{[A-Z0-9_]+\}\}/.test(text)) throw new ReportTemplateError('invalid_placeholder_context', '占位符不能出现在 style 中')
|
||||
validateCss(text, fileName)
|
||||
}
|
||||
for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child)
|
||||
}
|
||||
visit(fragment)
|
||||
}
|
||||
|
||||
function collectTemplateAssetRefs(html: string): string[] {
|
||||
const refs: string[] = []
|
||||
const fragment = parseFragment(html)
|
||||
const visit = (node: { nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }): void => {
|
||||
if (node.nodeName.toLowerCase() === 'img') {
|
||||
const src = node.attrs?.find((attr) => attr.name.toLowerCase() === 'src')?.value
|
||||
if (src) refs.push(path.posix.normalize(src.replace(/\\/g, '/')))
|
||||
}
|
||||
for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child)
|
||||
}
|
||||
visit(fragment)
|
||||
return refs
|
||||
}
|
||||
|
||||
async function readIndex(directory: string): Promise<TemplateIndex> {
|
||||
try {
|
||||
const value = JSON.parse(await fs.readFile(path.join(directory, INDEX_FILE), 'utf8')) as Partial<TemplateIndex>
|
||||
if (value.version !== 1 || !Array.isArray(value.templates)) throw new Error('invalid index')
|
||||
return { version: 1, templates: value.templates }
|
||||
} catch {
|
||||
return { version: 1, templates: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(directory: string, index: TemplateIndex): Promise<void> {
|
||||
const temporary = `${path.join(directory, INDEX_FILE)}.tmp-${process.pid}-${Date.now()}`
|
||||
await fs.writeFile(temporary, JSON.stringify(index, null, 2), 'utf8')
|
||||
await fs.rename(temporary, path.join(directory, INDEX_FILE))
|
||||
}
|
||||
|
||||
function builtInTemplate(template: ReportTemplateDefinition): InstalledReportTemplate {
|
||||
const resourceRoot = path.join(process.resourcesPath || process.cwd(), 'resources')
|
||||
const entryPath = path.join(resourceRoot, template.resourceFile)
|
||||
return {
|
||||
id: template.id,
|
||||
version: 'builtin',
|
||||
interfaceVersion: REPORT_TEMPLATE_INTERFACE_VERSION,
|
||||
source: 'builtin',
|
||||
name: template.name,
|
||||
author: 'TraceMemo',
|
||||
entryPath,
|
||||
capture: { width: template.captureWidth, maxWidth: template.maxCaptureWidth, maxHeight: 20000 },
|
||||
license: { spdx: 'NOASSERTION' }
|
||||
}
|
||||
}
|
||||
|
||||
export class ReportTemplateService {
|
||||
private readonly directory = rootPath()
|
||||
|
||||
async list(): Promise<InstalledReportTemplate[]> {
|
||||
await fs.mkdir(path.join(this.directory, INSTALLED_DIR), { recursive: true })
|
||||
const index = await readIndex(this.directory)
|
||||
return [builtInTemplate(DEFAULT_REPORT_TEMPLATE), ...REPORT_TEMPLATES.map(builtInTemplate), ...index.templates]
|
||||
}
|
||||
|
||||
async resolve(ref: ReportTemplateRef): Promise<InstalledReportTemplate> {
|
||||
const id = String(ref?.id || '').trim()
|
||||
if (!id) throw new ReportTemplateError('missing_template', '缺少模板 ID')
|
||||
const templates = await this.list()
|
||||
const candidates = templates.filter((template) => template.id === id)
|
||||
if (!candidates.length) throw new ReportTemplateError('template_not_found', `模板不存在:${id}`)
|
||||
if (candidates[0].source === 'builtin') {
|
||||
throw new ReportTemplateError('builtin_template_ref', `外部模板引用不能使用内置模板:${id}`)
|
||||
}
|
||||
const found = ref.version ? candidates.find((template) => template.version === ref.version) : candidates.sort((a, b) => b.version.localeCompare(a.version))[0]
|
||||
if (!found) throw new ReportTemplateError('template_version_not_found', `模板版本不存在:${id}@${ref.version}`)
|
||||
if (found.interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) throw new ReportTemplateError('unsupported_interface', `模板接口版本不兼容:${found.interfaceVersion}`)
|
||||
return found
|
||||
}
|
||||
|
||||
async install(packagePath: string, expectedRef?: ReportTemplateRef): Promise<InstalledReportTemplate> {
|
||||
const stat = await fs.stat(packagePath)
|
||||
if (!stat.isFile() || stat.size > REPORT_TEMPLATE_LIMITS.maxCompressedBytes) throw new ReportTemplateError('package_too_large', '模板 ZIP 超过大小限制')
|
||||
const archive = await Open.file(packagePath)
|
||||
if (archive.files.length === 0 || archive.files.length > REPORT_TEMPLATE_LIMITS.maxFiles) throw new ReportTemplateError('too_many_files', '模板包文件数量无效')
|
||||
const seen = new Set<string>()
|
||||
const entries: EntryInfo[] = []
|
||||
let declaredTotal = 0
|
||||
for (const entry of archive.files) {
|
||||
const normalized = validateRelativePath(entry.path, 'ZIP 条目')
|
||||
const collisionKey = normalized.toLowerCase()
|
||||
if (seen.has(collisionKey)) throw new ReportTemplateError('duplicate_entry', `ZIP 条目规范化后重复:${normalized}`)
|
||||
seen.add(collisionKey)
|
||||
if (entry.type !== 'Directory' && entry.type !== 'File') throw new ReportTemplateError('unsafe_entry', `ZIP 条目类型不受支持:${normalized}`)
|
||||
const type = entry.type === 'Directory' ? 'directory' : 'file'
|
||||
const size = Number(entry.uncompressedSize || 0)
|
||||
if (type === 'file' && size > REPORT_TEMPLATE_LIMITS.maxFileBytes) throw new ReportTemplateError('file_too_large', `ZIP 条目过大:${normalized}`)
|
||||
if (type === 'file') declaredTotal += size
|
||||
if (declaredTotal > REPORT_TEMPLATE_LIMITS.maxExtractedBytes) throw new ReportTemplateError('package_too_large', '模板包解压体积超过限制')
|
||||
if (type === 'file' && (normalized.endsWith('.html') || normalized.endsWith('.css')) && size > REPORT_TEMPLATE_LIMITS.maxHtmlBytes) throw new ReportTemplateError('file_too_large', `模板文件过大:${normalized}`)
|
||||
entries.push({ path: normalized, type, size, entry })
|
||||
}
|
||||
const manifestEntry = entries.find((entry) => entry.path === 'manifest.json' && entry.type === 'file')
|
||||
if (!manifestEntry) throw new ReportTemplateError('invalid_manifest', '模板包缺少 manifest.json')
|
||||
const manifest = parseManifest(JSON.parse((await manifestEntry.entry.buffer()).toString('utf8')))
|
||||
if (manifest.id.startsWith('builtin.')) throw new ReportTemplateError('reserved_id', '外部模板不能使用内置命名空间')
|
||||
if (
|
||||
expectedRef &&
|
||||
(manifest.id !== expectedRef.id || manifest.templateVersion !== expectedRef.version)
|
||||
) {
|
||||
throw new ReportTemplateError(
|
||||
'catalog_manifest_mismatch',
|
||||
`模板包 manifest 与目录条目不一致:${manifest.id}@${manifest.templateVersion}`
|
||||
)
|
||||
}
|
||||
const entry = entries.find((item) => item.path === manifest.entry && item.type === 'file')
|
||||
if (!entry) throw new ReportTemplateError('missing_entry', 'manifest.entry 文件不存在')
|
||||
const html = (await entry.entry.buffer()).toString('utf8')
|
||||
validateReportTemplateHtml(html, manifest.entry)
|
||||
const assetPaths = new Set(entries.filter((item) => item.type === 'file').map((item) => item.path))
|
||||
for (const asset of collectTemplateAssetRefs(html)) {
|
||||
if (!assetPaths.has(asset)) throw new ReportTemplateError('missing_asset', `模板图片资源不存在:${asset}`)
|
||||
}
|
||||
const preview = manifest.preview ? entries.find((item) => item.path === manifest.preview && item.type === 'file') : undefined
|
||||
if (manifest.preview && !preview) throw new ReportTemplateError('missing_preview', 'manifest.preview 文件不存在')
|
||||
for (const item of entries.filter((candidate) => candidate.type === 'file')) {
|
||||
const ext = path.extname(item.path).toLowerCase()
|
||||
if (item.path !== 'manifest.json' && item.path !== manifest.entry && item.path !== manifest.preview && !REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS.has(ext)) {
|
||||
throw new ReportTemplateError('unsupported_resource', `不支持的模板资源:${item.path}`)
|
||||
}
|
||||
}
|
||||
const staging = path.join(this.directory, STAGING_DIR, `${process.pid}-${Date.now()}-${crypto.randomUUID()}`)
|
||||
await fs.mkdir(staging, { recursive: true })
|
||||
try {
|
||||
let actualTotal = 0
|
||||
for (const item of entries) {
|
||||
const destination = path.resolve(staging, item.path)
|
||||
if (!isWithin(staging, destination)) throw new ReportTemplateError('invalid_path', `ZIP 条目越界:${item.path}`)
|
||||
if (item.type === 'directory') {
|
||||
await fs.mkdir(destination, { recursive: true })
|
||||
continue
|
||||
}
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
const stream = item.entry.stream()
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.from(chunk as Uint8Array)
|
||||
actualTotal += buffer.length
|
||||
if (actualTotal > REPORT_TEMPLATE_LIMITS.maxExtractedBytes || buffer.length > REPORT_TEMPLATE_LIMITS.maxFileBytes) throw new ReportTemplateError('package_too_large', '模板包实际解压体积超过限制')
|
||||
chunks.push(buffer)
|
||||
}
|
||||
await fs.writeFile(destination, Buffer.concat(chunks))
|
||||
const fileStat = await fs.lstat(destination)
|
||||
if (!fileStat.isFile()) throw new ReportTemplateError('invalid_file', `模板文件类型无效:${item.path}`)
|
||||
}
|
||||
const target = path.join(this.directory, INSTALLED_DIR, manifest.id, manifest.templateVersion)
|
||||
const existingIndex = await readIndex(this.directory)
|
||||
const existing = existingIndex.templates.find((item) => item.id === manifest.id && item.version === manifest.templateVersion)
|
||||
const digest = crypto.createHash('sha256').update(await fs.readFile(packagePath)).digest('hex')
|
||||
if (existing) {
|
||||
if (existing.sha256 !== digest) throw new ReportTemplateError('version_conflict', `模板版本已存在但内容不同:${manifest.id}@${manifest.templateVersion}`)
|
||||
return existing
|
||||
}
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.rename(staging, target)
|
||||
const installed: InstalledReportTemplate = {
|
||||
id: manifest.id, version: manifest.templateVersion, interfaceVersion: manifest.interfaceVersion,
|
||||
source: 'installed', name: manifest.name, author: manifest.author.name,
|
||||
entryPath: path.join(target, manifest.entry), ...(manifest.preview ? { previewPath: path.join(target, manifest.preview) } : {}),
|
||||
capture: manifest.capture, license: manifest.license, packagePath, sha256: digest, installedAt: new Date().toISOString()
|
||||
}
|
||||
existingIndex.templates = [...existingIndex.templates.filter((item) => !(item.id === installed.id && item.version === installed.version)), installed]
|
||||
await writeIndex(this.directory, existingIndex)
|
||||
return installed
|
||||
} finally {
|
||||
// 同版本幂等返回也必须清理 staging,避免重复安装逐渐堆积临时目录。
|
||||
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(id: string, version: string): Promise<void> {
|
||||
if (!SAFE_ID.test(id) || !SAFE_VERSION.test(version)) {
|
||||
throw new ReportTemplateError('invalid_template_ref', '模板 ID 或版本无效')
|
||||
}
|
||||
const index = await readIndex(this.directory)
|
||||
const target = index.templates.find((item) => item.id === id && item.version === version)
|
||||
if (!target) throw new ReportTemplateError('template_not_found', `模板版本不存在:${id}@${version}`)
|
||||
await fs.rm(path.join(this.directory, INSTALLED_DIR, id, version), { recursive: true, force: true })
|
||||
index.templates = index.templates.filter((item) => item !== target)
|
||||
await writeIndex(this.directory, index)
|
||||
}
|
||||
|
||||
async recover(): Promise<void> {
|
||||
const installedRoot = path.join(this.directory, INSTALLED_DIR)
|
||||
await fs.mkdir(installedRoot, { recursive: true })
|
||||
const previous = await readIndex(this.directory)
|
||||
const recovered: InstalledReportTemplate[] = []
|
||||
for (const id of await fs.readdir(installedRoot)) {
|
||||
for (const version of await fs.readdir(path.join(installedRoot, id))) {
|
||||
const entryPath = path.join(installedRoot, id, version)
|
||||
try {
|
||||
const manifest = parseManifest(JSON.parse(await fs.readFile(path.join(entryPath, 'manifest.json'), 'utf8')))
|
||||
const recoveredEntryPath = path.join(entryPath, manifest.entry)
|
||||
const entryStat = await fs.lstat(recoveredEntryPath)
|
||||
if (!entryStat.isFile()) continue
|
||||
const candidate = previous.templates.find((item) => item.id === manifest.id && item.version === manifest.templateVersion)
|
||||
recovered.push(candidate && candidate.source === 'installed'
|
||||
? { ...candidate, entryPath: recoveredEntryPath, previewPath: manifest.preview ? path.join(entryPath, manifest.preview) : undefined }
|
||||
: {
|
||||
id: manifest.id,
|
||||
version: manifest.templateVersion,
|
||||
interfaceVersion: manifest.interfaceVersion,
|
||||
source: 'installed',
|
||||
name: manifest.name,
|
||||
author: manifest.author.name,
|
||||
entryPath: recoveredEntryPath,
|
||||
...(manifest.preview ? { previewPath: path.join(entryPath, manifest.preview) } : {}),
|
||||
capture: manifest.capture,
|
||||
license: manifest.license
|
||||
})
|
||||
} catch { /* 忽略损坏的安装目录,保留其他模板 */ }
|
||||
}
|
||||
}
|
||||
await writeIndex(this.directory, { version: 1, templates: recovered })
|
||||
}
|
||||
}
|
||||
|
||||
export const reportTemplateService = new ReportTemplateService()
|
||||
@@ -0,0 +1,44 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import path from 'node:path'
|
||||
|
||||
const userData = process.env.TRACEMEMO_TEMPLATE_TEST_USER_DATA
|
||||
if (!userData) throw new Error('TRACEMEMO_TEMPLATE_TEST_USER_DATA is required')
|
||||
app.setPath('userData', userData)
|
||||
app.setPath('logs', path.join(userData, 'logs'))
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const { registerReportTemplateIpc } = await import('./report-template-ipc')
|
||||
const { reportTemplateService } = await import('./report-template-service')
|
||||
const {
|
||||
deleteGeneratedReport,
|
||||
listGeneratedReports,
|
||||
prepareGeneratedReportTemplateSwitch,
|
||||
saveGeneratedReport,
|
||||
updateGeneratedReportTemplate
|
||||
} = await import('./report-history-service')
|
||||
const { exportGroupReportSnapshot, extractGroupReportRenderSnapshot } = await import('./group-report-service')
|
||||
await reportTemplateService.recover()
|
||||
registerReportTemplateIpc()
|
||||
ipcMain.handle('report:listGenerated', () => listGeneratedReports())
|
||||
ipcMain.handle('report:saveGenerated', (_, request) => saveGeneratedReport(request))
|
||||
ipcMain.handle('report:updateGeneratedTemplate', (_, request) => updateGeneratedReportTemplate(request))
|
||||
ipcMain.handle('report:prepareTemplateSwitch', (_, request: { reportId: string }) =>
|
||||
prepareGeneratedReportTemplateSwitch(request.reportId, extractGroupReportRenderSnapshot)
|
||||
)
|
||||
ipcMain.handle('report:deleteGenerated', (_, reportId: string) => deleteGeneratedReport(reportId))
|
||||
ipcMain.handle('report:exportSnapshot', (_, request) => exportGroupReportSnapshot(request))
|
||||
const window = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
await window.loadFile(path.join(__dirname, '../renderer/index.html'))
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => app.quit())
|
||||
Vendored
+16
@@ -5,6 +5,11 @@ import {
|
||||
GroupReportExportResult,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type { InstalledReportTemplate, ReportTemplateOperationResult } from '../shared/report-template-package'
|
||||
import type {
|
||||
ReportTemplateCatalogInstallResult,
|
||||
ReportTemplateCatalogResult
|
||||
} from '../shared/report-template-market'
|
||||
import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test'
|
||||
import {
|
||||
DeleteGeneratedReportResult,
|
||||
@@ -400,6 +405,17 @@ declare global {
|
||||
exportGroupReportSnapshot: (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
) => Promise<GroupReportExportResult>
|
||||
listReportTemplates: () => Promise<InstalledReportTemplate[]>
|
||||
installReportTemplate: (packagePath: string) => Promise<ReportTemplateOperationResult>
|
||||
uninstallReportTemplate: (
|
||||
id: string,
|
||||
version: string
|
||||
) => Promise<ReportTemplateOperationResult>
|
||||
listReportTemplateCatalog: () => Promise<ReportTemplateCatalogResult>
|
||||
installReportTemplateFromCatalog: (
|
||||
id: string,
|
||||
version: string
|
||||
) => Promise<ReportTemplateCatalogInstallResult>
|
||||
prepareGeneratedReportTemplateSwitch: (
|
||||
reportId: string
|
||||
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
|
||||
|
||||
@@ -4,6 +4,11 @@ import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type { ReportTemplateOperationResult } from '../shared/report-template-package'
|
||||
import type {
|
||||
ReportTemplateCatalogInstallResult,
|
||||
ReportTemplateCatalogResult
|
||||
} from '../shared/report-template-market'
|
||||
import type {
|
||||
SaveGeneratedReportRequest,
|
||||
UpdateGeneratedReportTemplateRequest
|
||||
@@ -295,6 +300,18 @@ const api = {
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
exportGroupReportSnapshot: (request: GroupReportRenderSnapshotExportRequest) =>
|
||||
ipcRenderer.invoke('report:exportSnapshot', request),
|
||||
listReportTemplates: () => ipcRenderer.invoke('report-template:list'),
|
||||
installReportTemplate: (packagePath: string): Promise<ReportTemplateOperationResult> =>
|
||||
ipcRenderer.invoke('report-template:install', packagePath),
|
||||
uninstallReportTemplate: (id: string, version: string): Promise<ReportTemplateOperationResult> =>
|
||||
ipcRenderer.invoke('report-template:uninstall', id, version),
|
||||
listReportTemplateCatalog: (): Promise<ReportTemplateCatalogResult> =>
|
||||
ipcRenderer.invoke('report-template-market:list'),
|
||||
installReportTemplateFromCatalog: (
|
||||
id: string,
|
||||
version: string
|
||||
): Promise<ReportTemplateCatalogInstallResult> =>
|
||||
ipcRenderer.invoke('report-template-market:install', id, version),
|
||||
prepareGeneratedReportTemplateSwitch: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:prepareTemplateSwitch', { reportId }),
|
||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from './utils/message-pages'
|
||||
import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/message-monitor'
|
||||
import { enrichQuotedMessages } from './utils/quoted-messages'
|
||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
|
||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
|
||||
import { useToast } from './components/ui'
|
||||
@@ -1704,7 +1704,7 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleSwitchReportTemplate = async (
|
||||
report: GeneratedReportRecord,
|
||||
templateId: SelectableReportTemplateId
|
||||
templateId: ReportTemplateSelectionId
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const updated = await withTimeout(
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ReportRangeSelector } from './ReportRangeSelector'
|
||||
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
||||
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
|
||||
import { ReportSectionSelector } from './ReportSectionSelector'
|
||||
import { ReportTemplateSelector, SelectableReportTemplateId } from './ReportTemplateSelector'
|
||||
import { ReportTemplateSelector, ReportTemplateSelectionId } from './ReportTemplateSelector'
|
||||
import { Button, Input } from '../ui'
|
||||
|
||||
interface AiReportWorkspaceProps {
|
||||
@@ -47,8 +47,8 @@ interface AiReportWorkspaceProps {
|
||||
onRevealReport: () => Promise<{ success: boolean; error?: string }>
|
||||
onViewResult: () => void
|
||||
hasReportResult: boolean
|
||||
templateId: SelectableReportTemplateId
|
||||
onTemplateIdChange: (value: SelectableReportTemplateId) => void
|
||||
templateId: ReportTemplateSelectionId
|
||||
onTemplateIdChange: (value: ReportTemplateSelectionId) => void
|
||||
memberNamePreference: ReportMemberNamePreference
|
||||
onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void
|
||||
reportTimeoutSeconds: number
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
DEFAULT_REPORT_TEMPLATE,
|
||||
REPORT_TEMPLATES,
|
||||
encodeExternalReportTemplateId,
|
||||
type ReportTemplateDefinition,
|
||||
type SelectableReportTemplateId
|
||||
type ReportTemplateSelectionId
|
||||
} from '../../../../shared/report-templates'
|
||||
import type {
|
||||
ReportTemplateCatalog,
|
||||
ReportTemplateCatalogEntry
|
||||
} from '../../../../shared/report-template-market'
|
||||
import type { InstalledReportTemplate } from '../../../../shared/report-template-package'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -18,14 +24,30 @@ import {
|
||||
RadioGroupItem
|
||||
} from '../ui'
|
||||
|
||||
export type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||
export type {
|
||||
SelectableReportTemplateId,
|
||||
ReportTemplateSelectionId
|
||||
} from '../../../../shared/report-templates'
|
||||
|
||||
interface ReportTemplateSelectorProps {
|
||||
value: SelectableReportTemplateId
|
||||
onChange: (value: SelectableReportTemplateId) => void
|
||||
value: ReportTemplateSelectionId
|
||||
onChange: (value: ReportTemplateSelectionId) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type PreviewItem =
|
||||
| { kind: 'builtin'; template: ReportTemplateDefinition }
|
||||
| {
|
||||
kind: 'external'
|
||||
id: string
|
||||
version: string
|
||||
name: string
|
||||
description: string
|
||||
installed: boolean
|
||||
platform?: 'default' | 'mobile' | 'desktop'
|
||||
preview?: string
|
||||
}
|
||||
|
||||
const TemplateDiagram = ({
|
||||
template
|
||||
}: {
|
||||
@@ -69,11 +91,100 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
onChange,
|
||||
disabled
|
||||
}) => {
|
||||
const [previewing, setPreviewing] = useState<ReportTemplateDefinition | null>(null)
|
||||
const [previewing, setPreviewing] = useState<PreviewItem | null>(null)
|
||||
const [installed, setInstalled] = useState<InstalledReportTemplate[]>([])
|
||||
const [catalog, setCatalog] = useState<ReportTemplateCatalog | null>(null)
|
||||
const [marketError, setMarketError] = useState('')
|
||||
const [marketBusyKey, setMarketBusyKey] = useState('')
|
||||
const previewTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const mobileTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')
|
||||
const desktopTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')
|
||||
|
||||
const refreshMarket = async (): Promise<void> => {
|
||||
if (typeof window === 'undefined' || !window.api) return
|
||||
if (typeof window.api.listReportTemplates === 'function') {
|
||||
try {
|
||||
const listed = await window.api.listReportTemplates()
|
||||
if (Array.isArray(listed)) setInstalled(listed)
|
||||
} catch (error) {
|
||||
setMarketError(error instanceof Error ? error.message : '已安装模板加载失败')
|
||||
}
|
||||
}
|
||||
if (typeof window.api.listReportTemplateCatalog !== 'function') return
|
||||
try {
|
||||
const result = await window.api.listReportTemplateCatalog()
|
||||
if (result.success && result.catalog) {
|
||||
setCatalog(result.catalog)
|
||||
setMarketError('')
|
||||
} else {
|
||||
setMarketError(result.error || '远端模板目录加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
setMarketError(error instanceof Error ? error.message : '远端模板目录加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMarket()
|
||||
}, [])
|
||||
|
||||
const externalInstalled = useMemo(
|
||||
() => installed.filter((template) => template.source === 'installed'),
|
||||
[installed]
|
||||
)
|
||||
const catalogEntries = catalog?.templates || []
|
||||
const installedKeys = useMemo(
|
||||
() => new Set(externalInstalled.map((template) => encodeExternalReportTemplateId(template.id, template.version))),
|
||||
[externalInstalled]
|
||||
)
|
||||
|
||||
const platformLabel = (platform?: 'default' | 'mobile' | 'desktop'): string =>
|
||||
platform === 'desktop' ? '桌面宽屏' : platform === 'default' ? '经典长图' : '手机长图'
|
||||
|
||||
const catalogEntryFor = (template: InstalledReportTemplate): ReportTemplateCatalogEntry | undefined =>
|
||||
catalogEntries.find((entry) => entry.id === template.id && entry.version === template.version)
|
||||
|
||||
const installFromCatalog = async (entry: ReportTemplateCatalogEntry): Promise<void> => {
|
||||
const key = encodeExternalReportTemplateId(entry.id, entry.version)
|
||||
if (typeof window.api.installReportTemplateFromCatalog !== 'function') {
|
||||
setMarketError('当前 TraceMemo 版本不支持模板市场安装')
|
||||
return
|
||||
}
|
||||
setMarketBusyKey(key)
|
||||
setMarketError('')
|
||||
try {
|
||||
const result = await window.api.installReportTemplateFromCatalog(entry.id, entry.version)
|
||||
if (!result.success) {
|
||||
setMarketError(result.error || `模板安装失败:${entry.name}`)
|
||||
return
|
||||
}
|
||||
await refreshMarket()
|
||||
} catch (error) {
|
||||
setMarketError(error instanceof Error ? error.message : `模板安装失败:${entry.name}`)
|
||||
} finally {
|
||||
setMarketBusyKey('')
|
||||
}
|
||||
}
|
||||
|
||||
const uninstallExternal = async (template: InstalledReportTemplate): Promise<void> => {
|
||||
const key = encodeExternalReportTemplateId(template.id, template.version)
|
||||
setMarketBusyKey(key)
|
||||
setMarketError('')
|
||||
try {
|
||||
const result = await window.api.uninstallReportTemplate(template.id, template.version)
|
||||
if (!result.success) {
|
||||
setMarketError(result.error || `模板卸载失败:${template.name}`)
|
||||
return
|
||||
}
|
||||
if (value === key) onChange('v1')
|
||||
await refreshMarket()
|
||||
} catch (error) {
|
||||
setMarketError(error instanceof Error ? error.message : `模板卸载失败:${template.name}`)
|
||||
} finally {
|
||||
setMarketBusyKey('')
|
||||
}
|
||||
}
|
||||
|
||||
const renderGroup = (
|
||||
title: string,
|
||||
templates: readonly ReportTemplateDefinition[]
|
||||
@@ -106,7 +217,7 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
previewTriggerRef.current = event.currentTarget
|
||||
setPreviewing(template)
|
||||
setPreviewing({ kind: 'builtin', template })
|
||||
}}
|
||||
>
|
||||
查看版式
|
||||
@@ -128,12 +239,134 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
className="report-template-catalog"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextValue) => onChange(nextValue as SelectableReportTemplateId)}
|
||||
onValueChange={(nextValue) => onChange(nextValue as ReportTemplateSelectionId)}
|
||||
>
|
||||
{renderGroup('默认模板', [DEFAULT_REPORT_TEMPLATE])}
|
||||
{renderGroup('手机端 · 375–414 px', mobileTemplates)}
|
||||
{renderGroup('电脑端 · 1280–1920 px', desktopTemplates)}
|
||||
{externalInstalled.length > 0 && (
|
||||
<div className="report-template-group">
|
||||
<div className="report-template-group-title">已安装市场模板</div>
|
||||
<div className="report-template-list">
|
||||
{externalInstalled.map((template) => {
|
||||
const key = encodeExternalReportTemplateId(template.id, template.version)
|
||||
const entry = catalogEntryFor(template)
|
||||
const active = value === key
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
>
|
||||
<label htmlFor={`report-template-${key}`}>
|
||||
<RadioGroupItem
|
||||
id={`report-template-${key}`}
|
||||
value={key}
|
||||
aria-label={template.name}
|
||||
/>
|
||||
{entry?.preview ? (
|
||||
<img className="report-template-diagram" src={entry.preview} alt="" />
|
||||
) : (
|
||||
<TemplateDiagram template={DEFAULT_REPORT_TEMPLATE} />
|
||||
)}
|
||||
<div className="report-template-body">
|
||||
<div className="report-template-eyebrow">市场 · v{template.version}</div>
|
||||
<div className="report-template-title">{template.name}</div>
|
||||
<div className="report-template-tagline">
|
||||
{entry?.description || `${template.author} · ${platformLabel(entry?.platform)}`}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
previewTriggerRef.current = event.currentTarget
|
||||
setPreviewing({
|
||||
kind: 'external',
|
||||
id: template.id,
|
||||
version: template.version,
|
||||
name: template.name,
|
||||
description: entry?.description || `${template.author} · ${template.version}`,
|
||||
installed: true,
|
||||
platform: entry?.platform,
|
||||
preview: entry?.preview
|
||||
})
|
||||
}}
|
||||
>
|
||||
查看版式
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled || marketBusyKey === key}
|
||||
onClick={() => void uninstallExternal(template)}
|
||||
>
|
||||
{marketBusyKey === key ? '处理中…' : '卸载'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{catalogEntries.filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version))).length > 0 && (
|
||||
<div className="report-template-group report-template-market">
|
||||
<div className="report-template-group-title">模板市场</div>
|
||||
<div className="report-template-list">
|
||||
{catalogEntries
|
||||
.filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version)))
|
||||
.map((entry) => {
|
||||
const key = encodeExternalReportTemplateId(entry.id, entry.version)
|
||||
return (
|
||||
<div key={key} className="report-template-item">
|
||||
<div className="report-template-body">
|
||||
<div className="report-template-eyebrow">远端 · v{entry.version}</div>
|
||||
<div className="report-template-title">{entry.name}</div>
|
||||
<div className="report-template-tagline">{entry.description}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
接口 {entry.interfaceVersion} · {platformLabel(entry.platform)} · {entry.sizeBytes} bytes
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{entry.preview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
previewTriggerRef.current = event.currentTarget
|
||||
setPreviewing({
|
||||
kind: 'external',
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
installed: false,
|
||||
platform: entry.platform,
|
||||
preview: entry.preview
|
||||
})
|
||||
}}
|
||||
>
|
||||
查看版式
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled || marketBusyKey === key}
|
||||
onClick={() => void installFromCatalog(entry)}
|
||||
>
|
||||
{marketBusyKey === key ? '安装中…' : '安装'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup>
|
||||
{marketError && <p className="report-inline-error">模板市场:{marketError}</p>}
|
||||
<Dialog
|
||||
open={Boolean(previewing)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -151,20 +384,32 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
<DialogHeader className="pr-8">
|
||||
<div className="report-template-preview-heading">
|
||||
<div>
|
||||
<span>{previewing.label}</span>
|
||||
<DialogTitle>{previewing.name}</DialogTitle>
|
||||
<span>
|
||||
{previewing.kind === 'builtin'
|
||||
? previewing.template.label
|
||||
: `市场 · v${previewing.version}`}
|
||||
</span>
|
||||
<DialogTitle>
|
||||
{previewing.kind === 'builtin' ? previewing.template.name : previewing.name}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<em>
|
||||
{previewing.platform === 'desktop'
|
||||
? '桌面宽屏'
|
||||
: previewing.platform === 'default'
|
||||
? '经典长图'
|
||||
: '手机长图'}
|
||||
{platformLabel(
|
||||
previewing.kind === 'builtin' ? previewing.template.platform : previewing.platform
|
||||
)}
|
||||
</em>
|
||||
</div>
|
||||
<DialogDescription>{previewing.tagline}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{previewing.kind === 'builtin' ? previewing.template.tagline : previewing.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TemplateDiagram template={previewing} />
|
||||
{previewing.kind === 'builtin' ? (
|
||||
<TemplateDiagram template={previewing.template} />
|
||||
) : previewing.preview ? (
|
||||
<img src={previewing.preview} alt={`${previewing.name} 预览`} className="max-h-[60vh] w-full object-contain" />
|
||||
) : (
|
||||
<TemplateDiagram template={DEFAULT_REPORT_TEMPLATE} />
|
||||
)}
|
||||
<DialogDescription className="report-template-preview-note">
|
||||
生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&A、统计与关键词。
|
||||
</DialogDescription>
|
||||
@@ -172,14 +417,22 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">关闭</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange(previewing.id)
|
||||
setPreviewing(null)
|
||||
}}
|
||||
>
|
||||
选择此模板
|
||||
</Button>
|
||||
{previewing.kind === 'builtin' || previewing.installed ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange(
|
||||
previewing.kind === 'builtin'
|
||||
? previewing.template.id
|
||||
: encodeExternalReportTemplateId(previewing.id, previewing.version)
|
||||
)
|
||||
setPreviewing(null)
|
||||
}}
|
||||
>
|
||||
选择此模板
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled>请先安装</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
SELECTABLE_REPORT_TEMPLATES,
|
||||
type SelectableReportTemplateId
|
||||
encodeExternalReportTemplateId,
|
||||
type ReportTemplateSelectionId
|
||||
} from '../../../../shared/report-templates'
|
||||
import type { InstalledReportTemplate } from '../../../../shared/report-template-package'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -20,9 +22,9 @@ interface ReportToolbarProps {
|
||||
canSwitchTemplate: boolean
|
||||
canSendToGroup?: boolean
|
||||
sendToGroupHint?: string
|
||||
currentTemplateId?: SelectableReportTemplateId
|
||||
currentTemplateId?: ReportTemplateSelectionId
|
||||
isSwitchingTemplate: boolean
|
||||
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
|
||||
onSwitchTemplate: (templateId: ReportTemplateSelectionId) => void
|
||||
onRegenerate: () => void
|
||||
onCopyImage: () => void
|
||||
onReveal: () => void
|
||||
@@ -46,6 +48,16 @@ export function ReportToolbar({
|
||||
onShare,
|
||||
onSendToGroup
|
||||
}: ReportToolbarProps): React.ReactElement {
|
||||
const [installedTemplates, setInstalledTemplates] = useState<InstalledReportTemplate[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.api || typeof window.api.listReportTemplates !== 'function') return
|
||||
void window.api
|
||||
.listReportTemplates()
|
||||
.then((templates) => setInstalledTemplates(Array.isArray(templates) ? templates.filter((template) => template.source === 'installed') : []))
|
||||
.catch(() => setInstalledTemplates([]))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="report-viewer-toolbar">
|
||||
<DropdownMenu>
|
||||
@@ -81,6 +93,24 @@ export function ReportToolbar({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{installedTemplates.map((template) => {
|
||||
const templateId = encodeExternalReportTemplateId(template.id, template.version)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={templateId}
|
||||
className="grid min-h-11 min-w-0 grid-cols-[62px_minmax(0,1fr)_auto] gap-2"
|
||||
onSelect={() => onSwitchTemplate(templateId)}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">市场</span>
|
||||
<strong className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold">
|
||||
{template.name}
|
||||
</strong>
|
||||
{templateId === currentTemplateId && (
|
||||
<span className="text-xs text-primary">当前</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm" onClick={onRegenerate}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GeneratedReportRecord } from './types'
|
||||
import { ReportEmptyState } from './ReportEmptyState'
|
||||
import { ReportToolbar } from './ReportToolbar'
|
||||
import { ReportZoomBar } from './ReportZoomBar'
|
||||
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||
import type { ReportTemplateSelectionId } from '../../../../shared/report-templates'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import { WechatShareCardDialog } from './WechatShareCardDialog'
|
||||
import { PersonalWechatSendDialog } from '../chat/PersonalWechatSendDialog'
|
||||
@@ -18,7 +18,7 @@ interface ReportViewerProps {
|
||||
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
|
||||
onSwitchTemplate: (
|
||||
report: GeneratedReportRecord,
|
||||
templateId: SelectableReportTemplateId
|
||||
templateId: ReportTemplateSelectionId
|
||||
) => Promise<{ success: boolean; error?: string }>
|
||||
sendTarget?: Contact | null
|
||||
personalWechatSendSupported?: boolean
|
||||
@@ -141,7 +141,7 @@ export function ReportViewer({
|
||||
setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
|
||||
}
|
||||
|
||||
const handleSwitchTemplate = async (templateId: SelectableReportTemplateId): Promise<void> => {
|
||||
const handleSwitchTemplate = async (templateId: ReportTemplateSelectionId): Promise<void> => {
|
||||
if (!report || isSwitchingTemplate) return
|
||||
if (report.templateId === templateId) {
|
||||
setStatus('当前已是所选模板')
|
||||
|
||||
@@ -16,7 +16,11 @@ import type {
|
||||
ReportImageInsightSummary,
|
||||
ReportPreparationProgress
|
||||
} from '../utils/group-report-facts'
|
||||
import { SelectableReportTemplateId } from '../components/reports/ReportTemplateSelector'
|
||||
import {
|
||||
decodeExternalReportTemplateId,
|
||||
type ReportTemplateRequestId,
|
||||
type ReportTemplateSelectionId
|
||||
} from '../../../shared/report-templates'
|
||||
import {
|
||||
transcribeVoiceMessages as transcribeReportVoiceMessages,
|
||||
type VoiceTranscriptionProgress
|
||||
@@ -291,8 +295,8 @@ export function useGroupReportGeneration({
|
||||
closeResult: () => void
|
||||
copyImage: () => Promise<{ success: boolean; error?: string }>
|
||||
revealReport: () => Promise<{ success: boolean; error?: string }>
|
||||
templateId: SelectableReportTemplateId
|
||||
setTemplateId: (value: SelectableReportTemplateId) => void
|
||||
templateId: ReportTemplateSelectionId
|
||||
setTemplateId: (value: ReportTemplateSelectionId) => void
|
||||
memberNamePreference: ReportMemberNamePreference
|
||||
setMemberNamePreference: (value: ReportMemberNamePreference) => void
|
||||
reportTimeoutSeconds: number
|
||||
@@ -315,7 +319,7 @@ export function useGroupReportGeneration({
|
||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||
const [reportSnapshot, setReportSnapshot] = useState<GroupDailyReport | null>(null)
|
||||
const [reportMetadata, setReportMetadata] = useState<GroupReportMetadata | null>(null)
|
||||
const [templateId, setTemplateIdState] = useState<SelectableReportTemplateId>('v1')
|
||||
const [templateId, setTemplateIdState] = useState<ReportTemplateSelectionId>('v1')
|
||||
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
|
||||
() => {
|
||||
const saved = localStorage.getItem('group_report_member_name_preference')
|
||||
@@ -336,7 +340,7 @@ export function useGroupReportGeneration({
|
||||
localStorage.setItem('group_report_member_name_preference', value)
|
||||
setMemberNamePreferenceState(value)
|
||||
}, [])
|
||||
const setTemplateId = useCallback((value: SelectableReportTemplateId): void => {
|
||||
const setTemplateId = useCallback((value: ReportTemplateSelectionId): void => {
|
||||
setTemplateIdState(value)
|
||||
}, [])
|
||||
const setReportTimeoutSeconds = useCallback((value: number): void => {
|
||||
@@ -614,11 +618,14 @@ export function useGroupReportGeneration({
|
||||
|
||||
setPhase('exportingReport')
|
||||
currentFailedAt = '导出 HTML 与 PNG'
|
||||
const externalTemplateRef = decodeExternalReportTemplateId(templateId)
|
||||
const exported = await withTimeout(
|
||||
window.api.exportGroupReport({
|
||||
report,
|
||||
metadata: context.input.metadata,
|
||||
templateId
|
||||
...(externalTemplateRef
|
||||
? { templateRef: externalTemplateRef }
|
||||
: { templateId: templateId as ReportTemplateRequestId })
|
||||
}),
|
||||
'日报图片导出'
|
||||
)
|
||||
|
||||
@@ -9,7 +9,11 @@ import type {
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../../../shared/report-history'
|
||||
import type { SelectableReportTemplateId } from '../../../shared/report-templates'
|
||||
import {
|
||||
decodeExternalReportTemplateId,
|
||||
type ReportTemplateRequestId,
|
||||
type ReportTemplateSelectionId
|
||||
} from '../../../shared/report-templates'
|
||||
|
||||
interface ReportTemplateSwitchApi {
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
@@ -26,15 +30,19 @@ interface ReportTemplateSwitchApi {
|
||||
|
||||
export async function switchGeneratedReportTemplate(
|
||||
report: GeneratedReportRecord,
|
||||
templateId: SelectableReportTemplateId,
|
||||
templateId: ReportTemplateSelectionId,
|
||||
api: ReportTemplateSwitchApi
|
||||
): Promise<UpdateGeneratedReportTemplateResult> {
|
||||
const externalRef = decodeExternalReportTemplateId(templateId)
|
||||
const exportSelection = externalRef
|
||||
? { templateRef: externalRef }
|
||||
: { templateId: templateId as ReportTemplateRequestId }
|
||||
let exported: GroupReportExportResult
|
||||
if (report.reportSnapshot && report.reportMetadata) {
|
||||
exported = await api.exportGroupReport({
|
||||
report: report.reportSnapshot,
|
||||
metadata: report.reportMetadata,
|
||||
templateId
|
||||
...exportSelection
|
||||
})
|
||||
} else {
|
||||
const prepared = await api.prepareGeneratedReportTemplateSwitch(report.id)
|
||||
@@ -46,7 +54,7 @@ export async function switchGeneratedReportTemplate(
|
||||
}
|
||||
exported = await api.exportGroupReportSnapshot({
|
||||
snapshot: prepared.snapshot,
|
||||
templateId
|
||||
...exportSelection
|
||||
})
|
||||
}
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
@@ -56,6 +64,7 @@ export async function switchGeneratedReportTemplate(
|
||||
return api.updateGeneratedReportTemplate({
|
||||
reportId: report.id,
|
||||
templateId,
|
||||
...(externalRef ? { templateRef: externalRef } : {}),
|
||||
generatedImage: exported.imageDataUrl,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
|
||||
@@ -2,6 +2,7 @@ export type ReportHeat = '高' | '中' | '低'
|
||||
export type ReportMode = 'compact' | 'full'
|
||||
|
||||
import type { ReportTemplateRequestId } from './report-templates'
|
||||
import type { ReportTemplateRef } from './report-template-package'
|
||||
|
||||
export const selectHeroParticipantNames = (names: string[]): string[] =>
|
||||
Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 4)
|
||||
@@ -287,6 +288,8 @@ export interface GroupReportExportRequest {
|
||||
metadata: GroupReportMetadata
|
||||
/** v1 是默认经典模板,v2 仅保留旧调用兼容;另有五套新版产品模板。 */
|
||||
templateId?: ReportTemplateRequestId
|
||||
/** 外部模板只允许传 ID + 可选版本,由主进程 registry 解析入口路径。 */
|
||||
templateRef?: ReportTemplateRef
|
||||
}
|
||||
|
||||
export interface GroupReportExportResult {
|
||||
@@ -322,5 +325,6 @@ export interface GroupReportRenderSnapshot {
|
||||
|
||||
export interface GroupReportRenderSnapshotExportRequest {
|
||||
snapshot: GroupReportRenderSnapshot
|
||||
templateId: ReportTemplateRequestId
|
||||
templateId?: ReportTemplateRequestId
|
||||
templateRef?: ReportTemplateRef
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import type {
|
||||
GroupReportMetadata,
|
||||
GroupReportRenderSnapshot
|
||||
} from './group-report'
|
||||
import type { SelectableReportTemplateId } from './report-templates'
|
||||
import type { ReportTemplateSelectionId } from './report-templates'
|
||||
import type { ReportTemplateRef } from './report-template-package'
|
||||
|
||||
export type ReportAssetStatus = 'ready' | 'missing'
|
||||
export type GeneratedReportSource = 'manual' | 'scheduled'
|
||||
@@ -54,7 +55,7 @@ export interface GeneratedReportRecord {
|
||||
reportSnapshot?: GroupDailyReport
|
||||
reportMetadata?: GroupReportMetadata
|
||||
reportRenderSnapshot?: GroupReportRenderSnapshot
|
||||
templateId?: SelectableReportTemplateId
|
||||
templateId?: ReportTemplateSelectionId
|
||||
}
|
||||
|
||||
export interface SaveGeneratedReportRequest {
|
||||
@@ -88,12 +89,13 @@ export interface SaveGeneratedReportRequest {
|
||||
}[]
|
||||
reportSnapshot?: GroupDailyReport
|
||||
reportMetadata?: GroupReportMetadata
|
||||
templateId?: SelectableReportTemplateId
|
||||
templateId?: ReportTemplateSelectionId
|
||||
}
|
||||
|
||||
export interface UpdateGeneratedReportTemplateRequest {
|
||||
reportId: string
|
||||
templateId: SelectableReportTemplateId
|
||||
templateId: ReportTemplateSelectionId
|
||||
templateRef?: ReportTemplateRef
|
||||
generatedImage?: string
|
||||
htmlPath?: string
|
||||
pngPath?: string
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReportTemplateOperationResult } from './report-template-package'
|
||||
|
||||
export const REPORT_TEMPLATE_CATALOG_URL =
|
||||
'https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/main/catalog/v1/index.json'
|
||||
|
||||
export interface ReportTemplateCatalogEntry {
|
||||
id: string
|
||||
version: string
|
||||
interfaceVersion: string
|
||||
name: string
|
||||
description: string
|
||||
author: string
|
||||
platform?: 'default' | 'mobile' | 'desktop'
|
||||
tags: string[]
|
||||
license: string
|
||||
minAppVersion: string | null
|
||||
download: string
|
||||
sizeBytes: number
|
||||
sha256: string
|
||||
preview?: string
|
||||
publishedAt?: string | null
|
||||
status: 'published' | 'draft'
|
||||
}
|
||||
|
||||
export interface ReportTemplateCatalog {
|
||||
schemaVersion: '1'
|
||||
generatedAt?: string
|
||||
source?: {
|
||||
repository?: string
|
||||
commit?: string
|
||||
}
|
||||
status: 'published' | 'draft'
|
||||
templates: ReportTemplateCatalogEntry[]
|
||||
}
|
||||
|
||||
export interface ReportTemplateCatalogResult {
|
||||
success: boolean
|
||||
catalog?: ReportTemplateCatalog
|
||||
error?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface ReportTemplateCatalogInstallResult extends ReportTemplateOperationResult {
|
||||
catalogEntry?: ReportTemplateCatalogEntry
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ReportTemplatePlatform } from './report-templates'
|
||||
|
||||
export const REPORT_TEMPLATE_PACKAGE_PROTOCOL_VERSION = '1.0'
|
||||
export const REPORT_TEMPLATE_INTERFACE_VERSION = '1'
|
||||
|
||||
export const REPORT_TEMPLATE_LIMITS = {
|
||||
maxCompressedBytes: 20 * 1024 * 1024,
|
||||
maxExtractedBytes: 50 * 1024 * 1024,
|
||||
maxFiles: 100,
|
||||
maxFileBytes: 10 * 1024 * 1024,
|
||||
maxHtmlBytes: 2 * 1024 * 1024,
|
||||
maxCssBytes: 2 * 1024 * 1024,
|
||||
maxImageBytes: 10 * 1024 * 1024
|
||||
} as const
|
||||
|
||||
export type ReportTemplateSource = 'builtin' | 'installed'
|
||||
export interface ReportTemplateRef {
|
||||
id: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface ReportTemplateManifest {
|
||||
protocolVersion: string
|
||||
kind: 'daily-report'
|
||||
id: string
|
||||
name: string
|
||||
author: { name: string; homepage?: string }
|
||||
templateVersion: string
|
||||
interfaceVersion: string
|
||||
entry: string
|
||||
preview?: string
|
||||
capture: { width: number; maxWidth: number; maxHeight: number }
|
||||
license: { spdx: string; notice?: string }
|
||||
minAppVersion?: string
|
||||
platform?: ReportTemplatePlatform
|
||||
}
|
||||
|
||||
export interface InstalledReportTemplate {
|
||||
id: string
|
||||
version: string
|
||||
interfaceVersion: string
|
||||
source: ReportTemplateSource
|
||||
name: string
|
||||
author: string
|
||||
entryPath: string
|
||||
previewPath?: string
|
||||
capture: ReportTemplateManifest['capture']
|
||||
license: ReportTemplateManifest['license']
|
||||
packagePath?: string
|
||||
sha256?: string
|
||||
installedAt?: string
|
||||
}
|
||||
|
||||
export type ReportTemplateValueKind = 'text' | 'html' | 'class'
|
||||
export const REPORT_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, ReportTemplateValueKind>> = {
|
||||
REPORT_TITLE: 'text', REPORT_DATE: 'text', GROUP_NAME: 'text', DATE_RANGE: 'text',
|
||||
RECORD_NOTE: 'text', OVERVIEW: 'text', HERO_HEADLINE: 'text', HERO_SUMMARY: 'text',
|
||||
HERO_TAKEAWAY: 'text', HERO_PENDING: 'text', HERO_STATUS_LINE: 'text', MESSAGE_COUNT: 'text',
|
||||
ACTIVE_USERS: 'text', TIME_SPAN: 'text', TOPIC_COUNT: 'text', MEDIA_COUNT: 'text',
|
||||
CONCLUSION_COUNT: 'text', TODO_COUNT: 'text', UNRESOLVED_COUNT: 'text',
|
||||
ACTIVITY_TIMELINE: 'text', GENERATED_AT: 'text', FOOTER_NOTE: 'text',
|
||||
TEMPLATE_LABEL: 'text', TEMPLATE_NAME: 'text',
|
||||
HERO_AVATARS: 'html', TOPIC_CARDS: 'html', IMPORTANT_MESSAGES: 'html', QUOTE_BLOCKS: 'html',
|
||||
QA_CARDS: 'html', RESOURCE_ITEMS: 'html', TODO_CARDS: 'html', UNRESOLVED_CARDS: 'html',
|
||||
STORYLINE_CARDS: 'html', REVERSAL_CARDS: 'html', CHAIN_CARDS: 'html', VISION_CARDS: 'html',
|
||||
VOICE_CARDS: 'html', VOICE_RANK_CARDS: 'html', BADGE_CARDS: 'html', RANK_ITEMS: 'html',
|
||||
HEAT_BARS: 'html', CLOUD_TAGS: 'html',
|
||||
TEMPLATE_CLASS: 'class', REPORT_MODE_CLASS: 'class', HERO_AVATAR_CLASS: 'class',
|
||||
HERO_STATUS_EMPTY_CLASS: 'class', HERO_TAKEAWAY_EMPTY_CLASS: 'class', HERO_PENDING_EMPTY_CLASS: 'class',
|
||||
TOPICS_EMPTY_CLASS: 'class', TOPICS_MORE_NOTE: 'html', MESSAGES_EMPTY_CLASS: 'class',
|
||||
MESSAGES_MORE_NOTE: 'html', QUOTES_EMPTY_CLASS: 'class', QUOTES_MORE_NOTE: 'html',
|
||||
ACTIONS_EMPTY_CLASS: 'class', ACTIONS_MORE_NOTE: 'html', QA_EMPTY_CLASS: 'class', QA_MORE_NOTE: 'html',
|
||||
RESOURCES_EMPTY_CLASS: 'class', RESOURCES_MORE_NOTE: 'html', STORYLINES_EMPTY_CLASS: 'class',
|
||||
STORYLINES_MORE_NOTE: 'html', REVERSALS_EMPTY_CLASS: 'class', REVERSALS_MORE_NOTE: 'html',
|
||||
CHAINS_EMPTY_CLASS: 'class', CHAINS_MORE_NOTE: 'html', VISION_EMPTY_CLASS: 'class',
|
||||
VOICE_EMPTY_CLASS: 'class', VOICE_MORE_NOTE: 'html', VOICE_RANK_EMPTY_CLASS: 'class',
|
||||
BADGES_EMPTY_CLASS: 'class', BADGES_MORE_NOTE: 'html', KEYWORDS_EMPTY_CLASS: 'class',
|
||||
KEYWORDS_MORE_NOTE: 'html', ANALYTICS_EMPTY_CLASS: 'class', TODO_EMPTY_CLASS: 'class',
|
||||
UNRESOLVED_EMPTY_CLASS: 'class'
|
||||
}
|
||||
|
||||
export const REPORT_TEMPLATE_ALLOWED_TAGS = new Set([
|
||||
'html', 'head', 'body', 'meta', 'title', 'style', 'main', 'section', 'article', 'header',
|
||||
'footer', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'b', 'strong', 'em', 'i', 'small',
|
||||
'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'img', 'br'
|
||||
])
|
||||
export const REPORT_TEMPLATE_ALLOWED_ATTRS = new Set([
|
||||
'class', 'id', 'title', 'aria-hidden', 'aria-label', 'alt', 'width', 'height', 'role', 'content',
|
||||
'charset', 'name', 'content', 'src'
|
||||
])
|
||||
export const REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
||||
|
||||
export class ReportTemplateError extends Error {
|
||||
constructor(readonly code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ReportTemplateError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReportTemplateOperationResult {
|
||||
success: boolean
|
||||
template?: InstalledReportTemplate
|
||||
error?: string
|
||||
code?: string
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export type ReportTemplateId =
|
||||
|
||||
export type LegacyReportTemplateId = 'v1' | 'v2'
|
||||
export type SelectableReportTemplateId = 'v1' | ReportTemplateId
|
||||
export type ExternalReportTemplateId = `external:${string}@${string}`
|
||||
/** 日报即时生成/历史换版式可用的选择键;定时日报仍只接受 SelectableReportTemplateId。 */
|
||||
export type ReportTemplateSelectionId = SelectableReportTemplateId | ExternalReportTemplateId
|
||||
export type ReportTemplateRequestId = ReportTemplateId | LegacyReportTemplateId
|
||||
export type ReportTemplatePlatform = 'default' | 'mobile' | 'desktop'
|
||||
|
||||
@@ -121,5 +124,22 @@ export const isSelectableReportTemplateId = (
|
||||
): value is SelectableReportTemplateId =>
|
||||
SELECTABLE_REPORT_TEMPLATES.some((template) => template.id === value)
|
||||
|
||||
export const encodeExternalReportTemplateId = (
|
||||
id: string,
|
||||
version: string
|
||||
): ExternalReportTemplateId => `external:${id}@${version}`
|
||||
|
||||
export const decodeExternalReportTemplateId = (
|
||||
value: unknown
|
||||
): { id: string; version: string } | null => {
|
||||
if (typeof value !== 'string' || !value.startsWith('external:')) return null
|
||||
const match = /^external:([^@]+)@([^@]+)$/.exec(value)
|
||||
if (!match || !match[1] || !match[2]) return null
|
||||
return { id: match[1], version: match[2] }
|
||||
}
|
||||
|
||||
export const isExternalReportTemplateId = (value: unknown): value is ExternalReportTemplateId =>
|
||||
Boolean(decodeExternalReportTemplateId(value))
|
||||
|
||||
export const getReportTemplate = (value?: string): ReportTemplateDefinition =>
|
||||
SELECTABLE_REPORT_TEMPLATES.find((template) => template.id === value) || DEFAULT_REPORT_TEMPLATE
|
||||
|
||||
@@ -105,7 +105,14 @@ describe('daily report controls', () => {
|
||||
avatar: ''
|
||||
}
|
||||
]
|
||||
}))
|
||||
})),
|
||||
listReportTemplates: vi.fn(async () => []),
|
||||
listReportTemplateCatalog: vi.fn(async () => ({
|
||||
success: true,
|
||||
catalog: { schemaVersion: '1', status: 'published', templates: [] }
|
||||
})),
|
||||
installReportTemplateFromCatalog: vi.fn(async () => ({ success: true })),
|
||||
uninstallReportTemplate: vi.fn(async () => ({ success: true }))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -903,4 +910,45 @@ describe('daily report controls', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
|
||||
})
|
||||
|
||||
it('does not select a market template before it is installed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
Object.assign(window.api, {
|
||||
listReportTemplateCatalog: vi.fn(async () => ({
|
||||
success: true,
|
||||
catalog: {
|
||||
schemaVersion: '1',
|
||||
status: 'published',
|
||||
templates: [
|
||||
{
|
||||
id: 'community.github.example.market',
|
||||
version: '1.0.0',
|
||||
interfaceVersion: '1',
|
||||
name: '市场测试模板',
|
||||
description: '只允许先安装',
|
||||
author: 'fixture',
|
||||
tags: [],
|
||||
license: 'MIT',
|
||||
minAppVersion: null,
|
||||
download: 'https://raw.githubusercontent.com/Wxw-Gu/TraceMemo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/packages/community.github.example.market/1.0.0/template.zip',
|
||||
sizeBytes: 1,
|
||||
sha256: 'a'.repeat(64),
|
||||
preview: 'https://raw.githubusercontent.com/Wxw-Gu/TraceMemo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/previews/community.github.example.market.png',
|
||||
status: 'published'
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
|
||||
const marketItem = (await screen.findByText('市场测试模板')).closest('.report-template-item')
|
||||
expect(marketItem).not.toBeNull()
|
||||
await user.click(within(marketItem!).getByRole('button', { name: '查看版式' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '市场测试模板' })
|
||||
expect(within(dialog).getByRole('button', { name: '请先安装' })).toBeDisabled()
|
||||
expect(within(dialog).queryByRole('button', { name: '选择此模板' })).not.toBeInTheDocument()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
test('REPORT-TEMPLATE-MARKET-E2E-01 reads, installs, restores, renders, and switches published templates', async () => {
|
||||
test.setTimeout(120_000)
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-market-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-market-output-'))
|
||||
const launch = (): ReturnType<typeof electron.launch> =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
|
||||
const report = {
|
||||
overview: '虚构远端模板日报概览',
|
||||
hero: {
|
||||
headline: '今日群聊重点',
|
||||
summary: '虚构的结构化日报内容',
|
||||
keyTakeaway: '保留结构化快照并切换版式',
|
||||
pendingNote: '',
|
||||
statusLine: ''
|
||||
},
|
||||
topics: [
|
||||
{
|
||||
title: '模板市场联调',
|
||||
timeRange: '09:00-10:00',
|
||||
heat: '高' as const,
|
||||
participants: ['小明'],
|
||||
summary: '验证安装、重启恢复和导出',
|
||||
keywords: ['模板', '日报'],
|
||||
messages: []
|
||||
}
|
||||
],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: {
|
||||
topicHeat: [{ topic: '模板市场联调', score: 1 }],
|
||||
activeTimeline: '09:00-10:00',
|
||||
topSpeakers: [{ name: '小明', count: 1 }],
|
||||
voiceLeaderboard: []
|
||||
},
|
||||
keywords: ['模板', '日报'],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
const metadata = {
|
||||
groupName: '虚构市场验收群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '09:00-10:00',
|
||||
generatedAt: '2026-09-07 10:00',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'market fixture',
|
||||
heroParticipants: ['小明'],
|
||||
avatars: {}
|
||||
}
|
||||
|
||||
let app: Awaited<ReturnType<typeof electron.launch>> | null = null
|
||||
try {
|
||||
app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
const catalogResult = await page.evaluate(() => window.api.listReportTemplateCatalog())
|
||||
expect(catalogResult.success, catalogResult.error).toBe(true)
|
||||
const catalog = catalogResult.catalog
|
||||
expect(catalog?.status).toBe('published')
|
||||
expect(catalog?.templates).toHaveLength(3)
|
||||
const sourceCommit = catalog?.source?.commit
|
||||
expect(sourceCommit).toMatch(/^[a-f0-9]{40}$/)
|
||||
const entries = catalog!.templates
|
||||
expect(new Set(entries.map((entry) => entry.id))).toEqual(
|
||||
new Set([
|
||||
'community.github.tracememo.quickread',
|
||||
'community.github.tracememo.paperdaily',
|
||||
'community.github.tracememo.teamboard'
|
||||
])
|
||||
)
|
||||
for (const entry of entries) {
|
||||
expect(entry.interfaceVersion).toBe('1')
|
||||
expect(entry.status).toBe('published')
|
||||
expect(entry.download).toContain(`/Wxw-Gu/TraceMemo-Templates/${sourceCommit}/`)
|
||||
expect(entry.preview).toContain(`/Wxw-Gu/TraceMemo-Templates/${sourceCommit}/`)
|
||||
}
|
||||
|
||||
const exportsById = new Map<
|
||||
string,
|
||||
{ htmlPath: string; pngPath: string; imageDataUrl: string; version: string }
|
||||
>()
|
||||
for (const entry of entries) {
|
||||
const installed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.installReportTemplateFromCatalog(id, version),
|
||||
{ id: entry.id, version: entry.version }
|
||||
)
|
||||
expect(installed.success, installed.error).toBe(true)
|
||||
expect(installed.catalogEntry?.sha256).toBe(entry.sha256)
|
||||
expect(installed.template?.id).toBe(entry.id)
|
||||
expect(installed.template?.version).toBe(entry.version)
|
||||
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: entry.id, version: entry.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(exported.success, exported.error).toBe(true)
|
||||
expect(exported.htmlPath).toBeTruthy()
|
||||
expect(exported.pngPath).toBeTruthy()
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构的结构化日报内容')
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.length).toBeGreaterThan(1000)
|
||||
expect(png.readUInt32BE(16)).toBe(entry.platform === 'desktop' ? 1440 : 430)
|
||||
expect(png.readUInt32BE(20)).toBeGreaterThan(100)
|
||||
exportsById.set(entry.id, {
|
||||
htmlPath: exported.htmlPath!,
|
||||
pngPath: exported.pngPath!,
|
||||
imageDataUrl: exported.imageDataUrl!,
|
||||
version: entry.version
|
||||
})
|
||||
}
|
||||
|
||||
await app.close()
|
||||
app = await launch()
|
||||
page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const restored = await page.evaluate(() => window.api.listReportTemplates())
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
restored.some((template) => template.id === entry.id && template.version === entry.version)
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
const quickread = entries.find((entry) => entry.id.endsWith('.quickread'))!
|
||||
const paperdaily = entries.find((entry) => entry.id.endsWith('.paperdaily'))!
|
||||
const firstExport = exportsById.get(quickread.id)!
|
||||
const saved = await page.evaluate(
|
||||
async ({ firstExportValue, reportValue, metadataValue, templateId }) =>
|
||||
window.api.saveGeneratedReport({
|
||||
contactId: 'fixture-market-group',
|
||||
contactName: metadataValue.groupName,
|
||||
source: 'manual',
|
||||
dateRange: metadataValue.dateRange,
|
||||
messageCount: metadataValue.messageCount,
|
||||
generatedAt: '2026-09-07T10:00:00.000Z',
|
||||
reportDate: metadataValue.reportDate,
|
||||
generatedImage: firstExportValue.imageDataUrl,
|
||||
htmlPath: firstExportValue.htmlPath,
|
||||
pngPath: firstExportValue.pngPath,
|
||||
reportSnapshot: reportValue,
|
||||
reportMetadata: metadataValue,
|
||||
templateId
|
||||
}),
|
||||
{
|
||||
firstExportValue: firstExport,
|
||||
reportValue: report,
|
||||
metadataValue: metadata,
|
||||
templateId: `external:${quickread.id}@${quickread.version}`
|
||||
}
|
||||
)
|
||||
expect(saved.success, saved.error).toBe(true)
|
||||
const reportId = saved.record!.id
|
||||
|
||||
const switchedExport = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: paperdaily.id, version: paperdaily.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(switchedExport.success, switchedExport.error).toBe(true)
|
||||
const switched = await page.evaluate(
|
||||
async ({ reportIdValue, id, version, exported }) =>
|
||||
window.api.updateGeneratedReportTemplate({
|
||||
reportId: reportIdValue,
|
||||
templateId: `external:${id}@${version}`,
|
||||
templateRef: { id, version },
|
||||
generatedImage: exported.imageDataUrl,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
}),
|
||||
{
|
||||
reportIdValue: reportId,
|
||||
id: paperdaily.id,
|
||||
version: paperdaily.version,
|
||||
exported: {
|
||||
imageDataUrl: switchedExport.imageDataUrl!,
|
||||
htmlPath: switchedExport.htmlPath!,
|
||||
pngPath: switchedExport.pngPath!
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(switched.success, switched.error).toBe(true)
|
||||
expect(switched.record?.id).toBe(reportId)
|
||||
expect(switched.record?.templateId).toBe(`external:${paperdaily.id}@${paperdaily.version}`)
|
||||
expect(switched.record?.reportSnapshot).toEqual(report)
|
||||
expect(readFileSync(switched.record!.htmlPath!, 'utf8')).toContain('虚构的结构化日报内容')
|
||||
|
||||
const history = await page.evaluate(() => window.api.listGeneratedReports())
|
||||
expect(history.success).toBe(true)
|
||||
expect(history.reports?.find((record) => record.id === reportId)?.templateId).toBe(
|
||||
`external:${paperdaily.id}@${paperdaily.version}`
|
||||
)
|
||||
|
||||
const removed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.uninstallReportTemplate(id, version),
|
||||
{ id: paperdaily.id, version: paperdaily.version }
|
||||
)
|
||||
expect(removed.success, removed.error).toBe(true)
|
||||
const afterUninstall = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: paperdaily.id, version: paperdaily.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(afterUninstall.success).toBe(false)
|
||||
expect(afterUninstall.error).toContain('模板不存在')
|
||||
|
||||
const legacyHtml = join(outputDir, 'legacy-without-structured-data.html')
|
||||
writeFileSync(legacyHtml, '<!doctype html><html><body><h1>旧日报</h1></body></html>')
|
||||
const legacy = await page.evaluate(
|
||||
async ({ htmlPath, pngPath, imageDataUrl }) =>
|
||||
window.api.saveGeneratedReport({
|
||||
contactId: 'fixture-legacy-group',
|
||||
contactName: '虚构旧日报',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
generatedAt: '2026-09-07T11:00:00.000Z',
|
||||
htmlPath,
|
||||
pngPath,
|
||||
generatedImage: imageDataUrl
|
||||
}),
|
||||
{ htmlPath: legacyHtml, pngPath: firstExport.pngPath, imageDataUrl: firstExport.imageDataUrl }
|
||||
)
|
||||
expect(legacy.success).toBe(true)
|
||||
const legacySwitch = await page.evaluate(
|
||||
async ({ reportIdValue, id, version, imageDataUrl, htmlPath }) =>
|
||||
window.api.updateGeneratedReportTemplate({
|
||||
reportId: reportIdValue,
|
||||
templateId: `external:${id}@${version}`,
|
||||
templateRef: { id, version },
|
||||
generatedImage: imageDataUrl,
|
||||
htmlPath,
|
||||
pngPath: htmlPath
|
||||
}),
|
||||
{
|
||||
reportIdValue: legacy.record!.id,
|
||||
id: quickread.id,
|
||||
version: quickread.version,
|
||||
imageDataUrl: firstExport.imageDataUrl,
|
||||
htmlPath: legacyHtml
|
||||
}
|
||||
)
|
||||
expect(legacySwitch.success).toBe(false)
|
||||
expect(legacySwitch.error).toContain('旧报告未保存结构化数据')
|
||||
} finally {
|
||||
if (app) await app.close()
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,600 @@
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
import { createWriteStream, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { ZipArchive } from 'archiver'
|
||||
|
||||
const makeZip = async (
|
||||
directory: string,
|
||||
files: Record<string, string | Buffer>
|
||||
): Promise<string> => {
|
||||
const zipPath = join(directory, `${Math.random().toString(36).slice(2)}.zip`)
|
||||
const output = createWriteStream(zipPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||
archive.pipe(output)
|
||||
for (const [name, content] of Object.entries(files)) archive.append(content, { name })
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
output.on('close', resolvePromise)
|
||||
output.on('error', reject)
|
||||
archive.on('error', reject)
|
||||
void archive.finalize().catch(reject)
|
||||
})
|
||||
return zipPath
|
||||
}
|
||||
|
||||
const templateManifest = (patch: Record<string, unknown> = {}): string =>
|
||||
JSON.stringify({
|
||||
protocolVersion: '1.0',
|
||||
kind: 'daily-report',
|
||||
id: 'community.github.example.runtime-attack',
|
||||
name: '运行时安全测试模板',
|
||||
author: { name: 'fixture' },
|
||||
templateVersion: '1.0.0',
|
||||
interfaceVersion: '1',
|
||||
entry: 'template.html',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 20000 },
|
||||
license: { spdx: 'MIT' },
|
||||
...patch
|
||||
})
|
||||
|
||||
const runtimeAttackReport = (imageUrl: string) => ({
|
||||
overview: '虚构运行时安全测试',
|
||||
topics: [
|
||||
{
|
||||
title: '攻击 fixture',
|
||||
timeRange: '10:00-10:01',
|
||||
heat: '高' as const,
|
||||
participants: [],
|
||||
summary: '用于触发报告窗口资源请求',
|
||||
keywords: [],
|
||||
image: { imageUrl, note: 'fixture' }
|
||||
}
|
||||
],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
})
|
||||
|
||||
const runtimeAttackMetadata = {
|
||||
groupName: '虚构攻击测试群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '10:00-10:01',
|
||||
generatedAt: '2026-09-07 10:01',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'fixture security probe',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
}
|
||||
|
||||
const startProbeServer = async (): Promise<{
|
||||
server: ReturnType<typeof createServer>
|
||||
port: number
|
||||
httpRequests: string[]
|
||||
wsRequests: string[]
|
||||
}> => {
|
||||
const httpRequests: string[] = []
|
||||
const wsRequests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
httpRequests.push(request.url || '')
|
||||
response.statusCode = 200
|
||||
response.end('unexpected fixture response')
|
||||
})
|
||||
server.on('upgrade', (request, socket) => {
|
||||
wsRequests.push(request.url || '')
|
||||
socket.destroy()
|
||||
})
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const onError = (error: Error): void => {
|
||||
server.off('listening', onListening)
|
||||
reject(error)
|
||||
}
|
||||
const onListening = (): void => {
|
||||
server.off('error', onError)
|
||||
resolvePromise()
|
||||
}
|
||||
server.once('error', onError)
|
||||
server.once('listening', onListening)
|
||||
server.listen(0, '127.0.0.1')
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close()
|
||||
throw new Error('本地安全探针监听器未分配端口')
|
||||
}
|
||||
return { server, port: address.port, httpRequests, wsRequests }
|
||||
}
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-01 installs and renders a fixture without author code', async ({}, testInfo) => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-output-'))
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const installed = await page.evaluate(
|
||||
async (p) => window.api.installReportTemplate(p),
|
||||
resolve('examples/report-template-basic.zip')
|
||||
)
|
||||
expect(installed.success).toBe(true)
|
||||
expect(installed.template?.id).toBe('community.github.example.basic-feed')
|
||||
expect(installed.template?.version).toBe('1.0.0')
|
||||
await app.close()
|
||||
app = await launch()
|
||||
page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const reloaded = await page.evaluate(() => window.api.listReportTemplates())
|
||||
expect(
|
||||
reloaded.some(
|
||||
(item) => item.id === installed.template?.id && item.version === installed.template?.version
|
||||
)
|
||||
).toBe(true)
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata: {
|
||||
groupName: '虚构测试群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 3,
|
||||
activeUsers: 2,
|
||||
timeSpan: '09:00-10:00',
|
||||
generatedAt: '2026-09-07 10:00',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'fixture export',
|
||||
heroParticipants: [],
|
||||
avatars: []
|
||||
},
|
||||
report: {
|
||||
overview: '虚构日报概览',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
}),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(exported.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('Content-Security-Policy')
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构日报概览')
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.length).toBeGreaterThan(1000)
|
||||
expect(png.readUInt32BE(16)).toBe(430)
|
||||
expect(png.readUInt32BE(20)).toBeGreaterThan(100)
|
||||
await testInfo.attach('report-png', { path: exported.pngPath!, contentType: 'image/png' })
|
||||
const removed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.uninstallReportTemplate(id, version),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(removed.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构日报概览')
|
||||
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-CAPTURE-01 enforces manifest maxHeight in the production capture path', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-output-'))
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-fixture-'))
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const packagePath = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest({
|
||||
id: 'community.github.example.capture-cap',
|
||||
name: '截图高度限制测试',
|
||||
templateVersion: '1.0.0',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 800 }
|
||||
}),
|
||||
'template.html': `<!doctype html><html><head><style>html,body{margin:0;min-height:3000px}</style></head><body><h1>{{REPORT_TITLE}}</h1></body></html>`
|
||||
})
|
||||
const installed = await page.evaluate(
|
||||
async (path) => window.api.installReportTemplate(path),
|
||||
packagePath
|
||||
)
|
||||
expect(installed.success, installed.error).toBe(true)
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata: {
|
||||
groupName: '虚构截图限制群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '10:00-10:01',
|
||||
generatedAt: '2026-09-07 10:01',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'capture fixture',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
},
|
||||
report: {
|
||||
overview: '虚构截图高度限制',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
}),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(exported.success, exported.error).toBe(true)
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.readUInt32BE(16)).toBe(430)
|
||||
expect(png.readUInt32BE(20)).toBe(800)
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(fixtureDir, { recursive: true, force: true })
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-SECURITY-01 blocks runtime attacks in a real Electron report window', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-security-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-security-output-'))
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-template-security-fixture-'))
|
||||
const securityLog = join(outputDir, 'security-blocks.ndjson')
|
||||
const probeServer = await startProbeServer()
|
||||
const outsideImage = join(userData, 'outside.png')
|
||||
writeFileSync(
|
||||
outsideImage,
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir,
|
||||
TRACEMEMO_TEMPLATE_SECURITY_LOG: securityLog
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const runtimeZip = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest(),
|
||||
'template.html': `<!doctype html><html><head><style>body{font-family:sans-serif;padding:24px}</style></head><body><h1>{{REPORT_TITLE}}</h1><main>{{TOPIC_CARDS}}</main></body></html>`
|
||||
})
|
||||
const maliciousZip = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest({ templateVersion: '1.0.1' }),
|
||||
'template.html': '<script>window.__templateAuthorCode = true</script>'
|
||||
})
|
||||
|
||||
const rejected = await page.evaluate(
|
||||
async (packagePath) => window.api.installReportTemplate(packagePath),
|
||||
maliciousZip
|
||||
)
|
||||
expect(rejected.success).toBe(false)
|
||||
expect(rejected.code).toBe('unsafe_html')
|
||||
|
||||
const installed = await page.evaluate(
|
||||
async (packagePath) => window.api.installReportTemplate(packagePath),
|
||||
runtimeZip
|
||||
)
|
||||
expect(installed.success).toBe(true)
|
||||
expect(installed.template?.id).toBe('community.github.example.runtime-attack')
|
||||
|
||||
const outsideFileUrl = pathToFileURL(outsideImage).toString()
|
||||
const targets = {
|
||||
http: `http://127.0.0.1:${probeServer.port}/http-probe`,
|
||||
https: `https://127.0.0.1:${probeServer.port}/https-probe`,
|
||||
ws: `ws://127.0.0.1:${probeServer.port}/ws-probe`,
|
||||
wss: `wss://127.0.0.1:${probeServer.port}/wss-probe`
|
||||
}
|
||||
await app.evaluate(
|
||||
({ app: electronApp }, { fileUrl, targets }) => {
|
||||
const state = {
|
||||
hiddenWindows: 0,
|
||||
createdWindows: 0,
|
||||
navigations: [] as string[],
|
||||
childWindowUrls: [] as string[],
|
||||
probeStarted: false,
|
||||
probeDone: false,
|
||||
probe: undefined as
|
||||
| {
|
||||
http: boolean
|
||||
https: boolean
|
||||
ws: boolean
|
||||
wss: boolean
|
||||
localResource: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
targetBlank: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
;(
|
||||
globalThis as typeof globalThis & { __tmTemplateSecurity?: typeof state }
|
||||
).__tmTemplateSecurity = state
|
||||
electronApp.on('browser-window-created', (_event, browserWindow) => {
|
||||
state.createdWindows += 1
|
||||
if (browserWindow.isVisible()) return
|
||||
state.hiddenWindows += 1
|
||||
browserWindow.webContents.on('will-navigate', (_navigationEvent, url) => {
|
||||
state.navigations.push(url)
|
||||
})
|
||||
browserWindow.webContents.on('did-create-window', (_childWindow, details) => {
|
||||
state.childWindowUrls.push(details.url)
|
||||
})
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
if (state.probeStarted) return
|
||||
state.probeStarted = true
|
||||
const serializedFileUrl = JSON.stringify(fileUrl)
|
||||
const serializedTargets = JSON.stringify(targets)
|
||||
const requestViaSession = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
await Promise.race([
|
||||
browserWindow.webContents.session.fetch(url, { cache: 'no-store' }),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('network probe timeout')), 2000)
|
||||
)
|
||||
])
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
const networkProbe = Promise.all([
|
||||
requestViaSession(targets.http),
|
||||
requestViaSession(targets.https),
|
||||
requestViaSession(targets.ws),
|
||||
requestViaSession(targets.wss),
|
||||
requestViaSession(fileUrl)
|
||||
]).then(([http, https, ws, wss, localResource]) => ({
|
||||
http,
|
||||
https,
|
||||
ws,
|
||||
wss,
|
||||
localResource
|
||||
}))
|
||||
const rendererProbe = browserWindow.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const targets = ${serializedTargets}
|
||||
const originalUrl = location.href
|
||||
const rejectedSocket = (url) => new Promise((resolve) => {
|
||||
let settled = false
|
||||
let socket
|
||||
const finish = (rejected) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket?.close()
|
||||
resolve(rejected)
|
||||
}
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
socket.onerror = () => finish(true)
|
||||
socket.onopen = () => finish(false)
|
||||
setTimeout(() => finish(true), 1000)
|
||||
} catch {
|
||||
finish(true)
|
||||
}
|
||||
})
|
||||
const rejectedLocalImage = new Promise((resolve) => {
|
||||
const outside = new Image()
|
||||
outside.onload = () => resolve(outside.naturalWidth === 0)
|
||||
outside.onerror = () => resolve(true)
|
||||
setTimeout(() => resolve(false), 500)
|
||||
outside.src = ${serializedFileUrl}
|
||||
})
|
||||
const windowOpen = window.open(targets.https + '/new-window') === null
|
||||
const target = document.createElement('a')
|
||||
target.href = targets.https + '/target-blank'
|
||||
target.target = '_blank'
|
||||
target.textContent = 'open'
|
||||
document.body.append(target)
|
||||
const targetBlankClicked = target.target === '_blank' && target.href === targets.https + '/target-blank'
|
||||
target.click()
|
||||
target.remove()
|
||||
try { location.assign(targets.ws + '/renderer-navigation') } catch { /* will-navigate 事件会阻止导航 */ }
|
||||
try { location.assign(targets.https + '/navigation') } catch { /* will-navigate 事件会阻止导航 */ }
|
||||
return Promise.all([
|
||||
rejectedLocalImage,
|
||||
rejectedSocket(targets.ws + '/renderer-probe'),
|
||||
rejectedSocket(targets.wss + '/renderer-probe'),
|
||||
new Promise((resolve) => setTimeout(resolve, 100))
|
||||
]).then(([localResource, rendererWs, rendererWss]) => ({ localResource, rendererWs, rendererWss, windowOpen, targetBlankClicked, navigation: location.href === originalUrl }))
|
||||
})()`
|
||||
)
|
||||
void Promise.all([networkProbe, rendererProbe])
|
||||
.then(([network, result]) => {
|
||||
const values = result as {
|
||||
localResource: boolean
|
||||
rendererWs: boolean
|
||||
rendererWss: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
state.probe = {
|
||||
...network,
|
||||
localResource: values.localResource,
|
||||
windowOpen: values.windowOpen,
|
||||
navigation: values.navigation,
|
||||
ws: network.ws || values.rendererWs,
|
||||
wss: network.wss || values.rendererWss,
|
||||
targetBlankClicked: values.targetBlankClicked,
|
||||
targetBlank:
|
||||
values.targetBlankClicked &&
|
||||
state.createdWindows === 1 &&
|
||||
!state.childWindowUrls.includes(`${targets.https}/target-blank`)
|
||||
}
|
||||
state.probeDone = true
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
})
|
||||
},
|
||||
{ fileUrl: outsideFileUrl, targets }
|
||||
)
|
||||
|
||||
const reportWindowPromise = app.waitForEvent('window', {
|
||||
predicate: (candidate) => candidate !== page
|
||||
})
|
||||
const exportPromise = page.evaluate(
|
||||
async ({ id, version, metadata, report }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata,
|
||||
report
|
||||
}),
|
||||
{
|
||||
id: installed.template!.id,
|
||||
version: installed.template!.version,
|
||||
metadata: runtimeAttackMetadata,
|
||||
report: runtimeAttackReport(`${targets.https}/render-image`)
|
||||
}
|
||||
)
|
||||
await reportWindowPromise
|
||||
const exported = await exportPromise
|
||||
expect(exported.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构攻击测试群日报')
|
||||
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & { __tmTemplateSecurity?: { probeDone: boolean } }
|
||||
).__tmTemplateSecurity
|
||||
return state?.probeDone ?? false
|
||||
}),
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const securityLogLines = readFileSync(securityLog, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as { url: string; reason: string })
|
||||
const blockedUrls = securityLogLines.map((entry) => entry.url)
|
||||
for (const url of [targets.http, targets.https, targets.ws, targets.wss, outsideFileUrl]) {
|
||||
expect(blockedUrls, `缺少运行时拦截记录: ${url}`).toContain(url)
|
||||
const block = securityLogLines.find((entry) => entry.url === url)
|
||||
expect(block?.reason, `运行时拦截 reason 缺失: ${url}`).toBe(
|
||||
url.startsWith('file:') ? 'file-path-not-allowed' : 'scheme-not-allowed'
|
||||
)
|
||||
}
|
||||
expect(probeServer.httpRequests).toEqual([])
|
||||
expect(probeServer.wsRequests).toEqual([])
|
||||
|
||||
const securityState = await app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__tmTemplateSecurity?: {
|
||||
hiddenWindows: number
|
||||
navigations: string[]
|
||||
createdWindows: number
|
||||
childWindowUrls: string[]
|
||||
probeDone: boolean
|
||||
probe?: {
|
||||
http: boolean
|
||||
https: boolean
|
||||
ws: boolean
|
||||
wss: boolean
|
||||
localResource: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
targetBlank: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
).__tmTemplateSecurity
|
||||
return state
|
||||
})
|
||||
expect(securityState?.hiddenWindows).toBe(1)
|
||||
expect(securityState?.createdWindows).toBe(1)
|
||||
expect(securityState?.childWindowUrls).toEqual([])
|
||||
expect(securityState?.navigations).toContain(`${targets.https}/navigation`)
|
||||
expect(securityState?.probe, JSON.stringify(securityState)).toEqual({
|
||||
http: true,
|
||||
https: true,
|
||||
ws: true,
|
||||
wss: true,
|
||||
localResource: true,
|
||||
windowOpen: true,
|
||||
targetBlankClicked: true,
|
||||
targetBlank: true,
|
||||
navigation: true
|
||||
})
|
||||
expect(app.windows().length).toBe(1)
|
||||
} finally {
|
||||
await app.close()
|
||||
if (probeServer.server.listening) {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
probeServer.server.close((error) => (error ? reject(error) : resolvePromise()))
|
||||
})
|
||||
}
|
||||
rmSync(fixtureDir, { recursive: true, force: true })
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -245,6 +245,28 @@ describe('Local API authentication', () => {
|
||||
expect(fixture.testSend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects external template refs on the legacy report HTTP API', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${VALID_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
report: {},
|
||||
metadata: {},
|
||||
templateRef: { id: 'community.github.example.template', version: '1.0.0' }
|
||||
})
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
status: 400,
|
||||
error: 'HTTP API 暂不支持外部日报模板,请使用内置 templateId',
|
||||
details: { code: 'external_template_unsupported' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://localhost',
|
||||
'http://localhost:5173',
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tempDirectory: '',
|
||||
fetch: vi.fn(),
|
||||
install: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: (name: string) => (name === 'temp' ? mocks.tempDirectory : mocks.tempDirectory)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/report-template-service', () => ({
|
||||
reportTemplateService: {
|
||||
install: mocks.install
|
||||
}
|
||||
}))
|
||||
|
||||
import { ReportTemplateMarketService } from '../../src/main/report-template-market-service'
|
||||
import { REPORT_TEMPLATE_CATALOG_URL } from '../../src/shared/report-template-market'
|
||||
import {
|
||||
decodeExternalReportTemplateId,
|
||||
encodeExternalReportTemplateId
|
||||
} from '../../src/shared/report-templates'
|
||||
|
||||
const commit = 'a'.repeat(40)
|
||||
const packageBytes = Buffer.from('fixture-template-package')
|
||||
const packageSha256 = crypto.createHash('sha256').update(packageBytes).digest('hex')
|
||||
const templateId = 'community.github.tracememo.quickread'
|
||||
const templateVersion = '1.0.0'
|
||||
const downloadUrl = `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${commit}/packages/${templateId}/${templateVersion}/${templateId}-${templateVersion}.zip`
|
||||
const previewUrl = `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${commit}/previews/${templateId}/${templateVersion}.png`
|
||||
|
||||
const catalog = (
|
||||
patch: Record<string, unknown> = {}
|
||||
): {
|
||||
schemaVersion: string
|
||||
generatedAt: string
|
||||
source: { repository: string; commit: string }
|
||||
status: string
|
||||
templates: Array<Record<string, unknown>>
|
||||
} => ({
|
||||
schemaVersion: '1',
|
||||
generatedAt: '2026-09-07T08:08:13Z',
|
||||
source: { repository: 'Wxw-Gu/TraceMemo-Templates', commit },
|
||||
status: 'published',
|
||||
templates: [
|
||||
{
|
||||
id: templateId,
|
||||
version: templateVersion,
|
||||
interfaceVersion: '1',
|
||||
name: '极简速读',
|
||||
description: '虚构模板目录条目',
|
||||
author: 'fixture',
|
||||
platform: 'mobile',
|
||||
tags: ['fixture'],
|
||||
license: 'MIT',
|
||||
minAppVersion: null,
|
||||
download: downloadUrl,
|
||||
sizeBytes: packageBytes.length,
|
||||
sha256: packageSha256,
|
||||
preview: previewUrl,
|
||||
publishedAt: '2026-09-07T08:08:13Z',
|
||||
status: 'published'
|
||||
}
|
||||
],
|
||||
...patch
|
||||
})
|
||||
|
||||
const responseFor = (body: Buffer | string, status = 200): Response =>
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: { 'content-length': String(Buffer.byteLength(body)) }
|
||||
})
|
||||
|
||||
const installResult = {
|
||||
id: templateId,
|
||||
version: templateVersion,
|
||||
interfaceVersion: '1',
|
||||
source: 'installed' as const,
|
||||
name: '极简速读',
|
||||
author: 'fixture',
|
||||
entryPath: '/tmp/fixture-template/template.html',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 20_000 },
|
||||
license: { spdx: 'MIT' }
|
||||
}
|
||||
|
||||
describe('ReportTemplateMarketService', () => {
|
||||
let service: ReportTemplateMarketService
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.tempDirectory = await mkdtemp(join(tmpdir(), 'tracememo-template-market-test-'))
|
||||
service = new ReportTemplateMarketService()
|
||||
mocks.fetch.mockReset()
|
||||
mocks.install.mockReset()
|
||||
vi.stubGlobal('fetch', mocks.fetch)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
await rm(mocks.tempDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('loads and normalizes a published catalog entry from the fixed raw URL', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
|
||||
const result = await service.listCatalog()
|
||||
|
||||
expect(result).toEqual({ success: true, catalog: catalog() })
|
||||
expect(mocks.fetch).toHaveBeenCalledOnce()
|
||||
expect(mocks.fetch).toHaveBeenCalledWith(
|
||||
new URL(REPORT_TEMPLATE_CATALOG_URL),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json, application/zip, image/png',
|
||||
'User-Agent': 'TraceMemo'
|
||||
}),
|
||||
redirect: 'error'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects incompatible interfaces and non-raw download URLs before install', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(JSON.stringify(catalog({ templates: [{ ...catalog().templates[0], interfaceVersion: '2' }] })))
|
||||
)
|
||||
|
||||
const incompatible = await service.listCatalog()
|
||||
|
||||
expect(incompatible.success).toBe(false)
|
||||
expect(incompatible.code).toBe('unsupported_interface')
|
||||
expect(incompatible.error).toContain('模板接口版本不兼容')
|
||||
|
||||
mocks.fetch.mockReset()
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(JSON.stringify(catalog({ templates: [{ ...catalog().templates[0], download: 'https://evil.example/template.zip' }] })))
|
||||
)
|
||||
|
||||
const invalidUrl = await service.listCatalog()
|
||||
|
||||
expect(invalidUrl.success).toBe(false)
|
||||
expect(invalidUrl.code).toBe('invalid_catalog')
|
||||
expect(invalidUrl.error).toContain('只允许 GitHub raw HTTPS 地址')
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a catalog URL from another raw GitHub repository', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(
|
||||
JSON.stringify(
|
||||
catalog({
|
||||
templates: [
|
||||
{
|
||||
...catalog().templates[0],
|
||||
download: `https://raw.githubusercontent.com/another-owner/another-repo/${commit}/template.zip`
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const result = await service.listCatalog()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.code).toBe('invalid_catalog')
|
||||
expect(result.error).toContain('只允许 GitHub raw HTTPS 地址')
|
||||
})
|
||||
|
||||
it('rejects package references that are not pinned to the catalog source commit', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(
|
||||
JSON.stringify(
|
||||
catalog({
|
||||
templates: [
|
||||
{
|
||||
...catalog().templates[0],
|
||||
download: `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${'b'.repeat(40)}/packages/${templateId}/${templateVersion}/${templateId}-${templateVersion}.zip`
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const result = await service.listCatalog()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.code).toBe('invalid_catalog')
|
||||
expect(result.error).toContain('未固定到目录 source.commit')
|
||||
})
|
||||
|
||||
it('reports a missing catalog version without downloading a package', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
|
||||
const result = await service.installFromCatalog(templateId, '9.9.9')
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
code: 'catalog_template_not_found',
|
||||
error: `远端目录不存在:${templateId}@9.9.9`
|
||||
})
|
||||
expect(mocks.fetch).toHaveBeenCalledOnce()
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a package when downloaded bytes do not match catalog size or SHA-256', async () => {
|
||||
mocks.fetch
|
||||
.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
.mockResolvedValueOnce(responseFor(Buffer.from('tampered-package')))
|
||||
|
||||
const result = await service.installFromCatalog(templateId, templateVersion)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
code: 'download_integrity_failed',
|
||||
catalogEntry: expect.objectContaining({ id: templateId, version: templateVersion })
|
||||
})
|
||||
expect(mocks.fetch).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes a verified package to a temporary path, installs it, and removes the temporary directory', async () => {
|
||||
mocks.fetch
|
||||
.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
.mockResolvedValueOnce(responseFor(packageBytes))
|
||||
let packagePath = ''
|
||||
mocks.install.mockImplementationOnce(async (candidatePath: string) => {
|
||||
packagePath = candidatePath
|
||||
await expect(readFile(candidatePath)).resolves.toEqual(packageBytes)
|
||||
return installResult
|
||||
})
|
||||
|
||||
const result = await service.installFromCatalog(templateId, templateVersion)
|
||||
|
||||
expect(result).toEqual({ success: true, template: installResult, catalogEntry: catalog().templates[0] })
|
||||
expect(mocks.install).toHaveBeenCalledOnce()
|
||||
expect(packagePath).toContain(`${templateId}-${templateVersion}.zip`)
|
||||
await expect(access(packagePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('external report template selection keys', () => {
|
||||
it('round-trips IDs and versions without collapsing them into built-in IDs', () => {
|
||||
const selectionId = encodeExternalReportTemplateId(templateId, templateVersion)
|
||||
|
||||
expect(selectionId).toBe(`external:${templateId}@${templateVersion}`)
|
||||
expect(decodeExternalReportTemplateId(selectionId)).toEqual({
|
||||
id: templateId,
|
||||
version: templateVersion
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed, incomplete, or non-string external keys', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'mobile-feed',
|
||||
'external:',
|
||||
'external:community.github.example@',
|
||||
'external:@1.0.0',
|
||||
'external:community.github.example@1.0.0@extra',
|
||||
'external:community.github.example'
|
||||
]) {
|
||||
expect(decodeExternalReportTemplateId(value)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ZipArchive } from 'archiver'
|
||||
|
||||
const mockPaths = vi.hoisted(() => ({ userData: '' }))
|
||||
vi.mock('electron', () => ({ app: { getPath: () => mockPaths.userData } }))
|
||||
const userData = await mkdtemp(join(tmpdir(), 'tracememo-template-service-'))
|
||||
mockPaths.userData = userData
|
||||
|
||||
import { ReportTemplateError, REPORT_TEMPLATE_LIMITS } from '../../src/shared/report-template-package'
|
||||
import { ReportTemplateService } from '../../src/main/report-template-service'
|
||||
|
||||
async function makeZip(files: Record<string, string | Buffer>): Promise<string> {
|
||||
const zipPath = join(userData, `${Math.random().toString(36).slice(2)}.zip`)
|
||||
const output = (await import('node:fs')).createWriteStream(zipPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||
archive.pipe(output)
|
||||
for (const [name, content] of Object.entries(files)) archive.append(content, { name })
|
||||
await archive.finalize()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
output.on('close', () => resolve())
|
||||
output.on('error', reject)
|
||||
})
|
||||
return zipPath
|
||||
}
|
||||
|
||||
const manifest = (patch: Record<string, unknown> = {}): string => JSON.stringify({
|
||||
protocolVersion: '1.0', kind: 'daily-report', id: 'community.github.example.basic-feed', name: '示例',
|
||||
author: { name: 'example' }, templateVersion: '1.0.0', interfaceVersion: '1', entry: 'template.html',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 20000 }, license: { spdx: 'MIT' }, ...patch
|
||||
})
|
||||
|
||||
describe('ReportTemplateService', () => {
|
||||
let service: ReportTemplateService
|
||||
beforeEach(() => { service = new ReportTemplateService() })
|
||||
afterEach(async () => { await rm(join(userData, 'report-templates'), { recursive: true, force: true }) })
|
||||
|
||||
it('installs, lists, reloads and uninstalls a valid package', async () => {
|
||||
const zip = await makeZip({ 'manifest.json': manifest(), 'template.html': '<!doctype html><html><head><style>.x{color:red}</style></head><body><h1 class="{{TOPICS_EMPTY_CLASS}}">{{REPORT_TITLE}}</h1></body></html>' })
|
||||
const installed = await service.install(zip)
|
||||
expect(installed.source).toBe('installed')
|
||||
expect((await service.list()).some((item) => item.id === installed.id)).toBe(true)
|
||||
const reloaded = new ReportTemplateService()
|
||||
expect((await reloaded.list()).find((item) => item.id === installed.id)?.version).toBe('1.0.0')
|
||||
await service.uninstall(installed.id, installed.version)
|
||||
expect((await service.list()).some((item) => item.id === installed.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing or incompatible manifest fields', async () => {
|
||||
const missing = await makeZip({ 'manifest.json': '{}', 'template.html': '<p>x</p>' })
|
||||
await expect(service.install(missing)).rejects.toMatchObject({ code: 'unsupported_protocol' })
|
||||
const incompatible = await makeZip({ 'manifest.json': manifest({ interfaceVersion: '2' }), 'template.html': '<p>x</p>' })
|
||||
await expect(service.install(incompatible)).rejects.toMatchObject({ code: 'unsupported_interface' })
|
||||
})
|
||||
|
||||
it('rejects dangerous HTML, invalid interpolation and duplicate paths', async () => {
|
||||
const dangerous = await makeZip({ 'manifest.json': manifest(), 'template.html': '<script>alert(1)</script>' })
|
||||
await expect(service.install(dangerous)).rejects.toBeInstanceOf(ReportTemplateError)
|
||||
const attr = await makeZip({ 'manifest.json': manifest(), 'template.html': '<div class="{{REPORT_TITLE}}"></div>' })
|
||||
await expect(service.install(attr)).rejects.toMatchObject({ code: 'invalid_placeholder_context' })
|
||||
const css = await makeZip({ 'manifest.json': manifest(), 'template.html': '<style>.x{background:url(https://evil.test/a.png)}</style>' })
|
||||
await expect(service.install(css)).rejects.toMatchObject({ code: 'unsafe_url' })
|
||||
const missingAsset = await makeZip({ 'manifest.json': manifest(), 'template.html': '<img src="assets/missing.png">' })
|
||||
await expect(service.install(missingAsset)).rejects.toMatchObject({ code: 'missing_asset' })
|
||||
const validKinds = await makeZip({ 'manifest.json': manifest(), 'template.html': '<div class="{{TOPICS_EMPTY_CLASS}}">{{TOPICS_MORE_NOTE}}</div>' })
|
||||
await expect(service.install(validKinds)).resolves.toMatchObject({ id: 'community.github.example.basic-feed' })
|
||||
const duplicate = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>x</p>', 'TEMPLATE.HTML': '<p>y</p>' })
|
||||
await expect(service.install(duplicate)).rejects.toMatchObject({ code: 'duplicate_entry' })
|
||||
})
|
||||
|
||||
it('rejects traversal and oversized packages before staging output', async () => {
|
||||
const traversal = await makeZip({ 'manifest.json': manifest(), '../escape.txt': 'x', 'template.html': '<p>x</p>' })
|
||||
await expect(service.install(traversal)).rejects.toBeInstanceOf(ReportTemplateError)
|
||||
const oversized = await makeZip({ 'manifest.json': manifest(), 'template.html': Buffer.alloc(REPORT_TEMPLATE_LIMITS.maxFileBytes + 1) })
|
||||
await expect(service.install(oversized)).rejects.toMatchObject({ code: 'file_too_large' })
|
||||
await expect(readFile(join(userData, 'escape.txt'))).rejects.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects an uninstall reference that could escape the registry directory', async () => {
|
||||
await expect(service.uninstall('../outside', '1.0.0')).rejects.toMatchObject({
|
||||
code: 'invalid_template_ref'
|
||||
})
|
||||
await expect(
|
||||
service.uninstall('community.github.example.basic-feed', '../outside')
|
||||
).rejects.toMatchObject({ code: 'invalid_template_ref' })
|
||||
})
|
||||
|
||||
it('rejects external refs that target built-in templates', async () => {
|
||||
await expect(service.resolve({ id: 'v1', version: '1.0.0' })).rejects.toMatchObject({
|
||||
code: 'builtin_template_ref'
|
||||
})
|
||||
})
|
||||
|
||||
it('checks an expected catalog identity before staging a package', async () => {
|
||||
const zip = await makeZip({
|
||||
'manifest.json': manifest(),
|
||||
'template.html': '<p>{{REPORT_TITLE}}</p>'
|
||||
})
|
||||
await expect(
|
||||
service.install(zip, {
|
||||
id: 'community.github.example.other-template',
|
||||
version: '1.0.0'
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'catalog_manifest_mismatch' })
|
||||
})
|
||||
|
||||
it('is idempotent for identical versions and rejects content conflicts', async () => {
|
||||
const first = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>a</p>' })
|
||||
const second = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>b</p>' })
|
||||
const installed = await service.install(first)
|
||||
await expect(service.install(first)).resolves.toMatchObject({ id: installed.id, version: installed.version })
|
||||
await expect(service.install(second)).rejects.toMatchObject({ code: 'version_conflict' })
|
||||
expect(await readdir(join(userData, 'report-templates', 'staging'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rebuilds a damaged index from installed manifests', async () => {
|
||||
const zip = await makeZip({ 'manifest.json': manifest({ templateVersion: '2.0.0' }), 'template.html': '<p>{{REPORT_TITLE}}</p>' })
|
||||
const installed = await service.install(zip)
|
||||
await writeFile(join(userData, 'report-templates', 'index.json'), '{broken', 'utf8')
|
||||
const recovered = new ReportTemplateService()
|
||||
await recovered.recover()
|
||||
expect((await recovered.list()).find((item) => item.id === installed.id && item.version === '2.0.0')?.entryPath).toContain('installed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user