diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b818930 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +# CI — PR/push 的最低验证门槛(docs/release.md §4-1/2)。 +# 只跑纯 Node 部分(vitest + tsc + vite build);Tauri/NSIS 构建依赖本机 GNU 工具链, +# 仍走手工 release gate(docs/release.md §0/§3)。 +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run build diff --git a/AGENTS.md b/AGENTS.md index 26b4a9b..a29b349 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,10 @@ ResearchThread 是一个**本地优先**的桌面工具(Tauri 2,macOS + Wind - 阶段:**M3 完成 + 种子发布加固**(M1/M2/M3 全部落地,实机验证)——发布前审阅确认的九项门槛已全部代码落地: - **事务式写入**(`storage/atomicWrite.ts`):写 `.rt-tmp` → 原文件让位 `.rt-bak` → 新内容就位 → 清备份;任何一步失败或进程中断后,**原文件或备份必有一个可恢复**;启动时 `recoverInterruptedWrites()` 扫描恢复并记账。测试注入 rename 失败/持续故障/崩溃残留全覆盖 - **单实例**(`tauri-plugin-single-instance`):第二实例启动即聚焦已有主窗口后退出,杜绝双 watcher + 同名 tmp/bak 互踩 - - **外部编辑并发策略**:storage 保存前比对磁盘内容与应用最后已知内容(`seen` 缓存,读/写后更新),不一致抛 `ExternalConflictError`,UI 弹「覆盖保存(acknowledge 后重试)/ 重新加载 / 取消」。`updateTaskMeta` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 四路受保护;appendEntry 纯追加不设卡 + - **外部编辑并发策略**:storage 保存前比对磁盘内容与应用最后已知内容(`seen` 缓存,读/写后更新),不一致抛 `ExternalConflictError`,UI 弹「覆盖保存(acknowledge 后重试)/ 重新加载 / 取消」。`updateTaskMeta` / `appendEntry` / `updateEntry` / `updateProjectDescription` / `saveWeekly` **五路全部受保护**(appendEntry 同样检查冲突后整文件重写,比纯追加更安全) + - **软删除**:`deleteTask` / `deleteInboxItem` 移入数据目录 `.trash/`(时间戳前缀),不物理删除;恢复 = 移回原位或 git 找回(事务写保护不了 delete,这是当天未快照文件的唯一防线) + - **renderer 安全边界**:生产线 CSP 已开启(`app.security.csp`,`default-src 'self'` 起步);`shell` capability(cmd/powershell/node/npm/claude/codex/git,args:true)是高权限边界,任何新 HTML 渲染入口必须 escape-first(`src/lib/markdown.ts` 原则)——详见 docs/release.md §5-0 + - **CI 门槛**:`.github/workflows/ci.yml`(npm ci → test → build)是 PR 的 merge gate;Tauri/NSIS 仍手工构建(GNU 工具链,见下) - **每日快照健康**(`storage/snapshot.ts` `ensureSnapshotHealth()`):git 可用性检测 → 仓库级身份兜底(`--local`,不动全局配置)→ 补有效提交(修旧版 init 静默失败留下的空仓)→ 每日首启自动提交;任何失败 `ok:false`,主界面左下持久警告横幅 + 设置→数据「重试」 - **草稿与覆盖保护**:`editorState` 增 weekly 脏标记,脏编辑器存在时 `onCloseRequested` / `beforeunload` 拦截退出并确认;周文档重新生成与外部冲突覆盖均需二次点击确认(3 秒 armed) - **周报正确性**:任务定位主键改完整相对路径(修跨项目同名任务归错),活跃工作线 ☆ 置顶稳定排序 @@ -67,6 +70,7 @@ ResearchThread 是一个**本地优先**的桌面工具(Tauri 2,macOS + Wind ├── .gitignore # 至少忽略 .mcp.json ├── .activity/log.jsonl ├── inbox/ # 快速捕获收件箱(未来的自然语言命令队列) +├── .trash/ # 软删除暂存(删除不物理清除;用户手动恢复或清理) ├── spaces/ │ ├── research/ # 空间(≈ 手写页的分类) │ │ └── projects/ diff --git a/README.md b/README.md index 96b503f..72d9027 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ResearchThread -本地优先的科研/工作流程管理桌面应用(Tauri 2,macOS + Windows)。把手写「思路整理」工作流数字化:**空间 → 项目 → 任务**三级层级 + 双层上下文(项目整体上下文 / 任务上下文)+ 周文档。 +本地优先的科研/工作流程管理桌面应用(Tauri 2)。当前种子构建:**Windows x64**;macOS 为计划目标、尚未构建验证。把手写「思路整理」工作流数字化:**空间 → 项目 → 任务**三级层级 + 双层上下文(项目整体上下文 / 任务上下文)+ 周文档。 第二步(已规划、未开始):**人只说自然语言,agent 维护整个项目地图**——口述新任务/新项目/任务转折即可,agent 经 MCP 工具接口执行并定时产出日报/周报。 @@ -21,12 +21,13 @@ ## 技术栈 -- React 19 + Vite + Tailwind v4(SPA;2026-09-10 采纳外部按 UI.md 交付的 UI,剥离其 SSR/登录/数据库) -- Tauri 2(待接入,起步零自定义 Rust) -- 所有数据访问统一走 `src/dataAccess.ts` 接口([UI.md](UI.md) 第 2 节契约);当前为 mock 实现,M1 换接 storage 层 -- 数据层 `src/storage/`(待建):纯核心(core,无 Tauri API)+ IO 适配器(fsAdapter,plugin-fs)拆分——将来同一核心配 `node:fs` 即成 MCP server;`vitest` 单测守护写回契约(宽松读/规范写/保留未知字段) -- 终端面板(M2):`tauri-plugin-shell` + `@xterm/xterm`,经 AgentRunner 接口抽象(第二步对话面板与终端共用后端) -- 数据:Markdown 文件树 + YAML frontmatter,数据目录即 agent 工作区(`AGENTS.md` / `.mcp.json` / `settings.yaml`),可用 Obsidian / git 直接操作 +- React 19 + Vite + Tailwind v4(SPA;按 [UI.md](UI.md) 契约集成外部交付的 UI,剥离其 SSR/登录/数据库) +- Tauri 2 桌面壳(plugin-fs / plugin-shell / single-instance / global-shortcut;生产线启用 CSP,见 [docs/release.md](docs/release.md) §5) +- 数据访问统一走 `src/dataAccess.ts` 接口([UI.md](UI.md) 第 2 节契约):浏览器 dev → MockDataAccess;Tauri 桌面 → StorageDataAccess(Markdown 文件树落盘) +- 数据层 `src/storage/`:纯核心 `core.ts`(无 Tauri API)+ IO 适配器 `fsAdapter.ts`(plugin-fs)+ 事务式写入 `atomicWrite.ts` + 每日快照健康 `snapshot.ts`;同一核心配 `node:fs` 即成 2b 阶段 MCP server(`tests/storageNode.test.ts` 的适配器即雏形) +- Vitest 守护写回契约与数据安全(宽松读/规范写/保留未知字段/外部冲突防护/软删除/中断恢复);CI 见 `.github/workflows/ci.yml` +- 终端面板:`tauri-plugin-shell` + `@xterm/xterm`,经 AgentRunner 接口抽象(第二步对话面板与终端共用后端) +- 数据:Markdown 文件树 + YAML frontmatter,数据目录即 agent 工作区(`AGENTS.md` / `.mcp.json` / `settings.yaml`),可用 Obsidian / git 直接操作;删除进 `.trash/` 可恢复 UI 布局:左侧栏(空间/项目树)|中间展示区|底部可开关面板(第一阶段内置终端,第二步 agent 对话)|右侧可开关侧边栏(详情/上下文编辑)。 diff --git a/docs/release.md b/docs/release.md index 983916f..056c0ea 100644 --- a/docs/release.md +++ b/docs/release.md @@ -61,14 +61,20 @@ npm run make-release # ④ 归拢 release/(含门槛自检) ## 5. 数据安全红线(任何版本不得回退,回退 = 事故) +0. **renderer 安全边界**:生产线 CSP 必须开启(`tauri.conf.json` `app.security.csp`,当前 `default-src 'self'` + 按需放开),**禁止改回 `null`**;`shell` capability(cmd/powershell/node/npm/claude/codex/git,`args: true`)是**高权限边界**——任何新的 HTML 渲染入口(markdown 预览、agent 输出、CLI 输出渲染)必须先 HTML 转义再生成白名单标签(参照 `src/lib/markdown.ts` 的 escape-first 原则),改动必须过安全审查。理由:任何 renderer 注入 + shell 白名单 = 本机代码执行。**2a/2b 接入 agent 时必须重新收紧这份 allowlist**( Roadmap Issue #2)。 1. **写入只走事务**:所有文件写入经 `src/storage/atomicWrite.ts`(tmp → bak 让位 → 就位),保证任何失败/中断后原文件或备份必有一个可恢复;不许绕过它直接调 fs 写。 -2. **外部冲突守卫**:`updateTaskMeta` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 保存前的外部修改检查(`ExternalConflictError` → 用户选择)不许移除或改成静默合并。 +2. **外部冲突守卫**:`updateTaskMeta` / `appendEntry` / `updateEntry` / `updateProjectDescription` / `saveWeekly` 保存前的外部修改检查(`ExternalConflictError` → 用户选择)不许移除或改成静默合并。 3. **单实例**:`tauri-plugin-single-instance` 不许移除。 4. **快照失败必须可见**:git 缺失/提交失败 → 界面持久警告,不许回到静默降级。 5. **脏草稿退出保护**:`onCloseRequested` / `beforeunload` 拦截不许移除。 -6. **数据格式向后兼容**:文件树结构、frontmatter 契约、`status` 五态枚举是存量用户资产——宽松读 / 保留未知字段的写回契约(PLAN.md 第三节)不许破坏;确需破坏性变更:先用户确认、写迁移与回滚说明、升 minor。 -7. **卸载不删数据目录**:NSIS 卸载只删程序本体与应用界面设置;不得增加「删除数据」的卸载选项。 -8. **UI 不触文件**:所有数据访问仍必须经 `src/dataAccess.ts` 边界。 +6. **删除 = 软删除**:`deleteTask` / `deleteInboxItem` 只移入 `.trash/`(时间戳前缀,不覆盖、不清理),不做物理删除——事务写保护不了 delete,这是当天新文件尚未进 git 快照时唯一的丢失防线;恢复 = 从 `.trash/` 移回或 git 找回。 +7. **数据格式向后兼容**:文件树结构、frontmatter 契约、`status` 五态枚举是存量用户资产——宽松读 / 保留未知字段的写回契约(PLAN.md 第三节)不许破坏;确需破坏性变更:先用户确认、写迁移与回滚说明、升 minor。 +8. **卸载不删数据目录**:NSIS 卸载只删程序本体与应用界面设置;不得增加「删除数据」的卸载选项。 +9. **UI 不触文件**:所有数据访问仍必须经 `src/dataAccess.ts` 边界。 + +## 5b. CI 门槛 + +`.github/workflows/ci.yml` 在每个 PR / push 上强制 `npm ci → npm test → npm run build`——**merge gate 是 CI 绿,不是"本地跑过"的口头证据**。Tauri/NSIS 构建依赖本机 GNU 工具链,仍走 §4 手工 release gate;将来环境标准化后可加入 CI。 ## 6. 隐私红线(零遥测,任何版本不得突破) @@ -86,4 +92,5 @@ npm run make-release # ④ 归拢 release/(含门槛自检) | 版本 | 日期 | 构建提交 | 安装包 SHA-256 | 备注 | |---|---|---|---|---| -| 0.1.0-seed | 2026-09-11 | `87082ad` | `b3a893fbb46711d65383ce29f17db2762c56c405b3d40649671e89649031aa07` | 种子候选包;**尚未做 §4-6 干净环境验证,未发布** | +| 0.1.0-seed | 2026-09-11 | `701c538` | `b83a6c94ba7cbc757b7384699bcb62f5275ea69c8c69dfda2810bad9a7f60a48` | 种子候选包(含 CSP/软删除);**尚未做 §4-6 干净环境验证,未发布**。注:历史因作者邮箱重写过一次(旧 d082ded → 701c538,树内容相同) | +| ~~0.1.0-seed~~ | 2026-09-11 | ~~`87082ad`~~ | `b3a893fbb46711d65383ce29f17db2762c56c405b3d40649671e89649031aa07` | 已废弃(CSP 关闭、硬删除),勿分发 | diff --git a/docs/seed-manual.md b/docs/seed-manual.md index 9563506..1b22ebe 100644 --- a/docs/seed-manual.md +++ b/docs/seed-manual.md @@ -17,6 +17,7 @@ - **卸载应用不会删除数据目录**。卸载只移除程序本体与应用界面设置(主题、布局偏好等); 重新安装后数据原样回来。 - 建议偶尔把整个数据目录复制到别处(或网盘 / 移动硬盘)作为额外备份。 +- **删除是可恢复的**:在应用里删除任务 / 收件箱条目,文件会移到数据目录的 `.trash\` 文件夹(带删除时间戳),不会物理消失——误删后把它移回原目录即可;也可以从 git 历史找回。 ## 3. 版本保护(快照) diff --git a/index.html b/index.html index 1301f8d..914d414 100644 --- a/index.html +++ b/index.html @@ -10,28 +10,7 @@ content="科研人员的思路整理:空间、项目、任务与按日期追加的上下文日志。" /> - +
diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 0000000..b98fd20 --- /dev/null +++ b/public/theme-init.js @@ -0,0 +1,23 @@ +// public/theme-init.js — 首帧主题恢复(防闪烁)。 +// 外置为文件而非内联脚本:生产 CSP 为 script-src 'self',内联会被拦截。 +// 必须同步执行且先于任何渲染。 +(function () { + try { + var r = localStorage.getItem("rt-settings-v1"); + var s = r ? JSON.parse(r).state || {} : {}; + var t = s.theme || "light"; + if (t === "system") + t = matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + var e = document.documentElement; + e.dataset.theme = t; + e.dataset.canvas = s.canvas || "white"; + e.dataset.density = s.density || "compact"; + e.dataset.radius = s.radius || "md"; + e.dataset.sidebar = s.sidebarContrast || "soft"; + e.dataset.font = s.font || "sans"; + e.dataset.width = s.editorWidth || "medium"; + if (s.fontSize) e.style.setProperty("--text-ui", s.fontSize + "px"); + } catch (e) {} +})(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e85ce64..39571f5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost", + "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420" } }, "bundle": { diff --git a/src/lib/watcher.ts b/src/lib/watcher.ts index 4fbb7d7..cb975e1 100644 --- a/src/lib/watcher.ts +++ b/src/lib/watcher.ts @@ -39,7 +39,8 @@ export async function startWatcher(handlers: WatchHandlers): Promise<() => void> !rel.startsWith(".git/") && // git 内部簿记对 agent 无意义 !rel.endsWith(".rt-tmp") && // 原子写残片 !rel.endsWith(".rt-bak") && // 原子写备份 - !rel.startsWith(".activity/"), + !rel.startsWith(".activity/") && + !rel.startsWith(".trash/"), // 软删除暂存(恢复由人操作 spaces/ 时自然可见) ); if (external.length > 0) { void store.logExternalModify(external); diff --git a/src/storage/core.ts b/src/storage/core.ts index 05dc623..b9cf3a0 100644 --- a/src/storage/core.ts +++ b/src/storage/core.ts @@ -298,6 +298,7 @@ export const paths = { `spaces/${spaceId}/projects/${projectId}/tasks/${taskId}`, inboxDir: () => "inbox", inboxItem: (id: string) => `inbox/${id}`, + trashDir: () => ".trash", weeklyDir: () => "weekly", weeklyFile: (id: string) => `weekly/${id}`, dailyDir: () => "daily", diff --git a/src/storage/index.ts b/src/storage/index.ts index aa68a25..62a5341 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -446,8 +446,29 @@ export class StorageDataAccess implements AppDataAccess { async deleteTask(spaceId: string, projectId: string, taskId: string): Promise