# Mini-Todo 项目指南

本文档用于介绍 Mini-Todo 项目，帮助 AI 助手快速了解项目结构和开发规范。

## 项目简介

Mini-Todo 是一款 Windows / macOS 桌面待办事项管理应用，定位为简洁、聚焦的本地待办工具，支持子任务、四象限、日历、重复提醒、系统通知与
WebDAV 云同步，另有收纳桌面图标的桌面收纳箱（Windows；macOS 预览）。**macOS 版是预览版**：只在 GitHub 的 macOS runner 上自动化验证过，
未经真机验收（维护者没有 Mac），差异与限制见「macOS（预览）」与「已知偏差与限制」。

PC 端是 `pc-gpui/`：用 **Rust + GPUI**（`gpui-kit =0.7.3` = gpui-pre 0.3.9 + gpui-component 0.7.3）实现，**不依赖 WebView2**；Windows 上是单个
`mini-todo.exe`，macOS 上是 universal（arm64 + x86_64）的 `mini-todo.app`。3.0 起它是唯一的 PC 实现（目录名暂未改成 `pc/`）。
早期的 Tauri 2 + Vue 3 版（原 `pc/`）已在 3.0 删除，需要对照旧代码时从
git 历史取（删除前的最后一个提交是 `749405a`，例如 `git show 749405a:pc/src-tauri/src/commands/window.rs`）。GPUI 版与旧版**数据完全兼容**
（同一数据目录、同一数据库迁移、同一同步文档、同一导出格式、同一 settings 键），Windows 安装包原地升级旧版、mac 包与 2.5.0 mac 版是同一个
应用身份（拖进「应用程序」直接替换），见「兼容旧版沿用的名字」。

`cloud/` 是云端 HTTP API（mini-todo 在远程 VPS 上的复刻，通过同一个 WebDAV 通道与 PC 同步数据，供 AI / Claude Code Skill 通过 REST 读写），
详见下文「cloud/ 子项目」与 `cloud/README.md`。

## 技术栈

| 层级 | 技术选型 | 说明 |
|------|----------|------|
| UI 框架 | gpui-kit `=0.7.3`（gpui-pre 0.3.9 + gpui-base / gpui-component 0.7.3） | Zed 的 GPUI 每周快照，**精确钉版本**；Windows 走 DirectX 11 + DirectComposition（透明窗口），macOS 走 Metal（gpui-kit 强制 `runtime_shaders`：着色器运行时编译，构建期不需要 Metal 工具链），Linux 走 wgpu（Vulkan，CI / Xvfb 用 mesa lavapipe） |
| 控件 | 自绘 Element Plus 风格控件（`crates/app/src/ui/`） | 外观数值照抄 UI 规格；复杂输入基于 gpui-component（`InputState` 等），主题调成 EP（`#409EFF`） |
| 图标 | Element Plus Icons SVG | 从 `@element-plus/icons-vue` 抽取，`crates/app/assets/icons/{ep,app}/`，经 `assets.rs` 嵌入 |
| Markdown | gpui-base `TextView` + 自写块级排版（`markdown` crate，GFM：表格 / 任务清单 / 删除线 / 自动链接） | 编辑为"源码 + 预览"（已知偏差，见下文） |
| 业务核心 | `mini-todo-core`（`crates/core`） | rusqlite 0.32 bundled（SQLite）、reqwest 0.12 blocking（WebDAV / 节假日）、windows 0.58；macOS 收纳箱文件层用 libc / objc2 / objc2-foundation / objc2-app-kit（与 app 同版本）；不依赖 GPUI / Tauri / tokio，函数全部阻塞 |
| 农历 | tyme4rs | lunar-javascript 同一作者的 Rust 库，结果与旧前端逐日比对（`tests/fixtures/lunar_fixture.json`） |
| 平台集成 | windows 0.62（与 gpui 同版本）、tray-icon、notify-rust、auto-launch、rfd、embed-resource；macOS：objc2 0.6 / objc2-app-kit 0.3（与 gpui-pre-macos 同版本） | 托盘 / 菜单栏图标、Toast、开机自启、带过滤器的文件对话框（rfd 仅 Windows）、exe 图标与版本信息；macOS 的 NSWindow / NSScreen 调用（含收纳箱桌面层、取色面板 `NSColorPanel`）、系统通知走 GPUI `show_system_notification`（notify-rust 不在 mac 上编译） |
| 安装包 | NSIS（`pc-gpui/scripts/windows-installer.nsi`）+ 便携 zip；macOS universal dmg（`pc-gpui/scripts/macos/`） | 由 `release.yml` 构建：Windows x64 + macOS universal（整包 ad-hoc 签名，不公证） |
| 测试 | cargo test、`scripts/regress.py`（Xvfb 截图对照 + 冒烟）、`scripts/e2e-linux/`、`scripts/e2e-windows/`、`scripts/e2e-macos/`、`scripts/mac-check/`（mac 专属代码的本地交叉检查） | 见 `.trellis/spec/pc-gpui/verification.md` |

## 项目结构

> 项目按平台拆分子目录。`pc-gpui/` 为 Windows / macOS 桌面端（GPUI），`cloud/` 为云端 API，未来可平行加入 `mobile/`、`web/`。

```
mini-todo/
├── docs/                                   # 共享文档（跨平台）
│   └── 开发文档/                            # 开发相关文档
├── pc-gpui/                                # PC 端（Cargo workspace；详见各 crate README）
│   ├── Cargo.toml                          # [workspace] members = crates/core、crates/app；[workspace.package] version 是
│   │                                       # 版本号唯一来源（当前 3.0.2-pre）；release thin LTO + strip
│   ├── Cargo.lock                          # 提交；CI 一律 --locked
│   ├── crates/
│   │   ├── core/                           # mini-todo-core：业务核心（不依赖 GPUI / Tauri / tokio，全部阻塞函数）
│   │   │   ├── README.md                   # 公开 API、线程约束、与旧 Tauri 命令的对照
│   │   │   ├── src/
│   │   │   │   ├── db/                     # 连接 / 迁移前备份（connection、backup）、迁移 v1~v30（migrations）、模型（models）、
│   │   │   │   │                           # settings 读写助手（settings_kv）、变更计数 / 墓碑 / 合并（sync_store）、时间（time）、路径（paths）
│   │   │   │   ├── services/               # webdav.rs（条件 GET/PUT、PROPFIND）、secret.rs（DPAPI）、fence_fs/（收纳箱文件层；
│   │   │   │   │                           # Windows 走 shell.rs / sta.rs，macOS 走 mac.rs）
│   │   │   │   ├── todo.rs / data.rs       # 待办 / 子任务 CRUD、图片保存；导入导出、AppSettings 读写（唯一来源）
│   │   │   │   ├── sync/                   # sync::run(db, SyncMode) -> SyncReport（测试在 sync/tests.rs）
│   │   │   │   ├── settings.rs             # 单项设置、运行时偏好、屏幕组合 ID、screen_configs
│   │   │   │   ├── holiday.rs / reminder.rs  # 节假日（blocking）/ 提醒调度纯逻辑与通知窗口槽位、定位
│   │   │   │   ├── window_logic.rs         # 贴边隐藏状态机、needs_reattach、lacks_tool_window_style 等纯函数
│   │   │   │   ├── lunar.rs                # 农历（tyme4rs）
│   │   │   │   └── fence/                  # 收纳箱元数据 / 几何 / 屏幕组合布局（mod.rs）与内容对账、移入移回（items.rs）
│   │   │   ├── examples/seed.rs            # 把导出格式 4.0 的 JSON 导入隔离数据目录（截图 / e2e 用）
│   │   │   └── tests/fixtures/             # 农历对照数据（gen_lunar.cjs 用 lunar-javascript 生成）
│   │   └── app/                            # mini-todo：GPUI 界面、窗口、平台集成（lib mini_todo + bin mini-todo）
│   │       ├── README.md                   # 模块地图、约定、接口表、调试开关（维护者必读）
│   │       ├── build.rs                    # Windows：嵌 exe 图标（资源 ID 1）+ 版本信息；manifest 由 gpui 嵌，不要重复（其它目标直接 return）
│   │       ├── assets/                     # icon.ico；icon.icns（mac bundle 图标，取自 2.5.0）、tray-template.png（mac 菜单栏模板图标）；
│   │       │                               # icons/ep/*.svg（Element Plus）、icons/app/*.svg（自定义）
│   │       └── src/
│   │           ├── lib.rs / main.rs        # run()：日志 → 单实例 → 数据库 → Application(QuitMode::Explicit) → 主窗口 → 后台服务
│   │           ├── theme.rs                # 颜色 / 字体 / 圆角 / 间距 / 阴影 token 的唯一来源（按窗口分节）
│   │           ├── state.rs                # AppState（Entity + Global）：跨窗口共享状态与待办 / 设置写操作
│   │           ├── db.rs                   # Db 全局（Arc<Database>）、spawn_db（阻塞调用放后台）
│   │           ├── assets.rs               # EpIcon / AppIcon 嵌入表
│   │           ├── util/                   # datetime、image_ref（minitodo-image://）、file_link（链接白名单）、editor_shortcuts
│   │           ├── windows/                # WindowManager（mod.rs，含 DebugOpen）+ main_window/、editor/、subtask、completed、
│   │           │                           # notification、settings/、fence/、gallery（控件样例，仅调试）
│   │           ├── ui/                     # Element Plus 风格共享控件（button、input、ep_*、message、message_box、markdown*…）
│   │           ├── services/               # 托盘 / 菜单栏图标、单实例、开机自启、同步编排、自动同步、提醒调度、系统通知、窗口模式、收纳箱、
│   │           │                           # 版本检查、日志；mac_menu.rs（macOS 应用菜单与 Dock 唤起）
│   │           └── platform/               # GPUI 没有的窗口能力：windows.rs（Win32）、macos/（mod.rs GPUI 胶水 + ns.rs AppKit 原始调用，含收纳箱桌面层）、
│   │                                       # mac_geometry.rs（mac 坐标换算，全平台编译带单测）、fallback.rs（Linux）、embed.rs、subclass.rs…
│   └── scripts/
│       ├── README.md                       # regress.py / shot.sh / MINI_TODO_DEBUG_OPEN 说明，文末「macOS：打包、e2e 与交叉检查」
│       ├── regress.py                      # Xvfb 一键回归：场景截图 + 并排对照图 + 端到端冒烟
│       ├── shot.sh                         # 单个场景截图 / xdotool 交互
│       ├── sample-data.json                # 样例数据（导出格式 4.0）
│       ├── windows-installer.nsi           # NSIS 安装器（安装身份沿用旧 Tauri 版，见 .trellis/spec/pc-gpui/release.md）
│       ├── macos/                          # bundle.sh（组装 mini-todo.app + 整包 ad-hoc 签名）、make-dmg.sh（拖放安装 dmg）、gen-tray-icon.py
│       ├── mac-check/                      # 不属于 workspace 的小 crate：把不依赖 GPUI 的 mac 源文件（含 core 的 fence_fs/mac.rs）#[path] 进来，在 Linux / Windows 上
│       │                                   # --target aarch64-apple-darwin 做类型检查
│       ├── e2e-linux/                      # Linux 端到端（真实应用 + Apache / nginx WebDAV + cloud + Skill）
│       ├── e2e-windows/                    # Windows 端到端（真实窗口 + 真实输入 + Win32 断言；CI pc-gpui-e2e-windows.yml）
│       └── e2e-macos/                      # macOS 端到端（CGWindowList / 截图 / Vision OCR / AX / CGEvent；CI pc-gpui-e2e-macos.yml）
├── cloud/                                  # 云端 HTTP API（独立 Rust crate，见下文）
├── .github/workflows/                      # release / cache-warm / pc-gpui-ci / pc-gpui-e2e-windows / pc-gpui-e2e-macos / cloud-ci
├── CLAUDE.md                               # 项目指南（本文档）
├── README.md
└── AGENTS.md
```

