feat: 日报新增模板市场,支持社区模板安装与使用

This commit is contained in:
Wxw-Gu
2026-09-08 11:37:38 +08:00
parent 1156362c3c
commit 976aac32c5
35 changed files with 2957 additions and 82 deletions
+1
View File
@@ -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.
+9
View File
@@ -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>
+3
View File
@@ -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": {
+39 -5
View File
@@ -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
+18
View File
@@ -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()
+179 -19
View File
@@ -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, '&amp;')
@@ -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,
+5
View File
@@ -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
View File
@@ -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)
+41
View File
@@ -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))
}
+243
View File
@@ -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()
+403
View File
@@ -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()
+44
View File
@@ -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())
+16
View File
@@ -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>
+17
View File
@@ -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'),
+2 -2
View File
@@ -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&amp;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
+5 -1
View File
@@ -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
}
+6 -4
View File
@@ -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
+45
View File
@@ -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
}
+105
View File
@@ -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
}
+20
View File
@@ -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
+49 -1
View File
@@ -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()
})
})
+282
View File
@@ -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 })
}
})
+600
View File
@@ -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 })
}
})
+22
View File
@@ -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',
+274
View File
@@ -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()
}
})
})
+126
View File
@@ -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')
})
})