# 开发

> 从源码构建 DuckLocal（Rust + DuckDB）、运行测试、打包 macOS 应用，并为 macOS、Linux 和 Windows 发布版本。

Source: https://ducklocal.app/docs/zh/development

**[English](../development.md)**

## 构建与运行 {#build-and-run}

```bash
cargo run                     # 启动空的内存工作区
cargo run -- ./data/logs/     # 或直接打开数据
cargo test --workspace        # 运行测试套件，包括 Quick Look 引擎
```

DuckDB 从仓库自带的源码构建，首次构建耗时很长，还要占几 GB 磁盘。dev profile 的依赖以 `opt-level = 3` 编译，首次构建因此更慢，但开发时的迭代不受拖累：之后只有 `ducklocal` 自身会重新编译。

**精简构建。** `cargo build --profile compact --locked` 会在 `target/compact/` 下生成体积更小的二进制。`compact` profile 继承 `release`（LTO、剥离符号、`panic = "abort"`），但 Rust 代码按体积优化（`opt-level = "s"`），`libduckdb-sys` 仍保持 `opt-level = 3`，DuckDB 的扫描、过滤和聚合循环照样快。下载或磁盘体积比界面和查询速度更要紧时用它，大小和速度都请在目标机器上实测。发布工作流和它产出的安装包用的是普通的 `release` profile。

**工具链。** 仓库里没有 `rust-toolchain.toml`，也没有 `rust-version` 字段，实际的版本下限由依赖决定：`duckdb` 要求 `1.85.1`。CI 直接使用 stable。

## 钉住版本的工具包

UI 工具包 `gpui-kit` 来自仓库 `longbridge/gpui-kit`，特意钉在同一个 git revision 上。如果混用两个 revision，构建里就会出现两份 `gpui-base`，编译失败时报出一堆 trait 不匹配的错误，却没有一条指出真正的原因。

```bash
./scripts/check-deps.sh
```

这个脚本检查 Cargo 不管的几件事：对该仓库的所有依赖用的是同一个 revision；`Cargo.lock` 与之一致；没有哪个 crate 在构建里出现两次（一次来自该仓库，一次来自 crates.io）。脚本只读这两个清单文件，不联网，瞬间完成。CI 在跑测试之前会先执行它。升级工具包时，把 `Cargo.toml` 里所有 `rev` 一起改掉，执行 `cargo update`，再跑一次这个脚本。

按 git revision 钉依赖，也是 DuckLocal 无法发布到 crates.io 的原因：crates.io 不接受 git 依赖。分发靠的是下面的各平台发布包。

## 打包 macOS 应用

```bash
./scripts/bundle.sh
# 生成 target/release/DuckLocal.app
```

脚本会组装出应用包（可执行文件位于 `Contents/MacOS/ducklocal`，外加 `assets/Info.plist`、`assets/AppIcon.icns` 和 Quick Look 扩展），但**不做**用于分发的**签名**。产物在自己的机器上能运行，换到其他机器上 Gatekeeper 会拒绝打开。

### Quick Look 扩展

在访达里按空格预览 Parquet、DuckDB、CSV、TSV 和 JSON Lines 文件，靠的是应用 `Contents/PlugIns` 里的应用扩展 `DuckLocalPreview.appex`。它分两半：

- `quicklook/` 是 workspace 里的一个 crate，`ducklocal-quicklook`：用 Rust 写的预览引擎，编译成静态库。它用和应用相同的内置 DuckDB 打开文件（同一份锁文件，DuckDB 只编译一次），把列和前 50 行渲染成一个自包含的 HTML 页面。扩展是独立的沙盒进程，不能调用应用里的代码。
- `quicklook/extension/` 是包在外面的 Swift 外壳：`PreviewProvider.swift`、C 头文件、扩展自己的 `Info.plist`（声明它能预览的内容类型）和沙盒授权文件。

```bash
scripts/build-quicklook.sh <DuckLocal.app> [签名身份]
```

脚本先构建引擎（`cargo build --release -p ducklocal-quicklook`；设置了 `RUST_TARGET` 时按该目标构建，发布工作流就是这样复用应用已构建好的 DuckDB），再用 `swiftc` 针对 macOS 12 编译 Swift 外壳并链接引擎，复制 `Info.plist` 并写入应用的版本号，最后用扩展自己的授权文件给 `.appex` 签名；没给签名身份时做 ad hoc 签名。`bundle.sh` 调用它时不带签名身份。应用启动过一次或完成注册后，macOS 就能找到这个扩展。

## 打包、签名与公证

```bash
scripts/package-macos.sh <binary> <output.dmg> <version>
```

在仓库根目录运行。脚本会把 `DuckLocal.app` 和 `dmg-root` 写到当前工作目录，dmg 也放在旁边。没有凭据时脚本拒绝运行：未公证的产物在当前 macOS 上用户根本打不开，与其一不小心产出一个，不如尽早报错。

需要以下内容：