## 核心功能

### 待办管理
- 创建、编辑、删除待办事项
- 描述字段支持 Markdown（存于 `description`）：编辑为"源码 + 预览"——平时显示渲染结果，单击切到源码编辑，失焦回到渲染；
  粘贴普通文字 / Markdown 源码原样插入；渲染支持 GFM 表格 / 任务清单 / 删除线 / 自动链接。编辑模式另有源码 / 预览分栏的放大编辑弹窗
  （`windows/editor/desc_dialog.rs`，「最大化」联动窗口最大化）
- Markdown 中的图片统一存为 `minitodo-image://<文件名>`（`crates/app/src/util/image_ref.rs`），渲染时映射成本机 images 目录下的文件
  （`img(PathBuf)`）；兼容旧数据里的 `http://asset.localhost/<绝对路径>` / `asset://localhost/...`（按文件名映射到本机，修复跨设备裂图，
  保存时换回规范形式）。粘贴 / 拖入图片 → 后台 `core::todo::save_image(bytes, ext)`：扩展名白名单 png/jpg/jpeg/webp/gif/bmp、单张 ≤20MB、
  文件名由后端生成，插入 `![<文件名>](minitodo-image://<name>)`；点图片打开预览
- 链接点击走协议白名单（`util/file_link.rs`）：http/https/mailto 交给系统程序，`file:///` 在资源管理器（macOS 访达）定位（拒绝 UNC），
  其余（javascript:/data: 等）一律拦截；只读排版普通单击即打开，编辑排版下 Ctrl/Cmd+单击
- 编辑类窗口支持 Esc 关闭（有未保存修改时确认）、Ctrl/Cmd+Enter 保存；保存有防重入
- 只读详情模式：从列表 / 四象限 / 日历 / 已完成视图点击待办默认进入（`editor::open(.., Some(id), EditorMode::View)`），
  左侧简化展示标题、Markdown 渲染描述、通知状态、优先级，右侧子任务面板功能照常，[编辑] 按钮原地切换
- 支持一级子任务（含 Markdown 详情、图片上传；可从 .md/.txt 文件或文件夹批量导入）
- 四象限分类（重要紧急 / 重要不紧急 / 紧急不重要 / 不紧急不重要）
- 自定义颜色标识、完成状态标记、拖拽排序（列表 / 四象限）、置顶
- 开始/截止时间

### 子任务
- 标题 + Markdown 内容
- 支持图片粘贴/拖入上传
- 独立的子任务窗口（查看 / 编辑双模式，800×750）；子任务列表双击标题行内改名
- 新建待办（尚未入库）时子任务暂存在内存：子任务窗口直接读写编辑窗口的 `Entity<DraftSubtasks>`
- 完成态切换、排序

### 通知提醒
- 系统通知 / 应用内通知窗口。系统通知：Windows 用 Toast（`notify-rust`，后台线程发，失败不推进、下一分钟重试）；macOS 用 GPUI 的
  `show_system_notification`（`UNUserNotificationCenter`，归属 bundle id `com.tauri-app.mini-todo`，只能在主线程调用，调度器经通道交给前台；
  拿不到投递结果，交出即视为已投递；只有从 ad-hoc 签名的 `.app` 启动才可用，第一次发通知时才请求授权）
- 预设提前提醒（5/15/30 分钟）、自定义提前时间
- 应用内通知窗口（320×120、置顶、不抢焦点）按主显示器工作区（逻辑像素）定位，自下而上叠放、超出换列（core `reminder` 的槽位与叠放算法）；
  按窗口实际尺寸重新定位并记住，保证不出屏

### 重复提醒
- 按天 / 周 / 周几 / 月几号循环
- 触发后自动推进到下一次提醒时间点（月重复缺日期时以 notify_at 的日为锚，月末自动钳位）
- 应用启动时补发错过的重复提醒
- 提醒时间兼容空格 / `T` 两种格式；单条提醒出错不影响其它提醒

### 视图模式
- **列表视图**：按排序/优先级展示
- **四象限视图**：拖拽分类
- **日历视图**：按日期展示（含农历、节气、传统节日、法定节假日与调休；跨天条，点击打开只读详情），开启后主窗口加宽

### 数据导入导出
- 导出版本：`4.0`（`pc-gpui/crates/core/src/data.rs` 的 `EXPORT_VERSION`）
- 导出为 ZIP 压缩包（内含 `data.json`），默认文件名 `mini-todo-backup-YYYY-MM-DD.zip`
- 导入兼容 v3.0 和 v4.0 两个版本（按 `PK` 魔数区分 ZIP / JSON，直接 JSON 也支持）
  - v3.0 是历史导出格式（含已移除的 AI Agent 字段），反序列化时通过 serde 的未知字段忽略与 `#[serde(default)]` 静默跳过
- 导入是"恢复备份"语义：导入前自动 `VACUUM INTO` 备份到 `backups/`；保留原 id；导入后消失的记录写墓碑，
  被导入记录的 `updated_at` 刷新为当前时间（保证恢复结果在同步中胜出）

### WebDAV 云同步
- 智能同步 `sync::run(db, SyncMode::Smart { full })`：先 GET 远端并逐条合并（LWW + 墓碑），本地有变化才条件 PUT；标题栏同步按钮与设置页
  「立即同步」传 `full: true`（无条件 GET），自动同步传 `false`（每第 10 轮也做一次无条件 GET）。设置页另有带二次确认的
  「用云端覆盖本地」（`SyncMode::ForcePull`）、「用本地覆盖云端」（`SyncMode::ForcePush`）。不再有整库二选一的冲突对话框
- 同步范围：todos / subtasks / 墓碑 / 10 个应用设置项（按 `settingsUpdatedAt` 做 LWW，窗口位置与尺寸不从远端应用）/ 图片
- 自动同步可选（按间隔轮询，失败段只提示一次）；同步在后台线程执行，不阻塞界面；同一时刻只允许一个同步（`"同步正在进行中"`）
- gzip 压缩传输；**数据本身不加密**（传输安全取决于 https，WebDAV 服务商可读取内容）；
  WebDAV 密码在 Windows 上用 DPAPI 加密保存，macOS / Linux 上明文存在本机数据库里（与 2.5.0 一致；mac 设置页有提示，钥匙串要开发者签名，暂不做），
  界面只拿到 `has_password`，http:// 地址会提示明文风险

### 窗口特殊功能
- **普通模式**：可拖动、可缩放；主窗口是透明窗口，界面自己画圆角底色：浅色主题白底，深色主题用设置里的「界面底色」+「背景透明度」（透出桌面）
- **固定模式**（标题栏锁按钮 / 托盘「固定模式」）：
  - 固定在用户指定位置：不可拖动、不可缩放（`platform::subclass` 的 `FLAG_LOCK_GEOMETRY` 拦截命中测试）
  - 不在任务栏、不参与 Alt+Tab（`WS_EX_TOOLWINDOW`）；被 Win+D / Win+M 最小化后由 200ms 轮询立即还原
  - 可选贴边自动隐藏、唤起时置顶（设置 → 常规）；经典固定时没有圆角与描边
- **固定模式时嵌入桌面**（设置 → 常规「固定模式时，嵌入桌面中」，仅 Windows 显示，Win11 24H2+ 保证 Win+D 免疫）：
  - 不是第三种模式，而是固定模式的一个全局开关：开启后进入固定模式即走嵌入路径，关闭则是经典固定；当前已固定时切换开关立即生效
    （主窗口观察到 `AppState.fixed_embed_desktop` 变化 → `window_mode::on_embed_preference_changed`）
  - 主窗口仍是顶层窗口：owner=Progman（`GWLP_HWNDPARENT`）+ 永远置底（`SetWindowSubclass` 处理 `WM_WINDOWPOSCHANGING`，`FLAG_BOTTOM`）+
    去掉 `WS_MINIMIZEBOX` + `WS_EX_TOOLWINDOW`，位于桌面图标之上、所有应用窗口之下；Win+D / Win+M / 任务栏"显示桌面"时随 Progman 一起被抬起
  - 透明背景 / 圆角 / DPI 与普通模式零差异（不做 SetParent 子窗口化）；不可拖拽、不可缩放；贴边隐藏与唤起置顶不生效
  - 200ms 轮询检测 Explorer 重启与样式被改（`platform::embed::needs_reattach`），自动重挂
  - 持久化：settings `fixed_embed_desktop`（v27）；当前是否固定仍由 `is_fixed` / `screen_configs.is_fixed` 记录
  - 实现：`crates/app/src/services/window_mode.rs`（`TargetMode` / `apply_mode` / 200ms 轮询）、`platform/embed.rs`、`platform/subclass.rs`；
    纯逻辑在 core `window_logic.rs`；契约见 `.trellis/spec/backend/window-modes.md`
  - 非 Windows：开关不显示；`TargetMode::for_state` 只在 Windows 上选嵌入，同步来的开关不会让固定失败
