Presto 软件设计架构
Presto 是 Markdown → Typst → PDF 文档转换平台,通过二进制模板实现可扩展的排版能力,同时提供桌面端和 Web 端两种使用方式。
系统全景
核心技术栈
| 层级 | 技术 | 版本 | 用途 |
|---|---|---|---|
| 后端 | Go | 1.25 | 业务逻辑、模板管理、API 服务 |
| 桌面框架 | Wails | v2 | 原生窗口、系统 WebView、Go-JS 桥接 |
| 前端框架 | SvelteKit | 5 | SPA 路由、组件化 UI |
| 前端构建 | Vite | 7 | 开发服务器、生产构建 |
| 编辑器 | CodeMirror | 6 | Markdown 编辑、语法高亮 |
| 排版引擎 | Typst | 0.14.x | Typst 源码 → PDF/SVG |
项目目录结构
Presto/
├── cmd/
│ ├── presto-desktop/ # Wails 桌面端入口
│ │ ├── main.go # App 结构体 + Wails 配置 + main()
│ │ ├── updater.go # 三平台自动更新
│ │ └── build/ # 嵌入的前端构建产物
│ └── presto-server/
│ └── main.go # HTTP 服务端入口
├── internal/
│ ├── api/ # HTTP API 层(两个入口共享)
│ ├── template/ # 模板管理核心
│ └── typst/ # Typst 编译器封装
├── frontend/ # SvelteKit 5 前端
├── packaging/ # 平台打包脚本
├── Dockerfile # 服务端 Docker 镜像
└── Makefile # 构建脚本双入口架构
Presto 采用"共享核心 + 双壳"架构:两个入口程序(cmd/presto-desktop 和 cmd/presto-server)共享 internal/ 包的全部业务逻辑,通过不同的配置参数适配各自的运行环境。
为什么共享 internal 而非拆分微服务
Presto 的核心操作(模板执行、Typst 编译)都是本地 CPU 密集型任务,不涉及远程服务调用。拆分微服务只会增加进程间通信开销和部署复杂度,没有实际收益。共享 internal/ 包让两个入口的行为保持一致,一处修改两处生效。
桌面端 vs 服务端对比
| 维度 | Desktop (cmd/presto-desktop) | Server (cmd/presto-server) |
|---|---|---|
| 前端资源 | //go:embed all:build 嵌入二进制 | STATIC_DIR 文件系统路径 |
| 认证 | 无(APIKey 为空,跳过认证中间件) | Bearer Token(随机生成或环境变量) |
| 字体 | 系统字体 | 可配置 FONT_PATHS(冒号分隔) |
| Typst 查找 | findTypstBinary() 多路径搜索 | 直接 "typst"(依赖 PATH) |
| 网络绑定 | 无(Wails 内部通信) | HOST:PORT(默认 127.0.0.1:8080) |
| 部署方式 | 单文件应用(DMG/ZIP/tar.gz) | Docker 镜像或直接运行 |
模式切换机制
两个入口都调用 api.NewServer(ServerOptions{...}),通过 ServerOptions 字段差异实现行为切换:
APIKey为空 →authMiddleware跳过认证(桌面模式)APIKey非空 → 所有/api/*请求需要 Bearer Token,HTML 页面注入<meta name="api-key">标签供前端读取StaticDir为空 → 前端由 Wails 嵌入资源服务器提供StaticDir非空 → 从文件系统提供静态文件
Go 后端核心包
internal/ 下有三个核心包,职责清晰分离:
template/ — 模板系统核心
模板系统管理模板的完整生命周期:发现、安装、执行、卸载。
Manager(internal/template/manager.go)是模板系统的协调器,所有操作围绕 ~/.presto/templates/{name}/ 目录:
| 操作 | 方法 | 说明 |
|---|---|---|
| 列表 | List() | 扫描所有子目录,读取 manifest.json + 验证二进制存在,自动去重 |
| 获取 | Get(name) | 按名称查找已安装模板 |
| 安装 | Install(owner, repo, opts) | 从 GitHub 下载 + SHA256 校验 + 写入磁盘 |
| 卸载 | Uninstall(name) | 安全删除模板目录(路径穿越防护 + 符号链接检测) |
| 重命名 | Rename(old, new) | 三步磁盘重命名:二进制 → manifest → 目录 |
| 执行器 | Executor(t) | 为已安装模板创建 Executor 实例 |
Executor(internal/template/executor.go)封装模板协议的四种调用方式:
| 方法 | 对应协议 | 说明 |
|---|---|---|
Convert(markdown) | stdin/stdout | Markdown → Typst 转换 |
GetManifest() | --manifest | 获取模板元数据 |
GetExample() | --example | 获取示例文档 |
所有调用共享 run(args, stdin) 内部方法,统一执行 30 秒超时(SEC-12)和最小化环境变量 PATH=/usr/local/bin:/usr/bin:/bin(SEC-10)。
RegistryCache(internal/template/registry.go)是注册中心 CDN 的本地缓存层,三级回退策略:
- 内存缓存(
sync.RWMutex保护) - 磁盘缓存(JSON 文件)
- CDN 拉取(
https://presto.c-1o.top/templates/registry.json) - 过期缓存兜底(CDN 不可达时)
缓存 TTL 为 1 小时,启动时异步刷新。核心方法:
VerifySHA256()— 校验模板完整性LookupTrust()— 查询信任等级LookupByRepo()— 服务端安全查找,不信任客户端 URL(SEC-39)
GitHub 交互(internal/template/github.go)负责模板发现和下载:
DiscoverTemplates()— GitHub Search API,按topic:presto-template搜索Install()— 完整安装流程:名称验证 → 下载 URL 获取(注册表优先)→ 域名白名单校验 → 下载(100MB 限制)→ SHA256 校验 → 写入磁盘- 安全:6 个 GitHub 域名白名单(SEC-07)、official/verified 模板必须有 SHA256(SEC-01)、限制性文件权限 0700/0600(SEC-28/45)
api/ — HTTP API 层
api.Server 是两个入口的核心共享点,提供 15 个 REST 端点:
| 端点 | 方法 | 用途 |
|---|---|---|
/api/health | GET | 健康检查 |
/api/convert | POST | Markdown → Typst |
/api/compile | POST | Typst → PDF |
/api/compile-svg | POST | Typst → SVG 页面数组 |
/api/convert-and-compile | POST | Markdown → PDF(一步完成) |
/api/templates | GET | 列出已安装模板 |
/api/templates/discover | GET | GitHub 搜索模板 |
/api/templates/{id}/install | POST | 安装模板(仅接受 owner/repo) |
/api/templates/{id} | PATCH | 重命名模板 |
/api/templates/{id} | DELETE | 卸载模板 |
/api/templates/{id}/manifest | GET | 获取模板 manifest |
/api/templates/{id}/example | GET | 获取模板示例 |
/api/templates/import | POST | ZIP 导入模板 |
/api/batch/import-zip | POST | 批量 ZIP 导入 |
中间件链(从外到内):
- CORS — 6 个白名单 Origin(localhost:8080/5173、127.0.0.1、wails://wails、wails.localhost)
- 安全头 —
X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy: strict-origin-when-cross-origin - 认证 — Bearer Token +
subtle.ConstantTimeCompare防时序攻击(NEW-04),桌面模式跳过 - 限流 — 令牌桶算法,10 req/s,burst 30(SEC-19)
typst/ — 编译器封装
typst.Compiler(internal/typst/compiler.go)封装 Typst CLI,提供两种输出格式:
| 方法 | 输入 | 输出 | 超时 |
|---|---|---|---|
CompileString(src, workDir) | Typst 源码字符串 | PDF 字节 | 60 秒 |
CompileToSVG(src, workDir) | Typst 源码字符串 | SVG 页面数组 | 60 秒 |
Compile(typFile) | .typ 文件路径 | .pdf 文件 | 60 秒 |
ListFonts() | — | 可用字体列表 | — |
设计要点:
Root字段限制 Typst 的文件系统访问范围(SEC-02),桌面端设为os.TempDir(),不用/crypto/rand生成随机文件名后缀防止并发编译冲突(SEC-25)- 多页 SVG 输出按页码数字排序,单页自动回退
Wails 通信机制
桌面端的前端和 Go 后端之间有三种通信方式,各有适用场景:
方式 1:Go Binding(前端调用 Go)
前端通过 window.go.main.App.* 直接调用 Go 方法,Wails 自动处理序列化:
| 方法 | 用途 |
|---|---|
OpenFile() / OpenFiles() | 原生文件打开对话框 |
SavePDF(markdown, templateId, workDir) | 转换 + 原生保存对话框 |
SaveFile(b64Data, filename) | Base64 数据 → 原生保存 |
CompileSVG(typstSource, workDir) | Typst → SVG 编译 |
ImportBatchZip(filePath) | ZIP 批量导入 |
DeleteTemplate(name) | 卸载模板 |
GetVersion() | 获取版本号 |
CheckForUpdate() | 检查更新 |
DownloadAndInstallUpdate(url) | 下载并安装更新 |
方式 2:Wails Events(Go 推送前端)
Go 后端通过 runtime.EventsEmit() 主动推送事件到前端:
| 事件名 | 触发场景 |
|---|---|
native-file-drop | 用户拖放文件到窗口 |
url-scheme-open-template | presto://install/{name} URL scheme |
menu:open/export/settings/templates | 原生菜单点击 |
update:progress / update:status | 更新下载进度和状态 |
方式 3:HTTP API(前端 fetch)
前端通过 authFetch() 调用 /api/* 端点,与服务端模式共用同一套 API。
为什么部分操作绕过 HTTP 用 Wails Binding
Wails 的 WebView 存在已知限制:
- multipart Content-Type 头被剥离:FormData 上传(如 ZIP 导入)无法正常工作
- DELETE 方法可能不支持:部分 WebView 实现不完整
因此 CompileSVG、ImportBatchZip、DeleteTemplate 等操作通过 Go Binding 直调,绕过 WebView 的 HTTP 层。需要原生 UI 的操作(文件对话框、保存对话框) 天然适合 Binding 方式。
前端架构
前端采用 SvelteKit 5 + adapter-static 构建纯 SPA,通过 //go:embed 嵌入桌面端二进制 或独立部署为 Web 端。
路由结构
| 路由 | 页面 | 说明 |
|---|---|---|
/ | 主编辑器 | CodeMirror 编辑 + SVG 实时预览 + PDF 导出 |
/batch | 批量转换 | 多文件拖入、按模板分组、并发转换、ZIP 打包 |
/settings | 设置 | 模板管理、社区模板开关、版本更新 |
/store-templates | 模板商店 | 浏览注册中心、安装模板、URL scheme 深度链接 |
/showcase/* | 展示页面 | 预渲染的演示页面,嵌入官网 |
状态管理:Svelte 5 Runes
全面采用 Svelte 5 的 runes 模式($state、$derived、$effect), 不使用传统的 writable store。Store 文件使用 .svelte.ts 后缀:
| Store | 职责 |
|---|---|
editor.svelte.ts | 编辑器状态:markdown、typstSource、svgPages、selectedTemplate |
templates.svelte.ts | 已安装模板列表,从 API 加载,监听 templates-changed 事件 |
registry.svelte.ts | 远程注册表数据,开发环境用 mock,生产环境从 CDN 获取 |
file-router.svelte.ts | 统一文件处理:拖拽/打开的路由分发、ZIP 导入、toast 状态 |
wizard.svelte.ts | 新手引导系统:localStorage 持久化,多种触发机制 |
API 客户端
authFetch()(src/lib/api/client.ts)统一封装所有 HTTP 请求:
- 从
<meta name="api-key">读取 API Key(服务端模式注入) - 有 Key 时自动添加
Authorization: Bearer {key}头 - 桌面端无 Key,直接 fetch(认证中间件跳过)
- API 基础 URL 通过
VITE_API_URL环境变量配置,桌面端为空(同源)
数据流(端到端)
用户从打开应用到导出 PDF 的完整数据流:
桌面端的预览(CompileSVG)通过 Wails Binding 直调,不走 HTTP 层, 减少一次序列化开销。导出 PDF 时桌面端调用 SavePDF() Binding, 自动弹出原生保存对话框。
安全设计
Presto 在代码中使用 SEC-XX 标注体系标记安全措施,覆盖以下关键领域:
路径安全
- 路径穿越防护(SEC-05/06/30):所有文件操作验证绝对路径前缀, 拒绝包含
..的路径,模板名称正则校验^[a-zA-Z0-9][a-zA-Z0-9._-]*$ - 符号链接检测(SEC-38):卸载前用
Lstat检测,防止 TOCTOU 攻击 - 隐藏文件过滤(SEC-27):静态文件服务阻止访问 dotfile
网络安全
- 域名白名单(SEC-07/46):下载 URL 限制为 6 个 GitHub 域名, CDN 重定向也验证域名
- CORS 白名单(SEC-08):仅允许已知 Origin
- 常量时间比较(NEW-04):API Key 认证使用
subtle.ConstantTimeCompare - 令牌桶限流(SEC-19):10 req/s,burst 30
模板沙箱
模板二进制运行在严格受限环境中(详见二进制协议速查):
- 30 秒执行超时(SEC-12)
- 最小化环境变量(SEC-10)
- 无网络访问、无文件写入
- SHA256 校验链:注册表记录 → 下载校验 → 安装验证(SEC-01)
- official/verified 模板必须有 SHA256,否则拒绝安装
数据安全
- 请求体限制(SEC-11/13/29):API 10MB、ZIP 100MB、注册表 10MB
- 安全响应头(SEC-36):nosniff、DENY、strict-origin-when-cross-origin
- 错误信息隔离(SEC-15/16/35):客户端只看到通用错误消息, 详细信息记录在服务端日志
- 文件权限(SEC-28/45):目录 0700、文件 0600
跨平台自动更新
桌面端内置自动更新机制,检测 GitHub Releases 的最新版本并按平台执行安装。
更新流程
CheckForUpdate()— 查询 GitHub API,比较版本号,按{os}-{arch}匹配下载资源DownloadAndInstallUpdate(url)— 下载到临时目录,通过update:progress事件报告进度- 按平台执行安装 → 重启应用
三平台安装策略
| 平台 | 格式 | 策略 | 原理 |
|---|---|---|---|
| macOS | DMG | 挂载 → 复制替换 → 重启 | 运行中的二进制在内存中,可安全替换磁盘文件 |
| Windows | ZIP | 解压 → 生成 bat 脚本 → 延迟替换 | Windows 锁定运行中的 exe,需外部脚本等待退出后替换 |
| Linux | tar.gz | 解压 → 直接覆盖 → 重启 | 与 macOS 类似,运行中的二进制在内存中 |
所有平台都包含路径穿越防护(filepath.Clean + 前缀检查), Windows 额外防护 zip slip 攻击。