| 变量 | 含义 |
| --- | --- |
| `NOTARY_KEY` | App Store Connect API 密钥（`.p8`）的路径 |
| `NOTARY_KEY_ID` | 该密钥的 Key ID |
| `NOTARY_ISSUER` | 该密钥所属的 issuer UUID |
| Developer ID Application 签名身份 | 钥匙串中恰好只有一个时自动检测，否则需设置 `MACOS_SIGN_IDENTITY` |

脚本依次执行以下步骤：

1. 构建应用包，版本号取自参数。
2. 用 `build-quicklook.sh` 构建 Quick Look 扩展，以 Developer ID 和扩展自己的沙盒授权签名，绝不用 `scripts/entitlements.plist`：应用扩展必须启用沙盒，应用本身没有。
3. 由内向外、不用 `--deep`，以 `--options runtime --timestamp` 和 `scripts/entitlements.plist` 先给内部可执行文件、再给应用本身签名，并用 `codesign --verify --deep --strict` 校验。
4. 构建一个带 `/Applications` 符号链接的 UDZO dmg。
5. **给 dmg 本身签名**。
6. 用 `notarytool submit --wait --timeout 300m` 提交公证。
7. 装订公证票据。
8. 最后执行 `stapler validate` 和两次 `spctl --assess` 校验。

使用前需要了解两件事：

- 五小时的超时只是客户端不再等待，并不会取消提交。超时后 Apple 那边仍在继续处理，票据稍后照样能领取。但**票据只认提交时的那份字节**，所以不要重新构建，把票据装订到当初提交的那个文件上。
- 只有 dmg 经过公证，内部的 `.app` 上没有装订票据。从刚挂载的镜像首次启动时需要联网，向 Apple 校验。

必须启用 `com.apple.security.cs.disable-library-validation` 授权，因为 DuckDB 用 `dlopen` 加载扩展，`httpfs` 就是其中之一。

## CI 与发布

| 工作流 | 触发条件 | 作用 |
| --- | --- | --- |
| `ci.yml` | 推送到 `main`、任意 pull request | `macos-latest`：先执行 `scripts/check-deps.sh`、`cargo fmt --all --check`、`cargo clippy --locked --workspace --all-targets -- -D warnings`，再执行 `cargo test --locked --workspace`（应用和 Quick Look crate）。`windows-latest`：执行 `cargo test --locked`（跳过 `perf_probe` 测试），再用 `scripts/check-windows.py` 检查 debug 可执行文件。`ubuntu-22.04`：执行 `cargo build --locked` |
| `release.yml` | 匹配 `v*` 的 tag，或手动指定版本触发 | 三个构建：`aarch64-apple-darwin` 经 `package-macos.sh` 签名、公证为 `ducklocal-macos-arm64.dmg`；Linux 在 Ubuntu 22.04 上打成 `ducklocal-linux-x86_64.tar.gz`；Windows 先由 `scripts/check-windows.py` 检查 release 可执行文件，再打成 `ducklocal-windows-x86_64.zip`。发布任务等三个构建都完成（任何一个失败，整个发布就失败），再用自动生成的说明创建 GitHub 发布 |
| `gh-pages.yml` | `main` 分支上跳转脚本或工作流有改动时，或手动触发 | 在 GitHub Pages 上发布从旧地址 `jetsquirrel.github.io/DuckLocal/` 到新站点的跳转 |

发布构建需要以下仓库密钥：`MACOS_CERTIFICATE`、`MACOS_CERTIFICATE_PWD`、`NOTARY_KEY_BASE64`、`NOTARY_KEY_ID` 和 `NOTARY_ISSUER`。

## 平台支持

| 平台 | 发布包 | 说明 |
| --- | --- | --- |
| macOS 12+，Apple 芯片 | `.dmg`，已签名并公证，含 Quick Look 扩展 | CI 中运行测试，包括 Quick Look crate |
| Linux x86_64 | 二进制的 `.tar.gz` | 在 Ubuntu 22.04 上构建，glibc 版本够旧，大多数发行版都能用；系统库与 gpui-kit 自己的 CI 所装的一致 |
| Windows x86_64 | `ducklocal.exe` 的 `.zip` | CI 中运行测试。没有代码签名：没有 Windows 证书，所以首次启动时 SmartScreen 会弹出警告 |