- **macOS 上的固定模式**（2.5.0 mac 版的简化语义，同一套 `window_mode` 状态机与 200ms 轮询）：`setMovable:NO` + 去掉可缩放（解锁时恢复）、
  被最小化（⌘M）立即还原、托盘 / 贴边唤起临时置顶（`NSFloatingWindowLevel`）、贴边隐藏用 `NSEvent.mouseLocation` 全局光标
  （往有菜单栏的屏幕顶边藏会被 AppKit 挡回，状态回滚为显示；贴边隐藏中被 Dock / ⌘Tab 激活时按托盘唤起拉回，`window_mode::on_main_activated`）；
  没有"不进任务栏 / ⌘Tab"（程序坞图标与 ⌘Tab 是整个应用的），没有嵌入桌面。契约见 `.trellis/spec/backend/window-modes.md`「Scenario: macOS」
- **多屏幕位置记忆**：按屏幕组合（`screen_configs`）记住主窗口位置、尺寸与是否固定；托盘「重置位置」把主窗口放回主显示器左上区域。
  macOS 的物理像素口径见 `platform/mac_geometry.rs`（全局点坐标 × 所有屏的最大缩放；屏幕组合 ID 按各屏自身缩放，与 2.5.0 一致）

### 桌面收纳箱（Windows；macOS 预览）
- 托盘 / 菜单栏图标「新增收纳箱」在光标所在显示器工作区的右上角找空位新建（默认 4 列 × 3 行、标题"收纳箱 / 收纳箱 2…"）。每个收纳箱是一个
  无系统标题栏的透明 GPUI 窗口（`WindowKind::Fence`，登记 key `fence-{id}`，`crates/app/src/windows/fence/`）
- **Windows：始终嵌入桌面层**：与主窗口嵌入共用 `platform::embed`（owner=Progman + 永远置底 + `WS_EX_TOOLWINDOW` + 无 `WS_MINIMIZEBOX`），
  Win+D 后可见、不进任务栏 / Alt+Tab、不盖住应用窗口；建窗参数一次给齐（`resizable: false`、`minimizable: false`、先隐藏建好、
  定位并嵌入后再显示）；200ms 守护在 Explorer 重启后自动重挂（`services/fences.rs`）
- **macOS：桌面图标层之上的不激活面板**（不走 `platform::embed`）：建成 GPUI `WindowKind::PopUp`（NSPanel + `NonactivatingPanel`），先隐藏建好 →
  `setFrame:` 摆到库里的几何 → `Hwnd::make_desktop_layer`（`ns.rs`：层级 `CGWindowLevelForKey(kCGDesktopIconWindowLevelKey) + 1`，
  collectionBehavior 整体换成 `CanJoinAllSpaces | Stationary | IgnoresCycle | FullScreenNone` = `0x251`、`canHide = NO`、
  不进「窗口」菜单、无显示动画）→ `orderFrontRegardless`；日志自报实际属性（`[fence] 收纳箱窗口 #… 已放到桌面层：level=… behavior=0x…`，
  e2e 据此核对）。单击、拖动、拖入、打开项目都不激活应用；⌘H / 别的应用「隐藏其他」不隐藏它、不计入 Dock 唤起的"有可见窗口"；⌘M / ⌘W 对它无效；
  收纳箱窗口**绝不调** `set_topmost`（会把层级改回 0 / 3）。双击标题改名是唯一激活应用的操作（面板单击不一定成为 key 窗口）：先 `cx.activate(true)`
  再 `window.activate_window()`，150 / 500 / 1000ms 复查、不是 key 就重新拿回，开始后 1.2 秒内的失去 key / 输入框失焦不当作提交，宽限期结束时
  仍不是 key 才提交；改名期间菜单栏切到 mini-todo（`windows/fence/mod.rs` 的 `begin_mac_rename_focus`）。
  没有 Explorer 那样的重挂守护（层级是全局排序，未在真机验证 Finder 重启后的表现）
