Skip to content

Presto 软件设计架构

Presto 是 Markdown → Typst → PDF 文档转换平台,通过二进制模板实现可扩展的排版能力,同时提供桌面端和 Web 端两种使用方式。


系统全景

核心技术栈

层级技术版本用途
后端Go1.25业务逻辑、模板管理、API 服务
桌面框架Wailsv2原生窗口、系统 WebView、Go-JS 桥接
前端框架SvelteKit5SPA 路由、组件化 UI
前端构建Vite7开发服务器、生产构建
编辑器CodeMirror6Markdown 编辑、语法高亮
排版引擎Typst0.14.xTypst 源码 → PDF/SVG

项目目录结构

text
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-desktopcmd/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/ — 模板系统核心

模板系统管理模板的完整生命周期:发现、安装、执行、卸载。

Managerinternal/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 实例

Executorinternal/template/executor.go)封装模板协议的四种调用方式:

方法对应协议说明
Convert(markdown)stdin/stdoutMarkdown → Typst 转换
GetManifest()--manifest获取模板元数据
GetExample()--example获取示例文档

所有调用共享 run(args, stdin) 内部方法,统一执行 30 秒超时(SEC-12)和最小化环境变量 PATH=/usr/local/bin:/usr/bin:/bin(SEC-10)。

RegistryCacheinternal/template/registry.go)是注册中心 CDN 的本地缓存层,三级回退策略:

  1. 内存缓存(sync.RWMutex 保护)
  2. 磁盘缓存(JSON 文件)
  3. CDN 拉取(https://presto.c-1o.top/templates/registry.json
  4. 过期缓存兜底(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/healthGET健康检查
/api/convertPOSTMarkdown → Typst
/api/compilePOSTTypst → PDF
/api/compile-svgPOSTTypst → SVG 页面数组
/api/convert-and-compilePOSTMarkdown → PDF(一步完成)
/api/templatesGET列出已安装模板
/api/templates/discoverGETGitHub 搜索模板
/api/templates/{id}/installPOST安装模板(仅接受 owner/repo)
/api/templates/{id}PATCH重命名模板
/api/templates/{id}DELETE卸载模板
/api/templates/{id}/manifestGET获取模板 manifest
/api/templates/{id}/exampleGET获取模板示例
/api/templates/importPOSTZIP 导入模板
/api/batch/import-zipPOST批量 ZIP 导入

中间件链(从外到内):

  • CORS — 6 个白名单 Origin(localhost:8080/5173、127.0.0.1、wails://wails、wails.localhost)
  • 安全头 — X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-origin
  • 认证 — Bearer Token + subtle.ConstantTimeCompare 防时序攻击(NEW-04),桌面模式跳过
  • 限流 — 令牌桶算法,10 req/s,burst 30(SEC-19)

typst/ — 编译器封装

typst.Compilerinternal/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-templatepresto://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 实现不完整

因此 CompileSVGImportBatchZipDeleteTemplate 等操作通过 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 的最新版本并按平台执行安装。

更新流程

  1. CheckForUpdate() — 查询 GitHub API,比较版本号,按 {os}-{arch} 匹配下载资源
  2. DownloadAndInstallUpdate(url) — 下载到临时目录,通过 update:progress 事件报告进度
  3. 按平台执行安装 → 重启应用

三平台安装策略

平台格式策略原理
macOSDMG挂载 → 复制替换 → 重启运行中的二进制在内存中,可安全替换磁盘文件
WindowsZIP解压 → 生成 bat 脚本 → 延迟替换Windows 锁定运行中的 exe,需外部脚本等待退出后替换
Linuxtar.gz解压 → 直接覆盖 → 重启与 macOS 类似,运行中的二进制在内存中

所有平台都包含路径穿越防护(filepath.Clean + 前缀检查), Windows 额外防护 zip slip 攻击。

Presto — Markdown to PDF