测试在 macOS 和 Windows 上运行；Linux 在每个 pull request 上都会编译，但不跑测试。Windows 也跑测试，是因为它的路径（盘符、`\`、没有 `$HOME`）正是 macOS 上发现不了的出错点。随后 `scripts/check-windows.py`（只用 Python 标准库，在 Windows 上运行）不启动界面就检查可执行文件：是 GUI 子系统，所以不会弹出控制台窗口；内嵌了鸭子图标；`--version` 和一次 `query` 能输出到重定向的标准输出。没有 Intel Mac 版本。

快捷键用 gpui 的 `secondary` 修饰键绑定：在 macOS 上是 ⌘，在 Windows 和 Linux 上是 Ctrl。如果绑定成 `cmd`，在 Windows 和 Linux 上就成了 Windows 键，所有快捷键都按不出来。

## 文档站

网站放在独立的仓库 [JetSquirrel/ducklocal-site](https://github.com/JetSquirrel/ducklocal-site)，以静态资源的形式由 Cloudflare Workers 托管：

| 地址 | 内容 | 源码 |
| --- | --- | --- |
| [ducklocal.app](https://ducklocal.app/zh/) | 产品首页 | `home/`，静态 HTML 与 CSS |
| [ducklocal.app/docs](https://ducklocal.app/docs/zh/) | 这些指南，中英文 | `docs/`，[VitePress](https://vitepress.dev/) 站点 |

应用改动后，如果指南里的说法随之变化，需要向两个仓库都提交 pull request。在本地修改指南的前提条件：Node.js 22 或更新版本（推荐 24 LTS），以及一份站点仓库的克隆。然后运行：

```bash
npm --prefix docs ci             # 按锁文件安装依赖
npm --prefix docs run dev        # http://localhost:5173/docs/
sh scripts/build.sh              # 整个站点（产品首页与文档）生成到 dist/site
```

旧的 GitHub Pages 地址 `jetsquirrel.github.io/DuckLocal/` 保留为一组跳转，分别指向对应的新页面，由本仓库的 `gh-pages.yml` 发布。

## 项目结构

| 路径 | 内容 |
| --- | --- |
| `src/main.rs` | 入口点、提前分流 CLI、GUI 窗口设置 |
| `src/cli.rs` | 无界面参数、DuckDB 解析验证、独立连接、JSON 错误及其提示 |
| `tests/cli.rs` | 真实二进制集成测试，临时数据位于 `target/cli-tests` |
| `skills/ducklocal/` | 官方的 schema-first SQL agent skill（AI 智能体技能包），附 CLI 参考 |
| `src/sources.rs` | 把路径解析成要挂载的文件和要打开的数据库 |
| `src/db.rs` | DuckDB 连接、挂载文件、服务器信息 |
| `src/query.rs` | 查询执行、结果序列化、导出、EXPLAIN，以及**性能分析**背后的 EXPLAIN ANALYZE |
| `src/script.rs` | 把编辑器文本拆成“运行”要依次执行的语句，包括点命令 |
| `src/overview.rs` | 结果区的**概览**标签页：逐列统计与分布 |
| `src/extensions.rs` | 侧栏的**扩展**标签页：列出扩展，执行 `INSTALL`、`LOAD` 和 `UPDATE EXTENSIONS` |
| `src/storage.rs` | 读取 `.duckdb` 文件头：文件为什么打不开，能打开的文件哪些 DuckDB 版本能读 |
| `src/schema_cli.rs` | `ducklocal schema`：可查询的关系和列，大小适合放进提示词 |
| `src/profile.rs` | `ducklocal schema --stats` 背后的逐列统计 |
| `src/remote.rs` | `ducklocal open`：与运行中窗口之间的回环通道，双向 |
| `src/schema.rs` | 侧栏所用的 catalog 内省 |
| `src/spec/` | `.dash` 仪表盘（dashboard）：解析、校验、source、filter、审阅评论（`ducklocal comments`）、`ducklocal lsp`，以及仪表盘标签页和其中的图表 |
| `src/excel.rs` | Excel 与 ODS 工作簿，每个 sheet 载入为一张表 |
| `src/setup.rs` | **命令行与 AI…**：把 `ducklocal` 放进 PATH，安装 agent skill |
| `src/recents.rs` | 侧栏列出的最近打开的仪表盘 |
| `src/history.rs` | 应用自己的数据库：查询历史、已登记的文件、设置 |
| `src/s3.rs` | SigV4 签名与 S3 列举 |
| `src/i18n.rs` | 字符串表和语言选择 |
| `src/state.rs` | 各视图共享的应用状态 |
| `src/ui/` | 编辑器、结果表格、图表和地图（含 OpenStreetMap 瓦片）、侧栏、对话框、标题栏和状态栏、检查更新 |
| `src/windows.rs` | Windows 启动：CLI 命令沿用调用方的控制台，GUI 启动不开控制台 |
| `quicklook/` | macOS“快速查看”预览：一个供 Swift 扩展链接的 Rust 库，由 `scripts/build-quicklook.sh` 构建 |
| `src/assets.rs` | 组件库默认图标之外的内嵌图标 |
| `src/perf_probe.rs` | 仅供测试的性能探针：对随数据规模增长的代码路径，统计耗时、分配次数与分配字节数 |
| `assets/` | 图标（`.icns`、Windows 的 `.ico` 和资源脚本）、声明访达交给 DuckLocal 打开的文档类型的 `Info.plist`、README 与文档使用的示意图 |
| `scripts/` | `bundle.sh`、`build-quicklook.sh`、`package-macos.sh`、`check-deps.sh`、`check-windows.py`、`make-icons.py`、授权文件 |

## 测试说明

测试套件是 `cargo test --workspace`；单写 `cargo test` 只覆盖应用，不含 `quicklook/` crate。查询执行测试通过内存连接来跑 DuckDB；导出测试同时覆盖 CSV 与 Parquet，Parquet 的测试会用 `read_parquet` 把写出的文件读回来验证。