- **真收纳**：外部拖入（`ui::external_drop`）的文件 / 快捷方式 / 文件夹从桌面移到收纳根目录下的 `<收纳箱名>` 文件夹（Windows
  `%USERPROFILE%\MiniTodo收纳箱\`、macOS `~/MiniTodo收纳箱/`），重名加 ` (2)` 绝不覆盖（包 `X.app` → `X (2).app`）；
  Windows 经 `IFileOperation`（公共桌面项弹 UAC），macOS 逐项 `renamex_np(RENAME_EXCL)`（内核原子判重，撞名换下一个后缀名重试；跨卷才交给
  `NSFileManager`，同样不覆盖；**不用** `std::fs::rename`，它会静默覆盖）。**文件夹是内容的 source of truth**（`notify` 监听，
  资源管理器 / 访达里增删随之刷新），数据库只存元数据与项目的排序 / 原始路径 / 隐藏标记。不允许整体收纳用户桌面 / 公共桌面文件夹本身
- 双击打开（Windows `ShellExecuteExW`；macOS `NSWorkspace`，先拦下失效的替身 / 符号链接）、自绘右键菜单（打开 / 打开所在位置（macOS「在访达中显示」）/
  隐藏或取消隐藏 / 移回桌面 / 移动到…）、箱内拖动排序、Ctrl+滚轮图标大小（macOS 另认 ⌘+滚轮）、
  双击标题改名（收纳文件夹同步改名，失败则保留旧文件夹名并提示）、折叠（悬停 / 拖文件经过时临时展开）、
  「固定」= 锁定位置和大小（仍可拖入、折叠、改名）、每个收纳箱独立的背景色 / 透明度（「自定义颜色」：Windows `ChooseColorW`，macOS 系统取色面板
  `NSColorPanel`，非模态、每 150ms 读一次，面板关掉时生效，打开时会把应用切到前台）
- **隐藏图标**（v30，只影响收纳箱的显示，不改文件属性、不移动文件）：以 `.` 开头的项目自动隐藏（固定开启，不能单独取消隐藏；
  拖入时提示一次）；右键「隐藏」/「取消隐藏」写 `fence_items.hidden`（`fence::items::set_hidden`）；「更多」→「显示隐藏的图标」
  （每个收纳箱独立，`fences.show_hidden`）打开时隐藏项半透明显示，关闭时不显示，全部隐藏时空状态显示"N 个项目已隐藏"。
  对账保留隐藏标记（含只改大小写的重命名），跨箱移动不携带；带 Windows 隐藏 / 系统属性（macOS `UF_HIDDEN`，即 `chflags hidden`）的文件
  维持原样永不显示、不收纳；删除收纳箱时隐藏项一并移回
- **系统元数据文件**（所有平台）：`desktop.ini`、`Thumbs.db`、`.DS_Store`、`Icon\r`、`.localized` 不显示、不收纳、删除收纳箱时不算剩余
  （文件夹只剩它们时连同它们一起删掉）
- 移动 / 缩放：标题栏移动超过 3px 才 `platform::begin_window_drag`（标题栏不用 `drag_region()`，否则双击改名收不到），缩放用窗口内 8 个手柄 +
  按客户区摆窗（Windows `SetWindowPos`、macOS `setFrame:`）；只有用户拖动 / 缩放结束与托盘重置才 `fence::commit_geometry` / `reset_positions` 落库，
  系统挪窗不落库。几何一律是客户区的物理像素（macOS 是全局点 × 统一系数，见「多屏幕位置记忆」）
- 删除收纳箱：项目全部移回原位置（原目录不存在则回桌面）、空文件夹删除后才删记录并关窗口；有项目移不回去时删除中止、
  整个收纳箱保留并提示原因
- 按屏幕组合记忆位置（`fence_layouts`，ID 与 `screen_configs.config_id` 同格式，都由 core `settings::screen_config_id` 计算），约 2 秒检测一次
  屏幕组合变化；设置里删除屏幕配置时连带删除对应布局
- 托盘 / 菜单栏图标「显示/隐藏所有收纳箱」「重置收纳箱位置」作为兜底入口；首次收纳"由云同步管理的桌面项目"时提示一次（Windows：桌面被 OneDrive
  重定向；macOS：项目移动前由 iCloud 云盘管理（`NSURLIsUbiquitousItemKey`），移出就会从 iCloud 删除；两者共用 `fence_onedrive_notice_shown`）
- macOS 文件层的隐私权限（TCC）：拖放即授权被拖的项目、在桌面里新建项目不需要同意，但**列举** `~/Desktop` / `~/Documents` / `~/Downloads`
  会弹「文件与文件夹」授权框（ad-hoc 签名每次升级都会再弹），所以 mac 上规划移动时不列举收纳根目录以外的目标目录，撞名交给 `RENAME_EXCL`。
  授权框会阻塞调用线程（移动本来就在后台线程）；`bundle.sh` 的 Info.plist 带 `NSDesktopFolderUsageDescription` 等用途说明（不是身份键）
- 关键实现：core `fence/`、`services/fence_fs/`（macOS 后端 `mac.rs`）；app `services/fences.rs`、`windows/fence/`、`platform/embed.rs`（Windows）、
  `platform/macos/ns.rs`（`make_desktop_layer`、取色面板）；契约与易错点见 `.trellis/spec/backend/desktop-fences.md`
- 已知限制：Win10 / Win11 ≤23H2 上 Win+D 可能盖住收纳箱；macOS 版未经真机验收（见「已知偏差与限制」6）

### 系统功能
- 托盘（Windows）/ 菜单栏图标（macOS，单色模板图标 `assets/tray-template.png`，随菜单栏深浅色着色）：左键单击唤起主窗口（300ms 去抖）；
  右键菜单：添加待办项、新增收纳箱、显示/隐藏所有收纳箱、重置收纳箱位置、固定模式（勾选）、重置位置、打开设置、
  开机自启动（勾选）、退出
- 单实例：再次启动时把参数转交给已运行的实例并唤起其主窗口（开机自启拉起的不唤起）；Windows 命名互斥量 + `WM_COPYDATA`，macOS Unix socket
- 开机自启（Windows 注册表 Run 项 / macOS LaunchAgent）、版本更新检查（GitHub `releases/latest`，标题栏版本号旁红点；不会提示预发布版）
- 运行日志落盘（2MB 轮转，保留 1 份旧日志）

### macOS（预览）
- 能力对齐 2.5.0 mac 版（再加上 3.0 的全部界面与功能），另有桌面收纳箱（2.5.0 没有，见「桌面收纳箱」）；主窗口没有嵌入桌面；**未经真机验收**，
  只靠 CI 的 macOS runner 自动化验证（`pc-gpui-e2e-macos.yml`）与用户反馈
- 身份与 2.5.0 mac 版相同（bundle id、`.app` 名、数据目录、日志、单实例 socket、LaunchAgent，见「兼容旧版沿用的名字」）；
  Dock 图标照常显示（不设 `LSUIElement`，GPUI 启动时会把激活策略重设为 Regular，要隐藏只能用 `set_activation_policy`）；
  Info.plist 另带 `NS{Desktop,Documents,Downloads}FolderUsageDescription`（收纳箱万一触发「文件与文件夹」授权框时的中文说明，不是身份键）
- 窗口：所有窗口仍是自绘标题栏，系统红绿灯建窗后隐藏（`Hwnd::prepare_after_open`，改 style mask 后再隐藏一次），
  `WindowSpec` 设 `app_owns_titlebar_drag`；运行时摆窗 / 显示隐藏 / 置顶 / 几何锁定 / 收纳箱桌面层 / 取色面板在 `platform/macos/ns.rs`
  （objc2 调 NSWindow / NSScreen / NSColorPanel）
- 应用菜单（`services/mac_menu.rs`，GPUI 不会自动建）：Mini Todo（关于 / 设置… ⌘, / 服务 / 隐藏 ⌘H / 隐藏其他 ⌥⌘H / 全部显示 / 退出 ⌘Q）、
  编辑（撤销 / 重做 / 剪切 / 拷贝 / 粘贴 / 全选，系统文件面板里的 ⌘C / ⌘V 靠它）、窗口（最小化 ⌘M / 关闭窗口 ⌘W；收纳箱是活动窗口时两者都不做）
- 关窗口（含主窗口、⌘W）不退出进程，菜单栏图标常驻；点 Dock 图标 / 在访达里再次打开时唤起主窗口（`on_reopen`，GPUI 只在没有可见窗口时回调；
  收纳箱是 NSPanel，按 Apple 文档不算可见窗口，所以有收纳箱时也能唤起——e2e MF07）；
  ⌘Q / 应用菜单 / 菜单栏图标「退出」退出。从菜单栏图标 / 单实例唤起时先 `cx.activate(true)` 把应用切到前台
- 字体：界面 `.SystemUIFont`（San Francisco）+ `PingFang SC`，代码 `Menlo`，引用块 `Georgia` + `Songti SC`；设置里的字体下拉用 GPUI 的
  CoreText 枚举（去掉 `.` 开头的私有字体）
- 文件对话框（导入导出、子任务导入）用 GPUI 的原生 `NSSavePanel` / `NSOpenPanel`（非模态，没有扩展名过滤器）；WebDAV 密码明文保存（设置页提示）
- 开机自启从 App Translocation 临时位置（下载后没拖进「应用程序」就直接打开）运行时拒绝写入，提示先拖进「应用程序」

## 开发规范

### UI 设计规范
- **控件**：`crates/app/src/ui/` 下自绘的 Element Plus 风格控件，同一种控件只保留一个实现（缺能力就给现有控件加 builder 方法）
- **图标库**：Element Plus Icons（SVG 放 `crates/app/assets/icons/{ep,app}/`，在 `assets.rs` 表里登记）
- **颜色 / 尺寸 / 字体只从 `theme.rs` 取**，数值照抄 UI 规格（`.trellis/tasks/archive/2026-10/10-15-gpui-rewrite/research/ui-*.md`）
- **禁止使用 emoji 图标**
- 设计理念：简洁现代、去除卡片边框、极简列表

#### 优先级颜色
| 级别 | 颜色代码 | 描述 |
|------|----------|------|
| 高 | #EF4444 (红色) | 紧急重要任务 |
| 中 | #F59E0B (橙色) | 一般重要任务 |
| 低 | #10B981 (绿色) | 不紧急任务 |

### 数据库设计
- **数据库类型**：SQLite（rusqlite，bundled）
- **存储位置**：`%LOCALAPPDATA%\mini-todo\`（core `db::paths::app_data_dir()` = `dirs::data_local_dir()/mini-todo`；
  macOS 为 `~/Library/Application Support/mini-todo/`（与 2.5.0 mac 版相同）；Linux 为 `~/.local/share/mini-todo/`）
  - `data.db`、`images/`（用户图片）、`backups/`（迁移/导入前的 `VACUUM INTO` 快照，保留最近 5 份）、`holidays/`、
    `fence-icons/`（收纳箱图标 PNG 缓存，启动对账后清理过期项）、`ui-state.json`（展开全部子任务等界面状态）
  - 这个目录同时是 Windows 安装包的默认安装目录（与旧 Tauri 版一致）；安装器卸载只删自己装的程序文件，绝不递归删除该目录
  - 日志：`%LOCALAPPDATA%\com.tauri-app.mini-todo\logs\mini-todo.log`；macOS `~/Library/Logs/com.tauri-app.mini-todo/mini-todo.log`
    （没有 `logs` 子目录）（app `services::logging`，2MB 轮转；目录名 / 位置都为兼容旧版沿用）
  - 收纳箱文件：`%USERPROFILE%\MiniTodo收纳箱\<收纳箱文件夹>\`；macOS `~/MiniTodo收纳箱/<收纳箱文件夹>/`（3.0.2-pre 之后新增，2.5.0 mac 版没有）
    （用户可见的真实文件夹，有收纳箱后才创建；不在数据目录里，卸载 / 删库都不会丢文件）
- **迁移版本**：当前 v1~v30，通过 `pc-gpui/crates/core/src/db/migrations.rs` 的迁移表管理（`LATEST_VERSION`）；有待执行迁移时先备份，
  迁移期间关闭外键、提交前 `foreign_key_check`；库版本比程序新时只记警告、按现状继续运行（没有降级）
  - v23：移除所有 AI Agent / 任务调度 / 工作流相关表和字段（详见迁移注释）
  - v24：新增 `webdav_last_modified` settings key
  - v25：新增 `top_on_wake` settings key，贴边唤起时是否临时置顶
  - v26：新增 `window_bg_color` / `window_bg_alpha` settings key，窗口底色与背景透明度
  - v27：新增 `fixed_embed_desktop` settings key，固定模式时是否嵌入桌面（仅 Windows）
  - v28：`sync_meta`（本地变更计数 `local_seq` / `synced_seq`，todos/subtasks 增删改触发器维护）、
    `tombstones` 表、存量时间统一为 `YYYY-MM-DD HH:MM:SS`、新增 `webdav_remote_etag` / `webdav_synced_settings_at`
  - v29：桌面收纳箱 `fences` / `fence_items` / `fence_layouts` 三张表（后两张 `ON DELETE CASCADE`）与 settings key
    `fence_onedrive_notice_shown`；均为设备本地数据，不接 `local_seq` 触发器与墓碑、不导出不同步
  - v30：收纳箱隐藏图标：`fence_items.hidden`（右键「隐藏」）、`fences.show_hidden`（「显示隐藏的图标」），均 `INTEGER NOT NULL DEFAULT 0`；
    同 v29 是设备本地数据。旧版本打开 v30 库照常运行，只是看不到隐藏效果

#### 主要数据表

| 表名 | 说明 |
|------|------|
| `todos` | 待办事项（含重复提醒字段） |
| `subtasks` | 子任务（标题 + Markdown 内容 + 完成态） |
| `settings` | 应用设置（键值对，带 `updated_at`，只经 `db::settings_kv` 写入） |
| `screen_configs` | 屏幕配置 |
| `sync_meta` | 同步计数器（`local_seq` / `synced_seq`），由触发器维护 |
| `tombstones` | 删除墓碑 `(entity_type, entity_id, deleted_at)`，保留 30 天 |
| `fences` | 桌面收纳箱元数据（标题、收纳文件夹名、物理像素几何、折叠 / 锁定、外观、图标大小、显示隐藏的图标） |
| `fence_items` | 收纳箱项目的排序、原始路径与隐藏标记（内容以收纳文件夹为准，列举时对账） |
| `fence_layouts` | 收纳箱按屏幕组合记忆的位置 `(fence_id, screen_config_id)` |
| `migrations` | 迁移版本记录 |

#### 时间格式约定（PC / cloud 一致）

- 存储与同步一律 `YYYY-MM-DD HH:MM:SS`（本地墙钟、无时区）：`notifyAt` / `startTime` / `endTime` /
  `createdAt` / `updatedAt` / 墓碑 `deletedAt` / `settingsUpdatedAt`
- 读取兼容空格 / `T`、有无秒、小数秒、`Z`/`±HH:MM` 后缀（换算本地）、仅日期（开始 00:00:00、截止 23:59:00、提醒 09:00:00）
- 存储格式以 core `db::time` 为准（`normalize_datetime`、`format_canonical`、`parse_local_datetime`），界面显示 / 输入用
  `crates/app/src/util/datetime.rs`；**禁止**手写 `split('T')` 之类的解析
- 对已有记录写 `updated_at` 时取 `max(now, 旧值 + 1 秒)`（Rust `db::time::superseding`，SQL 片段 `SQL_SET_UPDATED_AT`），
  保证同一记录的新版本时间戳严格递增（秒级 LWW 下同秒两次编辑不丢）；参与同步的设置项按整个设置块的版本同样递增；
  删除墓碑 `deleted_at = max(now, 记录 updated_at)`；无法解析的时间在比较中视为最旧

### 数据导入导出与同步

> **重要**：当数据库结构变更（新增表/字段/设置项）时，必须同步更新导入导出、WebDAV 同步与 cloud 的字段校验！
> 同一份数据还会被 cloud 与其它设备上已安装的旧版本读写，规则见 `.trellis/spec/pc-gpui/data-compat.md`。

- **导出版本号**：当前 `4.0`（`pc-gpui/crates/core/src/data.rs` → `EXPORT_VERSION`）
- **关键文件**（均在 `pc-gpui/crates/core/src/` 下）：
  - 模型定义：`db/models.rs` → `Todo`、`SubTask`、`ExportData`、`AppSettings`
  - 设置读写（唯一来源）：`data.rs` → `read_app_settings` / `write_app_settings`；
    参与同步的键 `SYNCED_SETTING_KEYS` 与设置版本 `settings_version` 在 `db/settings_kv.rs`
  - 导入导出：`data.rs` → `export_json` / `import_json` / `export_to_file` / `import_from_file`（仅手动导入导出使用）
  - WebDAV 同步：`sync/mod.rs` → `SyncData`、`run`、`run_sync_blocking`、`build_sync_doc`；
    记录读写/合并/墓碑：`db/sync_store.rs`（`merge_remote`、`load_todos_with_subtasks`）
  - cloud 写入校验：`cloud/src/model.rs`（新增记录字段必须同步加入 `TODO_FIELDS` / `SUBTASK_FIELDS`，否则 AI 写入会被 400）
  - 界面侧没有单独的前端类型：`crates/app` 直接用 core 的 Rust 模型

#### sync-data.json.gz 结构

```jsonc
{
  "version": "4.0", "deviceId": "…", "updatedAt": "…",        // 元信息
  "todos": [ /* Todo，嵌套 subtasks */ ],
  "settings": { /* AppSettings */ }, "settingsUpdatedAt": "YYYY-MM-DD HH:MM:SS",
  "images": ["<安全文件名>"],
  "tombstones": [ { "entityType": "todo" | "subtask", "entityId": 123, "deletedAt": "YYYY-MM-DD HH:MM:SS" } ]
  // 其它顶层键：所有写入方（PC / cloud）必须原样保留
}
```

#### 合并规则（PC `sync_store::merge_remote` 与 cloud `sync/merge.rs` 一致）

1. 记录级 LWW：比较规范化后的 `updatedAt`，**平局保留本地**
2. 墓碑：同 `(entityType, entityId)` 且 `deletedAt >= updatedAt` 时删除/压制记录；todo 墓碑连带子任务；
   删除后又被编辑（`updatedAt > deletedAt`）的记录保留
3. 只在一侧存在的记录保留（并集），**不做"缺席即删除"**（cloud 仅对没有 `tombstones` 键的旧版文档、且自身不 dirty 时保留旧行为）
4. 墓碑取并集、同键取较大 `deletedAt`，保留 30 天
5. 设置：远端 `settingsUpdatedAt` 比本地同步键的 `max(updated_at)` 新才应用；`windowPosition` / `windowSize` 不从远端应用；
   从未同步过的新设备直接采用远端设置
6. 远端记录反序列化失败只跳过并计数（`recordsSkipped`），不当作删除；远端文档整体解析失败直接报错、不上传

#### 当前同步覆盖范围

| 数据 | 是否同步 | 说明 |
|------|---------|------|
| `todos`（全字段） | 是 | 含重复提醒字段 |
| `subtasks`（全字段） | 是 | 标题 + Markdown 内容 + 完成态 |
| `tombstones` | 是 | 删除双向传播，保留 30 天 |
| `settings`（部分） | 是 | `SYNCED_SETTING_KEYS` 的 10 项（含 `is_fixed` / `fixed_embed_desktop`），按 `settingsUpdatedAt` LWW；不含 WebDAV 配置；窗口位置/尺寸只随导出备份 |
| `images`（文件） | 是 | 先传图片再传 sync-data；一次 PROPFIND 列远端清单；文件名经 `is_safe_image_name` 校验 |
| `screen_configs` | 否 | 设备特定的屏幕配置 |
| `fences` / `fence_items` / `fence_layouts` | 否 | 桌面收纳箱：路径与屏幕坐标是设备相关数据，性质同 `screen_configs`；也不进导入导出 |
| settings `fence_onedrive_notice_shown` | 否 | 设备本地提示标记（Windows 的 OneDrive 与 macOS 的 iCloud 提示共用），有意不进 `AppSettings` / `SYNCED_SETTING_KEYS` |
| `sync_meta` / `migrations` | 否 | 本机状态 / 结构性表 |

#### 向后兼容

- 旧 v3.0 备份内的 `agent_configs` / `workflow_steps` / `task_dependencies` / `prompt_templates` /
  `agent_executions` 等字段，以及 todo/subtask 上的 agent/调度/工作流字段，在 v4.0 反序列化时
  通过 serde 的"未知字段忽略"机制自动跳过，不会报错。
- `SyncData` 新字段一律 `#[serde(default)]`；未知顶层键经 `#[serde(flatten)] extra` 原样带回上传。
- 其它设备上可能还在跑旧版本（≤ 2.5.0 的 Tauri 版或更早的 3.x）：新增字段 / 设置键在它们那里会被丢掉，见 `.trellis/spec/pc-gpui/data-compat.md`。

#### 维护检查清单

当新增数据库迁移时，请检查（路径均在 `pc-gpui/crates/core/src/` 下，另注明的除外）：
1. 新增的 **settings 键值** 是否已加入 `AppSettings`（含 `#[serde(default)]`）、`data.rs` 的 `read_app_settings` / `write_app_settings`；
   需要跨设备同步的再加入 `SYNCED_SETTING_KEYS`（设备相关的不要加）；需要界面的再改 `crates/app`（`state.rs`、`windows/settings/`）。
   例外：纯设备本地的提示 / 状态键（如 v29 的 `fence_onedrive_notice_shown`）有意不进 `AppSettings`，
   登记在 `.trellis/spec/backend/sync-protocol.md` §3.6
2. 所有 settings 写入走 `db::settings_kv::set_setting`（禁止 `INSERT OR REPLACE`，否则每次写都会刷新同步版本）
3. 新增的 **数据表** 是否需要同步：需要则加触发器维护 `local_seq`、在 `db/sync_store.rs` 中实现读写/合并/墓碑，
   并纳入 `sync/mod.rs` 的 `build_sync_doc` 与 cloud 的 `sync/doc.rs`、`sync/merge.rs`
4. 新增的 **记录字段**：`db/models.rs`（`#[serde(default)]`）、`db/sync_store.rs` 的行读写、`data.rs` 的导入导出、
   cloud `src/model.rs` 允许字段与类型
5. 时间字段经 `db::time::normalize_datetime` 规范化；界面经 `crates/app/src/util/datetime.rs`
6. 旧版数据导入的兼容性（新字段必须有默认值），以及对仍在用旧版本的设备的影响（`.trellis/spec/pc-gpui/data-compat.md`）
7. 导出版本号是否需要递增
8. `pc-gpui/` 下 `cargo test --workspace --locked` 与 `cloud/` 下 `cargo test --locked` 都要绿；同步相关的再跑 Linux e2e 的 S 组

## 兼容旧版沿用的名字

旧 Tauri 版（≤ 2.5.0）用过的这些名字全部原样沿用，保证安装包原地升级、数据与设置不丢、两版不会同时运行。**不要改**：

| 项 | 值 | 位置 |
|---|---|---|
| 数据目录 | `%LOCALAPPDATA%\mini-todo\` | core `db/paths.rs` |
| 日志 | `%LOCALAPPDATA%\com.tauri-app.mini-todo\logs\mini-todo.log`（旧 Tauri identifier 作目录名） | app `services/logging.rs` |
| 单实例 | 命名互斥量 `com.tauri-app.mini-todo-sim`；接收窗口类名 `com.tauri-app.mini-todo-sic`、标题 `com.tauri-app.mini-todo-siw`；`WM_COPYDATA`（`dwData = 1542`，内容 `"<cwd>\|<arg0>\|…"`）——与旧版 `tauri-plugin-single-instance` 同名同格式，新旧两版互相识别 | app `services/single_instance.rs` |
| 开机自启 | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\mini-todo = "<exe>" --autostart`；已开启时每次启动把路径刷新为当前 exe | app `services/autostart.rs` |
| 系统通知 AppUserModelID | `com.tauri-app.mini-todo` | app `lib.rs` 的 `APP_IDENTIFIER` |
| 安装身份 | 产品名 `mini-todo`、卸载键 `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\mini-todo`、默认目录 `%LOCALAPPDATA%\mini-todo`（= 数据目录，卸载只删程序文件）、`$SMPROGRAMS\mini-todo.lnk`；已装旧版时先以 `/S /UPDATE` 调用旧卸载器再安装 | `pc-gpui/scripts/windows-installer.nsi`，契约见 `.trellis/spec/pc-gpui/release.md` |
| 收纳根目录 | `%USERPROFILE%\MiniTodo收纳箱\` | core `services/fence_fs/location.rs` |

macOS（与 2.5.0 mac 版 `mini-todo_2.5.0_universal.dmg` 的实物 Info.plist 与插件实现逐项一致；同样**不要改**）：

| 项 | 值 | 位置 |
|---|---|---|
| 应用身份 | bundle id `com.tauri-app.mini-todo`（= `APP_IDENTIFIER`）；`mini-todo.app`，`CFBundleExecutable` / `CFBundleName` / `CFBundleDisplayName` 都是 `mini-todo`；不设 `LSUIElement` ——用户把新包拖进「应用程序」时直接替换旧版，通知授权、LaunchAgent 里的路径都不变 | `pc-gpui/scripts/macos/bundle.sh`，契约见 `.trellis/spec/pc-gpui/release.md`「macOS 打包」 |
| 数据目录 | `~/Library/Application Support/mini-todo/`（`dirs::data_local_dir()/mini-todo`，与 Windows 同一表达式） | core `db/paths.rs` |
| 日志 | `~/Library/Logs/com.tauri-app.mini-todo/mini-todo.log`（2.5.0 tauri-plugin-log `LogDir` 的位置，没有 `logs` 子目录） | app `services/logging.rs` |
| 单实例 | Unix socket `/tmp/com_tauri_app_mini_todo_si.sock`；第二个实例写 `"<cwd>\0\0<arg0>\0<arg1>…"` 后关闭连接退出；首个实例遇 `NotFound` / `ConnectionRefused` 删残留后 `bind`，退出时只删自己绑定的那个文件——与 tauri-plugin-single-instance 2.3.7 mac 实现同名同格式，新旧两版互相识别 | app `services/single_instance/unix_socket.rs` |
| 开机自启 | LaunchAgent `~/Library/LaunchAgents/mini-todo.plist`：`Label = mini-todo`、`ProgramArguments = [<可执行文件绝对路径>, "--autostart"]`、`RunAtLoad`（auto-launch `set_use_launch_agent(true)`，不用 AppleScript 登录项）；已开启时每次启动刷新路径 | app `services/autostart.rs` |
| 系统通知归属 | bundle id `com.tauri-app.mini-todo`（通知中心的授权与显示设置挂在它上面） | app `services/notify.rs` |

## 核心架构概念

### crate 划分与线程模型

- `crates/core`（`mini-todo-core`）：数据库、同步、导入导出、提醒、收纳箱文件层等全部业务逻辑，**阻塞函数**，错误是面向用户的中文 `String`；
  不依赖 GPUI / Tauri / tokio（`reqwest::blocking` 不能在 tokio runtime 里调用）。公开 API 见 `pc-gpui/crates/core/README.md`。
- `crates/app`（`mini-todo`）：一个进程一个 GPUI `Application`（`QuitMode::Explicit`，托盘常驻），所有窗口共享同一个 `App`。
  阻塞调用一律放后台：`db::spawn_db(cx, |db| core::…)` 返回 `Task<Result<T, String>>`，回前台用 `WeakEntity::update`。
- App 被借用时（事件处理 / `update` / `render`）不能同步调用会给窗口发消息的 Win32：`platform::*(window, cx, …)` 写操作已自动延后，
  `platform::Hwnd` 原始调用只在 `cx.spawn` 的 async 块里、`update` 之外用（`.trellis/spec/pc-gpui/gpui-conventions.md` §2.2）。
  macOS 的 `setFrame:` / `orderOut:` / 改 style mask 同样会同步触发 GPUI 回调，规则相同；AppKit 调用另外只能在主线程（GPUI 的前台），
  `Hwnd` 在 mac 上是 NSWindow 的窗口号、每次重新查找（`gpui-conventions.md` §2.15）。

### 启动顺序（`crates/app/src/lib.rs` 的 `run()`）

```
日志 → 单实例（已有实例：转交参数后退出）→ 打开数据库（失败弹框退出）→ Application(QuitMode::Explicit；macOS 另注册 on_reopen）
  → 主题 →（macOS）应用菜单 → UiLayer / Db / WindowManager / AppState → 单实例唤起监听 → 主窗口（按屏幕配置恢复几何与固定模式）
  → 托盘 / 菜单栏图标 → 自动同步定时器 → 提醒调度 → 恢复收纳箱窗口 → 开机自启自愈 → 加载设置与待办 →（debug）MINI_TODO_DEBUG_OPEN
```

### 跨窗口状态与通信

旧版的 Tauri invoke / emit 在 GPUI 版里都变成同进程的直接调用：

| 旧版（Tauri / WebView） | GPUI 版 |
|---|---|
| Pinia `todoStore` / `appStore` | `state::AppState`（`Entity` + Global），各窗口 `cx.observe`；写操作经 `AppState` 方法在后台执行 |
| `invoke` 命令 / `spawn_blocking` | `db::spawn_db(cx, \|db\| core::…)` 放后台，`WeakEntity::update` 回前台 |
| `sync-completed` | `services::sync::run_sync` → `AppState::apply_sync_report`（本地数据变了 → 重新拉待办；`settings_applied` → 重读设置） |
| `data-imported` / `app-settings-changed` / `todo-font-changed` | 直接改 `AppState`，所有窗口随之刷新 |
| `subtask-memory-ready` / `-init` / `-save` | 子任务窗口直接读写新建待办窗口的 `Entity<DraftSubtasks>`（`subtask::open_draft`） |
| `fence-items-changed` | `fence::items::FenceRefresh`：目录监听回调（`fence-watch` 线程）经 channel 回前台 → 对应收纳箱窗口 |
| 托盘事件 | tray-icon 回调（Windows 窗口过程 / macOS 主线程的 AppKit 事件）只把事件投进 channel → 前台执行动作（`services::tray`） |
| 独立 WebView 窗口（label） | GPUI 窗口：`windows::WindowManager` 统一登记 / 模态子窗口 / 遮罩 / owner / 关闭后刷新 / 居中 |
| tao 窗口能力 + `commands/window.rs` Win32 | `platform/`（`windows.rs` Win32、`macos/` AppKit、`fallback.rs` Linux、`embed.rs` 嵌入桌面、`subclass.rs` 置底与几何锁定） |
| Tauri 默认 App 菜单（mac） | `services::mac_menu`（`set_menus` + 全局 action + 快捷键；`on_reopen` 唤起主窗口） |

### 窗口一览（`windows::WindowManager`，`WindowKind`）

| 窗口 | 入口 | 说明 |
|---|---|---|
| 主窗口 | `main_window::open` / `show_and_activate` | 380×600，最小 320×400，透明，可缩放；开日历时加宽 |
| 待办详情 / 编辑 | `editor::open(cx, parent, todo_id, EditorMode::{View, Edit})` | 1080×600；`todo_id = None` 为新建 |
| 子任务 | `subtask::open` / `subtask::open_draft` | 800×750，查看 / 编辑 |
| 已完成 | `completed::open` | 460×550，搜索、恢复、删除 |
| 设置 | `settings::open(cx, parent, SettingsTab::{General, Appearance, Data, Screen, About})` | 680×560；常规 / 外观 / 数据与同步 / 屏幕配置 / 关于 |
| 应用内通知 | `notification::open` | 320×120，透明、`WindowKind::PopUp`、不抢焦点 |
| 收纳箱 | `services::fences`（`fence::open` 等转发） | 每个收纳箱一个窗口，key `fence-{id}`；Windows 始终嵌入桌面层，macOS 是桌面图标层之上的不激活面板（`PopUp`）；不可关闭（删除走 `close_deleted`） |
| 控件样例 | `gallery::open` | 仅调试（`MINI_TODO_DEBUG_OPEN=gallery`） |

打开方是主窗口 / 已完成 / 编辑窗口时，子窗口是模态的（同一时刻一个，打开方显示遮罩，Windows 上以打开方为 owner；macOS 没有 owner 关系）；
打开方是主窗口时，子窗口关闭后自动 `reload_todos`，设置窗口关闭后再重读设置并重启自动同步。

### 通知调度流程

```
services::scheduler（后台线程）
  ├── 启动 5s 后 reminder::catch_up_missed_repeats（补发错过的重复提醒）
  └── 每到整分钟：notification_kind(db) → reminder::run_due(db, deliver)
        ├── 扫描 completed = 0 AND notified = 0 AND notify_at - COALESCE(notify_before,0) <= now（按时间排序）
        ├── 逐条投递，单条出错只记日志、不影响其它提醒
        ├── System → 系统通知：Windows Toast 在后台线程发（失败不推进）；macOS 经通道交给前台 show_system_notification
        │     （拿不到投递结果，交出即视为已投递）
        ├── App → 经通道交给前台建应用内通知窗口（建窗失败不推进、下一分钟重试）
        ├── 重复提醒：解析 notify_at（空格 / T 均可）→ 计算下一次 → 写规范格式并重置 notified = 0
        └── 非重复：notified = 1
```

### 待办编辑/详情流程

```
列表 / 四象限 / 日历 / 已完成
  └── 点击待办 → editor::open(.., Some(id), EditorMode::View)（新建：FAB / 托盘「添加待办项」→ None，直接编辑）
       └── 编辑窗口：只读详情（标题 + MD 渲染 + 通知状态 + 优先级）⇄ [编辑] 原地切换表单
            ├── 写操作经 AppState（create_todo / update_todo / …，后台执行、更新本地副本）
            └── 描述字段使用 ui::markdown_editor（源码 + 预览，值为存储形式 minitodo-image://）
```

### 子任务编辑流程

```
编辑窗口子任务面板
  └── 查看 / 编辑 → subtask::open（已落库）/ subtask::open_draft（新建待办的暂存子任务）
       └── ui::markdown_editor 加载 Markdown
            └── 图片粘贴 / 拖入 → 后台 core::todo::save_image → images 目录 → Markdown 写 minitodo-image://<name>
```

### 主窗口刷新

- 每 5s `AppState::refresh_if_changed`（读 `todo::change_seq` = `sync_meta.local_seq`，变化时才 `fetch_todos`）；子窗口关闭、同步完成也走同一个
  单飞 `fetch_todos`（拉取期间有本地写操作则丢弃并补拉）；主窗口有模态子窗口时后台刷新跳过

### WebDAV 同步流程

```
services::sync::run_sync(cx, SyncMode)（后台线程；AppState.syncing 期间标题栏图标旋转）
  └── core sync::run(db, mode)（单同步互斥，"同步正在进行中"）
        ├── GET sync-data（有基准 ETag 时 If-None-Match；本地有未上传变更 / 手动同步 / 每第 10 轮时无条件 GET）
        │     ├── 304 → 远端未变
        │     ├── 200 → 解析（失败直接报错）→ 单事务逐条合并（LWW + 墓碑 + 设置 LWW）→ 下载缺失图片
        │     └── 404 → 首次上传
        ├── 需要上传？（local_seq ≠ synced_seq / 设置更新 / 远端缺本地记录或墓碑）
        │     ├── 先上传缺失图片（一次 PROPFIND Depth:1 列清单）
        │     └── 条件 PUT：已知 ETag 时 If-Match（去 W/），从不发 If-Unmodified-Since
        │           412 → 等 1.1s → 重新 GET → 合并 → PUT（最多 4 次）
        └── 成功：基准取 PUT 响应 ETag → HEAD → PROPFIND；记录 synced_seq；返回 SyncReport
  └── AppState::apply_sync_report（本地数据变了 → 重新拉待办；settings_applied → 重读设置与运行时偏好）
SyncMode::ForcePull  让本地等于远端（删除本地独有记录，不写墓碑）
SyncMode::ForcePush  让远端等于本地（为远端独有记录写墓碑后上传）
```

WebDAV 服务端差异（实测，见 `.trellis/spec/backend/sync-protocol.md`）：Apache mod_dav 的 `If-Unmodified-Since`
对未改动文件也会 412、写入后 1 秒内 ETag 为弱；nginx/Caddy 忽略 PUT 前置条件、PROPFIND 不返回 getetag、
`If-Modified-Since` 按整秒比较。所以两端只用 `If-Match` / `If-None-Match`，并且写前总是先 GET 合并。

### 桌面收纳箱流程

```
托盘 / 菜单栏图标「新增收纳箱」→ services::fences::create_fence
  └── 后台 fence::create（先建文件夹 → INSERT fences → 记当前屏幕组合布局）
       → open_fence_window（先隐藏建窗 → 按客户区定位 → Windows：TOOLWINDOW + 嵌入桌面层 / macOS：PopUp 面板 make_desktop_layer
         → 显示；失败 rollback_create）
       → ensure_watcher；200ms 守护（Windows：Explorer 重启重挂；每 10 tick 检测屏幕组合）
拖入 → 后台 fence::items::move_in（先登记 begin_moving →（macOS）判定 iCloud 项目 → IFileOperation / renamex_np(RENAME_EXCL)
       → upsert fence_items，搬运 / 记录原始路径）→ FenceRefresh
目录变化 → notify（300ms 去抖）→ fence-watch 线程里数据库锁内列举对账 → FenceRefresh 经 channel → 对应收纳箱窗口
删除 → 后台 fence::delete → restore_all → 文件夹已清空才删记录 + close_deleted 关窗，否则整个收纳箱保留
```

### 已知偏差与限制

1. **Markdown 编辑**：旧版 Milkdown 所见即所得无法在 GPUI 中 1:1 还原，编辑模式改为"Markdown 源码 + 渲染预览"（放大编辑弹窗是源码 / 预览分栏）；
   只读渲染、图片粘贴 / 拖入上传、`minitodo-image://` 引用、链接白名单规则不变。
2. **中文换行**：GPUI 的换行器不做避头尾，全角标点（`，。、；：！？）`）可能落到行首，多行中文的折行位置与浏览器可能差一个字。
3. GPUI 没有文字阴影、多色标渐变、位图变换等，个别装饰用近似实现（见 spec `gpui-conventions.md` §2.13）。
4. **Linux 仅用于 CI、截图回归与 e2e**：没有托盘 / 单实例 / 开机自启 / 嵌入桌面 / 收纳箱真实文件操作，最后一个窗口关闭即退出，设置里的字体下拉为空。
   Windows 专有能力（嵌入桌面、收纳箱的 Shell 实现、DPAPI、Toast、托盘、DirectComposition 透明）在 Linux 上只做交叉 clippy，靠 CI 的 Windows e2e 与用户实机验收；
   mac 专属代码见 6。
5. Win10 / Win11 ≤23H2 上 Win+D 可能盖住嵌入桌面的主窗口与收纳箱（显示桌面抬起的是 `WorkerW` 而不是 Progman）。
6. **macOS 是预览版，未经真机验收**：维护者没有 Mac，mac 代码只能在 macOS runner 上编译、跑 e2e（runner 屏幕 1x、单显示器、半虚拟化 GPU），
   Retina、多显示器 / 混合缩放、Spaces / 全屏、阴影与圆角观感、Intel 机型（x86_64 切片只在 arm64 runner 上交叉编译、`lipo` 合并，
   CI 没有运行过它）都靠用户反馈。另外：
   - 只有 ad-hoc 签名、不公证：首次打开要放行（`xattr -cr` 或「隐私与安全性 → 仍要打开」），每次升级都要重新放行
   - 系统通知拿不到投递结果（授权被拒也视为已投递），`cargo run` 的裸二进制收不到通知；runner 上测不了通知横幅与授权
   - 主窗口没有嵌入桌面，固定模式没有"不进 Dock / ⌘Tab"；固定模式在 Spaces / 全屏下的行为（`collectionBehavior`）没有处理
   - 收纳箱（3.0.2-pre 之后新增）只在 runner 上跑过 e2e（MF 组：桌面层层级、监听、真实拖入、移回、删除、⌘H、Dock 唤起、改名、拖动落库、菜单栏三项、
     重启恢复），全部在临时目录里、没碰过真实 `~/Desktop`。改名（MF08）在 runner 上用粘贴 + 回车验证（系统输入流的 ⌘V / 回车；runner 上逐字注入的
     Unicode 按键本来就进不了 GPUI 输入框，普通窗口也一样）。没验证过：真实桌面的隐私授权（TCC）流程、iCloud「桌面与文稿」、跨卷移动、
     Spaces / 台前调度 / 调度中心 /「显示桌面」/「点按墙纸显示桌面」下的表现、Retina / 多显示器、收纳箱改名时的中文输入法、
     打开 / 在访达中显示（e2e 不测，会拉起别的应用）
   - 收纳箱的小偏差：访达拖入时光标带绿色"+"（GPUI 对外部拖入一律回报"拷贝"），实际执行的是移动；「自定义颜色」的取色面板会把应用切到前台
   - 混合缩放（Retina 笔记本 + 1x 外接屏）时主窗口位置的物理像素口径与 2.5.0 不同，2.5.0 记下的位置可能恢复不到（设备本地数据，只丢一次）
   - 文件对话框没有扩展名过滤器；WebDAV 密码明文；最低 macOS 11（2.5.0 写的是 10.13）

## cloud/ 子项目（云端 API）

`cloud/` 是一个独立的 Rust crate（**不在** `pc-gpui/` 的 Cargo workspace 里），部署到 VPS 上为
AI / Claude Code Skill 提供 mini-todo 数据的 HTTP REST 访问能力。部署、配置、Skill 见 `cloud/README.md`。
与 PC 的契约（和上文"时间格式约定""合并规则"是同一套）：

- **共用同一个 WebDAV `sync-data.json.gz` 通道**。云端是 WebDAV 客户端，不依赖 PC 在线
- **WebDAV 是 source of truth**，云端 SQLite 是缓存；未推送的本地改动（`meta.dirty`）推送成功前不会被拉取覆盖
- **时间**：`YYYY-MM-DD HH:MM:SS`，按 `config.toml` 的 `timezone`（IANA，须与 PC 一致）每次重新换算墙钟（DST 安全）
- **写冲突**：写前先 GET 合并，条件 PUT 只用 `If-Match`（去 `W/`）；412 等待后重新 GET → 合并 → PUT
- **字段契约**：SQLite 是 KV-style（`todos(id, data_json, updated_at)`），但 API 写入只接受 PC 模型里的字段
  （`src/model.rs`，以 `pc-gpui/crates/core/src/db/models.rs` 为准），未知字段 / 类型错误 → 400；合并远端时未知字段原样保留（可能是更新版 PC 加的新字段）
- **墓碑**：DELETE 写 `tombstones`，随 sync-data 双向传播，保留 30 天
- **短码**：`todo_seq` 表给每个 todo 分配 `C{seq}`，API 路径与 CLI 都可用；只存在于 cloud，不进同步文档

```
cloud/
├── src/main.rs               # 启动：config → SQLite → 首次 pull（spawn_blocking）→ worker → axum；停机限时补推
├── src/config.rs             # config.toml；缺必填字段报错退出
├── src/time.rs               # 规范时间格式、宽松解析、按 IANA 时区换算
├── src/model.rs              # 记录字段契约：写入校验、别名映射、派生 priority
├── src/db/                   # todos / subtasks / settings / meta / tombstones / todo_seq；normalize.rs 修存量数据
├── src/sync/webdav.rs        # GET（If-None-Match）/ PUT（If-Match）/ HEAD / PROPFIND / MKCOL
├── src/sync/doc.rs           # sync-data 文档读写（保留未知顶层键）
├── src/sync/merge.rs         # 与 PC 一致的记录 LWW + 墓碑 + 设置合并
├── src/sync/{pull,push}.rs   # 拉取合并；写前合并 + 条件 PUT + 412 重试；图片推送
├── src/sync/worker.rs        # pull 轮询（每 10 轮一次无条件全量）、push 去抖 1.5s（最长 10s）、失败指数退避
├── src/sync/images.rs        # 图片镜像（缺什么下什么，失败退避重试）
├── src/sync/mock_dav.rs      # 测试用进程内 WebDAV，可按 Apache / nginx 实测语义模拟
├── src/api/                  # axum：Bearer 鉴权、X-Sync-Status 头、5xx 不外泄内部细节
├── deploy/                   # minitodo-cloud.service（systemd 沙箱加固）、Caddyfile.example
└── skill/minitodo/           # Claude Code Skill：SKILL.md + minitodo.py（含单测）+ install.{sh,ps1}
```

REST API（全部需要 `Authorization: Bearer <api_key>`，`:id` 可用 `C{seq}` 短码）：

| Method | Path | 说明 |
|---|---|---|
| GET | `/health` | `{status, sync, lastPullAt}` |
| GET | `/todos?...` | 列表；query: `completed` / `priority` / `quadrant` / `dueDateBefore` / `dueDateAfter` / `startDate` / `q` / `sort=±field` / `limit` / `offset` / `withSubtasks` |
| GET | `/todos/:id?withSubtasks=true` | 详情；默认嵌套 subtasks |
| POST | `/todos` | 创建；必填 `title`，其余字段按契约校验 |
| PATCH | `/todos/:id` | merge 更新；未提及字段保留 |
| DELETE | `/todos/:id` | 删除（连带 subtasks，写墓碑） |
| POST | `/todos/:id/subtasks` | 创建子任务；必填 `title` |
| PATCH | `/subtasks/:id` | merge 更新子任务（`parentId` 不可改） |
| DELETE | `/subtasks/:id` | 删除子任务 |
| GET | `/images/:name` | 返回图片 bytes |
| POST | `/images` | multipart 上传，`file` 字段；返回 `{name}` |
| POST | `/sync` | 手动 pull + push；全部成功 200、部分失败 207，返回 `{pull, push, pullError?, pushError?}` |
| POST | `/sync/pull` | 仅从 WebDAV 拉取 |
| POST | `/sync/push` | 仅推送到 WebDAV |

todo 可写字段以 `src/model.rs` 的 `TODO_FIELDS` 为准（`title` / `description` / `color` / `quadrant` / `notifyAt` /
`startTime` / `endTime` / `notifyBefore` / `notified` / `completed` / `sortOrder` / 重复提醒 5 个字段）。别名：
`dueDate`→`endTime`（仅日期补 23:59:00）、`priority`（high/medium/low）→`color`（#EF4444 / #F59E0B / #10B981）、
`notes`→`description`；`quadrant` 接受 1-4 或 `urgent_important` / `important_not_urgent` 等别名。`id` / `createdAt` /
`updatedAt` 等服务端字段写入时忽略。响应附派生字段 `priority`（自定义颜色为 null）与 `seq`，派生字段不入库。

排序字段白名单：`dueDate` / `startTime` / `priority` / `quadrant` / `sortOrder` /
`updatedAt` / `createdAt` / `title`，前缀 `-` 倒序、`+` 或无前缀正序。

所有响应附 `X-Sync-Status: healthy | stale | offline` 与 `X-Last-Sync-At`；offline
时额外加 `Warning: 110 "sync offline"`。

## 开发命令

```bash
# PC（在 pc-gpui/ 下），门槛见 .trellis/spec/pc-gpui/verification.md
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked                          # core（合并、迁移、WebDAV 互操作 tiny_http 模拟、农历对照……）+ app（util / 纯逻辑）
cargo test -p mini-todo-core                             # 只测 core（不编译 GPUI）
# Windows 目标交叉检查（Linux 上开发时用；覆盖全部 cfg(windows) 代码；需 rustup target add x86_64-pc-windows-gnu + mingw gcc；只 check 不链接）
cargo clippy --workspace --all-targets --target x86_64-pc-windows-gnu -- -D warnings
# mac 专属代码的本地交叉检查（Linux / Windows 上都能跑；只覆盖不依赖 GPUI 的 mac 源文件，需 rustup target add aarch64-apple-darwin）
cargo clippy --manifest-path scripts/mac-check/Cargo.toml --target aarch64-apple-darwin -- -D warnings
MINI_TODO_DATA_DIR=/tmp/mt cargo run -p mini-todo        # 隔离数据目录运行 debug 构建（Windows 上与安装版并存实测时必须带上）
cargo build --release -p mini-todo --locked              # Windows：需 Windows SDK 的 fxc.exe（可用 GPUI_FXC_PATH 指定）→ target/release/mini-todo.exe
# macOS（只能在 Mac 上）：universal → .app（ad-hoc 签名）→ dmg，见 pc-gpui/scripts/README.md「macOS：打包、e2e 与交叉检查」
cargo build --release -p mini-todo --locked --target aarch64-apple-darwin --target x86_64-apple-darwin
bash scripts/macos/bundle.sh target/aarch64-apple-darwin/release/mini-todo target/x86_64-apple-darwin/release/mini-todo dist
scripts/regress.py --before <改动前的输出> --out /tmp/regress  # Xvfb 视觉回归：场景截图 + 并排对照图（--only 'main-*' 只跑部分）
scripts/regress.py --smoke --no-build                    # 跨窗口端到端冒烟（按数据库断言）
scripts/shot.sh --name main-list --window "Mini Todo" /tmp/shots   # 单个场景截图 / xdotool 交互

# cloud（在 cloud/ 下）
cargo test --locked        # 含 mock_dav 场景测试（Apache / nginx 语义）
python3 -m unittest discover -s skill/minitodo -p 'test_*.py'

# 端到端（仓库根目录）：Linux 见 pc-gpui/scripts/e2e-linux/README.md；Windows 见 pc-gpui/scripts/e2e-windows/README.md；
# macOS 见 pc-gpui/scripts/e2e-macos/README.md（一般推 claude/** 分支由 CI pc-gpui-e2e-macos.yml 跑）
python3 pc-gpui/scripts/e2e-linux/run_e2e.py pc-gpui/target/debug/mini-todo cloud/target/debug/minitodo-cloud [用例前缀 ...]
python pc-gpui/scripts/e2e-windows/run_e2e.py --exe pc-gpui/target/debug/mini-todo.exe --seed pc-gpui/target/debug/examples/seed.exe --out <输出目录>
python3 pc-gpui/scripts/e2e-macos/run_e2e.py --app <mini-todo.app> --seed pc-gpui/target/debug/examples/seed --out <输出目录> --work <工作目录>
```

- Windows debug 构建的 exe 在运行时按**构建机**上 cargo registry 的绝对路径编译着色器，只能在构建它的那台机器上运行；分发一律用 release 构建。
  macOS 的着色器源码嵌在二进制里（`runtime_shaders`），debug 构建也能拷到别的 Mac 上跑；但系统通知等要 bundle id，得用 `bundle.sh` 包成 `.app` 再启动。
- Linux / Windows 上编不了整个 app 的 mac 目标（gpui-pre-apple 的 build.rs 按宿主机生成着色器源码，bundled SQLite 等 C 编译要 Apple SDK）：
  mac 的 GPUI 胶水只有 CI 的 macOS 作业能编译，所以 mac 改动要推分支跑 CI（`.trellis/spec/pc-gpui/verification.md` §7）。
- 安装包与便携 zip、dmg 只由 `release.yml` 构建（NSIS 脚本 `pc-gpui/scripts/windows-installer.nsi`、mac 打包脚本 `pc-gpui/scripts/macos/`，
  本地编译方法见 `.trellis/spec/pc-gpui/release.md`）。

debug 专用环境变量（**只在 debug 构建读取，release 一律忽略**；与正在运行的安装版并存做实测时必须带上，详见
`.trellis/spec/backend/desktop-fences.md` §3.6、`pc-gpui/crates/app/README.md` §5 与 `pc-gpui/scripts/README.md`）：

| 变量 | 作用 |
|---|---|
| `MINI_TODO_DATA_DIR` | 数据目录（库 / 图片 / 备份 / 图标缓存）、日志（`<dir>\logs`）都改到该目录；不启用单实例、不刷新开机自启注册表项；托盘提示"Mini Todo（开发版）" |
| `MINI_TODO_FENCES_ROOT` | 收纳根目录改为该目录（代替 `%USERPROFILE%\MiniTodo收纳箱` / macOS `~/MiniTodo收纳箱`） |
| `MINI_TODO_DESKTOP_DIR` | "用户桌面"改为该目录（收纳箱移回的兜底目标、"不能收纳桌面本身"的保护、云同步提示的"来自桌面"判断）；mac e2e 用它避开真实 `~/Desktop` 的隐私授权框 |
| `MINI_TODO_SCREEN_ID_FILE` | 文件内容非空时替代屏幕组合 ID，用于模拟显示器组合变化 |
| `MINI_TODO_DEBUG_OPEN` | 加载完数据后额外打开的窗口（`main`、`editor:<id>[:view\|:edit]`、`settings[:<页>]`、`fence:new` 等，格式见 `pc-gpui/scripts/README.md`） |
| `MINI_TODO_DEBUG_LATEST_VERSION` | 版本检查不联网，直接当作 GitHub 最新版本（如 `v2.6.0`） |
| `MINI_TODO_DEBUG_DIALOG_PATH` | 导出 / 导入的文件对话框不弹出，直接返回该路径 |
| `MINI_TODO_DEBUG_WINDOWS_UI` | `1` 时 Linux / macOS 上也显示 Windows 专有的设置项（截图对照用） |
| `MINI_TODO_DEBUG_FENCE_STATE` | `onedrive` / `drag-hover`：收纳箱直接显示云同步提示面板（Windows OneDrive / macOS iCloud 文案）/ 拖入悬停框 |

### CI 与发布（`.github/workflows/`）

- `release.yml`：推 `v*` tag → 先建 **draft** release（tag 含 `-` 自动标 pre-release）→ Windows 作业构建 pc-gpui release 版并打 NSIS 安装包
  `mini-todo_<版本>_x64-setup.exe` + 便携包 `mini-todo_<版本>_x64-portable.zip`、macOS 作业（`macos-15` arm64，一次构建 aarch64 + x86_64 →
  `lipo` → `bundle.sh` 组装 `.app` 并整包 ad-hoc 签名 → `make-dmg.sh`）出 `mini-todo_<版本>_universal.dmg`、cloud 作业构建 `minitodo-cloud`
  （linux x86_64 / aarch64）→ publish 作业上传 `SHA256SUMS.txt` 并把 draft 转正。Windows / cloud 失败时不发布；**macOS 失败不挡发布**
  （mac 是预览平台：照常转正，只出 warning，之后 Re-run failed jobs 或 `workflow_dispatch` 补建 dmg）。`workflow_dispatch`（tag + platforms：
  `windows` / `macos` / `cloud-x86_64` / `cloud-aarch64`）给已有 release 补建产物。tag 必须等于 `v<pc-gpui 工作区版本>`，否则直接失败
- `cache-warm.yml`：在 main 上预热 release 用的 Rust 缓存（`release.yml` 只读缓存；`gpui-windows` / `gpui-macos` / `cloud` 三个作业与 release 逐字对齐）
- `pc-gpui-ci.yml`：Windows fmt / clippy / test + release 构建（上传 `mini-todo-gpui-windows-x64` 产物），Linux clippy / test，
  macOS clippy / test + universal release 构建并组装 `.app`（上传 `mini-todo-gpui-macos-universal` 产物）
- `pc-gpui-e2e-windows.yml`：Windows e2e（debug 套件 + release 冒烟 / 单实例）；手动触发，或推送到 `claude/**` 分支且改动 `pc-gpui/**` 时运行
- `pc-gpui-e2e-macos.yml`：macOS 编译门 + e2e（`checks` clippy / test、`smoke` debug `.app` 的 M 组与收纳箱 MF 组、`release-smoke` release `.app` 不隔离的 P 组）；
  触发条件同 Windows e2e。mac 代码没有别的编译门，改了就推分支看它
- `cloud-ci.yml`：fmt / clippy / test + Skill 单测
- **版本号只有一处**：`pc-gpui/Cargo.toml` 的 `[workspace.package] version`（改完 `Cargo.lock` 同步），当前 `3.0.2-pre`。cloud 版本在
  `cloud/Cargo.toml`，与 tag 无关。发布步骤与安装器契约见 `.trellis/spec/pc-gpui/release.md`

## 注意事项

1. **目标平台**：Windows 10/11（x64）；macOS 11+（universal，预览版，未经真机验收）；Linux 可编译运行（CI 与 e2e 用），不提供 Linux 桌面包。
   主窗口嵌入桌面只在 Windows 上有；桌面收纳箱 Windows 与 macOS（预览）都有，Linux 上没有真实文件操作（只用于截图 / 冒烟）
2. **运行环境**：开发只需 Rust stable；Windows release 构建另需 Windows SDK（`fxc.exe`）；macOS 构建需 Xcode Command Line Tools（clang + SDK，
   不需要 Metal 工具链）。用户运行不需要 WebView2
3. **图标使用**：仅使用 Element Plus Icons，禁止 emoji
4. **GPUI 约定**：改 `pc-gpui/crates/app` 前读 `.trellis/spec/pc-gpui/gpui-conventions.md`（后台执行、App 借用期间不同步调 Win32、
   拖动窗口、z 序与弹层、`img(PathBuf)`、theme token 唯一来源、`ui/` 控件复用、debug 开关只在 debug 构建生效；macOS 的 AppKit 主线程、
   窗口号、显示器本地坐标、红绿灯、字体别名、mac-check 见 §2.15）；
   `gpui-kit` 精确钉 `=0.7.3`，不要随手升级；改了界面跑 `scripts/regress.py` 并逐张看对照图
5. **Serde 命名**：Rust 模型使用 `#[serde(rename_all = "camelCase")]`（同步文档、导出文件、cloud API 都是驼峰）
6. **进程管理**：Windows 平台需要外部子进程时使用 `taskkill` 终止子进程树
7. **日志**：Rust 侧用 `log::info!` / `warn!` / `error!`（app `services::logging` 落盘），不要用 `println!` / `eprintln!`
   （release 是 Windows 子系统程序，没有控制台；macOS 从访达 / Dock 启动同样看不到标准输出）
8. **设置读写**：一律经 `db::settings_kv`（只在值变化时更新 `updated_at`，否则会干扰设置同步）
9. **安全边界**：本地图片只按文件名映射到本机 images 目录（`util/image_ref.rs`），Markdown 里存 `minitodo-image://<name>`；
   链接只放行 http(s) / mailto / 本地文件（`util/file_link.rs`，拒绝 UNC）；图片文件名经 `is_safe_image_name` 校验；
   收纳箱图标只从 `fence-icons/` 缓存加载
10. **时间**：见"时间格式约定"，只用 `db::time` 与 `util/datetime.rs` 解析与格式化
11. **兼容名字不要改**：数据目录、日志目录、单实例 / 开机自启 / AppUserModelID / 安装身份、mac 的 bundle id / `.app` 名 / socket /
    LaunchAgent 沿用旧版（见「兼容旧版沿用的名字」）；
    数据结构改动按 `.trellis/spec/pc-gpui/data-compat.md` 考虑 cloud 与仍在用旧版本的设备
12. **带 owner HWND 的收纳箱移动**（`fence::delete`、`fence::items::{move_in, restore, move_to}`）绝不在 UI 主线程调用（Windows 系统对话框会死锁；
    macOS 上隐私授权框、iCloud 下载、跨卷复制同样会阻塞调用线程）
