From a29803727878d94e32ed83788d75225dd23e687a Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:05:49 +0800 Subject: [PATCH 01/34] =?UTF-8?q?docs(=E6=B8=85=E7=90=86):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=8F=90=E9=86=92=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录目标列表隔离、重要提醒保护、双扫描确认、删除前备份、恢复命令和隐私边界。 影响范围:设计规格,不改变当前运行行为。 --- .../2026-07-30-reminder-pruning-design.md | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-reminder-pruning-design.md diff --git a/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md b/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md new file mode 100644 index 0000000..3e3ddc9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md @@ -0,0 +1,257 @@ +# TaskForge 今日提醒自动清理设计 + +日期:2026-07-30 +状态:已完成对话设计,等待书面规格复核 + +## 目标 + +在不影响 TaskForge 双向完成同步的前提下,自动删除 Apple 提醒事项 +`TaskForge 今日` 列表中的外来提醒。清理必须接近实时、可恢复、默认保护 +历史记录,并且不能读取或删除其他提醒列表中的项目。 + +## 非目标 + +- 不删除或修改 TaskForge 源任务。 +- 不清理 Apple 提醒事项中的其他列表。 +- 不清理已完成提醒。 +- 不清理带重要标记的提醒。 +- 不把“任务暂时离开 TaskForge 当前缓存”直接解释为任务已删除。 +- 不把提醒标题、笔记、Vault 路径或原始 EventKit ID 写入公开日志、 + Git 历史或 GitHub。 + +## 固定安全边界 + +清理器只能获取配置列表 `TaskForge 今日` 中的提醒。列表过滤应在 +EventKit 查询入口完成,而不是先读取所有列表后再过滤。去重归档列表、 +清理备份以及任何其他 Apple 提醒列表都不在清理范围内。 + +只有未完成提醒可能成为候选。已完成提醒无论是否带 TaskForge 标记都保留, +以免破坏历史记录。 + +以下任一条件成立时,提醒属于重要提醒并受到保护: + +- EventKit 可见的 Apple 内建优先级大于零; +- 标题去除开头空白后,以 `!`、`!`、`❗`、`‼️`、`⭐` 或 `📌` + 之一开头。 + +## “TaskForge 中存在”的定义 + +清理器按以下顺序确认提醒是否属于 TaskForge: + +1. 提醒带有当前 Vault 的有效 TaskForge 标记,并且标记中的任务 ID + 存在于当前 TaskForge 快照中,则任务存在。 +2. 当前快照没有对应 ID 时,如果提醒包含有效的持久源引用,并且可以从 + Vault 源文件中确认同一任务仍然存在,则任务存在。 +3. 没有有效标记,或标记既不能匹配当前快照、也不能通过源引用确认时, + 才视为 TaskForge 中不存在。 + +源文件不可读、TaskForge 快照解码失败、权限不足、源位置存在歧义或检查 +过程中出现 I/O 错误时,必须失败关闭:保留提醒,不得把错误降级为“不存在”。 + +这一定义允许已经离开 TaskForge 缓存、但源任务仍存在的历史提醒继续参与 +Apple 完成状态反向回写。旧版本创建且缺少持久源引用的未完成提醒,只有在 +当前快照也无法匹配时才会进入清理候选。 + +## 候选判定 + +提醒必须同时满足以下条件: + +1. 位于配置的 `TaskForge 今日` 列表; +2. 未完成; +3. 不属于重要提醒; +4. 当前 TaskForge 快照没有对应任务; +5. 真实源文件也无法确认对应任务存在。 + +候选判定实现为 Foundation-only 的纯逻辑策略,EventKit 协调层只负责把 +提醒属性转换为策略输入。这样可以在不访问真实提醒库的情况下完整测试判定表。 + +## 双扫描确认状态机 + +第一次发现候选时不得删除,只把以下信息写入本机候选账本: + +- EventKit calendar item identifier; +- 目标列表 identifier; +- 首次发现时间; +- 与候选判定相关的属性指纹; +- 清理规则版本。 + +候选账本位于: + +`~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json` + +账本使用权限 `0600`,通过临时文件加原子替换写入,不进入 Git 仓库。 + +第二次或后续扫描只有同时满足以下条件才可进入删除阶段: + +- 距离首次发现至少 60 秒; +- EventKit ID 和目标列表 ID 仍一致; +- 重新获取的提醒仍满足全部候选条件; +- 与候选判定相关的属性指纹没有变化; +- TaskForge 快照和源文件检查均成功完成。 + +如果提醒被完成、增加优先级、增加重要标题符号、恢复 TaskForge 身份、 +移出目标列表、属性发生变化或不再存在,应立即撤销对应候选记录。属性变化后 +若提醒仍符合条件,应作为新的首次发现重新计时。 + +候选账本损坏或无法写入时,清理器记录匿名错误并跳过删除。watcher 重启后 +从账本继续确认;规则版本变化时旧候选必须重新经过首次发现。 + +## 同步顺序与触发 + +每轮协调按以下顺序执行: + +1. 扫描 Apple 已完成提醒并反向回写 TaskForge 源; +2. 把 TaskForge 今日任务正向创建或更新到 Apple; +3. 在最新状态上运行自动清理。 + +该顺序确保提醒不会在完成状态尚未回写前被清理,也确保正向同步能够先恢复 +正确的 TaskForge 身份。 + +清理沿用现有 watcher 触发源: + +- Apple `EKEventStoreChanged` 通知,750 毫秒防抖; +- TaskForge 任务库每秒 mtime 检查; +- 每分钟漏失兜底; +- 07:00、11:00、15:00 定时兜底。 + +60 秒是最短确认间隔,不承诺精确删除时刻。正常情况下,候选会在首次发现后 +约 60–120 秒内完成二次确认。 + +## 删除前备份 + +每批准备删除的提醒都必须先写入本机备份: + +`~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` + +备份文件包含: + +- 批次 ID、创建时间、规则版本和校验摘要; +- 原列表信息; +- 标题、笔记、URL、优先级; +- 截止日期、开始日期; +- EventKit 可读取并可重建的闹钟和重复规则。 + +原始 EventKit ID 和创建时间可用于审计,但 EventKit 不保证恢复时复用这些 +系统生成字段。恢复验收以用户可编辑字段为准。 + +备份以权限 `0600` 原子写入,并在 EventKit 删除前重新读取和校验。任何备份 +写入或校验失败都必须拒绝整批删除。 + +删除使用 EventKit 延迟提交组成批次。提交失败后必须重新读取实际状态, +按实际结果报告,不能假设整批成功或失败。无论结果如何,备份都保留用于恢复。 + +## 命令接口 + +新增以下命令: + +| 命令 | 行为 | +|---|---| +| `--prune-dry-run` | 只读分类并报告首次候选数和已满足二次确认数;不写账本、不删除 | +| `--prune-once` | 推进一次候选状态;登记首次候选或删除已等待至少 60 秒的候选 | +| `--restore-last-prune` | 将最近一个尚未恢复的删除批次恢复到 `TaskForge 今日` | + +恢复成功后,新 EventKit ID 会重新进入普通规则。为了给用户修正任务身份或增加 +重要标记的时间,恢复批次享有 24 小时的本机清理宽限期;宽限信息只保存在 +候选账本中。宽限结束后,如仍满足候选条件,必须重新完成两次扫描才可删除。 + +`--watch` 在每轮反向和正向同步后自动推进相同状态机。普通 `--sync` 也推进 +一次,以保持命令行为一致。 + +## 日志与隐私 + +日志只允许输出: + +- 首次候选、撤销、待确认、删除、恢复和失败的数量; +- 使用本机固定随机盐生成的截断 EventKit ID 哈希; +- 不包含用户内容的错误类别。 + +日志不得输出提醒标题、笔记、Vault 路径、源文件路径、原始任务 ID 或原始 +EventKit ID。现有反向同步日志会继续遵循项目当前行为,本功能不得扩大其内容 +暴露范围。 + +候选账本、备份、运行日志和本机盐都属于运行时私有数据。仓库的忽略规则、 +隐私文档和安全文档必须明确说明这些数据不会进入公共 GitHub 仓库。 + +## 错误处理 + +- TaskForge 快照不可用:整轮清理跳过,反向和正向流程按现有错误路径处理。 +- 源文件检查不可判定:相关提醒保留,候选记录撤销或暂停,不删除。 +- 目标列表不存在:清理返回零,不为清理单独创建列表。 +- 候选账本不可读写:保留所有提醒并报告匿名错误。 +- 备份不可写或校验失败:整批不删除。 +- EventKit 删除提交失败:重新读取实际状态并保留备份。 +- 恢复时目标列表不存在:沿用正向同步的安全建表逻辑;无法建表则不消费备份。 +- 并发协调:沿用现有单轮互斥和追加一次协调机制,不并行删除。 + +## 测试策略 + +### 纯逻辑测试 + +覆盖以下判定和状态转换: + +- 其他列表永远不进入候选; +- 已完成提醒受保护; +- EventKit 优先级和六种标题前缀分别受保护; +- 当前 TaskForge ID 匹配时受保护; +- 当前缓存缺席但真实源任务存在时受保护; +- 快照、权限、I/O 或源位置检查失败时受保护; +- 无标记手工提醒成为候选; +- 缺失源引用且当前快照无匹配的旧提醒成为候选; +- 首次发现不删除; +- 不足 60 秒不删除; +- 至少 60 秒且属性未变才允许删除; +- 状态变化撤销候选; +- 属性变化后重新计时; +- 规则版本变化后重新计时; +- 恢复后 24 小时宽限及宽限结束后的重新确认。 + +### 隔离 EventKit 测试 + +使用独立临时提醒列表和临时 TaskForge 数据,验证: + +- 普通外来提醒只在二次确认后删除; +- 重要提醒、已完成提醒、当前 TaskForge 提醒和有效历史 TaskForge 提醒保留; +- 另一个临时列表中的外来提醒保持不变; +- 删除前备份存在、权限正确且摘要有效; +- `--restore-last-prune` 能恢复用户可编辑字段; +- watcher 收到 EventKit 变化后不会形成删除循环; +- 测试结束后清理临时列表和临时文件。 + +隔离测试不得使用或修改生产 `TaskForge 今日` 中的真实提醒。 + +### 生产验收 + +1. 运行完整纯逻辑测试和现有回归测试。 +2. 完成隔离 EventKit 端到端测试。 +3. 对生产 `TaskForge 今日` 运行 `--prune-dry-run`,只报告匿名数量。 +4. 构建、签名并安装 App,重启 LaunchAgent。 +5. 检查第一轮只登记候选,没有立即删除。 +6. 等待第二轮,核对删除数量、备份权限和匿名日志。 +7. 验证 `--audit` 仍然无重复。 +8. 验证 watcher、每分钟兜底及 07:00、11:00、15:00 配置仍然存在。 + +## 公共仓库交付 + +实现提交必须同步更新: + +- `README.md`:功能、命令、判定表、恢复步骤和示例; +- `docs/ARCHITECTURE.md`:清理状态机、同步顺序和数据边界; +- `docs/TROUBLESHOOTING.md`:误删恢复、候选不删除和权限故障; +- `PRIVACY.md`:本机候选账本、备份和匿名日志; +- `SECURITY.md`:失败关闭和跨列表隔离; +- `CHANGELOG.md`:新增功能和兼容性说明。 + +推送前检查 Git 暂存内容和历史,确保没有真实提醒标题、笔记、Vault 路径、 +用户名、本机绝对路径、运行日志、候选账本或备份文件。 + +## 验收标准 + +- 生产清理只作用于配置的 `TaskForge 今日` 列表。 +- 只有未完成、不重要、且不能从缓存或真实源确认的提醒会被删除。 +- 删除至少经过间隔 60 秒的两次独立确认。 +- 所有删除在提交前都有权限 `0600` 的可校验本机备份。 +- 最近删除批次能够恢复用户可编辑字段。 +- 历史 TaskForge 完成回写继续工作,TaskForge 源任务只会变为 `done`, + 不会因本功能被删除。 +- 其他 Apple 提醒列表和已完成历史保持不变。 +- 公共仓库不含私人提醒内容或本机运行数据。 From 7e6319dc27277a9ee8f1cdeaf2b220fc526c8577 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:15:37 +0800 Subject: [PATCH 02/34] =?UTF-8?q?docs(=E8=AE=A1=E5=88=92):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=8F=90=E9=86=92=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 TDD 拆分候选策略、源存在性、双扫描状态机、本机备份、EventKit 隔离测试、生产部署和隐私验收。 同时将已批准的设计规格状态更新为复核通过。 --- .../plans/2026-07-30-reminder-pruning.md | 1604 +++++++++++++++++ .../2026-07-30-reminder-pruning-design.md | 2 +- 2 files changed, 1605 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-30-reminder-pruning.md diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md new file mode 100644 index 0000000..e2c8b58 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -0,0 +1,1604 @@ +# TaskForge 今日提醒自动清理实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 +> superpowers:subagent-driven-development(推荐)或 +> superpowers:executing-plans 逐任务实现此计划。步骤使用复选框 +>(`- [ ]`)语法来跟踪进度。 + +**目标:** 在 `TaskForge 今日` 列表内自动删除连续两次确认均不属于 +TaskForge、未完成且不重要的提醒,同时提供本机备份和恢复能力。 + +**架构:** 把候选判定、源任务存在性和双扫描状态机放入可独立测试的 +`TaskForgeReminderCore`;把 EventKit 查询、备份适配和删除协调放入新的 +`TaskForgeReminderEventKit` 库。命令行程序在现有“反向 → 正向”之后调用 +清理器,所有不可判定错误都失败关闭。 + +**技术栈:** Swift 5.9、Foundation、CryptoKit、EventKit、CoreLocation、 +Swift Package Manager、LaunchAgent、macOS 13+ + +--- + +## 文件结构 + +### 新建 + +- `Sources/TaskForgeReminderCore/ReminderPruning.swift` + - 重要标记判断、TaskForge 存在性枚举、候选策略、双扫描状态机。 +- `Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift` + - 候选账本、备份 DTO、SHA-256 包装、权限 `0600` 的原子文件存储。 +- `Sources/TaskForgeReminderEventKit/ReminderPruner.swift` + - 只查询目标列表、将 EventKit 项目转成候选输入、执行二次确认删除。 +- `Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift` + - EventKit 提醒与可恢复备份 DTO 之间的双向转换。 +- `Tests/TaskForgeReminderEventKitTests/main.swift` + - 使用随机命名临时列表的隔离 EventKit 端到端测试。 + +### 修改 + +- `Package.swift` + - 增加 EventKit 库和隔离测试可执行目标。 +- `Sources/TaskForgeReminderCore/Core.swift` + - 增加源任务存在性检查,复用现有 TaskForge 源元数据。 +- `Tests/TaskForgeReminderCoreTests/main.swift` + - 增加候选、安全边界、状态机、源检查和持久化测试。 +- `Sources/TaskForgeReminderSync/SyncEngine.swift` + - 配置并调用清理器;保持“反向 → 正向 → 清理”顺序。 +- `Sources/TaskForgeReminderSync/Command.swift` + - 增加 `--prune-dry-run`、`--prune-once`、`--restore-last-prune`。 +- `.gitignore` + - 明确忽略清理候选、备份、盐值和测试运行数据的同名导出。 +- `README.md` + - 增加功能、判定表、命令、恢复方法和运行时目录。 +- `docs/ARCHITECTURE.md` + - 增加清理状态机、EventKit 隔离和同步顺序。 +- `docs/TROUBLESHOOTING.md` + - 增加候选未删除、恢复和权限故障排查。 +- `PRIVACY.md` + - 记录本机候选账本、备份字段、匿名日志和不上传承诺。 +- `SECURITY.md` + - 记录失败关闭、目标列表边界和删除前备份。 +- `CHANGELOG.md` + - 记录自动清理功能。 + +## 任务 1:建立重要标记与候选判定策略 + +**文件:** + +- 创建:`Sources/TaskForgeReminderCore/ReminderPruning.swift` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的候选判定测试** + +在测试数组结尾、`completed source inspector...` 测试之后加入表驱动测试: + +```swift +("prune policy protects non-target, completed and important reminders", { + let target = "calendar-target" + let protected = [ + ReminderPruneObservation( + itemIdentifier: "other-list", + calendarIdentifier: "calendar-other", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "a", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "completed", + calendarIdentifier: target, + isCompleted: true, + priority: 0, + title: "普通提醒", + fingerprint: "b", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "priority", + calendarIdentifier: target, + isCompleted: false, + priority: 1, + title: "普通提醒", + fingerprint: "c", + taskPresence: .absent + ) + ] + for observation in protected { + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: target + ), + "\(observation.itemIdentifier) must be protected" + ) + } +}), +("prune policy recognizes every approved title prefix", { + for (index, prefix) in ["!", "!", "❗", "‼️", "⭐", "📌"].enumerated() { + let observation = ReminderPruneObservation( + itemIdentifier: "important-\(index)", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: " \(prefix) 保留", + fingerprint: "\(index)", + taskPresence: .absent + ) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: "calendar-target" + ), + "\(prefix) must protect the reminder" + ) + } +}), +("prune policy only selects an unimportant absent TaskForge task", { + let base = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + try require( + ReminderPruneCandidatePolicy.isCandidate( + base, + targetCalendarIdentifier: "calendar-target" + ), + "external reminder should become a candidate" + ) + for presence in [ + TaskForgeReminderPresence.currentSnapshot, + .sourceConfirmed, + .indeterminate + ] { + let protected = base.withTaskPresence(presence) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + protected, + targetCalendarIdentifier: "calendar-target" + ), + "\(presence) must fail closed" + ) + } +}) +``` + +- [ ] **步骤 2:运行测试并确认因类型未定义而失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'ReminderPruneObservation' in scope`。 + +- [ ] **步骤 3:实现最小候选策略** + +创建 `Sources/TaskForgeReminderCore/ReminderPruning.swift`: + +```swift +import Foundation + +public enum TaskForgeReminderPresence: String, Codable, Equatable, Sendable { + case currentSnapshot + case sourceConfirmed + case absent + case indeterminate +} + +public struct ReminderPruneObservation: Equatable, Sendable { + public let itemIdentifier: String + public let calendarIdentifier: String + public let isCompleted: Bool + public let priority: Int + public let title: String + public let fingerprint: String + public let taskPresence: TaskForgeReminderPresence + + public init( + itemIdentifier: String, + calendarIdentifier: String, + isCompleted: Bool, + priority: Int, + title: String, + fingerprint: String, + taskPresence: TaskForgeReminderPresence + ) { + self.itemIdentifier = itemIdentifier + self.calendarIdentifier = calendarIdentifier + self.isCompleted = isCompleted + self.priority = priority + self.title = title + self.fingerprint = fingerprint + self.taskPresence = taskPresence + } + + public func withTaskPresence( + _ value: TaskForgeReminderPresence + ) -> ReminderPruneObservation { + ReminderPruneObservation( + itemIdentifier: itemIdentifier, + calendarIdentifier: calendarIdentifier, + isCompleted: isCompleted, + priority: priority, + title: title, + fingerprint: fingerprint, + taskPresence: value + ) + } +} + +public enum ReminderPruneCandidatePolicy { + public static let importantPrefixes = [ + "!", "!", "❗", "‼️", "⭐", "📌" + ] + + public static func isCandidate( + _ observation: ReminderPruneObservation, + targetCalendarIdentifier: String + ) -> Bool { + guard observation.calendarIdentifier == targetCalendarIdentifier else { + return false + } + guard !observation.isCompleted, observation.priority == 0 else { + return false + } + let title = observation.title.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !importantPrefixes.contains(where: title.hasPrefix) else { + return false + } + return observation.taskPresence == .absent + } +} +``` + +- [ ] **步骤 4:运行测试确认通过** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:现有 24 项和新增 3 项全部通过,末行是 +`27/27 tests passed`。 + +- [ ] **步骤 5:提交候选策略** + +```bash +git add Sources/TaskForgeReminderCore/ReminderPruning.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 添加提醒候选安全策略" +``` + +## 任务 2:实现真实源任务存在性检查 + +**文件:** + +- 修改:`Sources/TaskForgeReminderCore/Core.swift:719` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的源存在性测试** + +```swift +("source presence confirms exact and uniquely moved inline tasks", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 2 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\n- [ ] 保留任务\n" + ) == .present, + "exact source line should be present" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\nheading\n" + ) == .present, + "uniquely moved source line should be present" + ) +}), +("source presence distinguishes absent from ambiguous", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 3 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\nother\n" + ) == .absent, + "missing source line should be absent" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n- [ ] 保留任务\n" + ) == .indeterminate, + "ambiguous source lines must fail closed" + ) +}), +("source presence protects an existing TaskNotes file", { + let task = TaskForgeTask( + identifier: "note", + title: "任务笔记", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/TaskNotes/任务.md", + sourceType: "taskNotes", + originalLine: "tasknotes:{}", + lineNumber: 1 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "---\nstatus: open\n---\n" + ) == .present, + "readable TaskNotes source should be present" + ) +}) +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'TaskSourcePresenceInspector' in scope`。 + +- [ ] **步骤 3:在完成检查器之前加入源存在性检查器** + +在 `Core.swift` 的 `TaskCompletionSourceInspector` 前增加: + +```swift +public enum TaskSourcePresence: String, Codable, Equatable, Sendable { + case present + case absent + case indeterminate +} + +public enum TaskSourcePresenceInspector { + public static func inspect( + task: TaskForgeTask, + contents: String + ) -> TaskSourcePresence { + switch task.sourceType?.lowercased() { + case "markdowninline": + guard let originalLine = task.originalLine else { + return .indeterminate + } + let lines = contents.components(separatedBy: "\n") + if + let lineNumber = task.lineNumber, + lines.indices.contains(lineNumber - 1), + lines[lineNumber - 1] == originalLine + { + return .present + } + let matches = lines.filter { $0 == originalLine }.count + if matches == 1 { + return .present + } + return matches == 0 ? .absent : .indeterminate + case "tasknotes": + return .present + default: + return .indeterminate + } + } +} +``` + +文件不存在、Vault 越界、读取失败和编码失败不传入此函数,由 EventKit +协调层分别映射为 `.absent` 或 `.indeterminate`。 + +- [ ] **步骤 4:运行测试确认通过** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:`30/30 tests passed`。 + +- [ ] **步骤 5:提交源检查** + +```bash +git add Sources/TaskForgeReminderCore/Core.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 验证历史任务真实源" +``` + +## 任务 3:实现双扫描确认状态机 + +**文件:** + +- 修改:`Sources/TaskForgeReminderCore/ReminderPruning.swift` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的首次发现和二次确认测试** + +```swift +("prune state requires two unchanged scans at least sixty seconds apart", { + let firstDate = Date(timeIntervalSince1970: 1_000) + let observation = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [observation], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: firstDate + ) + try require(first.readyIdentifiers.isEmpty, "first scan must not delete") + try require(first.firstSeenIdentifiers == ["external"], "candidate not recorded") + + let early = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(59) + ) + try require(early.readyIdentifiers.isEmpty, "59 seconds is too early") + + let ready = ReminderPruneStateMachine.plan( + observations: [observation], + prior: early.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(60) + ) + try require(ready.readyIdentifiers == ["external"], "candidate should be ready") +}), +("prune state revokes or restarts changed candidates", { + let now = Date(timeIntervalSince1970: 2_000) + let original = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "v1", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [original], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: now + ) + let changed = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "改过的提醒", + fingerprint: "v2", + taskPresence: .absent + ) + let restarted = ReminderPruneStateMachine.plan( + observations: [changed], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(120) + ) + try require(restarted.readyIdentifiers.isEmpty, "changed item must restart") + try require( + restarted.nextLedger.entries["external"]?.firstSeen + == now.addingTimeInterval(120), + "changed item should receive a new firstSeen" + ) +}), +("prune state restarts after restore grace and rule changes", { + let now = Date(timeIntervalSince1970: 3_000) + let observation = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "恢复提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let prior = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: now.addingTimeInterval(60) + ) + ]) + let duringGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require(duringGrace.readyIdentifiers.isEmpty, "grace must protect") + + let afterGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: duringGrace.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(61) + ) + try require( + afterGrace.firstSeenIdentifiers == ["restored"], + "expired grace must start a fresh first scan" + ) + try require(afterGrace.readyIdentifiers.isEmpty, "grace expiry must not delete") + + let oldRules = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion - 1, + graceUntil: nil + ) + ]) + let versionReset = ReminderPruneStateMachine.plan( + observations: [observation], + prior: oldRules, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + versionReset.firstSeenIdentifiers == ["restored"], + "rules change must restart confirmation" + ) + + let completed = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: true, + priority: 0, + title: "恢复提醒", + fingerprint: "completed", + taskPresence: .absent + ) + let revoked = ReminderPruneStateMachine.plan( + observations: [completed], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + revoked.revokedIdentifiers == ["restored"], + "completed reminder must revoke its candidate" + ) + try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") +}) +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'ReminderPruneStateMachine' in scope`。 + +- [ ] **步骤 3:实现 Codable 账本和状态机** + +在 `ReminderPruning.swift` 增加: + +```swift +public struct ReminderPruneLedgerEntry: Codable, Equatable, Sendable { + public var firstSeen: Date + public var fingerprint: String + public var calendarIdentifier: String + public var rulesVersion: Int + public var graceUntil: Date? + + public init( + firstSeen: Date, + fingerprint: String, + calendarIdentifier: String, + rulesVersion: Int, + graceUntil: Date? + ) { + self.firstSeen = firstSeen + self.fingerprint = fingerprint + self.calendarIdentifier = calendarIdentifier + self.rulesVersion = rulesVersion + self.graceUntil = graceUntil + } +} + +public struct ReminderPruneLedger: Codable, Equatable, Sendable { + public var entries: [String: ReminderPruneLedgerEntry] + + public init(entries: [String: ReminderPruneLedgerEntry] = [:]) { + self.entries = entries + } +} + +public struct ReminderPrunePlan: Equatable, Sendable { + public let firstSeenIdentifiers: [String] + public let waitingIdentifiers: [String] + public let readyIdentifiers: [String] + public let revokedIdentifiers: [String] + public let nextLedger: ReminderPruneLedger +} + +public enum ReminderPruneStateMachine { + public static let rulesVersion = 1 + + public static func plan( + observations: [ReminderPruneObservation], + prior: ReminderPruneLedger, + targetCalendarIdentifier: String, + now: Date, + confirmationInterval: TimeInterval = 60 + ) -> ReminderPrunePlan { + var next = ReminderPruneLedger() + var firstSeen: [String] = [] + var waiting: [String] = [] + var ready: [String] = [] + let candidates = observations.filter { + ReminderPruneCandidatePolicy.isCandidate( + $0, + targetCalendarIdentifier: targetCalendarIdentifier + ) + } + + for observation in candidates { + let old = prior.entries[observation.itemIdentifier] + let isSame = old?.fingerprint == observation.fingerprint + && old?.calendarIdentifier == observation.calendarIdentifier + && old?.rulesVersion == rulesVersion + if !isSame { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } else if let old, let graceUntil = old.graceUntil { + if now < graceUntil { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } else { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } + } else if let old, now.timeIntervalSince(old.firstSeen) >= confirmationInterval { + next.entries[observation.itemIdentifier] = old + ready.append(observation.itemIdentifier) + } else if let old { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } + } + + let active = Set(candidates.map(\.itemIdentifier)) + let revoked = prior.entries.keys.filter { !active.contains($0) }.sorted() + return ReminderPrunePlan( + firstSeenIdentifiers: firstSeen.sorted(), + waitingIdentifiers: waiting.sorted(), + readyIdentifiers: ready.sorted(), + revokedIdentifiers: revoked, + nextLedger: next + ) + } +} +``` + +上述第三个测试同时固定 `rulesVersion` 变化、`graceUntil` 到期后的重新 +计时,以及提醒完成后的候选撤销。 + +- [ ] **步骤 4:运行测试确认通过** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:至少 `33/33 tests passed`。 + +- [ ] **步骤 5:提交状态机** + +```bash +git add Sources/TaskForgeReminderCore/ReminderPruning.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 添加双扫描确认状态机" +``` + +## 任务 4:实现本机候选账本和可校验备份 + +**文件:** + +- 创建:`Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的权限、原子读写和损坏拒绝测试** + +使用 `FileManager.default.temporaryDirectory` 下的 UUID 目录,并在 `defer` +中删除。先在测试辅助函数区增加: + +```swift +func pruneBackupFixture() -> ReminderPruneBackupBatch { + ReminderPruneBackupBatch( + identifier: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + createdAt: Date(timeIntervalSince1970: 20), + targetCalendarIdentifier: "calendar", + targetCalendarTitle: "TaskForge 今日", + items: [ + ReminderPruneBackupItem( + originalItemIdentifier: "item", + title: "普通提醒", + notes: "本地测试", + url: URL(string: "taskforge-test://item"), + priority: 0, + dueDateComponents: DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 7, + day: 30 + ), + startDateComponents: nil, + alarms: [], + recurrenceRules: [] + ) + ], + restoredAt: nil + ) +} +``` + +再加入测试: + +```swift +("prune local store writes private ledger and verified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let ledger = ReminderPruneLedger(entries: [ + "item": ReminderPruneLedgerEntry( + firstSeen: Date(timeIntervalSince1970: 10), + fingerprint: "fingerprint", + calendarIdentifier: "calendar", + rulesVersion: 1, + graceUntil: nil + ) + ]) + try store.saveLedger(ledger) + try require(try store.loadLedger() == ledger, "ledger round-trip failed") + + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.ledgerURL.path + )[.posixPermissions] as? NSNumber, + "permissions missing" + ) + try require(permissions.intValue & 0o777 == 0o600, "ledger must be 0600") + + let batch = pruneBackupFixture() + let url = try store.saveBackup(batch) + try require(try store.loadBackup(at: url) == batch, "backup verification failed") +}), +("prune local store rejects a modified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + try Data("tampered".utf8).write(to: url, options: .atomic) + do { + _ = try store.loadBackup(at: url) + throw TestFailure(description: "tampered backup was accepted") + } catch let error as ReminderPruneStoreError { + try require(error == .checksumMismatch, "unexpected store error") + } +}) +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'ReminderPruneLocalStore' in scope`。 + +- [ ] **步骤 3:定义备份 DTO 和本地存储** + +创建 `ReminderPrunePersistence.swift`,包含以下公开接口: + +```swift +import CryptoKit +import Foundation + +public struct ReminderLocationBackup: Codable, Equatable, Sendable { + public var title: String + public var latitude: Double? + public var longitude: Double? + public var radius: Double +} + +public struct ReminderWeekdayBackup: Codable, Equatable, Sendable { + public var dayOfTheWeekRawValue: Int + public var weekNumber: Int +} + +public struct ReminderAlarmBackup: Codable, Equatable, Sendable { + public var absoluteDate: Date? + public var relativeOffset: TimeInterval? + public var structuredLocation: ReminderLocationBackup? + public var proximityRawValue: Int? +} + +public struct ReminderRecurrenceBackup: Codable, Equatable, Sendable { + public var frequencyRawValue: Int + public var interval: Int + public var daysOfWeek: [ReminderWeekdayBackup] + public var daysOfMonth: [Int] + public var monthsOfYear: [Int] + public var weeksOfYear: [Int] + public var daysOfYear: [Int] + public var setPositions: [Int] + public var endDate: Date? + public var occurrenceCount: Int? +} + +public struct ReminderPruneBackupItem: Codable, Equatable, Sendable { + public var originalItemIdentifier: String + public var title: String + public var notes: String? + public var url: URL? + public var priority: Int + public var dueDateComponents: DateComponents? + public var startDateComponents: DateComponents? + public var alarms: [ReminderAlarmBackup] + public var recurrenceRules: [ReminderRecurrenceBackup] +} + +public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { + public var identifier: UUID + public var createdAt: Date + public var targetCalendarIdentifier: String + public var targetCalendarTitle: String + public var items: [ReminderPruneBackupItem] + public var restoredAt: Date? +} + +public enum ReminderPruneStoreError: Error, Equatable { + case invalidLedger + case invalidBackup + case checksumMismatch + case permissions + case noUnrestoredBackup +} + +public final class ReminderPruneLocalStore: @unchecked Sendable { + public static let defaultRoot = URL( + fileURLWithPath: + "\(NSHomeDirectory())/Library/Application Support/" + + "TaskForgeReminderSync", + isDirectory: true + ) + public let rootURL: URL + public var ledgerURL: URL { + rootURL.appendingPathComponent("PruneCandidates.json") + } + + public init(rootURL: URL = defaultRoot) { + self.rootURL = rootURL + } + + public func loadLedger() throws -> ReminderPruneLedger + public func saveLedger(_ ledger: ReminderPruneLedger) throws + public func saveBackup(_ batch: ReminderPruneBackupBatch) throws -> URL + public func loadBackup(at url: URL) throws -> ReminderPruneBackupBatch + public func latestUnrestoredBackup() throws -> (URL, ReminderPruneBackupBatch)? + public func markRestored(at url: URL, date: Date) throws + public func loadOrCreateHashSalt() throws -> Data +} +``` + +所有 DTO 都实现显式 `public init(...)`,参数顺序与上方属性顺序一致;不能依赖 +Swift 默认的 internal memberwise initializer,否则测试目标无法构造 fixture。 + +实现细节: + +1. 目录权限设为 `0700`,文件权限设为 `0600`。 +2. 编码器使用 `.sortedKeys`。 +3. 备份写成 `{checksum, payload}` 包装;checksum 是 payload JSON 的 + SHA-256。 +4. 所有写入先写同目录 UUID 临时文件、`synchronize()`、设置权限,再 + `replaceItemAt` 或 `moveItem`。 +5. JSON 解码、权限或校验错误不得返回空账本,必须抛出 + `ReminderPruneStoreError`,由上层失败关闭。 + +- [ ] **步骤 4:运行测试确认通过并检查无残留临时目录** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:新增持久化测试通过;测试内的 `defer` 删除 UUID 临时目录。 + +- [ ] **步骤 5:提交本机存储** + +```bash +git add Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 添加私有候选账本和备份" +``` + +## 任务 5:建立 EventKit 清理库和备份适配 + +**文件:** + +- 修改:`Package.swift` +- 创建:`Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift` +- 创建:`Sources/TaskForgeReminderEventKit/ReminderPruner.swift` + +- [ ] **步骤 1:先增加 EventKit 目标并确认最小骨架可编译** + +把 `Package.swift` 的 executable dependencies 改为: + +```swift +.target( + name: "TaskForgeReminderEventKit", + dependencies: ["TaskForgeReminderCore"] +), +.executableTarget( + name: "TaskForgeReminderSync", + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ] +), +.executableTarget( + name: "TaskForgeReminderCoreTests", + dependencies: ["TaskForgeReminderCore"], + path: "Tests/TaskForgeReminderCoreTests" +), +.executableTarget( + name: "TaskForgeReminderEventKitTests", + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ], + path: "Tests/TaskForgeReminderEventKitTests" +) +``` + +先创建两个只含 import 的源文件和一个输出 `SKIP` 的测试入口,然后运行: + +```bash +swift build +``` + +预期:`Build complete!`,证明新目标边界可编译。 + +- [ ] **步骤 2:实现 EventKit 备份双向适配器** + +`ReminderBackupAdapter.swift` 的固定接口: + +```swift +import CoreLocation +import EventKit +import Foundation +import TaskForgeReminderCore + +enum ReminderBackupAdapter { + static func capture(_ reminder: EKReminder) -> ReminderPruneBackupItem + static func restore( + _ backup: ReminderPruneBackupItem, + into reminder: EKReminder + ) +} +``` + +逐字段映射: + +- `title`、`notes`、`url`、`priority`; +- `dueDateComponents`、`startDateComponents`; +- `alarms` 的 absolute/relative/location/proximity; +- `recurrenceRules` 的 frequency、interval、weekday、月份/周/日位置和 end。 + +恢复时先清除新提醒的默认 alarms/recurrence,再按 DTO 重建。 +EventKit 不允许写回的创建时间和原 ID只作审计,不伪装成已恢复字段。 + +- [ ] **步骤 3:实现只查询目标列表的清理器** + +`ReminderPruner.swift` 的公共接口: + +```swift +import CryptoKit +import EventKit +import Foundation +import TaskForgeReminderCore + +public struct ReminderPruneConfiguration: Sendable { + public var listName: String + public var localRoot: URL + public var confirmationInterval: TimeInterval + public var restoreGraceInterval: TimeInterval + + public init( + listName: String, + localRoot: URL, + confirmationInterval: TimeInterval = 60, + restoreGraceInterval: TimeInterval = 86_400 + ) { + self.listName = listName + self.localRoot = localRoot + self.confirmationInterval = confirmationInterval + self.restoreGraceInterval = restoreGraceInterval + } +} + +public struct ReminderPruneCounts: Equatable, Sendable { + public var scanned = 0 + public var firstSeen = 0 + public var waiting = 0 + public var ready = 0 + public var deleted = 0 + public var restored = 0 + public var protected = 0 + public var failed = 0 +} + +@MainActor +public final class ReminderPruner { + public init( + eventStore: EKEventStore, + configuration: ReminderPruneConfiguration, + log: @escaping @Sendable (String) -> Void, + logError: @escaping @Sendable (String) -> Void + ) + + public func dryRun( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts + + public func advance( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts + + public func restoreLast( + now: Date = Date() + ) async throws -> ReminderPruneCounts +} +``` + +核心约束按此顺序编码: + +1. 用 `store.calendars(for: .reminder).first { $0.title == listName }` + 找到唯一目标列表;找不到时返回零。 +2. 只用 `predicateForReminders(in: [targetCalendar])` 查询,不获取其他列表。 +3. marker 匹配当前快照时返回 `.currentSnapshot`。 +4. marker 不匹配缓存时,解码 `TaskSourceReference`: + - 源路径不在 Vault 内:`.indeterminate`; + - 文件不存在:`.absent`; + - 读取或 UTF-8 失败:`.indeterminate`; + - `TaskSourcePresenceInspector` 的结果映射到 presence。 +5. 指纹只包含目标列表 ID、item ID、完成、优先级、标题、notes 和 presence, + 先做 SHA-256,原内容不写账本。 +6. `dryRun` 只读取账本并计算,不保存账本、不备份、不删除。 +7. `advance` 先保存 nextLedger;ready 项逐条重新获取和分类,仍 ready 才加入 + 备份批次。 +8. 备份写入并回读校验后才调用 `remove(reminder, commit: false)`; + 最后 `commit()`。 +9. commit 后重新读取目标列表,只把实际已不存在的 item ID 从 ledger 移除并 + 再次原子保存;仍存在的 ready entry 保留以便下轮重试。 +10. commit 抛错时同样重新读取目标列表,按实际存在情况统计,不能假定批次 + 全成或全败;备份始终保留。 +11. 日志只用本机盐对 EventKit ID 做 SHA-256 后截取前 12 位。 + +- [ ] **步骤 4:实现恢复与 24 小时宽限** + +`restoreLast`: + +1. 读取 `latestUnrestoredBackup()`; +2. 查找目标列表;不存在时用同一 EventKit source 创建; +3. 为每个备份项目创建 `EKReminder` 并调用 adapter; +4. commit 成功后重新读取恢复项,按与 advance 相同的函数计算 fingerprint, + 再把新 item ID 写入 ledger,`firstSeen = now`、 + `graceUntil = now + 86_400`; +5. 成功写入 ledger 后再 `markRestored`; +6. 任一步失败都保留未消费备份。 + +- [ ] **步骤 5:构建并提交 EventKit 边界** + +运行: + +```bash +swift build +swift run TaskForgeReminderCoreTests +``` + +预期:构建成功,Core 测试全部通过。 + +提交: + +```bash +git add Package.swift Sources/TaskForgeReminderEventKit \ + Tests/TaskForgeReminderEventKitTests/main.swift +git commit -m "feat(EventKit): 实现可恢复提醒清理器" +``` + +## 任务 6:接入命令行和同步循环 + +**文件:** + +- 修改:`Sources/TaskForgeReminderSync/Command.swift:5-329` +- 修改:`Sources/TaskForgeReminderSync/SyncEngine.swift:7-645` + +- [ ] **步骤 1:添加命令解析分支并先确认编译失败** + +在 `RunMode` 增加: + +```swift +case pruneDryRun +case pruneOnce +case restoreLastPrune +``` + +在 parser 中映射: + +```swift +case "--prune-dry-run": + options.mode = .pruneDryRun +case "--prune-once": + options.mode = .pruneOnce +case "--restore-last-prune": + options.mode = .restoreLastPrune +``` + +把三种模式加入需要 EventKit 权限的 switch,然后在 `run` 中暂时调用尚未定义的 +engine 方法。运行: + +```bash +swift build +``` + +预期:编译失败,包含 +`value of type 'SyncEngine' has no member 'prune'`。 + +- [ ] **步骤 2:让 SyncEngine 持有同一个 EventKit store 和清理器** + +修改 imports 和属性: + +```swift +import TaskForgeReminderEventKit + +private let store: EKEventStore +private let pruner: ReminderPruner + +init(configuration: SyncConfiguration, calendar: Calendar) { + let store = EKEventStore() + self.configuration = configuration + self.calendar = calendar + self.store = store + self.pruner = ReminderPruner( + eventStore: store, + configuration: ReminderPruneConfiguration( + listName: configuration.listName, + localRoot: ReminderPruneLocalStore.defaultRoot, + confirmationInterval: 60, + restoreGraceInterval: 86_400 + ), + log: log, + logError: logError + ) +} +``` + +新增包装方法: + +```swift +func prune(dryRun: Bool) async throws -> ReminderPruneCounts { + let snapshot = try loadSnapshotWithRetry() + return dryRun + ? try await pruner.dryRun(snapshot: snapshot) + : try await pruner.advance(snapshot: snapshot) +} + +func restoreLastPrune() async throws -> ReminderPruneCounts { + try await pruner.restoreLast() +} +``` + +- [ ] **步骤 3:固定协调顺序为反向、正向、清理** + +在 `reconcile(reason:)` 中: + +```swift +let reverseCounts = try await reverse( + dryRun: false, + taskIdentifier: nil, + requireCandidate: false +) +let forwardCounts = try await forward() +let pruneCounts = try await prune(dryRun: false) +log( + "清理同步:首次 \(pruneCounts.firstSeen)," + + "等待 \(pruneCounts.waiting),删除 \(pruneCounts.deleted)," + + "失败 \(pruneCounts.failed)" +) +``` + +普通 `--sync` 同样在 forward 后调用 `prune(dryRun: false)`。 +`--prune-dry-run` 只调用 dry-run;`--prune-once` 只推进一轮; +`--restore-last-prune` 只恢复最近批次。 + +- [ ] **步骤 4:补全帮助和匿名输出** + +帮助文本加入三条命令;输出仅包含计数: + +```swift +print( + "清理预演:扫描 \(counts.scanned),首次候选 \(counts.firstSeen)," + + "已满足二次确认 \(counts.ready)。" +) +print("预演模式:没有写候选账本,没有删除提醒。") +``` + +禁止在清理命令输出 `title`、`notes`、Vault 路径或原始 ID。 + +- [ ] **步骤 5:构建并运行回归测试** + +```bash +swift build +swift run TaskForgeReminderCoreTests +.build/debug/TaskForgeReminderSync --help +``` + +预期: + +- 构建和测试通过; +- help 中出现三个新命令; +- 默认 `--dry-run` 行为不变。 + +- [ ] **步骤 6:提交命令接入** + +```bash +git add Sources/TaskForgeReminderSync/Command.swift \ + Sources/TaskForgeReminderSync/SyncEngine.swift +git commit -m "feat(同步): 接入自动清理和恢复命令" +``` + +## 任务 7:完成隔离 EventKit 端到端测试 + +**文件:** + +- 修改:`Tests/TaskForgeReminderEventKitTests/main.swift` + +- [ ] **步骤 1:编写带显式开关的隔离测试入口** + +未设置环境变量时只输出: + +```swift +import Darwin +import EventKit +import Foundation +import TaskForgeReminderCore +import TaskForgeReminderEventKit + +struct IntegrationFailure: Error, CustomStringConvertible { + let description: String +} + +func require( + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + guard condition() else { + throw IntegrationFailure(description: message) + } +} + +guard ProcessInfo.processInfo.environment[ + "TASKFORGE_RUN_EVENTKIT_TESTS" +] == "1" else { + print("SKIP EventKit integration tests require explicit opt-in") + exit(0) +} +``` + +启用后: + +1. 请求 reminders full access; +2. 创建 `TaskForgeReminderSync Test ` 目标列表和另一个列表; +3. 用 `defer` 删除两个临时列表; +4. 创建普通外来、priority=1、六个标题前缀、已完成、当前 TaskForge、 + 有效源引用、其他列表外来提醒; +5. 在临时 Vault 写一个 `- [ ] 历史任务` 源文件,并把对应 + `TaskSourceReference` 放入历史提醒 notes; +6. 用临时目录保存 ledger 和备份; +7. 所有 EventKit fetch 都显式传入临时 calendar 数组,测试代码不得调用 + `predicateForReminders(in: nil)`。 + +- [ ] **步骤 2:断言第一次扫描不删除、第二次扫描只删除普通外来提醒** + +调用时传入可控时间: + +```swift +let first = try await pruner.advance( + snapshot: snapshot, + now: Date(timeIntervalSince1970: 10_000) +) +try require(first.deleted == 0, "first scan deleted immediately") + +let second = try await pruner.advance( + snapshot: snapshot, + now: Date(timeIntervalSince1970: 10_061) +) +try require(second.deleted == 1, "only the plain external reminder should delete") +``` + +重新读取两个临时列表,逐项断言: + +- 目标列表普通外来提醒不存在; +- priority、六个标题前缀、已完成、当前任务、历史源提醒仍存在; +- 另一个列表的提醒仍存在。 + +- [ ] **步骤 3:断言备份权限、字段恢复和宽限** + +1. 检查最新备份文件权限为 `0600`; +2. 调用 `restoreLast(now:)`; +3. 重新读取目标列表,验证标题、notes、URL、priority、dates、alarms、 + recurrence; +4. 在 `now + 61` 再推进,断言 24 小时宽限阻止删除; +5. 在 `now + 86_401` 后首次扫描只登记,不立即删除。 + +- [ ] **步骤 4:运行隔离测试和普通构建** + +```bash +swift build +TASKFORGE_RUN_EVENTKIT_TESTS=1 \ + swift run TaskForgeReminderEventKitTests +``` + +预期:测试输出只含临时列表名和 PASS 计数,不含生产提醒;末行 +`EventKit integration tests passed`。退出后在 Apple 提醒事项中不存在测试列表。 + +- [ ] **步骤 5:提交隔离测试** + +```bash +git add Tests/TaskForgeReminderEventKitTests/main.swift +git commit -m "test(EventKit): 验证清理隔离和恢复闭环" +``` + +## 任务 8:更新公共文档和隐私边界 + +**文件:** + +- 修改:`.gitignore` +- 修改:`README.md:7-225` +- 修改:`docs/ARCHITECTURE.md:1-120` +- 修改:`docs/TROUBLESHOOTING.md` +- 修改:`PRIVACY.md:5-55` +- 修改:`SECURITY.md:24-32` +- 修改:`CHANGELOG.md` + +- [ ] **步骤 1:更新 README 的功能、命令和安全规则** + +必须明确: + +- 只清理配置列表、未完成、不重要、TaskForge 缓存和源都不存在的提醒; +- 六个标题前缀和 EventKit priority 保护; +- 两次扫描至少相隔 60 秒; +- `--prune-dry-run`、`--prune-once`、`--restore-last-prune`; +- 备份和候选账本路径; +- 恢复后 24 小时宽限; +- TaskForge 源任务仍只改为 `done`,绝不因清理而删除。 + +删除 README 原来的“不会根据 TaskForge 删除操作自动删除 Apple 提醒事项” +限制,替换为新规则,避免文档自相矛盾。 + +- [ ] **步骤 2:更新架构、排障、隐私、安全和 changelog** + +逐文件加入规格中的固定边界。`.gitignore` 增加: + +```gitignore +PruneCandidates.json +PruneBackups/ +PruneHashSalt +*.prune-test.json +``` + +注意这些运行文件正常位于仓库外,规则用于防止用户复制调试数据后误提交。 + +- [ ] **步骤 3:执行文档矛盾和隐私扫描** + +```bash +rg -n "不会.*删除|不自动删除|其他.*列表|prune|清理|恢复" \ + README.md docs PRIVACY.md SECURITY.md CHANGELOG.md +rg -n "/Users/[^/]+/|/var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,}" \ + README.md docs PRIVACY.md SECURITY.md CHANGELOG.md +git diff --check +``` + +预期: + +- 旧限制已被新边界替换,没有矛盾; +- 第二条命令无输出; +- `git diff --check` 无输出。 + +- [ ] **步骤 4:提交公共文档** + +```bash +git add .gitignore README.md docs/ARCHITECTURE.md \ + docs/TROUBLESHOOTING.md PRIVACY.md SECURITY.md CHANGELOG.md +git commit -m "docs(清理): 说明自动删除和恢复边界" +``` + +## 任务 9:完整验证、生产部署和 GitHub 发布 + +**文件:** + +- 验证:全部代码、测试、文档和运行时安装 +- 不创建包含私人数据的新仓库文件 + +- [ ] **步骤 1:运行完整本地验证** + +```bash +swift run TaskForgeReminderCoreTests +swift build +TASKFORGE_RUN_EVENTKIT_TESTS=1 \ + swift run TaskForgeReminderEventKitTests +./scripts/build-app.sh +plutil -lint dist/TaskForgeReminderSync.app/Contents/Info.plist +codesign --verify --deep --strict \ + dist/TaskForgeReminderSync.app +``` + +预期:Core 测试全部通过、EventKit 隔离测试通过、构建成功、plist 和签名通过。 + +- [ ] **步骤 2:在生产列表执行匿名预演** + +```bash +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --prune-dry-run +``` + +记录并向用户报告扫描、首次候选和 ready 数量;不得输出标题、笔记或原始 ID。 +如果命令输出任何私人字段,停止部署并修复。 + +- [ ] **步骤 3:安装并确认第一轮只登记候选** + +```bash +./scripts/install-daily-sync.sh +launchctl print \ + "gui/$(id -u)/local.codex.taskforge-reminder-sync" +tail -80 "$HOME/Library/Logs/TaskForgeReminderSync.log" +``` + +预期: + +- LaunchAgent state 为 `running`; +- 第一轮清理 `deleted 0`; +- 日志只有数量和截断哈希; +- `PruneCandidates.json` 权限为 `600`。 + +- [ ] **步骤 4:等待二次确认并核对可恢复备份** + +等待 watcher 的下一次每分钟兜底,随后运行: + +```bash +tail -120 "$HOME/Library/Logs/TaskForgeReminderSync.log" +find "$HOME/Library/Application Support/TaskForgeReminderSync/PruneBackups" \ + -type f -exec stat -f '%Sp %N' {} \\; +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --audit +``` + +预期: + +- 只有连续两次符合条件的数量被删除; +- 每个删除批次先有 `-rw-------` 备份; +- audit 仍显示重复状态通过。 + +如果生产 dry-run 为零,接受“没有需要删除的真实候选”;不创建生产测试提醒。 + +- [ ] **步骤 5:检查监听、计划时间和错误日志** + +```bash +rg -n "Apple 提醒事项变化|每分钟漏失兜底|7:00|11:00|15:00|清理同步" \ + "$HOME/Library/Logs/TaskForgeReminderSync.log" +tail -80 "$HOME/Library/Logs/TaskForgeReminderSync.error.log" +``` + +预期:新清理和原有触发均存在;错误日志没有新的未处理错误。 + +- [ ] **步骤 6:执行推送前隐私和 Git 检查** + +```bash +git status --short --branch +git diff origin/main...HEAD --check +git diff origin/main...HEAD --name-only +git diff origin/main...HEAD -- | \ + rg -n '(/Users/[^/]+/|/var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,})' +``` + +预期: + +- worktree 干净; +- diff 检查无错误; +- 变更文件均在计划内; +- 隐私扫描无输出。 + +- [ ] **步骤 7:推送当前分支并检查 GitHub Actions** + +```bash +git push origin HEAD +gh run list --limit 5 +``` + +预期:push 成功;新 workflow run 最终为 `completed success`。如果 CI 失败, +先读取失败日志并修复,不把本机 EventKit 权限测试加入云端 CI。 + +- [ ] **步骤 8:最终交付报告** + +向用户报告: + +- GitHub commit 和仓库 URL; +- Core 与隔离 EventKit 测试数量; +- 生产 dry-run、首次扫描、二次确认删除的匿名数量; +- LaunchAgent listener/PID/state; +- 备份路径与权限; +- `--restore-last-prune` 用法; +- 其他提醒列表、已完成提醒和 TaskForge 源任务保持不变的验证证据。 diff --git a/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md b/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md index 3e3ddc9..7f59f44 100644 --- a/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md +++ b/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md @@ -1,7 +1,7 @@ # TaskForge 今日提醒自动清理设计 日期:2026-07-30 -状态:已完成对话设计,等待书面规格复核 +状态:已于 2026-07-30 完成书面规格复核 ## 目标 From 05ddbc5614f39e7cb0323d20b78aa418239f9e2d Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:17:44 +0800 Subject: [PATCH 03/34] =?UTF-8?q?chore(=E5=BC=80=E5=8F=91):=20=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E9=9A=94=E7=A6=BB=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=92=8C?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=B4=A6=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 785e421..24b2ef4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ __pycache__/ .env .env.* Backups/ +.worktrees/ +.superpowers/ From 65887e1a11182fe6dcfca13e0466448290916c5a Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:20:38 +0800 Subject: [PATCH 04/34] =?UTF-8?q?docs(=E8=AE=A1=E5=88=92):=20=E5=BB=B6?= =?UTF-8?q?=E5=90=8E=E5=88=9B=E5=BB=BA=20EventKit=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免在中间提交中保留没有断言的占位测试;真实隔离测试目标在任务 7 一次性创建。 --- .../plans/2026-07-30-reminder-pruning.md | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md index e2c8b58..5fb8ad4 100644 --- a/docs/superpowers/plans/2026-07-30-reminder-pruning.md +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -1011,18 +1011,10 @@ git commit -m "feat(清理): 添加私有候选账本和备份" name: "TaskForgeReminderCoreTests", dependencies: ["TaskForgeReminderCore"], path: "Tests/TaskForgeReminderCoreTests" -), -.executableTarget( - name: "TaskForgeReminderEventKitTests", - dependencies: [ - "TaskForgeReminderCore", - "TaskForgeReminderEventKit" - ], - path: "Tests/TaskForgeReminderEventKitTests" ) ``` -先创建两个只含 import 的源文件和一个输出 `SKIP` 的测试入口,然后运行: +先创建两个只含 import 的源文件,然后运行: ```bash swift build @@ -1176,7 +1168,6 @@ swift run TaskForgeReminderCoreTests ```bash git add Package.swift Sources/TaskForgeReminderEventKit \ - Tests/TaskForgeReminderEventKitTests/main.swift git commit -m "feat(EventKit): 实现可恢复提醒清理器" ``` @@ -1325,9 +1316,23 @@ git commit -m "feat(同步): 接入自动清理和恢复命令" **文件:** -- 修改:`Tests/TaskForgeReminderEventKitTests/main.swift` +- 修改:`Package.swift` +- 创建:`Tests/TaskForgeReminderEventKitTests/main.swift` + +- [ ] **步骤 1:增加真实测试目标和带显式开关的隔离测试入口** + +在 `Package.swift` 增加: -- [ ] **步骤 1:编写带显式开关的隔离测试入口** +```swift +.executableTarget( + name: "TaskForgeReminderEventKitTests", + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ], + path: "Tests/TaskForgeReminderEventKitTests" +) +``` 未设置环境变量时只输出: @@ -1419,7 +1424,7 @@ TASKFORGE_RUN_EVENTKIT_TESTS=1 \ - [ ] **步骤 5:提交隔离测试** ```bash -git add Tests/TaskForgeReminderEventKitTests/main.swift +git add Package.swift Tests/TaskForgeReminderEventKitTests/main.swift git commit -m "test(EventKit): 验证清理隔离和恢复闭环" ``` From a4c7573eeb6cf03535a19f8250d3b12d65521192 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:21:03 +0800 Subject: [PATCH 05/34] =?UTF-8?q?docs(=E8=AE=A1=E5=88=92):=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=20SDD=20=E4=BB=BB=E5=8A=A1=E6=8F=90=E5=8F=96=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-30-reminder-pruning.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md index 5fb8ad4..72bb5ff 100644 --- a/docs/superpowers/plans/2026-07-30-reminder-pruning.md +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -60,7 +60,7 @@ Swift Package Manager、LaunchAgent、macOS 13+ - `CHANGELOG.md` - 记录自动清理功能。 -## 任务 1:建立重要标记与候选判定策略 +## Task 1:建立重要标记与候选判定策略 **文件:** @@ -279,7 +279,7 @@ git add Sources/TaskForgeReminderCore/ReminderPruning.swift \ git commit -m "feat(清理): 添加提醒候选安全策略" ``` -## 任务 2:实现真实源任务存在性检查 +## Task 2:实现真实源任务存在性检查 **文件:** @@ -440,7 +440,7 @@ git add Sources/TaskForgeReminderCore/Core.swift \ git commit -m "feat(清理): 验证历史任务真实源" ``` -## 任务 3:实现双扫描确认状态机 +## Task 3:实现双扫描确认状态机 **文件:** @@ -754,7 +754,7 @@ git add Sources/TaskForgeReminderCore/ReminderPruning.swift \ git commit -m "feat(清理): 添加双扫描确认状态机" ``` -## 任务 4:实现本机候选账本和可校验备份 +## Task 4:实现本机候选账本和可校验备份 **文件:** @@ -983,7 +983,7 @@ git add Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift \ git commit -m "feat(清理): 添加私有候选账本和备份" ``` -## 任务 5:建立 EventKit 清理库和备份适配 +## Task 5:建立 EventKit 清理库和备份适配 **文件:** @@ -1171,7 +1171,7 @@ git add Package.swift Sources/TaskForgeReminderEventKit \ git commit -m "feat(EventKit): 实现可恢复提醒清理器" ``` -## 任务 6:接入命令行和同步循环 +## Task 6:接入命令行和同步循环 **文件:** @@ -1312,7 +1312,7 @@ git add Sources/TaskForgeReminderSync/Command.swift \ git commit -m "feat(同步): 接入自动清理和恢复命令" ``` -## 任务 7:完成隔离 EventKit 端到端测试 +## Task 7:完成隔离 EventKit 端到端测试 **文件:** @@ -1428,7 +1428,7 @@ git add Package.swift Tests/TaskForgeReminderEventKitTests/main.swift git commit -m "test(EventKit): 验证清理隔离和恢复闭环" ``` -## 任务 8:更新公共文档和隐私边界 +## Task 8:更新公共文档和隐私边界 **文件:** @@ -1492,7 +1492,7 @@ git add .gitignore README.md docs/ARCHITECTURE.md \ git commit -m "docs(清理): 说明自动删除和恢复边界" ``` -## 任务 9:完整验证、生产部署和 GitHub 发布 +## Task 9:完整验证、生产部署和 GitHub 发布 **文件:** From 620d773315fabb800e334b31cf6280b191ebc92c Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:22:44 +0800 Subject: [PATCH 06/34] =?UTF-8?q?feat(=E6=B8=85=E7=90=86):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=8F=90=E9=86=92=E5=80=99=E9=80=89=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPruning.swift | 75 +++++++++++++++ Tests/TaskForgeReminderCoreTests/main.swift | 93 +++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 Sources/TaskForgeReminderCore/ReminderPruning.swift diff --git a/Sources/TaskForgeReminderCore/ReminderPruning.swift b/Sources/TaskForgeReminderCore/ReminderPruning.swift new file mode 100644 index 0000000..ccc3dca --- /dev/null +++ b/Sources/TaskForgeReminderCore/ReminderPruning.swift @@ -0,0 +1,75 @@ +import Foundation + +public enum TaskForgeReminderPresence: String, Codable, Equatable, Sendable { + case currentSnapshot + case sourceConfirmed + case absent + case indeterminate +} + +public struct ReminderPruneObservation: Equatable, Sendable { + public let itemIdentifier: String + public let calendarIdentifier: String + public let isCompleted: Bool + public let priority: Int + public let title: String + public let fingerprint: String + public let taskPresence: TaskForgeReminderPresence + + public init( + itemIdentifier: String, + calendarIdentifier: String, + isCompleted: Bool, + priority: Int, + title: String, + fingerprint: String, + taskPresence: TaskForgeReminderPresence + ) { + self.itemIdentifier = itemIdentifier + self.calendarIdentifier = calendarIdentifier + self.isCompleted = isCompleted + self.priority = priority + self.title = title + self.fingerprint = fingerprint + self.taskPresence = taskPresence + } + + public func withTaskPresence( + _ value: TaskForgeReminderPresence + ) -> ReminderPruneObservation { + ReminderPruneObservation( + itemIdentifier: itemIdentifier, + calendarIdentifier: calendarIdentifier, + isCompleted: isCompleted, + priority: priority, + title: title, + fingerprint: fingerprint, + taskPresence: value + ) + } +} + +public enum ReminderPruneCandidatePolicy { + public static let importantPrefixes = [ + "!", "!", "❗", "‼️", "⭐", "📌" + ] + + public static func isCandidate( + _ observation: ReminderPruneObservation, + targetCalendarIdentifier: String + ) -> Bool { + guard observation.calendarIdentifier == targetCalendarIdentifier else { + return false + } + guard !observation.isCompleted, observation.priority == 0 else { + return false + } + let title = observation.title.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !importantPrefixes.contains(where: title.hasPrefix) else { + return false + } + return observation.taskPresence == .absent + } +} diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index dde007f..479d974 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -779,6 +779,99 @@ private let tests: [TestCase] = [ ), "completed historical source should be recognized" ) + }), + ("prune policy protects non-target, completed and important reminders", { + let target = "calendar-target" + let protected = [ + ReminderPruneObservation( + itemIdentifier: "other-list", + calendarIdentifier: "calendar-other", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "a", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "completed", + calendarIdentifier: target, + isCompleted: true, + priority: 0, + title: "普通提醒", + fingerprint: "b", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "priority", + calendarIdentifier: target, + isCompleted: false, + priority: 1, + title: "普通提醒", + fingerprint: "c", + taskPresence: .absent + ) + ] + for observation in protected { + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: target + ), + "\(observation.itemIdentifier) must be protected" + ) + } + }), + ("prune policy recognizes every approved title prefix", { + for (index, prefix) in ["!", "!", "❗", "‼️", "⭐", "📌"].enumerated() { + let observation = ReminderPruneObservation( + itemIdentifier: "important-\(index)", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: " \(prefix) 保留", + fingerprint: "\(index)", + taskPresence: .absent + ) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: "calendar-target" + ), + "\(prefix) must protect the reminder" + ) + } + }), + ("prune policy only selects an unimportant absent TaskForge task", { + let base = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + try require( + ReminderPruneCandidatePolicy.isCandidate( + base, + targetCalendarIdentifier: "calendar-target" + ), + "external reminder should become a candidate" + ) + for presence in [ + TaskForgeReminderPresence.currentSnapshot, + .sourceConfirmed, + .indeterminate + ] { + let protected = base.withTaskPresence(presence) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + protected, + targetCalendarIdentifier: "calendar-target" + ), + "\(presence) must fail closed" + ) + } }) ] From 2bdd5fb9527954a012b1f1659481e3b95a483d2a Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:26:24 +0800 Subject: [PATCH 07/34] =?UTF-8?q?feat(=E6=B8=85=E7=90=86):=20=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=8E=86=E5=8F=B2=E4=BB=BB=E5=8A=A1=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/TaskForgeReminderCore/Core.swift | 37 +++++++++++ Tests/TaskForgeReminderCoreTests/main.swift | 74 +++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/Sources/TaskForgeReminderCore/Core.swift b/Sources/TaskForgeReminderCore/Core.swift index fec0bb2..71dfd18 100644 --- a/Sources/TaskForgeReminderCore/Core.swift +++ b/Sources/TaskForgeReminderCore/Core.swift @@ -716,6 +716,43 @@ public enum TaskReminderDeduplicationPolicy { } } +public enum TaskSourcePresence: String, Codable, Equatable, Sendable { + case present + case absent + case indeterminate +} + +public enum TaskSourcePresenceInspector { + public static func inspect( + task: TaskForgeTask, + contents: String + ) -> TaskSourcePresence { + switch task.sourceType?.lowercased() { + case "markdowninline": + guard let originalLine = task.originalLine else { + return .indeterminate + } + let lines = contents.components(separatedBy: "\n") + if + let lineNumber = task.lineNumber, + lines.indices.contains(lineNumber - 1), + lines[lineNumber - 1] == originalLine + { + return .present + } + let matches = lines.filter { $0 == originalLine }.count + if matches == 1 { + return .present + } + return matches == 0 ? .absent : .indeterminate + case "tasknotes": + return .present + default: + return .indeterminate + } + } +} + public enum TaskCompletionSourceInspector { public static func isCompleted( task: TaskForgeTask, diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 479d974..4547bf7 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -780,6 +780,80 @@ private let tests: [TestCase] = [ "completed historical source should be recognized" ) }), + ("source presence confirms exact and uniquely moved inline tasks", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 2 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\n- [ ] 保留任务\n" + ) == .present, + "exact source line should be present" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\nheading\n" + ) == .present, + "uniquely moved source line should be present" + ) + }), + ("source presence distinguishes absent from ambiguous", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 3 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\nother\n" + ) == .absent, + "missing source line should be absent" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n- [ ] 保留任务\n" + ) == .indeterminate, + "ambiguous source lines must fail closed" + ) + }), + ("source presence protects an existing TaskNotes file", { + let task = TaskForgeTask( + identifier: "note", + title: "任务笔记", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/TaskNotes/任务.md", + sourceType: "taskNotes", + originalLine: "tasknotes:{}", + lineNumber: 1 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "---\nstatus: open\n---\n" + ) == .present, + "readable TaskNotes source should be present" + ) + }), ("prune policy protects non-target, completed and important reminders", { let target = "calendar-target" let protected = [ From b5e80683d3f38c1d4f95d625f78bf730b5e3cb5e Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:30:01 +0800 Subject: [PATCH 08/34] =?UTF-8?q?test(=E6=B8=85=E7=90=86):=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=BA=90=E6=A3=80=E6=9F=A5=E7=BC=BA=E5=A4=B1=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/TaskForgeReminderCoreTests/main.swift | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 4547bf7..1a854bb 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -854,6 +854,52 @@ private let tests: [TestCase] = [ "readable TaskNotes source should be present" ) }), + ("source presence fails closed for missing or unknown source metadata", { + let cases = [ + TaskForgeTask( + identifier: "missing-line", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: nil, + lineNumber: 1 + ), + TaskForgeTask( + identifier: "missing-type", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: nil, + originalLine: "- [ ] 保留任务", + lineNumber: 1 + ), + TaskForgeTask( + identifier: "unknown-type", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "unsupported", + originalLine: "- [ ] 保留任务", + lineNumber: 1 + ) + ] + for task in cases { + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n" + ) == .indeterminate, + "\(task.identifier) must fail closed" + ) + } + }), ("prune policy protects non-target, completed and important reminders", { let target = "calendar-target" let protected = [ From 10083cdbda462ca7b9a37e0c926eb0a792a0a316 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:33:19 +0800 Subject: [PATCH 09/34] =?UTF-8?q?feat(=E6=B8=85=E7=90=86):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8F=8C=E6=89=AB=E6=8F=8F=E7=A1=AE=E8=AE=A4=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPruning.swift | 108 ++++++++++++ Tests/TaskForgeReminderCoreTests/main.swift | 156 ++++++++++++++++++ 2 files changed, 264 insertions(+) diff --git a/Sources/TaskForgeReminderCore/ReminderPruning.swift b/Sources/TaskForgeReminderCore/ReminderPruning.swift index ccc3dca..f4dbb76 100644 --- a/Sources/TaskForgeReminderCore/ReminderPruning.swift +++ b/Sources/TaskForgeReminderCore/ReminderPruning.swift @@ -73,3 +73,111 @@ public enum ReminderPruneCandidatePolicy { return observation.taskPresence == .absent } } + +public struct ReminderPruneLedgerEntry: Codable, Equatable, Sendable { + public var firstSeen: Date + public var fingerprint: String + public var calendarIdentifier: String + public var rulesVersion: Int + public var graceUntil: Date? + + public init( + firstSeen: Date, + fingerprint: String, + calendarIdentifier: String, + rulesVersion: Int, + graceUntil: Date? + ) { + self.firstSeen = firstSeen + self.fingerprint = fingerprint + self.calendarIdentifier = calendarIdentifier + self.rulesVersion = rulesVersion + self.graceUntil = graceUntil + } +} + +public struct ReminderPruneLedger: Codable, Equatable, Sendable { + public var entries: [String: ReminderPruneLedgerEntry] + + public init(entries: [String: ReminderPruneLedgerEntry] = [:]) { + self.entries = entries + } +} + +public struct ReminderPrunePlan: Equatable, Sendable { + public let firstSeenIdentifiers: [String] + public let waitingIdentifiers: [String] + public let readyIdentifiers: [String] + public let revokedIdentifiers: [String] + public let nextLedger: ReminderPruneLedger +} + +public enum ReminderPruneStateMachine { + public static let rulesVersion = 1 + + public static func plan( + observations: [ReminderPruneObservation], + prior: ReminderPruneLedger, + targetCalendarIdentifier: String, + now: Date, + confirmationInterval: TimeInterval = 60 + ) -> ReminderPrunePlan { + var next = ReminderPruneLedger() + var firstSeen: [String] = [] + var waiting: [String] = [] + var ready: [String] = [] + let candidates = observations.filter { + ReminderPruneCandidatePolicy.isCandidate( + $0, + targetCalendarIdentifier: targetCalendarIdentifier + ) + } + + for observation in candidates { + let old = prior.entries[observation.itemIdentifier] + let isSame = old?.fingerprint == observation.fingerprint + && old?.calendarIdentifier == observation.calendarIdentifier + && old?.rulesVersion == rulesVersion + if !isSame { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } else if let old, let graceUntil = old.graceUntil { + if now < graceUntil { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } else { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } + } else if let old, now.timeIntervalSince(old.firstSeen) >= confirmationInterval { + next.entries[observation.itemIdentifier] = old + ready.append(observation.itemIdentifier) + } else if let old { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } + } + + let active = Set(candidates.map(\.itemIdentifier)) + let revoked = prior.entries.keys.filter { !active.contains($0) }.sorted() + return ReminderPrunePlan( + firstSeenIdentifiers: firstSeen.sorted(), + waitingIdentifiers: waiting.sorted(), + readyIdentifiers: ready.sorted(), + revokedIdentifiers: revoked, + nextLedger: next + ) + } +} diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 1a854bb..d1382ae 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -992,6 +992,162 @@ private let tests: [TestCase] = [ "\(presence) must fail closed" ) } + }), + ("prune state requires two unchanged scans at least sixty seconds apart", { + let firstDate = Date(timeIntervalSince1970: 1_000) + let observation = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [observation], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: firstDate + ) + try require(first.readyIdentifiers.isEmpty, "first scan must not delete") + try require(first.firstSeenIdentifiers == ["external"], "candidate not recorded") + + let early = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(59) + ) + try require(early.readyIdentifiers.isEmpty, "59 seconds is too early") + + let ready = ReminderPruneStateMachine.plan( + observations: [observation], + prior: early.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(60) + ) + try require(ready.readyIdentifiers == ["external"], "candidate should be ready") + }), + ("prune state revokes or restarts changed candidates", { + let now = Date(timeIntervalSince1970: 2_000) + let original = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "v1", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [original], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: now + ) + let changed = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "改过的提醒", + fingerprint: "v2", + taskPresence: .absent + ) + let restarted = ReminderPruneStateMachine.plan( + observations: [changed], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(120) + ) + try require(restarted.readyIdentifiers.isEmpty, "changed item must restart") + try require( + restarted.nextLedger.entries["external"]?.firstSeen + == now.addingTimeInterval(120), + "changed item should receive a new firstSeen" + ) + }), + ("prune state restarts after restore grace and rule changes", { + let now = Date(timeIntervalSince1970: 3_000) + let observation = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "恢复提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let prior = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: now.addingTimeInterval(60) + ) + ]) + let duringGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require(duringGrace.readyIdentifiers.isEmpty, "grace must protect") + + let afterGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: duringGrace.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(61) + ) + try require( + afterGrace.firstSeenIdentifiers == ["restored"], + "expired grace must start a fresh first scan" + ) + try require(afterGrace.readyIdentifiers.isEmpty, "grace expiry must not delete") + + let oldRules = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion - 1, + graceUntil: nil + ) + ]) + let versionReset = ReminderPruneStateMachine.plan( + observations: [observation], + prior: oldRules, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + versionReset.firstSeenIdentifiers == ["restored"], + "rules change must restart confirmation" + ) + + let completed = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: true, + priority: 0, + title: "恢复提醒", + fingerprint: "completed", + taskPresence: .absent + ) + let revoked = ReminderPruneStateMachine.plan( + observations: [completed], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + revoked.revokedIdentifiers == ["restored"], + "completed reminder must revoke its candidate" + ) + try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") }) ] From 24326492c53fd6c199c007beec05b65c58f56c52 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:37:18 +0800 Subject: [PATCH 10/34] =?UTF-8?q?fix(=E6=B8=85=E7=90=86):=20=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E7=A1=AE=E8=AE=A4=E9=97=B4=E9=9A=94=E4=B8=8B=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPruning.swift | 4 +- Tests/TaskForgeReminderCoreTests/main.swift | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPruning.swift b/Sources/TaskForgeReminderCore/ReminderPruning.swift index f4dbb76..59b8f01 100644 --- a/Sources/TaskForgeReminderCore/ReminderPruning.swift +++ b/Sources/TaskForgeReminderCore/ReminderPruning.swift @@ -122,6 +122,7 @@ public enum ReminderPruneStateMachine { now: Date, confirmationInterval: TimeInterval = 60 ) -> ReminderPrunePlan { + let effectiveConfirmationInterval = max(confirmationInterval, 60) var next = ReminderPruneLedger() var firstSeen: [String] = [] var waiting: [String] = [] @@ -161,7 +162,8 @@ public enum ReminderPruneStateMachine { ) firstSeen.append(observation.itemIdentifier) } - } else if let old, now.timeIntervalSince(old.firstSeen) >= confirmationInterval { + } else if let old, + now.timeIntervalSince(old.firstSeen) >= effectiveConfirmationInterval { next.entries[observation.itemIdentifier] = old ready.append(observation.itemIdentifier) } else if let old { diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index d1382ae..f18d395 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1068,6 +1068,48 @@ private let tests: [TestCase] = [ "changed item should receive a new firstSeen" ) }), + ("prune state clamps custom confirmation intervals to sixty seconds", { + let now = Date(timeIntervalSince1970: 2_500) + let observation = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [observation], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: now + ) + for confirmationInterval: TimeInterval in [0, 59] { + let early = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(59), + confirmationInterval: confirmationInterval + ) + try require( + early.readyIdentifiers.isEmpty, + "\(confirmationInterval) seconds must not bypass confirmation" + ) + } + let ready = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(60), + confirmationInterval: 0 + ) + try require( + ready.readyIdentifiers == ["external"], + "clamped interval should allow readiness at sixty seconds" + ) + }), ("prune state restarts after restore grace and rule changes", { let now = Date(timeIntervalSince1970: 3_000) let observation = ReminderPruneObservation( From f57c4690a30cb24067c2cf964a2a0b6cc32ee41c Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:43:13 +0800 Subject: [PATCH 11/34] =?UTF-8?q?feat(=E6=B8=85=E7=90=86):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=A7=81=E6=9C=89=E5=80=99=E9=80=89=E8=B4=A6=E6=9C=AC?= =?UTF-8?q?=E5=92=8C=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 427 ++++++++++++++++++ Tests/TaskForgeReminderCoreTests/main.swift | 80 ++++ 2 files changed, 507 insertions(+) create mode 100644 Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift new file mode 100644 index 0000000..227d71e --- /dev/null +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -0,0 +1,427 @@ +import CryptoKit +import Foundation + +public struct ReminderLocationBackup: Codable, Equatable, Sendable { + public var title: String + public var latitude: Double? + public var longitude: Double? + public var radius: Double + + public init( + title: String, + latitude: Double?, + longitude: Double?, + radius: Double + ) { + self.title = title + self.latitude = latitude + self.longitude = longitude + self.radius = radius + } +} + +public struct ReminderWeekdayBackup: Codable, Equatable, Sendable { + public var dayOfTheWeekRawValue: Int + public var weekNumber: Int + + public init(dayOfTheWeekRawValue: Int, weekNumber: Int) { + self.dayOfTheWeekRawValue = dayOfTheWeekRawValue + self.weekNumber = weekNumber + } +} + +public struct ReminderAlarmBackup: Codable, Equatable, Sendable { + public var absoluteDate: Date? + public var relativeOffset: TimeInterval? + public var structuredLocation: ReminderLocationBackup? + public var proximityRawValue: Int? + + public init( + absoluteDate: Date?, + relativeOffset: TimeInterval?, + structuredLocation: ReminderLocationBackup?, + proximityRawValue: Int? + ) { + self.absoluteDate = absoluteDate + self.relativeOffset = relativeOffset + self.structuredLocation = structuredLocation + self.proximityRawValue = proximityRawValue + } +} + +public struct ReminderRecurrenceBackup: Codable, Equatable, Sendable { + public var frequencyRawValue: Int + public var interval: Int + public var daysOfWeek: [ReminderWeekdayBackup] + public var daysOfMonth: [Int] + public var monthsOfYear: [Int] + public var weeksOfYear: [Int] + public var daysOfYear: [Int] + public var setPositions: [Int] + public var endDate: Date? + public var occurrenceCount: Int? + + public init( + frequencyRawValue: Int, + interval: Int, + daysOfWeek: [ReminderWeekdayBackup], + daysOfMonth: [Int], + monthsOfYear: [Int], + weeksOfYear: [Int], + daysOfYear: [Int], + setPositions: [Int], + endDate: Date?, + occurrenceCount: Int? + ) { + self.frequencyRawValue = frequencyRawValue + self.interval = interval + self.daysOfWeek = daysOfWeek + self.daysOfMonth = daysOfMonth + self.monthsOfYear = monthsOfYear + self.weeksOfYear = weeksOfYear + self.daysOfYear = daysOfYear + self.setPositions = setPositions + self.endDate = endDate + self.occurrenceCount = occurrenceCount + } +} + +public struct ReminderPruneBackupItem: Codable, Equatable, Sendable { + public var originalItemIdentifier: String + public var title: String + public var notes: String? + public var url: URL? + public var priority: Int + public var dueDateComponents: DateComponents? + public var startDateComponents: DateComponents? + public var alarms: [ReminderAlarmBackup] + public var recurrenceRules: [ReminderRecurrenceBackup] + + public init( + originalItemIdentifier: String, + title: String, + notes: String?, + url: URL?, + priority: Int, + dueDateComponents: DateComponents?, + startDateComponents: DateComponents?, + alarms: [ReminderAlarmBackup], + recurrenceRules: [ReminderRecurrenceBackup] + ) { + self.originalItemIdentifier = originalItemIdentifier + self.title = title + self.notes = notes + self.url = url + self.priority = priority + self.dueDateComponents = dueDateComponents + self.startDateComponents = startDateComponents + self.alarms = alarms + self.recurrenceRules = recurrenceRules + } +} + +public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { + public var identifier: UUID + public var createdAt: Date + public var targetCalendarIdentifier: String + public var targetCalendarTitle: String + public var items: [ReminderPruneBackupItem] + public var restoredAt: Date? + + public init( + identifier: UUID, + createdAt: Date, + targetCalendarIdentifier: String, + targetCalendarTitle: String, + items: [ReminderPruneBackupItem], + restoredAt: Date? + ) { + self.identifier = identifier + self.createdAt = createdAt + self.targetCalendarIdentifier = targetCalendarIdentifier + self.targetCalendarTitle = targetCalendarTitle + self.items = items + self.restoredAt = restoredAt + } +} + +public enum ReminderPruneStoreError: Error, Equatable { + case invalidLedger + case invalidBackup + case checksumMismatch + case permissions + case noUnrestoredBackup +} + +public final class ReminderPruneLocalStore: @unchecked Sendable { + public static let defaultRoot = URL( + fileURLWithPath: + "\(NSHomeDirectory())/Library/Application Support/" + + "TaskForgeReminderSync", + isDirectory: true + ) + + public let rootURL: URL + + public var ledgerURL: URL { + rootURL.appendingPathComponent("PruneCandidates.json") + } + + public init(rootURL: URL = defaultRoot) { + self.rootURL = rootURL + } + + public func loadLedger() throws -> ReminderPruneLedger { + try ensureDirectory(rootURL) + guard fileManager.fileExists(atPath: ledgerURL.path) else { + return ReminderPruneLedger() + } + + try ensurePrivateFile(ledgerURL) + let data = try readData(at: ledgerURL, error: .invalidLedger) + do { + return try decoder.decode(ReminderPruneLedger.self, from: data) + } catch { + throw ReminderPruneStoreError.invalidLedger + } + } + + public func saveLedger(_ ledger: ReminderPruneLedger) throws { + try ensureDirectory(rootURL) + let data: Data + do { + data = try encoder.encode(ledger) + } catch { + throw ReminderPruneStoreError.invalidLedger + } + try writeAtomically(data, to: ledgerURL) + } + + public func saveBackup(_ batch: ReminderPruneBackupBatch) throws -> URL { + let url = try backupURL(for: batch.identifier) + try saveBackup(batch, to: url) + return url + } + + public func loadBackup(at url: URL) throws -> ReminderPruneBackupBatch { + try ensureDirectory(rootURL) + let backups = try backupsURL() + guard url.standardizedFileURL.deletingLastPathComponent() == backups else { + throw ReminderPruneStoreError.invalidBackup + } + + let data = try readData(at: url, error: .invalidBackup) + let envelope: BackupEnvelope + do { + envelope = try decoder.decode(BackupEnvelope.self, from: data) + } catch { + throw ReminderPruneStoreError.checksumMismatch + } + + let payload: Data + do { + payload = try encoder.encode(envelope.payload) + } catch { + throw ReminderPruneStoreError.invalidBackup + } + guard envelope.checksum == checksum(for: payload) else { + throw ReminderPruneStoreError.checksumMismatch + } + try ensurePrivateFile(url) + return envelope.payload + } + + public func latestUnrestoredBackup() throws -> (URL, ReminderPruneBackupBatch)? { + try ensureDirectory(rootURL) + let backups = try backupsURL() + let urls: [URL] + do { + urls = try fileManager.contentsOfDirectory( + at: backups, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == "json" } + } catch { + throw ReminderPruneStoreError.permissions + } + + let batches = try urls.map { url in + (url, try loadBackup(at: url)) + } + return batches + .filter { $0.1.restoredAt == nil } + .sorted { + if $0.1.createdAt == $1.1.createdAt { + return $0.0.lastPathComponent > $1.0.lastPathComponent + } + return $0.1.createdAt > $1.1.createdAt + } + .first + } + + public func markRestored(at url: URL, date: Date) throws { + var batch = try loadBackup(at: url) + batch.restoredAt = date + try saveBackup(batch, to: url) + } + + public func loadOrCreateHashSalt() throws -> Data { + try ensureDirectory(rootURL) + let saltURL = rootURL.appendingPathComponent("PruneHashSalt") + if fileManager.fileExists(atPath: saltURL.path) { + try ensurePrivateFile(saltURL) + let salt = try readData(at: saltURL, error: .invalidBackup) + guard salt.count == 32 else { + throw ReminderPruneStoreError.invalidBackup + } + return salt + } + + let salt = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } + try writeAtomically(salt, to: saltURL) + return salt + } + + private let fileManager = FileManager.default + + private var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + } + + private var decoder: JSONDecoder { + JSONDecoder() + } + + private func backupsURL() throws -> URL { + let url = rootURL.appendingPathComponent("PruneBackups", isDirectory: true) + try ensureDirectory(url) + return url.standardizedFileURL + } + + private func backupURL(for identifier: UUID) throws -> URL { + try backupsURL().appendingPathComponent("\(identifier.uuidString).json") + } + + private func saveBackup( + _ batch: ReminderPruneBackupBatch, + to url: URL + ) throws { + let backups = try backupsURL() + guard url.standardizedFileURL.deletingLastPathComponent() == backups else { + throw ReminderPruneStoreError.invalidBackup + } + + let payload: Data + let data: Data + do { + payload = try encoder.encode(batch) + data = try encoder.encode( + BackupEnvelope( + checksum: checksum(for: payload), + payload: batch + ) + ) + } catch { + throw ReminderPruneStoreError.invalidBackup + } + try writeAtomically(data, to: url) + } + + private func ensureDirectory(_ url: URL) throws { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw ReminderPruneStoreError.permissions + } + } else { + do { + try fileManager.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + throw ReminderPruneStoreError.permissions + } + } + try ensurePermissions(of: url, expected: 0o700) + } + + private func ensurePrivateFile(_ url: URL) throws { + try ensurePermissions(of: url, expected: 0o600) + } + + private func ensurePermissions(of url: URL, expected: Int) throws { + let attributes: [FileAttributeKey: Any] + do { + attributes = try fileManager.attributesOfItem(atPath: url.path) + } catch { + throw ReminderPruneStoreError.permissions + } + guard + let permissions = attributes[.posixPermissions] as? NSNumber, + permissions.intValue & 0o777 == expected + else { + throw ReminderPruneStoreError.permissions + } + } + + private func readData( + at url: URL, + error storeError: ReminderPruneStoreError + ) throws -> Data { + do { + return try Data(contentsOf: url) + } catch { + throw storeError + } + } + + private func writeAtomically(_ data: Data, to url: URL) throws { + let parent = url.deletingLastPathComponent() + try ensureDirectory(parent) + let temporaryURL = parent.appendingPathComponent( + ".\(UUID().uuidString).tmp" + ) + defer { + try? fileManager.removeItem(at: temporaryURL) + } + + guard fileManager.createFile(atPath: temporaryURL.path, contents: nil) else { + throw ReminderPruneStoreError.permissions + } + do { + let handle = try FileHandle(forWritingTo: temporaryURL) + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + try fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: temporaryURL.path + ) + try ensurePrivateFile(temporaryURL) + + if fileManager.fileExists(atPath: url.path) { + _ = try fileManager.replaceItemAt(url, withItemAt: temporaryURL) + } else { + try fileManager.moveItem(at: temporaryURL, to: url) + } + try ensurePrivateFile(url) + } catch let error as ReminderPruneStoreError { + throw error + } catch { + throw ReminderPruneStoreError.permissions + } + } + + private func checksum(for data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +private struct BackupEnvelope: Codable { + let checksum: String + let payload: ReminderPruneBackupBatch +} diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index f18d395..877103d 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -185,6 +185,35 @@ private func taskStoreFixture() -> Data { ) } +private func pruneBackupFixture() -> ReminderPruneBackupBatch { + ReminderPruneBackupBatch( + identifier: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + createdAt: Date(timeIntervalSince1970: 20), + targetCalendarIdentifier: "calendar", + targetCalendarTitle: "TaskForge 今日", + items: [ + ReminderPruneBackupItem( + originalItemIdentifier: "item", + title: "普通提醒", + notes: "本地测试", + url: URL(string: "taskforge-test://item"), + priority: 0, + dueDateComponents: DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 7, + day: 30 + ), + startDateComponents: nil, + alarms: [], + recurrenceRules: [] + ) + ], + restoredAt: nil + ) +} + private let tests: [TestCase] = [ ("TaskForge v6 MessagePack store decodes task records", { let snapshot = try TaskForgeTaskStore.decode(taskStoreFixture()) @@ -1190,6 +1219,57 @@ private let tests: [TestCase] = [ "completed reminder must revoke its candidate" ) try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") + }), + ("prune local store writes private ledger and verified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let ledger = ReminderPruneLedger(entries: [ + "item": ReminderPruneLedgerEntry( + firstSeen: Date(timeIntervalSince1970: 10), + fingerprint: "fingerprint", + calendarIdentifier: "calendar", + rulesVersion: 1, + graceUntil: nil + ) + ]) + try store.saveLedger(ledger) + try require(try store.loadLedger() == ledger, "ledger round-trip failed") + + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.ledgerURL.path + )[.posixPermissions] as? NSNumber, + "permissions missing" + ) + try require(permissions.intValue & 0o777 == 0o600, "ledger must be 0600") + + let rootPermissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: root.path + )[.posixPermissions] as? NSNumber, + "root permissions missing" + ) + try require(rootPermissions.intValue & 0o777 == 0o700, "root must be 0700") + + let batch = pruneBackupFixture() + let url = try store.saveBackup(batch) + try require(try store.loadBackup(at: url) == batch, "backup verification failed") + }), + ("prune local store rejects a modified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + try Data("tampered".utf8).write(to: url, options: .atomic) + do { + _ = try store.loadBackup(at: url) + throw TestFailure(description: "tampered backup was accepted") + } catch let error as ReminderPruneStoreError { + try require(error == .checksumMismatch, "unexpected store error") + } }) ] From 7f651ac5713187697c51fd377d67c93d499c4b7a Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:55:02 +0800 Subject: [PATCH 12/34] =?UTF-8?q?fix(=E6=B8=85=E7=90=86):=20=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E6=9C=AC=E5=9C=B0=E5=A4=87=E4=BB=BD=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 88 +++++++-- Tests/TaskForgeReminderCoreTests/main.swift | 175 +++++++++++++++++- 2 files changed, 246 insertions(+), 17 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 227d71e..60fbeb3 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -1,4 +1,5 @@ import CryptoKit +import Darwin import Foundation public struct ReminderLocationBackup: Codable, Equatable, Sendable { @@ -173,7 +174,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { public func loadLedger() throws -> ReminderPruneLedger { try ensureDirectory(rootURL) - guard fileManager.fileExists(atPath: ledgerURL.path) else { + guard try itemExists(at: ledgerURL) else { return ReminderPruneLedger() } @@ -266,9 +267,12 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } public func loadOrCreateHashSalt() throws -> Data { + saltLock.lock() + defer { saltLock.unlock() } + try ensureDirectory(rootURL) let saltURL = rootURL.appendingPathComponent("PruneHashSalt") - if fileManager.fileExists(atPath: saltURL.path) { + if try itemExists(at: saltURL) { try ensurePrivateFile(saltURL) let salt = try readData(at: saltURL, error: .invalidBackup) guard salt.count == 32 else { @@ -283,6 +287,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } private let fileManager = FileManager.default + private let saltLock = NSLock() private var encoder: JSONEncoder { let encoder = JSONEncoder() @@ -330,9 +335,11 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } private func ensureDirectory(_ url: URL) throws { - var isDirectory: ObjCBool = false - if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) { - guard isDirectory.boolValue else { + if let attributes = try attributesIfItemExists(at: url) { + guard + attributes[.type] as? String + == FileAttributeType.typeDirectory.rawValue + else { throw ReminderPruneStoreError.permissions } } else { @@ -343,7 +350,9 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { attributes: [.posixPermissions: 0o700] ) } catch { - throw ReminderPruneStoreError.permissions + guard try attributesIfItemExists(at: url) != nil else { + throw ReminderPruneStoreError.permissions + } } } try ensurePermissions(of: url, expected: 0o700) @@ -354,18 +363,59 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } private func ensurePermissions(of url: URL, expected: Int) throws { - let attributes: [FileAttributeKey: Any] - do { - attributes = try fileManager.attributesOfItem(atPath: url.path) - } catch { + guard let attributes = try attributesIfItemExists(at: url) else { throw ReminderPruneStoreError.permissions } - guard - let permissions = attributes[.posixPermissions] as? NSNumber, - permissions.intValue & 0o777 == expected - else { + let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue + ?? (attributes[.posixPermissions] as? Int) + guard let permissions, permissions & 0o777 == expected else { throw ReminderPruneStoreError.permissions } + try ensureOwnedByCurrentUser(url) + try ensureNoExtendedACL(url) + } + + private func ensureOwnedByCurrentUser(_ url: URL) throws { + var status = stat() + guard lstat(url.path, &status) == 0, status.st_uid == getuid() else { + throw ReminderPruneStoreError.permissions + } + } + + private func ensureNoExtendedACL(_ url: URL) throws { + guard let acl = acl_get_file(url.path, ACL_TYPE_EXTENDED) else { + guard errno == ENOENT else { + throw ReminderPruneStoreError.permissions + } + return + } + acl_free(UnsafeMutableRawPointer(acl)) + throw ReminderPruneStoreError.permissions + } + + private func itemExists(at url: URL) throws -> Bool { + try attributesIfItemExists(at: url) != nil + } + + private func attributesIfItemExists( + at url: URL + ) throws -> [FileAttributeKey: Any]? { + do { + return try fileManager.attributesOfItem(atPath: url.path) + } catch { + guard isNoSuchFileError(error) else { + throw ReminderPruneStoreError.permissions + } + return nil + } + } + + private func isNoSuchFileError(_ error: Error) -> Bool { + let error = error as NSError + return (error.domain == NSCocoaErrorDomain + && (error.code == NSFileNoSuchFileError + || error.code == NSFileReadNoSuchFileError)) + || (error.domain == NSPOSIXErrorDomain && error.code == ENOENT) } private func readData( @@ -403,8 +453,14 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { ) try ensurePrivateFile(temporaryURL) - if fileManager.fileExists(atPath: url.path) { - _ = try fileManager.replaceItemAt(url, withItemAt: temporaryURL) + if try itemExists(at: url) { + try ensurePrivateFile(url) + _ = try fileManager.replaceItemAt( + url, + withItemAt: temporaryURL, + backupItemName: nil, + options: [.usingNewMetadataOnly] + ) } else { try fileManager.moveItem(at: temporaryURL, to: url) } diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 877103d..31f1ee8 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation import TaskForgeReminderCore @@ -1263,13 +1264,185 @@ private let tests: [TestCase] = [ defer { try? FileManager.default.removeItem(at: root) } let store = ReminderPruneLocalStore(rootURL: root) let url = try store.saveBackup(pruneBackupFixture()) - try Data("tampered".utf8).write(to: url, options: .atomic) + let object = try requireValue( + try JSONSerialization.jsonObject(with: Data(contentsOf: url)) + as? [String: Any], + "backup envelope should be JSON" + ) + var modified = object + var payload = try requireValue( + object["payload"] as? [String: Any], + "backup payload should be an object" + ) + payload["targetCalendarTitle"] = "tampered" + modified["payload"] = payload + try JSONSerialization.data( + withJSONObject: modified, + options: [.sortedKeys] + ).write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) do { _ = try store.loadBackup(at: url) throw TestFailure(description: "tampered backup was accepted") } catch let error as ReminderPruneStoreError { try require(error == .checksumMismatch, "unexpected store error") } + }), + ("prune local store refuses insecure overwrite before replacing the ledger", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let original = ReminderPruneLedger(entries: [:]) + let replacement = ReminderPruneLedger(entries: [ + "item": ReminderPruneLedgerEntry( + firstSeen: Date(timeIntervalSince1970: 100), + fingerprint: "replacement", + calendarIdentifier: "calendar", + rulesVersion: 1, + graceUntil: nil + ) + ]) + try store.saveLedger(original) + let originalData = try Data(contentsOf: store.ledgerURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: store.ledgerURL.path + ) + + do { + try store.saveLedger(replacement) + throw TestFailure(description: "insecure ledger was overwritten") + } catch let error as ReminderPruneStoreError { + try require(error == .permissions, "unexpected overwrite error") + } + try require( + try Data(contentsOf: store.ledgerURL) == originalData, + "insecure ledger contents changed before rejection" + ) + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.ledgerURL.path + )[.posixPermissions] as? NSNumber, + "ledger permissions missing" + ) + try require( + permissions.intValue & 0o777 == 0o644, + "insecure ledger permissions changed before rejection" + ) + }), + ("prune local store fails closed for corrupt ledger and backup inventory", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + try store.saveLedger(ReminderPruneLedger()) + try Data("not ledger json".utf8).write(to: store.ledgerURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: store.ledgerURL.path + ) + do { + _ = try store.loadLedger() + throw TestFailure(description: "corrupt ledger returned an empty ledger") + } catch let error as ReminderPruneStoreError { + try require(error == .invalidLedger, "unexpected corrupt ledger error") + } + + let backupURL = try store.saveBackup(pruneBackupFixture()) + try Data("not backup json".utf8).write(to: backupURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: backupURL.path + ) + do { + _ = try store.latestUnrestoredBackup() + throw TestFailure(description: "corrupt backup was ignored") + } catch let error as ReminderPruneStoreError { + try require(error == .checksumMismatch, "unexpected corrupt backup error") + } + }), + ("prune local store preserves restored backups and private runtime permissions", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let backupURL = try store.saveBackup(pruneBackupFixture()) + let restoredAt = Date(timeIntervalSince1970: 99) + try store.markRestored(at: backupURL, date: restoredAt) + try require( + try store.loadBackup(at: backupURL).restoredAt == restoredAt, + "restored backup must still verify" + ) + try require( + try store.latestUnrestoredBackup() == nil, + "restored backup must not remain latest unrestored" + ) + + let salt = try store.loadOrCreateHashSalt() + try require(salt.count == 32, "salt must be 32 bytes") + try require(salt == store.loadOrCreateHashSalt(), "salt should be reused") + for (url, expected) in [ + (root, 0o700), + (backupURL.deletingLastPathComponent(), 0o700), + (backupURL, 0o600), + (root.appendingPathComponent("PruneHashSalt"), 0o600) + ] { + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: url.path + )[.posixPermissions] as? NSNumber, + "permissions missing for \(url.lastPathComponent)" + ) + try require( + permissions.intValue & 0o777 == expected, + "unexpected permissions for \(url.lastPathComponent)" + ) + } + }), + ("prune local store creates one shared hash salt under concurrency", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let queue = DispatchQueue(label: "prune-salt", attributes: .concurrent) + let group = DispatchGroup() + let start = DispatchSemaphore(value: 0) + let lock = NSLock() + var salts: [Data] = [] + var failures: [Error] = [] + + for _ in 0..<32 { + group.enter() + queue.async { + start.wait() + do { + let salt = try store.loadOrCreateHashSalt() + lock.lock() + salts.append(salt) + lock.unlock() + } catch { + lock.lock() + failures.append(error) + lock.unlock() + } + group.leave() + } + } + for _ in 0..<32 { + start.signal() + } + group.wait() + + try require(failures.isEmpty, "concurrent salt creation failed") + try require(salts.count == 32, "missing concurrent salt result") + try require( + salts.dropFirst().allSatisfy { $0 == salts[0] }, + "concurrent callers received different salts" + ) }) ] From ea1cac7cc0197af828c1b6707e90b44c4a17a0cf Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:01:11 +0800 Subject: [PATCH 13/34] =?UTF-8?q?fix(=E6=B8=85=E7=90=86):=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E6=9C=AC=E6=9C=BA=E6=B8=85=E7=90=86=E7=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 39 +++++++++++++++++-- Tests/TaskForgeReminderCoreTests/main.swift | 24 ++++++++---- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 60fbeb3..3a44b5c 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -267,11 +267,20 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } public func loadOrCreateHashSalt() throws -> Data { - saltLock.lock() - defer { saltLock.unlock() } + Self.saltSetupLock.lock() + defer { Self.saltSetupLock.unlock() } try ensureDirectory(rootURL) let saltURL = rootURL.appendingPathComponent("PruneHashSalt") + let lockURL = rootURL.appendingPathComponent("PruneHashSalt.lock") + let lockDescriptor = try openSaltLock(at: lockURL) + defer { _ = close(lockDescriptor) } + guard flock(lockDescriptor, LOCK_EX) == 0 else { + throw ReminderPruneStoreError.permissions + } + defer { _ = flock(lockDescriptor, LOCK_UN) } + + try ensurePrivateFile(lockURL) if try itemExists(at: saltURL) { try ensurePrivateFile(saltURL) let salt = try readData(at: saltURL, error: .invalidBackup) @@ -287,7 +296,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } private let fileManager = FileManager.default - private let saltLock = NSLock() + private static let saltSetupLock = NSLock() private var encoder: JSONEncoder { let encoder = JSONEncoder() @@ -393,6 +402,30 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { throw ReminderPruneStoreError.permissions } + private func openSaltLock(at url: URL) throws -> Int32 { + let descriptor = open( + url.path, + O_RDWR | O_CREAT | O_EXCL, + mode_t(0o600) + ) + if descriptor >= 0 { + guard fchmod(descriptor, mode_t(0o600)) == 0 else { + _ = close(descriptor) + throw ReminderPruneStoreError.permissions + } + return descriptor + } + guard errno == EEXIST else { + throw ReminderPruneStoreError.permissions + } + + let existingDescriptor = open(url.path, O_RDWR) + guard existingDescriptor >= 0 else { + throw ReminderPruneStoreError.permissions + } + return existingDescriptor + } + private func itemExists(at url: URL) throws -> Bool { try attributesIfItemExists(at: url) != nil } diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 31f1ee8..3736421 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1403,30 +1403,32 @@ private let tests: [TestCase] = [ ) } }), - ("prune local store creates one shared hash salt under concurrency", { + ("prune local stores create one shared hash salt across instances", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } - let store = ReminderPruneLocalStore(rootURL: root) + let stores = (0..<32).map { _ in + ReminderPruneLocalStore(rootURL: root) + } let queue = DispatchQueue(label: "prune-salt", attributes: .concurrent) let group = DispatchGroup() let start = DispatchSemaphore(value: 0) let lock = NSLock() var salts: [Data] = [] - var failures: [Error] = [] + var failures: [String] = [] - for _ in 0..<32 { + for index in stores.indices { group.enter() queue.async { start.wait() do { - let salt = try store.loadOrCreateHashSalt() + let salt = try stores[index].loadOrCreateHashSalt() lock.lock() salts.append(salt) lock.unlock() } catch { lock.lock() - failures.append(error) + failures.append(String(describing: error)) lock.unlock() } group.leave() @@ -1437,12 +1439,20 @@ private let tests: [TestCase] = [ } group.wait() - try require(failures.isEmpty, "concurrent salt creation failed") + try require( + failures.isEmpty, + "concurrent salt creation failed: \(failures)" + ) try require(salts.count == 32, "missing concurrent salt result") try require( salts.dropFirst().allSatisfy { $0 == salts[0] }, "concurrent callers received different salts" ) + try require( + try ReminderPruneLocalStore(rootURL: root).loadOrCreateHashSalt() + == salts[0], + "persisted salt differs from concurrent callers" + ) }) ] From 5ea2dd4c29a46b8bdf289f4e56f87d39edc1fe53 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:12:46 +0800 Subject: [PATCH 14/34] =?UTF-8?q?feat(EventKit):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=8F=AF=E6=81=A2=E5=A4=8D=E6=8F=90=E9=86=92=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 9 +- .../ReminderBackupAdapter.swift | 263 +++++++++ .../ReminderPruner.swift | 541 ++++++++++++++++++ 3 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift create mode 100644 Sources/TaskForgeReminderEventKit/ReminderPruner.swift diff --git a/Package.swift b/Package.swift index 99f07d4..caeb88d 100644 --- a/Package.swift +++ b/Package.swift @@ -21,9 +21,16 @@ let package = Package( .target( name: "TaskForgeReminderCore" ), + .target( + name: "TaskForgeReminderEventKit", + dependencies: ["TaskForgeReminderCore"] + ), .executableTarget( name: "TaskForgeReminderSync", - dependencies: ["TaskForgeReminderCore"] + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ] ), .executableTarget( name: "TaskForgeReminderCoreTests", diff --git a/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift b/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift new file mode 100644 index 0000000..48fd5f9 --- /dev/null +++ b/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift @@ -0,0 +1,263 @@ +import CoreLocation +import EventKit +import Foundation +import TaskForgeReminderCore + +enum ReminderBackupAdapter { + static func capture(_ reminder: EKReminder) -> ReminderPruneBackupItem { + ReminderPruneBackupItem( + originalItemIdentifier: reminder.calendarItemIdentifier, + title: reminder.title ?? "", + notes: reminder.notes, + url: reminder.url, + priority: reminder.priority, + dueDateComponents: reminder.dueDateComponents, + startDateComponents: reminder.startDateComponents, + alarms: (reminder.alarms ?? []).map(captureAlarm), + recurrenceRules: (reminder.recurrenceRules ?? []).map( + captureRecurrence + ) + ) + } + + static func restore( + _ backup: ReminderPruneBackupItem, + into reminder: EKReminder + ) { + reminder.title = backup.title + reminder.notes = backup.notes + reminder.url = backup.url + reminder.priority = backup.priority + reminder.dueDateComponents = backup.dueDateComponents + reminder.startDateComponents = backup.startDateComponents + + (reminder.alarms ?? []).forEach(reminder.removeAlarm) + (reminder.recurrenceRules ?? []).forEach( + reminder.removeRecurrenceRule + ) + + for backupAlarm in backup.alarms { + let alarm: EKAlarm + if let absoluteDate = backupAlarm.absoluteDate { + alarm = EKAlarm(absoluteDate: absoluteDate) + } else { + alarm = EKAlarm( + relativeOffset: backupAlarm.relativeOffset ?? 0 + ) + } + if let backupLocation = backupAlarm.structuredLocation { + let location = EKStructuredLocation( + title: backupLocation.title + ) + if + let latitude = backupLocation.latitude, + let longitude = backupLocation.longitude + { + location.geoLocation = CLLocation( + latitude: latitude, + longitude: longitude + ) + } + location.radius = backupLocation.radius + alarm.structuredLocation = location + } + if let proximity = alarmProximity( + rawValue: backupAlarm.proximityRawValue + ) { + alarm.proximity = proximity + } + reminder.addAlarm(alarm) + } + + for backupRule in backup.recurrenceRules { + guard + let frequency = recurrenceFrequency( + rawValue: backupRule.frequencyRawValue + ), + backupRule.interval > 0, + validRecurrenceValues(backupRule), + let daysOfWeek = recurrenceDays( + from: backupRule.daysOfWeek + ) + else { + continue + } + let rule = EKRecurrenceRule( + recurrenceWith: frequency, + interval: backupRule.interval, + daysOfTheWeek: daysOfWeek.nilIfEmpty, + daysOfTheMonth: backupRule.daysOfMonth.numbers.nilIfEmpty, + monthsOfTheYear: backupRule.monthsOfYear.numbers.nilIfEmpty, + weeksOfTheYear: backupRule.weeksOfYear.numbers.nilIfEmpty, + daysOfTheYear: backupRule.daysOfYear.numbers.nilIfEmpty, + setPositions: backupRule.setPositions.numbers.nilIfEmpty, + end: recurrenceEnd( + date: backupRule.endDate, + count: backupRule.occurrenceCount + ) + ) + reminder.addRecurrenceRule(rule) + } + } + + private static func captureAlarm(_ alarm: EKAlarm) -> ReminderAlarmBackup { + let location = alarm.structuredLocation.map { + ReminderLocationBackup( + title: $0.title ?? "", + latitude: $0.geoLocation?.coordinate.latitude, + longitude: $0.geoLocation?.coordinate.longitude, + radius: $0.radius + ) + } + return ReminderAlarmBackup( + absoluteDate: alarm.absoluteDate, + relativeOffset: alarm.absoluteDate == nil + ? alarm.relativeOffset + : nil, + structuredLocation: location, + proximityRawValue: alarm.proximity.rawValue + ) + } + + private static func captureRecurrence( + _ rule: EKRecurrenceRule + ) -> ReminderRecurrenceBackup { + let end = rule.recurrenceEnd + return ReminderRecurrenceBackup( + frequencyRawValue: rule.frequency.rawValue, + interval: rule.interval, + daysOfWeek: (rule.daysOfTheWeek ?? []).map { + ReminderWeekdayBackup( + dayOfTheWeekRawValue: $0.dayOfTheWeek.rawValue, + weekNumber: $0.weekNumber + ) + }, + daysOfMonth: (rule.daysOfTheMonth ?? []).map(\.intValue), + monthsOfYear: (rule.monthsOfTheYear ?? []).map(\.intValue), + weeksOfYear: (rule.weeksOfTheYear ?? []).map(\.intValue), + daysOfYear: (rule.daysOfTheYear ?? []).map(\.intValue), + setPositions: (rule.setPositions ?? []).map(\.intValue), + endDate: end?.endDate, + occurrenceCount: end.flatMap { + $0.occurrenceCount > 0 ? Int($0.occurrenceCount) : nil + } + ) + } + + private static func recurrenceFrequency( + rawValue: Int + ) -> EKRecurrenceFrequency? { + switch rawValue { + case EKRecurrenceFrequency.daily.rawValue: + return .daily + case EKRecurrenceFrequency.weekly.rawValue: + return .weekly + case EKRecurrenceFrequency.monthly.rawValue: + return .monthly + case EKRecurrenceFrequency.yearly.rawValue: + return .yearly + default: + return nil + } + } + + private static func recurrenceDays( + from backups: [ReminderWeekdayBackup] + ) -> [EKRecurrenceDayOfWeek]? { + var result: [EKRecurrenceDayOfWeek] = [] + for backup in backups { + guard + let weekday = weekday(rawValue: backup.dayOfTheWeekRawValue), + (-53...53).contains(backup.weekNumber) + else { + return nil + } + result.append( + EKRecurrenceDayOfWeek( + dayOfTheWeek: weekday, + weekNumber: backup.weekNumber + ) + ) + } + return result + } + + private static func validRecurrenceValues( + _ backup: ReminderRecurrenceBackup + ) -> Bool { + backup.daysOfMonth.allSatisfy { + $0 != 0 && (-31...31).contains($0) + } + && backup.monthsOfYear.allSatisfy { (1...12).contains($0) } + && backup.weeksOfYear.allSatisfy { + $0 != 0 && (-53...53).contains($0) + } + && backup.daysOfYear.allSatisfy { + $0 != 0 && (-366...366).contains($0) + } + && backup.setPositions.allSatisfy { + $0 != 0 && (-366...366).contains($0) + } + } + + private static func weekday(rawValue: Int) -> EKWeekday? { + switch rawValue { + case EKWeekday.sunday.rawValue: + return .sunday + case EKWeekday.monday.rawValue: + return .monday + case EKWeekday.tuesday.rawValue: + return .tuesday + case EKWeekday.wednesday.rawValue: + return .wednesday + case EKWeekday.thursday.rawValue: + return .thursday + case EKWeekday.friday.rawValue: + return .friday + case EKWeekday.saturday.rawValue: + return .saturday + default: + return nil + } + } + + private static func alarmProximity( + rawValue: Int? + ) -> EKAlarmProximity? { + switch rawValue { + case EKAlarmProximity.none.rawValue: + return EKAlarmProximity.none + case EKAlarmProximity.enter.rawValue: + return .enter + case EKAlarmProximity.leave.rawValue: + return .leave + default: + return nil + } + } + + private static func recurrenceEnd( + date: Date?, + count: Int? + ) -> EKRecurrenceEnd? { + if let date { + return EKRecurrenceEnd(end: date) + } + if let count, count > 0 { + return EKRecurrenceEnd(occurrenceCount: count) + } + return nil + } +} + +private extension Array { + var nilIfEmpty: Self? { + isEmpty ? nil : self + } +} + +private extension Array where Element == Int { + var numbers: [NSNumber] { + map(NSNumber.init(value:)) + } +} diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift new file mode 100644 index 0000000..46b1d00 --- /dev/null +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -0,0 +1,541 @@ +import CryptoKit +import EventKit +import Foundation +import TaskForgeReminderCore + +public struct ReminderPruneConfiguration: Sendable { + public var listName: String + public var localRoot: URL + public var confirmationInterval: TimeInterval + public var restoreGraceInterval: TimeInterval + + public init( + listName: String, + localRoot: URL, + confirmationInterval: TimeInterval = 60, + restoreGraceInterval: TimeInterval = 86_400 + ) { + self.listName = listName + self.localRoot = localRoot + self.confirmationInterval = confirmationInterval + self.restoreGraceInterval = restoreGraceInterval + } +} + +public struct ReminderPruneCounts: Equatable, Sendable { + public var scanned = 0 + public var firstSeen = 0 + public var waiting = 0 + public var ready = 0 + public var deleted = 0 + public var restored = 0 + public var protected = 0 + public var failed = 0 +} + +public enum ReminderPrunerError: Error, LocalizedError { + case noReminderSource + case backupVerificationFailed + case restoredReminderReadbackFailed + + public var errorDescription: String? { + switch self { + case .noReminderSource: + return "找不到可以创建提醒事项列表的账户。" + case .backupVerificationFailed: + return "清理备份写入后的回读校验失败。" + case .restoredReminderReadbackFailed: + return "恢复提交后未能回读全部新提醒,备份仍未消费。" + } + } +} + +@MainActor +public final class ReminderPruner { + public init( + eventStore: EKEventStore, + configuration: ReminderPruneConfiguration, + log: @escaping @Sendable (String) -> Void, + logError: @escaping @Sendable (String) -> Void + ) { + self.eventStore = eventStore + self.configuration = configuration + self.localStore = ReminderPruneLocalStore( + rootURL: configuration.localRoot + ) + self.log = log + self.logError = logError + } + + public func dryRun( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts { + guard let targetCalendar = targetCalendar() else { + return ReminderPruneCounts() + } + let reminders = await fetchReminders(in: targetCalendar) + let observations = reminders.compactMap { + observation( + for: $0, + targetCalendar: targetCalendar, + snapshot: snapshot + ) + } + let prior = try localStore.loadLedger() + let plan = ReminderPruneStateMachine.plan( + observations: observations, + prior: prior, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + now: now, + confirmationInterval: configuration.confirmationInterval + ) + let counts = counts( + scanned: reminders.count, + observations: observations, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + plan: plan + ) + log(summary(prefix: "清理预演", counts: counts)) + return counts + } + + public func advance( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts { + guard let targetCalendar = targetCalendar() else { + return ReminderPruneCounts() + } + let initialReminders = await fetchReminders(in: targetCalendar) + let observations = initialReminders.compactMap { + observation( + for: $0, + targetCalendar: targetCalendar, + snapshot: snapshot + ) + } + let prior = try localStore.loadLedger() + let initialPlan = ReminderPruneStateMachine.plan( + observations: observations, + prior: prior, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + now: now, + confirmationInterval: configuration.confirmationInterval + ) + try localStore.saveLedger(initialPlan.nextLedger) + + var counts = counts( + scanned: initialReminders.count, + observations: observations, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + plan: initialPlan + ) + var confirmed: [EKReminder] = [] + for identifier in initialPlan.readyIdentifiers { + let current = await fetchReminders(in: targetCalendar).first { + $0.calendarItemIdentifier == identifier + } + guard + let current, + let currentObservation = observation( + for: current, + targetCalendar: targetCalendar, + snapshot: snapshot + ) + else { + continue + } + let recheck = ReminderPruneStateMachine.plan( + observations: [currentObservation], + prior: initialPlan.nextLedger, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + now: now, + confirmationInterval: configuration.confirmationInterval + ) + if recheck.readyIdentifiers == [identifier] { + confirmed.append(current) + } + } + counts.ready = confirmed.count + counts.protected += initialPlan.readyIdentifiers.count - confirmed.count + guard !confirmed.isEmpty else { + log(summary(prefix: "清理推进", counts: counts)) + return counts + } + + let backup = ReminderPruneBackupBatch( + identifier: UUID(), + createdAt: now, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + targetCalendarTitle: targetCalendar.title, + items: confirmed.map(ReminderBackupAdapter.capture), + restoredAt: nil + ) + let backupURL = try localStore.saveBackup(backup) + guard try localStore.loadBackup(at: backupURL) == backup else { + throw ReminderPrunerError.backupVerificationFailed + } + + let confirmedIdentifiers = Set( + confirmed.map(\.calendarItemIdentifier) + ) + for reminder in confirmed { + do { + try eventStore.remove(reminder, commit: false) + } catch { + let identifier = loggedIdentifier( + reminder.calendarItemIdentifier + ) + logError( + "清理暂存失败 [\(identifier)]。" + ) + } + } + + do { + try eventStore.commit() + } catch { + logError("清理提交失败,将按回读实际状态结算。") + } + let targetCalendarIdentifier = targetCalendar.calendarIdentifier + eventStore.reset() + + var ledger = initialPlan.nextLedger + guard + let refreshedCalendar = eventStore.calendar( + withIdentifier: targetCalendarIdentifier + ) + else { + counts.failed = confirmedIdentifiers.count + log(summary(prefix: "清理推进", counts: counts)) + return counts + } + let remaining = Set( + await fetchReminders(in: refreshedCalendar).map( + \.calendarItemIdentifier + ) + ) + for identifier in confirmedIdentifiers { + if remaining.contains(identifier) { + counts.failed += 1 + } else { + ledger.entries.removeValue(forKey: identifier) + counts.deleted += 1 + } + } + try localStore.saveLedger(ledger) + log(summary(prefix: "清理推进", counts: counts)) + return counts + } + + public func restoreLast( + now: Date = Date() + ) async throws -> ReminderPruneCounts { + guard let (backupURL, backup) = try localStore.latestUnrestoredBackup() + else { + return ReminderPruneCounts() + } + let calendar = try targetCalendar() + ?? createTargetCalendar( + title: backup.targetCalendarTitle, + preferredSource: eventStore.calendar( + withIdentifier: backup.targetCalendarIdentifier + )?.source + ) + var created: [EKReminder] = [] + do { + for item in backup.items { + let reminder = EKReminder(eventStore: eventStore) + reminder.calendar = calendar + ReminderBackupAdapter.restore(item, into: reminder) + try eventStore.save(reminder, commit: false) + created.append(reminder) + } + try eventStore.commit() + } catch { + eventStore.reset() + throw error + } + + let createdIdentifiers = Set( + created.map(\.calendarItemIdentifier) + ) + let restoredCalendarIdentifier = calendar.calendarIdentifier + eventStore.reset() + guard + let refreshedCalendar = eventStore.calendar( + withIdentifier: restoredCalendarIdentifier + ) + else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + let refreshed = await fetchReminders(in: refreshedCalendar) + let restored = refreshed.filter { + createdIdentifiers.contains($0.calendarItemIdentifier) + } + guard restored.count == backup.items.count else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + + var ledger = try localStore.loadLedger() + let graceUntil = now.addingTimeInterval( + max(configuration.restoreGraceInterval, 86_400) + ) + for reminder in restored { + let presence = restoredSourcePresence(notes: reminder.notes) + let identifier = reminder.calendarItemIdentifier + ledger.entries[identifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: fingerprint( + reminder: reminder, + targetCalendarIdentifier: + refreshedCalendar.calendarIdentifier, + presence: presence + ), + calendarIdentifier: refreshedCalendar.calendarIdentifier, + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: graceUntil + ) + } + try localStore.saveLedger(ledger) + try localStore.markRestored(at: backupURL, date: now) + + var counts = ReminderPruneCounts() + counts.scanned = refreshed.count + counts.restored = restored.count + log(summary(prefix: "清理恢复", counts: counts)) + return counts + } + + private let eventStore: EKEventStore + private let configuration: ReminderPruneConfiguration + private let localStore: ReminderPruneLocalStore + private let log: @Sendable (String) -> Void + private let logError: @Sendable (String) -> Void + + private func targetCalendar() -> EKCalendar? { + eventStore.calendars(for: .reminder).first { + $0.title == configuration.listName + } + } + + private func createTargetCalendar( + title: String, + preferredSource: EKSource? + ) throws -> EKCalendar { + guard + let source = preferredSource + ?? eventStore.defaultCalendarForNewReminders()?.source + ?? eventStore.calendars(for: .reminder).first?.source + else { + throw ReminderPrunerError.noReminderSource + } + let calendar = EKCalendar(for: .reminder, eventStore: eventStore) + calendar.title = title + calendar.source = source + try eventStore.saveCalendar(calendar, commit: true) + return calendar + } + + private func fetchReminders(in calendar: EKCalendar) async -> [EKReminder] { + let predicate = eventStore.predicateForReminders(in: [calendar]) + return await withCheckedContinuation { continuation in + _ = eventStore.fetchReminders(matching: predicate) { reminders in + continuation.resume(returning: reminders ?? []) + } + } + } + + private func observation( + for reminder: EKReminder, + targetCalendar: EKCalendar, + snapshot: TaskForgeSnapshot + ) -> ReminderPruneObservation? { + let identifier = reminder.calendarItemIdentifier + guard !identifier.isEmpty else { + return nil + } + let presence = sourcePresence( + notes: reminder.notes, + snapshot: snapshot + ) + return ReminderPruneObservation( + itemIdentifier: identifier, + calendarIdentifier: targetCalendar.calendarIdentifier, + isCompleted: reminder.isCompleted, + priority: reminder.priority, + title: reminder.title ?? "", + fingerprint: fingerprint( + reminder: reminder, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + presence: presence + ), + taskPresence: presence + ) + } + + private func sourcePresence( + notes: String?, + snapshot: TaskForgeSnapshot + ) -> TaskForgeReminderPresence { + if + let marker = TaskSyncMarker.extract(from: notes), + snapshot.tasks.contains(where: { + marker == TaskSyncMarker.make( + vaultPath: snapshot.vaultPath, + taskIdentifier: $0.identifier + ) + }) + { + return .currentSnapshot + } + guard + let reference = TaskSourceReference.decode(from: notes), + let path = reference.task.filePath + else { + return .indeterminate + } + + let vaultURL = URL( + fileURLWithPath: snapshot.vaultPath, + isDirectory: true + ).standardizedFileURL.resolvingSymlinksInPath() + let sourceURL = URL(fileURLWithPath: path) + .standardizedFileURL + .resolvingSymlinksInPath() + guard isDescendant(sourceURL, of: vaultURL) else { + return .indeterminate + } + + do { + _ = try FileManager.default.attributesOfItem( + atPath: sourceURL.path + ) + } catch { + return isNoSuchFileError(error) ? .absent : .indeterminate + } + guard + let data = try? Data(contentsOf: sourceURL), + let contents = String(data: data, encoding: .utf8) + else { + return .indeterminate + } + switch TaskSourcePresenceInspector.inspect( + task: reference.task, + contents: contents + ) { + case .present: + return .sourceConfirmed + case .absent: + return .absent + case .indeterminate: + return .indeterminate + } + } + + private func restoredSourcePresence( + notes: String? + ) -> TaskForgeReminderPresence { + guard + let marker = TaskSyncMarker.extract(from: notes), + let decoded = TaskSyncMarker.decode(marker) + else { + return .indeterminate + } + return sourcePresence( + notes: notes, + snapshot: TaskForgeSnapshot( + version: 6, + vaultPath: decoded.vaultPath, + tasks: [] + ) + ) + } + + private func fingerprint( + reminder: EKReminder, + targetCalendarIdentifier: String, + presence: TaskForgeReminderPresence + ) -> String { + let fields: [String] = [ + targetCalendarIdentifier, + reminder.calendarItemIdentifier, + reminder.isCompleted ? "1" : "0", + String(reminder.priority), + reminder.title ?? "", + reminder.notes ?? "", + presence.rawValue + ] + var data = Data() + for field in fields { + let bytes = Data(field.utf8) + var length = UInt64(bytes.count).bigEndian + withUnsafeBytes(of: &length) { + data.append(contentsOf: $0) + } + data.append(bytes) + } + return SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } + + private func counts( + scanned: Int, + observations: [ReminderPruneObservation], + targetCalendarIdentifier: String, + plan: ReminderPrunePlan + ) -> ReminderPruneCounts { + var counts = ReminderPruneCounts() + counts.scanned = scanned + counts.firstSeen = plan.firstSeenIdentifiers.count + counts.waiting = plan.waitingIdentifiers.count + counts.ready = plan.readyIdentifiers.count + let candidates = observations.filter { + ReminderPruneCandidatePolicy.isCandidate( + $0, + targetCalendarIdentifier: targetCalendarIdentifier + ) + }.count + counts.protected = max(scanned - candidates, 0) + return counts + } + + private func isDescendant(_ child: URL, of parent: URL) -> Bool { + let parentComponents = parent.pathComponents + let childComponents = child.pathComponents + return childComponents.count > parentComponents.count + && Array(childComponents.prefix(parentComponents.count)) + == parentComponents + } + + private func isNoSuchFileError(_ error: Error) -> Bool { + let error = error as NSError + return (error.domain == NSCocoaErrorDomain + && (error.code == NSFileNoSuchFileError + || error.code == NSFileReadNoSuchFileError)) + || (error.domain == NSPOSIXErrorDomain && error.code == ENOENT) + } + + private func loggedIdentifier(_ identifier: String) -> String { + guard var data = try? localStore.loadOrCreateHashSalt() else { + return "hash-unavailable" + } + data.append(Data(identifier.utf8)) + return SHA256.hash(data: data) + .prefix(6) + .map { String(format: "%02x", $0) } + .joined() + } + + private func summary( + prefix: String, + counts: ReminderPruneCounts + ) -> String { + "\(prefix):扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),就绪 \(counts.ready)," + + "删除 \(counts.deleted),恢复 \(counts.restored)," + + "保护 \(counts.protected),失败 \(counts.failed)" + } +} From e17dc65a0838869f75c9cf7a51dd594dda54b1af Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:15:53 +0800 Subject: [PATCH 15/34] =?UTF-8?q?fix(EventKit):=20=E6=8C=89=E5=8E=9F?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E6=81=A2=E5=A4=8D=E6=8F=90=E9=86=92=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 3 +++ .../ReminderPruner.swift | 24 +++++++++---------- Tests/TaskForgeReminderCoreTests/main.swift | 8 ++++++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 3a44b5c..236b6f8 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -126,6 +126,7 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { public var createdAt: Date public var targetCalendarIdentifier: String public var targetCalendarTitle: String + public var targetSourceIdentifier: String public var items: [ReminderPruneBackupItem] public var restoredAt: Date? @@ -134,6 +135,7 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { createdAt: Date, targetCalendarIdentifier: String, targetCalendarTitle: String, + targetSourceIdentifier: String, items: [ReminderPruneBackupItem], restoredAt: Date? ) { @@ -141,6 +143,7 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { self.createdAt = createdAt self.targetCalendarIdentifier = targetCalendarIdentifier self.targetCalendarTitle = targetCalendarTitle + self.targetSourceIdentifier = targetSourceIdentifier self.items = items self.restoredAt = restoredAt } diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index 46b1d00..ca1536d 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -34,14 +34,14 @@ public struct ReminderPruneCounts: Equatable, Sendable { } public enum ReminderPrunerError: Error, LocalizedError { - case noReminderSource + case backupReminderSourceUnavailable case backupVerificationFailed case restoredReminderReadbackFailed public var errorDescription: String? { switch self { - case .noReminderSource: - return "找不到可以创建提醒事项列表的账户。" + case .backupReminderSourceUnavailable: + return "找不到备份指定的提醒事项账户,备份仍未消费。" case .backupVerificationFailed: return "清理备份写入后的回读校验失败。" case .restoredReminderReadbackFailed: @@ -169,6 +169,8 @@ public final class ReminderPruner { createdAt: now, targetCalendarIdentifier: targetCalendar.calendarIdentifier, targetCalendarTitle: targetCalendar.title, + targetSourceIdentifier: + targetCalendar.source.sourceIdentifier, items: confirmed.map(ReminderBackupAdapter.capture), restoredAt: nil ) @@ -239,9 +241,7 @@ public final class ReminderPruner { let calendar = try targetCalendar() ?? createTargetCalendar( title: backup.targetCalendarTitle, - preferredSource: eventStore.calendar( - withIdentifier: backup.targetCalendarIdentifier - )?.source + sourceIdentifier: backup.targetSourceIdentifier ) var created: [EKReminder] = [] do { @@ -322,14 +322,12 @@ public final class ReminderPruner { private func createTargetCalendar( title: String, - preferredSource: EKSource? + sourceIdentifier: String ) throws -> EKCalendar { - guard - let source = preferredSource - ?? eventStore.defaultCalendarForNewReminders()?.source - ?? eventStore.calendars(for: .reminder).first?.source - else { - throw ReminderPrunerError.noReminderSource + guard let source = eventStore.sources.first(where: { + $0.sourceIdentifier == sourceIdentifier + }) else { + throw ReminderPrunerError.backupReminderSourceUnavailable } let calendar = EKCalendar(for: .reminder, eventStore: eventStore) calendar.title = title diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 3736421..329473e 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -192,6 +192,7 @@ private func pruneBackupFixture() -> ReminderPruneBackupBatch { createdAt: Date(timeIntervalSince1970: 20), targetCalendarIdentifier: "calendar", targetCalendarTitle: "TaskForge 今日", + targetSourceIdentifier: "source", items: [ ReminderPruneBackupItem( originalItemIdentifier: "item", @@ -1256,7 +1257,12 @@ private let tests: [TestCase] = [ let batch = pruneBackupFixture() let url = try store.saveBackup(batch) - try require(try store.loadBackup(at: url) == batch, "backup verification failed") + let loadedBatch = try store.loadBackup(at: url) + try require(loadedBatch == batch, "backup verification failed") + try require( + loadedBatch.targetSourceIdentifier == "source", + "backup source identifier round-trip failed" + ) }), ("prune local store rejects a modified backup", { let root = FileManager.default.temporaryDirectory From 18e5a1c199a9c399da49070a4abc6c6c315ed167 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:37:18 +0800 Subject: [PATCH 16/34] =?UTF-8?q?fix(EventKit):=20=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=81=A2=E5=A4=8D=E7=8A=B6=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 156 +++++- .../ReminderBackupAdapter.swift | 10 +- .../ReminderPruner.swift | 528 +++++++++++++++--- Tests/TaskForgeReminderCoreTests/main.swift | 111 +++- 4 files changed, 723 insertions(+), 82 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 236b6f8..715831d 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -97,6 +97,7 @@ public struct ReminderPruneBackupItem: Codable, Equatable, Sendable { public var startDateComponents: DateComponents? public var alarms: [ReminderAlarmBackup] public var recurrenceRules: [ReminderRecurrenceBackup] + public var taskPresence: TaskForgeReminderPresence public init( originalItemIdentifier: String, @@ -107,7 +108,8 @@ public struct ReminderPruneBackupItem: Codable, Equatable, Sendable { dueDateComponents: DateComponents?, startDateComponents: DateComponents?, alarms: [ReminderAlarmBackup], - recurrenceRules: [ReminderRecurrenceBackup] + recurrenceRules: [ReminderRecurrenceBackup], + taskPresence: TaskForgeReminderPresence ) { self.originalItemIdentifier = originalItemIdentifier self.title = title @@ -118,6 +120,7 @@ public struct ReminderPruneBackupItem: Codable, Equatable, Sendable { self.startDateComponents = startDateComponents self.alarms = alarms self.recurrenceRules = recurrenceRules + self.taskPresence = taskPresence } } @@ -127,16 +130,35 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { public var targetCalendarIdentifier: String public var targetCalendarTitle: String public var targetSourceIdentifier: String + public var rulesVersion: Int public var items: [ReminderPruneBackupItem] + public var actuallyDeletedIdentifiers: [String]? + public var restoreAttemptIdentifier: UUID? + public var restoredItemIdentifiers: [String: String] public var restoredAt: Date? + public var actuallyDeletedItems: [ReminderPruneBackupItem]? { + guard let identifiers = actuallyDeletedIdentifiers else { + return nil + } + let deleted = Set(identifiers) + let result = items.filter { + deleted.contains($0.originalItemIdentifier) + } + return result.count == deleted.count ? result : nil + } + public init( identifier: UUID, createdAt: Date, targetCalendarIdentifier: String, targetCalendarTitle: String, targetSourceIdentifier: String, + rulesVersion: Int, items: [ReminderPruneBackupItem], + actuallyDeletedIdentifiers: [String]?, + restoreAttemptIdentifier: UUID?, + restoredItemIdentifiers: [String: String], restoredAt: Date? ) { self.identifier = identifier @@ -144,7 +166,11 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { self.targetCalendarIdentifier = targetCalendarIdentifier self.targetCalendarTitle = targetCalendarTitle self.targetSourceIdentifier = targetSourceIdentifier + self.rulesVersion = rulesVersion self.items = items + self.actuallyDeletedIdentifiers = actuallyDeletedIdentifiers + self.restoreAttemptIdentifier = restoreAttemptIdentifier + self.restoredItemIdentifiers = restoredItemIdentifiers self.restoredAt = restoredAt } } @@ -190,6 +216,23 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } } + public func loadLedgerReadOnly() throws -> ReminderPruneLedger { + guard try attributesIfItemExists(at: rootURL) != nil else { + return ReminderPruneLedger() + } + try ensurePermissions(of: rootURL, expected: 0o700) + guard try itemExists(at: ledgerURL) else { + return ReminderPruneLedger() + } + try ensurePrivateFile(ledgerURL) + let data = try readData(at: ledgerURL, error: .invalidLedger) + do { + return try decoder.decode(ReminderPruneLedger.self, from: data) + } catch { + throw ReminderPruneStoreError.invalidLedger + } + } + public func saveLedger(_ ledger: ReminderPruneLedger) throws { try ensureDirectory(rootURL) let data: Data @@ -231,6 +274,9 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { guard envelope.checksum == checksum(for: payload) else { throw ReminderPruneStoreError.checksumMismatch } + guard isValidBackup(envelope.payload) else { + throw ReminderPruneStoreError.invalidBackup + } try ensurePrivateFile(url) return envelope.payload } @@ -265,10 +311,71 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { public func markRestored(at url: URL, date: Date) throws { var batch = try loadBackup(at: url) + guard batch.actuallyDeletedIdentifiers != nil else { + throw ReminderPruneStoreError.invalidBackup + } batch.restoredAt = date try saveBackup(batch, to: url) } + public func recordActuallyDeletedIdentifiers( + _ identifiers: [String], + at url: URL + ) throws { + var batch = try loadBackup(at: url) + let original = Set(batch.items.map(\.originalItemIdentifier)) + let deleted = Set(identifiers) + guard deleted.isSubset(of: original) else { + throw ReminderPruneStoreError.invalidBackup + } + if let existing = batch.actuallyDeletedIdentifiers { + guard Set(existing) == deleted else { + throw ReminderPruneStoreError.invalidBackup + } + return + } + batch.actuallyDeletedIdentifiers = deleted.sorted() + try saveBackup(batch, to: url) + } + + public func beginRestoreAttempt( + at url: URL + ) throws -> ReminderPruneBackupBatch { + var batch = try loadBackup(at: url) + guard batch.actuallyDeletedIdentifiers != nil else { + throw ReminderPruneStoreError.invalidBackup + } + if batch.restoreAttemptIdentifier == nil { + batch.restoreAttemptIdentifier = UUID() + try saveBackup(batch, to: url) + } + return batch + } + + public func recordRestoreReadback( + _ identifiers: [String: String], + at url: URL + ) throws { + var batch = try loadBackup(at: url) + guard + batch.restoreAttemptIdentifier != nil, + let deleted = batch.actuallyDeletedIdentifiers, + Set(identifiers.keys) == Set(deleted), + identifiers.values.allSatisfy({ !$0.isEmpty }), + Set(identifiers.values).count == identifiers.count + else { + throw ReminderPruneStoreError.invalidBackup + } + if !batch.restoredItemIdentifiers.isEmpty { + guard batch.restoredItemIdentifiers == identifiers else { + throw ReminderPruneStoreError.invalidBackup + } + return + } + batch.restoredItemIdentifiers = identifiers + try saveBackup(batch, to: url) + } + public func loadOrCreateHashSalt() throws -> Data { Self.saltSetupLock.lock() defer { Self.saltSetupLock.unlock() } @@ -330,6 +437,9 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { throw ReminderPruneStoreError.invalidBackup } + guard isValidBackup(batch) else { + throw ReminderPruneStoreError.invalidBackup + } let payload: Data let data: Data do { @@ -346,6 +456,50 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { try writeAtomically(data, to: url) } + private func isValidBackup(_ batch: ReminderPruneBackupBatch) -> Bool { + let originalIdentifiers = batch.items.map(\.originalItemIdentifier) + let original = Set(originalIdentifiers) + guard + !batch.targetCalendarIdentifier.isEmpty, + !batch.targetSourceIdentifier.isEmpty, + batch.rulesVersion > 0, + original.count == originalIdentifiers.count, + originalIdentifiers.allSatisfy({ !$0.isEmpty }) + else { + return false + } + if let deleted = batch.actuallyDeletedIdentifiers { + guard + Set(deleted).count == deleted.count, + Set(deleted).isSubset(of: original) + else { + return false + } + } else if batch.restoreAttemptIdentifier != nil + || !batch.restoredItemIdentifiers.isEmpty + || batch.restoredAt != nil + { + return false + } + if !batch.restoredItemIdentifiers.isEmpty { + guard + batch.restoreAttemptIdentifier != nil, + let deleted = batch.actuallyDeletedIdentifiers, + Set(batch.restoredItemIdentifiers.keys) == Set(deleted), + batch.restoredItemIdentifiers.values.allSatisfy({ + !$0.isEmpty + }), + Set(batch.restoredItemIdentifiers.values).count + == batch.restoredItemIdentifiers.count + else { + return false + } + } + return batch.restoredAt == nil + || batch.actuallyDeletedIdentifiers?.isEmpty == true + || !batch.restoredItemIdentifiers.isEmpty + } + private func ensureDirectory(_ url: URL) throws { if let attributes = try attributesIfItemExists(at: url) { guard diff --git a/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift b/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift index 48fd5f9..26e5f4c 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift @@ -5,6 +5,13 @@ import TaskForgeReminderCore enum ReminderBackupAdapter { static func capture(_ reminder: EKReminder) -> ReminderPruneBackupItem { + capture(reminder, taskPresence: .indeterminate) + } + + static func capture( + _ reminder: EKReminder, + taskPresence: TaskForgeReminderPresence + ) -> ReminderPruneBackupItem { ReminderPruneBackupItem( originalItemIdentifier: reminder.calendarItemIdentifier, title: reminder.title ?? "", @@ -16,7 +23,8 @@ enum ReminderBackupAdapter { alarms: (reminder.alarms ?? []).map(captureAlarm), recurrenceRules: (reminder.recurrenceRules ?? []).map( captureRecurrence - ) + ), + taskPresence: taskPresence ) } diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index ca1536d..c3eb683 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -1,4 +1,5 @@ import CryptoKit +import Darwin import EventKit import Foundation import TaskForgeReminderCore @@ -22,6 +23,104 @@ public struct ReminderPruneConfiguration: Sendable { } } +@MainActor +private final class ReminderPrunerOperationGate { + static let shared = ReminderPrunerOperationGate() + + private var held: Set = [] + private var waiters: [ + String: [CheckedContinuation] + ] = [:] + + func acquire(_ key: String) async { + if held.insert(key).inserted { + return + } + await withCheckedContinuation { continuation in + waiters[key, default: []].append(continuation) + } + } + + func release(_ key: String) { + if var pending = waiters[key], !pending.isEmpty { + let next = pending.removeFirst() + if pending.isEmpty { + waiters.removeValue(forKey: key) + } else { + waiters[key] = pending + } + next.resume() + } else { + held.remove(key) + } + } +} + +private final class ReminderPrunerFileLock { + init(rootURL: URL, exclusive: Bool) throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "TaskForgeReminderSync-PruneLocks", + isDirectory: true + ) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directory.path + ) + } catch { + throw ReminderPrunerError.operationLockFailed + } + let rootPath = rootURL.standardizedFileURL + .resolvingSymlinksInPath().path + let name = SHA256.hash(data: Data(rootPath.utf8)) + .map { String(format: "%02x", $0) } + .joined() + let lockURL = directory.appendingPathComponent("\(name).lock") + let opened = open( + lockURL.path, + O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard opened >= 0, fchmod(opened, mode_t(0o600)) == 0 else { + if opened >= 0 { + _ = close(opened) + } + throw ReminderPrunerError.operationLockFailed + } + let operation = exclusive ? LOCK_EX : LOCK_SH + var result: Int32 + repeat { + result = flock(opened, operation) + } while result != 0 && errno == EINTR + guard result == 0 else { + _ = close(opened) + throw ReminderPrunerError.operationLockFailed + } + descriptor = opened + } + + deinit { + unlock() + } + + func unlock() { + guard let descriptor else { + return + } + _ = flock(descriptor, LOCK_UN) + _ = close(descriptor) + self.descriptor = nil + } + + private var descriptor: Int32? +} + public struct ReminderPruneCounts: Equatable, Sendable { public var scanned = 0 public var firstSeen = 0 @@ -36,6 +135,12 @@ public struct ReminderPruneCounts: Equatable, Sendable { public enum ReminderPrunerError: Error, LocalizedError { case backupReminderSourceUnavailable case backupVerificationFailed + case backupRulesVersionUnsupported + case deletionOutcomeUnresolved + case ambiguousTargetCalendar + case operationLockFailed + case reminderFetchFailed + case restoreReadbackAmbiguous case restoredReminderReadbackFailed public var errorDescription: String? { @@ -44,6 +149,18 @@ public enum ReminderPrunerError: Error, LocalizedError { return "找不到备份指定的提醒事项账户,备份仍未消费。" case .backupVerificationFailed: return "清理备份写入后的回读校验失败。" + case .backupRulesVersionUnsupported: + return "备份清理规则版本不兼容,备份仍未消费。" + case .deletionOutcomeUnresolved: + return "备份尚未记录可核实的实际删除结果,已拒绝恢复。" + case .ambiguousTargetCalendar: + return "原账户中存在多个同名提醒列表,已拒绝选择。" + case .operationLockFailed: + return "无法取得提醒清理操作锁。" + case .reminderFetchFailed: + return "提醒事项读取失败,已按失败关闭。" + case .restoreReadbackAmbiguous: + return "恢复尝试的提醒回读不唯一,备份仍未消费。" case .restoredReminderReadbackFailed: return "恢复提交后未能回读全部新提醒,备份仍未消费。" } @@ -71,10 +188,20 @@ public final class ReminderPruner { snapshot: TaskForgeSnapshot, now: Date = Date() ) async throws -> ReminderPruneCounts { + await ReminderPrunerOperationGate.shared.acquire(operationKey) + defer { + ReminderPrunerOperationGate.shared.release(operationKey) + } + let operationLock = try ReminderPrunerFileLock( + rootURL: configuration.localRoot, + exclusive: false + ) + defer { operationLock.unlock() } + guard let targetCalendar = targetCalendar() else { return ReminderPruneCounts() } - let reminders = await fetchReminders(in: targetCalendar) + let reminders = try await fetchReminders(in: targetCalendar) let observations = reminders.compactMap { observation( for: $0, @@ -82,7 +209,7 @@ public final class ReminderPruner { snapshot: snapshot ) } - let prior = try localStore.loadLedger() + let prior = try localStore.loadLedgerReadOnly() let plan = ReminderPruneStateMachine.plan( observations: observations, prior: prior, @@ -104,10 +231,24 @@ public final class ReminderPruner { snapshot: TaskForgeSnapshot, now: Date = Date() ) async throws -> ReminderPruneCounts { + await ReminderPrunerOperationGate.shared.acquire(operationKey) + defer { + ReminderPrunerOperationGate.shared.release(operationKey) + } + let operationLock = try ReminderPrunerFileLock( + rootURL: configuration.localRoot, + exclusive: true + ) + defer { operationLock.unlock() } + guard let targetCalendar = targetCalendar() else { return ReminderPruneCounts() } - let initialReminders = await fetchReminders(in: targetCalendar) + let initialReminders = try await fetchReminders(in: targetCalendar) + try reconcileUnresolvedBackup( + targetCalendar: targetCalendar, + reminders: initialReminders + ) let observations = initialReminders.compactMap { observation( for: $0, @@ -131,13 +272,17 @@ public final class ReminderPruner { targetCalendarIdentifier: targetCalendar.calendarIdentifier, plan: initialPlan ) - var confirmed: [EKReminder] = [] + let freshReminders = try await fetchReminders(in: targetCalendar) + var freshByIdentifier: [String: EKReminder] = [:] + for reminder in freshReminders { + freshByIdentifier[reminder.calendarItemIdentifier] = reminder + } + var confirmed: [ + (reminder: EKReminder, observation: ReminderPruneObservation) + ] = [] for identifier in initialPlan.readyIdentifiers { - let current = await fetchReminders(in: targetCalendar).first { - $0.calendarItemIdentifier == identifier - } guard - let current, + let current = freshByIdentifier[identifier], let currentObservation = observation( for: current, targetCalendar: targetCalendar, @@ -154,7 +299,7 @@ public final class ReminderPruner { confirmationInterval: configuration.confirmationInterval ) if recheck.readyIdentifiers == [identifier] { - confirmed.append(current) + confirmed.append((current, currentObservation)) } } counts.ready = confirmed.count @@ -171,7 +316,16 @@ public final class ReminderPruner { targetCalendarTitle: targetCalendar.title, targetSourceIdentifier: targetCalendar.source.sourceIdentifier, - items: confirmed.map(ReminderBackupAdapter.capture), + rulesVersion: ReminderPruneStateMachine.rulesVersion, + items: confirmed.map { + ReminderBackupAdapter.capture( + $0.reminder, + taskPresence: $0.observation.taskPresence + ) + }, + actuallyDeletedIdentifiers: nil, + restoreAttemptIdentifier: nil, + restoredItemIdentifiers: [:], restoredAt: nil ) let backupURL = try localStore.saveBackup(backup) @@ -180,9 +334,12 @@ public final class ReminderPruner { } let confirmedIdentifiers = Set( - confirmed.map(\.calendarItemIdentifier) + confirmed.map(\.reminder.calendarItemIdentifier) ) - for reminder in confirmed { + let targetCalendarIdentifier = targetCalendar.calendarIdentifier + let targetSourceIdentifier = targetCalendar.source.sourceIdentifier + for candidate in confirmed { + let reminder = candidate.reminder do { try eventStore.remove(reminder, commit: false) } catch { @@ -200,24 +357,27 @@ public final class ReminderPruner { } catch { logError("清理提交失败,将按回读实际状态结算。") } - let targetCalendarIdentifier = targetCalendar.calendarIdentifier eventStore.reset() var ledger = initialPlan.nextLedger guard let refreshedCalendar = eventStore.calendar( withIdentifier: targetCalendarIdentifier - ) + ), + refreshedCalendar.source.sourceIdentifier == targetSourceIdentifier else { - counts.failed = confirmedIdentifiers.count - log(summary(prefix: "清理推进", counts: counts)) - return counts + throw ReminderPrunerError.reminderFetchFailed } let remaining = Set( - await fetchReminders(in: refreshedCalendar).map( + try await fetchReminders(in: refreshedCalendar).map( \.calendarItemIdentifier ) ) + let actuallyDeleted = confirmedIdentifiers.subtracting(remaining) + try localStore.recordActuallyDeletedIdentifiers( + actuallyDeleted.sorted(), + at: backupURL + ) for identifier in confirmedIdentifiers { if remaining.contains(identifier) { counts.failed += 1 @@ -234,23 +394,120 @@ public final class ReminderPruner { public func restoreLast( now: Date = Date() ) async throws -> ReminderPruneCounts { - guard let (backupURL, backup) = try localStore.latestUnrestoredBackup() + await ReminderPrunerOperationGate.shared.acquire(operationKey) + defer { + ReminderPrunerOperationGate.shared.release(operationKey) + } + let operationLock = try ReminderPrunerFileLock( + rootURL: configuration.localRoot, + exclusive: true + ) + defer { operationLock.unlock() } + + guard let (backupURL, originalBackup) = + try localStore.latestUnrestoredBackup() else { return ReminderPruneCounts() } - let calendar = try targetCalendar() - ?? createTargetCalendar( - title: backup.targetCalendarTitle, - sourceIdentifier: backup.targetSourceIdentifier - ) - var created: [EKReminder] = [] - do { - for item in backup.items { + guard + originalBackup.rulesVersion + == ReminderPruneStateMachine.rulesVersion + else { + throw ReminderPrunerError.backupRulesVersionUnsupported + } + guard + let items = originalBackup.actuallyDeletedItems + else { + throw ReminderPrunerError.deletionOutcomeUnresolved + } + if items.isEmpty { + try localStore.markRestored(at: backupURL, date: now) + return ReminderPruneCounts() + } + + let backup = try localStore.beginRestoreAttempt(at: backupURL) + guard let attemptIdentifier = backup.restoreAttemptIdentifier else { + throw ReminderPrunerError.deletionOutcomeUnresolved + } + var calendar = try restoreTargetCalendar(for: backup) + var reminders = try await fetchReminders(in: calendar) + var restoredIdentifiers = backup.restoredItemIdentifiers + + if restoredIdentifiers.isEmpty { + var stagedCreation = false + for item in items { + let marker = restoreMarker( + backupIdentifier: backup.identifier, + attemptIdentifier: attemptIdentifier, + originalItemIdentifier: item.originalItemIdentifier + ) + let matches = reminders.filter { + containsRestoreMarker($0.notes, marker: marker) + } + guard matches.count <= 1 else { + throw ReminderPrunerError.restoreReadbackAmbiguous + } + guard matches.isEmpty else { + continue + } let reminder = EKReminder(eventStore: eventStore) reminder.calendar = calendar ReminderBackupAdapter.restore(item, into: reminder) + reminder.notes = addingRestoreMarker( + to: item.notes, + marker: marker + ) try eventStore.save(reminder, commit: false) - created.append(reminder) + stagedCreation = true + } + if stagedCreation { + let calendarIdentifier = calendar.calendarIdentifier + let sourceIdentifier = calendar.source.sourceIdentifier + do { + try eventStore.commit() + } catch { + eventStore.reset() + throw error + } + eventStore.reset() + calendar = try exactRestoreCalendar( + identifier: calendarIdentifier, + sourceIdentifier: sourceIdentifier + ) + } + reminders = try await fetchReminders(in: calendar) + for item in items { + let marker = restoreMarker( + backupIdentifier: backup.identifier, + attemptIdentifier: attemptIdentifier, + originalItemIdentifier: item.originalItemIdentifier + ) + let matches = reminders.filter { + containsRestoreMarker($0.notes, marker: marker) + } + guard matches.count == 1, let match = matches.first else { + throw matches.isEmpty + ? ReminderPrunerError.restoredReminderReadbackFailed + : ReminderPrunerError.restoreReadbackAmbiguous + } + restoredIdentifiers[item.originalItemIdentifier] = + match.calendarItemIdentifier + } + try localStore.recordRestoreReadback( + restoredIdentifiers, + at: backupURL + ) + } + + let restoredPairs = try restoreReadbackPairs( + items: items, + identifiers: restoredIdentifiers, + reminders: reminders + ) + do { + for pair in restoredPairs { + ReminderBackupAdapter.restore(pair.item, into: pair.reminder) + try eventStore.save(pair.reminder, commit: false) } try eventStore.commit() } catch { @@ -258,32 +515,25 @@ public final class ReminderPruner { throw error } - let createdIdentifiers = Set( - created.map(\.calendarItemIdentifier) - ) let restoredCalendarIdentifier = calendar.calendarIdentifier + let restoredSourceIdentifier = calendar.source.sourceIdentifier eventStore.reset() - guard - let refreshedCalendar = eventStore.calendar( - withIdentifier: restoredCalendarIdentifier - ) - else { - throw ReminderPrunerError.restoredReminderReadbackFailed - } - let refreshed = await fetchReminders(in: refreshedCalendar) - let restored = refreshed.filter { - createdIdentifiers.contains($0.calendarItemIdentifier) - } - guard restored.count == backup.items.count else { - throw ReminderPrunerError.restoredReminderReadbackFailed - } - + let refreshedCalendar = try exactRestoreCalendar( + identifier: restoredCalendarIdentifier, + sourceIdentifier: restoredSourceIdentifier + ) + let refreshed = try await fetchReminders(in: refreshedCalendar) + let finalPairs = try restoreReadbackPairs( + items: items, + identifiers: restoredIdentifiers, + reminders: refreshed + ) var ledger = try localStore.loadLedger() let graceUntil = now.addingTimeInterval( max(configuration.restoreGraceInterval, 86_400) ) - for reminder in restored { - let presence = restoredSourcePresence(notes: reminder.notes) + for pair in finalPairs { + let reminder = pair.reminder let identifier = reminder.calendarItemIdentifier ledger.entries[identifier] = ReminderPruneLedgerEntry( firstSeen: now, @@ -291,10 +541,10 @@ public final class ReminderPruner { reminder: reminder, targetCalendarIdentifier: refreshedCalendar.calendarIdentifier, - presence: presence + presence: pair.item.taskPresence ), calendarIdentifier: refreshedCalendar.calendarIdentifier, - rulesVersion: ReminderPruneStateMachine.rulesVersion, + rulesVersion: backup.rulesVersion, graceUntil: graceUntil ) } @@ -303,7 +553,7 @@ public final class ReminderPruner { var counts = ReminderPruneCounts() counts.scanned = refreshed.count - counts.restored = restored.count + counts.restored = finalPairs.count log(summary(prefix: "清理恢复", counts: counts)) return counts } @@ -314,37 +564,176 @@ public final class ReminderPruner { private let log: @Sendable (String) -> Void private let logError: @Sendable (String) -> Void + private var operationKey: String { + configuration.localRoot.standardizedFileURL + .resolvingSymlinksInPath().path + } + private func targetCalendar() -> EKCalendar? { eventStore.calendars(for: .reminder).first { $0.title == configuration.listName } } - private func createTargetCalendar( - title: String, - sourceIdentifier: String + private func reconcileUnresolvedBackup( + targetCalendar: EKCalendar, + reminders: [EKReminder] + ) throws { + guard + let (url, backup) = try localStore.latestUnrestoredBackup(), + backup.actuallyDeletedIdentifiers == nil + else { + return + } + guard + backup.targetCalendarIdentifier + == targetCalendar.calendarIdentifier, + backup.targetSourceIdentifier + == targetCalendar.source.sourceIdentifier + else { + throw ReminderPrunerError.deletionOutcomeUnresolved + } + let remaining = Set( + reminders.map(\.calendarItemIdentifier) + ) + let attempted = Set( + backup.items.map(\.originalItemIdentifier) + ) + try localStore.recordActuallyDeletedIdentifiers( + attempted.subtracting(remaining).sorted(), + at: url + ) + } + + private func restoreTargetCalendar( + for backup: ReminderPruneBackupBatch ) throws -> EKCalendar { + let calendars = eventStore.calendars(for: .reminder) + if let exact = calendars.first(where: { + $0.calendarIdentifier == backup.targetCalendarIdentifier + && $0.source.sourceIdentifier + == backup.targetSourceIdentifier + }) { + return exact + } + let named = calendars.filter { + $0.title == backup.targetCalendarTitle + && $0.source.sourceIdentifier + == backup.targetSourceIdentifier + } + guard named.count <= 1 else { + throw ReminderPrunerError.ambiguousTargetCalendar + } + if let existing = named.first { + return existing + } guard let source = eventStore.sources.first(where: { - $0.sourceIdentifier == sourceIdentifier + $0.sourceIdentifier == backup.targetSourceIdentifier }) else { throw ReminderPrunerError.backupReminderSourceUnavailable } let calendar = EKCalendar(for: .reminder, eventStore: eventStore) - calendar.title = title + calendar.title = backup.targetCalendarTitle calendar.source = source try eventStore.saveCalendar(calendar, commit: true) return calendar } - private func fetchReminders(in calendar: EKCalendar) async -> [EKReminder] { + private func exactRestoreCalendar( + identifier: String, + sourceIdentifier: String + ) throws -> EKCalendar { + guard let calendar = eventStore.calendars(for: .reminder).first(where: { + $0.calendarIdentifier == identifier + && $0.source.sourceIdentifier == sourceIdentifier + }) else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + return calendar + } + + private func fetchReminders( + in calendar: EKCalendar + ) async throws -> [EKReminder] { let predicate = eventStore.predicateForReminders(in: [calendar]) - return await withCheckedContinuation { continuation in + return try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<[EKReminder], Error>) in _ = eventStore.fetchReminders(matching: predicate) { reminders in - continuation.resume(returning: reminders ?? []) + guard let reminders else { + continuation.resume( + throwing: ReminderPrunerError.reminderFetchFailed + ) + return + } + continuation.resume(returning: reminders) } } } + private func restoreReadbackPairs( + items: [ReminderPruneBackupItem], + identifiers: [String: String], + reminders: [EKReminder] + ) throws -> [ + (item: ReminderPruneBackupItem, reminder: EKReminder) + ] { + guard + Set(identifiers.keys) + == Set(items.map(\.originalItemIdentifier)) + else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + var remindersByIdentifier: [String: EKReminder] = [:] + for reminder in reminders { + let identifier = reminder.calendarItemIdentifier + guard remindersByIdentifier[identifier] == nil else { + throw ReminderPrunerError.restoreReadbackAmbiguous + } + remindersByIdentifier[identifier] = reminder + } + return try items.map { item in + guard + let restoredIdentifier = + identifiers[item.originalItemIdentifier], + let reminder = + remindersByIdentifier[restoredIdentifier] + else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + return (item, reminder) + } + } + + private func restoreMarker( + backupIdentifier: UUID, + attemptIdentifier: UUID, + originalItemIdentifier: String + ) -> String { + let itemHash = SHA256.hash( + data: Data(originalItemIdentifier.utf8) + ).map { String(format: "%02x", $0) }.joined() + return "TaskForge-Prune-Restore-ID: " + + "\(backupIdentifier.uuidString):" + + "\(attemptIdentifier.uuidString):\(itemHash)" + } + + private func containsRestoreMarker( + _ notes: String?, + marker: String + ) -> Bool { + notes?.components(separatedBy: "\n").contains(marker) == true + } + + private func addingRestoreMarker( + to notes: String?, + marker: String + ) -> String { + guard let notes, !notes.isEmpty else { + return marker + } + return notes + "\n" + marker + } + private func observation( for reminder: EKReminder, targetCalendar: EKCalendar, @@ -392,7 +781,7 @@ public final class ReminderPruner { let reference = TaskSourceReference.decode(from: notes), let path = reference.task.filePath else { - return .indeterminate + return .absent } let vaultURL = URL( @@ -432,25 +821,6 @@ public final class ReminderPruner { } } - private func restoredSourcePresence( - notes: String? - ) -> TaskForgeReminderPresence { - guard - let marker = TaskSyncMarker.extract(from: notes), - let decoded = TaskSyncMarker.decode(marker) - else { - return .indeterminate - } - return sourcePresence( - notes: notes, - snapshot: TaskForgeSnapshot( - version: 6, - vaultPath: decoded.vaultPath, - tasks: [] - ) - ) - } - private func fingerprint( reminder: EKReminder, targetCalendarIdentifier: String, diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 329473e..545d1f7 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -193,6 +193,7 @@ private func pruneBackupFixture() -> ReminderPruneBackupBatch { targetCalendarIdentifier: "calendar", targetCalendarTitle: "TaskForge 今日", targetSourceIdentifier: "source", + rulesVersion: ReminderPruneStateMachine.rulesVersion, items: [ ReminderPruneBackupItem( originalItemIdentifier: "item", @@ -209,9 +210,13 @@ private func pruneBackupFixture() -> ReminderPruneBackupBatch { ), startDateComponents: nil, alarms: [], - recurrenceRules: [] + recurrenceRules: [], + taskPresence: .absent ) ], + actuallyDeletedIdentifiers: nil, + restoreAttemptIdentifier: nil, + restoredItemIdentifiers: [:], restoredAt: nil ) } @@ -1222,6 +1227,95 @@ private let tests: [TestCase] = [ ) try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") }), + ("prune read-only ledger load never creates its root", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + try require( + try store.loadLedgerReadOnly() == ReminderPruneLedger(), + "missing read-only ledger should be empty" + ) + try require( + !FileManager.default.fileExists(atPath: root.path), + "read-only ledger load must not create its root" + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try require( + try store.loadLedgerReadOnly() == ReminderPruneLedger(), + "missing read-only ledger should remain empty" + ) + try require( + !FileManager.default.fileExists(atPath: store.ledgerURL.path), + "read-only ledger load must not create the ledger" + ) + }), + ("prune backup persists deletion and idempotent restore readback", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + var batch = pruneBackupFixture() + var notDeleted = batch.items[0] + notDeleted.originalItemIdentifier = "not-deleted" + batch.items.append(notDeleted) + let url = try store.saveBackup(batch) + try store.recordActuallyDeletedIdentifiers(["item"], at: url) + let firstAttempt = try store.beginRestoreAttempt(at: url) + let secondAttempt = try store.beginRestoreAttempt(at: url) + try require( + firstAttempt.restoreAttemptIdentifier + == secondAttempt.restoreAttemptIdentifier, + "restore attempt must be stable across retries" + ) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: url + ) + let loaded = try store.loadBackup(at: url) + try require( + loaded.actuallyDeletedIdentifiers == ["item"], + "actual deletion result did not persist" + ) + try require( + loaded.actuallyDeletedItems?.map(\.originalItemIdentifier) + == ["item"], + "restore selection must exclude attempted but retained items" + ) + try require( + loaded.restoredItemIdentifiers == ["item": "restored-item"], + "restore readback did not persist" + ) + do { + try store.recordActuallyDeletedIdentifiers([], at: url) + throw TestFailure( + description: "deletion result overwrite should fail" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected deletion overwrite error" + ) + } + do { + try store.recordRestoreReadback( + ["item": "different-restored-item"], + at: url + ) + throw TestFailure( + description: "restore readback overwrite should fail" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restore overwrite error" + ) + } + }), ("prune local store writes private ledger and verified backup", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1263,6 +1357,15 @@ private let tests: [TestCase] = [ loadedBatch.targetSourceIdentifier == "source", "backup source identifier round-trip failed" ) + try require( + loadedBatch.rulesVersion + == ReminderPruneStateMachine.rulesVersion, + "backup rules version round-trip failed" + ) + try require( + loadedBatch.items.first?.taskPresence == .absent, + "backup task presence round-trip failed" + ) }), ("prune local store rejects a modified backup", { let root = FileManager.default.temporaryDirectory @@ -1378,6 +1481,12 @@ private let tests: [TestCase] = [ let store = ReminderPruneLocalStore(rootURL: root) let backupURL = try store.saveBackup(pruneBackupFixture()) let restoredAt = Date(timeIntervalSince1970: 99) + try store.recordActuallyDeletedIdentifiers(["item"], at: backupURL) + _ = try store.beginRestoreAttempt(at: backupURL) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: backupURL + ) try store.markRestored(at: backupURL, date: restoredAt) try require( try store.loadBackup(at: backupURL).restoredAt == restoredAt, From dd89f3416a5830ea042a81e7367e2b367a88e888 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:48:06 +0800 Subject: [PATCH 17/34] =?UTF-8?q?fix(=E6=B8=85=E7=90=86):=20=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E5=A4=87=E4=BB=BD=E7=89=88=E6=9C=AC=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E9=A2=84=E6=BC=94=E5=8F=AA=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 51 +++++++- .../ReminderPruning.swift | 25 ++++ .../ReminderPruner.swift | 118 +++++------------- Tests/TaskForgeReminderCoreTests/main.swift | 75 +++++++++++ 4 files changed, 178 insertions(+), 91 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 715831d..9953cf2 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -130,6 +130,7 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { public var targetCalendarIdentifier: String public var targetCalendarTitle: String public var targetSourceIdentifier: String + public var backupSchemaVersion: Int public var rulesVersion: Int public var items: [ReminderPruneBackupItem] public var actuallyDeletedIdentifiers: [String]? @@ -154,6 +155,7 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { targetCalendarIdentifier: String, targetCalendarTitle: String, targetSourceIdentifier: String, + backupSchemaVersion: Int, rulesVersion: Int, items: [ReminderPruneBackupItem], actuallyDeletedIdentifiers: [String]?, @@ -166,6 +168,7 @@ public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { self.targetCalendarIdentifier = targetCalendarIdentifier self.targetCalendarTitle = targetCalendarTitle self.targetSourceIdentifier = targetSourceIdentifier + self.backupSchemaVersion = backupSchemaVersion self.rulesVersion = rulesVersion self.items = items self.actuallyDeletedIdentifiers = actuallyDeletedIdentifiers @@ -462,7 +465,8 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { guard !batch.targetCalendarIdentifier.isEmpty, !batch.targetSourceIdentifier.isEmpty, - batch.rulesVersion > 0, + batch.backupSchemaVersion > 0, + batch.rulesVersion >= 0, original.count == originalIdentifiers.count, originalIdentifiers.allSatisfy({ !$0.isEmpty }) else { @@ -667,6 +671,51 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } } +public final class ReminderPruneOperationFileLock: @unchecked Sendable { + public init( + exclusive: Bool, + anchorURL: URL = URL( + fileURLWithPath: "/tmp", + isDirectory: true + ) + ) throws { + let path = anchorURL.standardizedFileURL + .resolvingSymlinksInPath().path + let opened = open( + path, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_DIRECTORY + ) + guard opened >= 0 else { + throw ReminderPruneStoreError.permissions + } + let operation = exclusive ? LOCK_EX : LOCK_SH + var result: Int32 + repeat { + result = flock(opened, operation) + } while result != 0 && errno == EINTR + guard result == 0 else { + _ = close(opened) + throw ReminderPruneStoreError.permissions + } + descriptor = opened + } + + deinit { + unlock() + } + + public func unlock() { + guard let descriptor else { + return + } + _ = flock(descriptor, LOCK_UN) + _ = close(descriptor) + self.descriptor = nil + } + + private var descriptor: Int32? +} + private struct BackupEnvelope: Codable { let checksum: String let payload: ReminderPruneBackupBatch diff --git a/Sources/TaskForgeReminderCore/ReminderPruning.swift b/Sources/TaskForgeReminderCore/ReminderPruning.swift index 59b8f01..5fa97c3 100644 --- a/Sources/TaskForgeReminderCore/ReminderPruning.swift +++ b/Sources/TaskForgeReminderCore/ReminderPruning.swift @@ -183,3 +183,28 @@ public enum ReminderPruneStateMachine { ) } } + +public enum ReminderPruneRestorePolicy { + public static let currentBackupSchemaVersion = 1 + + public static func supportsBackupSchema(_ version: Int) -> Bool { + version == currentBackupSchemaVersion + } + + public static func graceLedgerEntry( + fingerprint: String, + calendarIdentifier: String, + now: Date, + restoreGraceInterval: TimeInterval = 86_400 + ) -> ReminderPruneLedgerEntry { + ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: fingerprint, + calendarIdentifier: calendarIdentifier, + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: now.addingTimeInterval( + max(restoreGraceInterval, 86_400) + ) + ) + } +} diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index c3eb683..7f96c6a 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -56,71 +56,6 @@ private final class ReminderPrunerOperationGate { } } -private final class ReminderPrunerFileLock { - init(rootURL: URL, exclusive: Bool) throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent( - "TaskForgeReminderSync-PruneLocks", - isDirectory: true - ) - do { - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - try FileManager.default.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: directory.path - ) - } catch { - throw ReminderPrunerError.operationLockFailed - } - let rootPath = rootURL.standardizedFileURL - .resolvingSymlinksInPath().path - let name = SHA256.hash(data: Data(rootPath.utf8)) - .map { String(format: "%02x", $0) } - .joined() - let lockURL = directory.appendingPathComponent("\(name).lock") - let opened = open( - lockURL.path, - O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, - mode_t(0o600) - ) - guard opened >= 0, fchmod(opened, mode_t(0o600)) == 0 else { - if opened >= 0 { - _ = close(opened) - } - throw ReminderPrunerError.operationLockFailed - } - let operation = exclusive ? LOCK_EX : LOCK_SH - var result: Int32 - repeat { - result = flock(opened, operation) - } while result != 0 && errno == EINTR - guard result == 0 else { - _ = close(opened) - throw ReminderPrunerError.operationLockFailed - } - descriptor = opened - } - - deinit { - unlock() - } - - func unlock() { - guard let descriptor else { - return - } - _ = flock(descriptor, LOCK_UN) - _ = close(descriptor) - self.descriptor = nil - } - - private var descriptor: Int32? -} - public struct ReminderPruneCounts: Equatable, Sendable { public var scanned = 0 public var firstSeen = 0 @@ -135,7 +70,7 @@ public struct ReminderPruneCounts: Equatable, Sendable { public enum ReminderPrunerError: Error, LocalizedError { case backupReminderSourceUnavailable case backupVerificationFailed - case backupRulesVersionUnsupported + case backupSchemaVersionUnsupported case deletionOutcomeUnresolved case ambiguousTargetCalendar case operationLockFailed @@ -149,8 +84,8 @@ public enum ReminderPrunerError: Error, LocalizedError { return "找不到备份指定的提醒事项账户,备份仍未消费。" case .backupVerificationFailed: return "清理备份写入后的回读校验失败。" - case .backupRulesVersionUnsupported: - return "备份清理规则版本不兼容,备份仍未消费。" + case .backupSchemaVersionUnsupported: + return "备份格式版本不兼容,备份仍未消费。" case .deletionOutcomeUnresolved: return "备份尚未记录可核实的实际删除结果,已拒绝恢复。" case .ambiguousTargetCalendar: @@ -192,10 +127,7 @@ public final class ReminderPruner { defer { ReminderPrunerOperationGate.shared.release(operationKey) } - let operationLock = try ReminderPrunerFileLock( - rootURL: configuration.localRoot, - exclusive: false - ) + let operationLock = try operationFileLock(exclusive: false) defer { operationLock.unlock() } guard let targetCalendar = targetCalendar() else { @@ -235,10 +167,7 @@ public final class ReminderPruner { defer { ReminderPrunerOperationGate.shared.release(operationKey) } - let operationLock = try ReminderPrunerFileLock( - rootURL: configuration.localRoot, - exclusive: true - ) + let operationLock = try operationFileLock(exclusive: true) defer { operationLock.unlock() } guard let targetCalendar = targetCalendar() else { @@ -316,6 +245,8 @@ public final class ReminderPruner { targetCalendarTitle: targetCalendar.title, targetSourceIdentifier: targetCalendar.source.sourceIdentifier, + backupSchemaVersion: + ReminderPruneRestorePolicy.currentBackupSchemaVersion, rulesVersion: ReminderPruneStateMachine.rulesVersion, items: confirmed.map { ReminderBackupAdapter.capture( @@ -398,10 +329,7 @@ public final class ReminderPruner { defer { ReminderPrunerOperationGate.shared.release(operationKey) } - let operationLock = try ReminderPrunerFileLock( - rootURL: configuration.localRoot, - exclusive: true - ) + let operationLock = try operationFileLock(exclusive: true) defer { operationLock.unlock() } guard let (backupURL, originalBackup) = @@ -410,10 +338,11 @@ public final class ReminderPruner { return ReminderPruneCounts() } guard - originalBackup.rulesVersion - == ReminderPruneStateMachine.rulesVersion + ReminderPruneRestorePolicy.supportsBackupSchema( + originalBackup.backupSchemaVersion + ) else { - throw ReminderPrunerError.backupRulesVersionUnsupported + throw ReminderPrunerError.backupSchemaVersionUnsupported } guard let items = originalBackup.actuallyDeletedItems @@ -529,14 +458,11 @@ public final class ReminderPruner { reminders: refreshed ) var ledger = try localStore.loadLedger() - let graceUntil = now.addingTimeInterval( - max(configuration.restoreGraceInterval, 86_400) - ) for pair in finalPairs { let reminder = pair.reminder let identifier = reminder.calendarItemIdentifier - ledger.entries[identifier] = ReminderPruneLedgerEntry( - firstSeen: now, + ledger.entries[identifier] = + ReminderPruneRestorePolicy.graceLedgerEntry( fingerprint: fingerprint( reminder: reminder, targetCalendarIdentifier: @@ -544,8 +470,8 @@ public final class ReminderPruner { presence: pair.item.taskPresence ), calendarIdentifier: refreshedCalendar.calendarIdentifier, - rulesVersion: backup.rulesVersion, - graceUntil: graceUntil + now: now, + restoreGraceInterval: configuration.restoreGraceInterval ) } try localStore.saveLedger(ledger) @@ -569,6 +495,18 @@ public final class ReminderPruner { .resolvingSymlinksInPath().path } + private func operationFileLock( + exclusive: Bool + ) throws -> ReminderPruneOperationFileLock { + do { + return try ReminderPruneOperationFileLock( + exclusive: exclusive + ) + } catch { + throw ReminderPrunerError.operationLockFailed + } + } + private func targetCalendar() -> EKCalendar? { eventStore.calendars(for: .reminder).first { $0.title == configuration.listName diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 545d1f7..4a63028 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -193,6 +193,8 @@ private func pruneBackupFixture() -> ReminderPruneBackupBatch { targetCalendarIdentifier: "calendar", targetCalendarTitle: "TaskForge 今日", targetSourceIdentifier: "source", + backupSchemaVersion: + ReminderPruneRestorePolicy.currentBackupSchemaVersion, rulesVersion: ReminderPruneStateMachine.rulesVersion, items: [ ReminderPruneBackupItem( @@ -1227,6 +1229,74 @@ private let tests: [TestCase] = [ ) try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") }), + ("restore policy separates backup schema from candidate rules", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + var legacyRulesBackup = pruneBackupFixture() + legacyRulesBackup.rulesVersion = 0 + let url = try store.saveBackup(legacyRulesBackup) + let loaded = try store.loadBackup(at: url) + try require( + ReminderPruneRestorePolicy.supportsBackupSchema( + loaded.backupSchemaVersion + ), + "supported schema must remain restorable" + ) + try require( + !ReminderPruneRestorePolicy.supportsBackupSchema( + loaded.backupSchemaVersion + 1 + ), + "unknown backup schema must be rejected" + ) + try require( + loaded.rulesVersion == 0, + "candidate rules version must remain audit data" + ) + let now = Date(timeIntervalSince1970: 100) + let entry = ReminderPruneRestorePolicy.graceLedgerEntry( + fingerprint: "restored", + calendarIdentifier: "calendar", + now: now, + restoreGraceInterval: 1 + ) + try require( + entry.rulesVersion == ReminderPruneStateMachine.rulesVersion, + "restored grace must use current candidate rules" + ) + try require( + entry.graceUntil == now.addingTimeInterval(86_400), + "restored grace must remain at least 24 hours" + ) + }), + ("prune operation flock does not change the filesystem tree", { + let anchor = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: anchor) } + try FileManager.default.createDirectory( + at: anchor, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + let before = try FileManager.default.contentsOfDirectory( + atPath: anchor.path + ).sorted() + let shared = try ReminderPruneOperationFileLock( + exclusive: false, + anchorURL: anchor + ) + shared.unlock() + let exclusive = try ReminderPruneOperationFileLock( + exclusive: true, + anchorURL: anchor + ) + exclusive.unlock() + let after = try FileManager.default.contentsOfDirectory( + atPath: anchor.path + ).sorted() + try require(before == after, "flock must not create a lock path") + }), ("prune read-only ledger load never creates its root", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1357,6 +1427,11 @@ private let tests: [TestCase] = [ loadedBatch.targetSourceIdentifier == "source", "backup source identifier round-trip failed" ) + try require( + loadedBatch.backupSchemaVersion + == ReminderPruneRestorePolicy.currentBackupSchemaVersion, + "backup schema version round-trip failed" + ) try require( loadedBatch.rulesVersion == ReminderPruneStateMachine.rulesVersion, From 2f6eb2306d81e3bfc4cd9e0464bd84aadf44ba00 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:58:26 +0800 Subject: [PATCH 18/34] =?UTF-8?q?fix(=E5=A4=87=E4=BB=BD):=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=97=A7=E6=A0=BC=E5=BC=8F=E5=B9=B6=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=94=81=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 174 ++++++++++++++++-- Tests/TaskForgeReminderCoreTests/main.swift | 96 ++++++++++ 2 files changed, 251 insertions(+), 19 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 9953cf2..68e343f 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -261,27 +261,66 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } let data = try readData(at: url, error: .invalidBackup) - let envelope: BackupEnvelope + let hasSchemaVersion: Bool do { - envelope = try decoder.decode(BackupEnvelope.self, from: data) + guard + let root = try JSONSerialization.jsonObject(with: data) + as? [String: Any], + let payload = root["payload"] as? [String: Any] + else { + throw ReminderPruneStoreError.checksumMismatch + } + hasSchemaVersion = payload["backupSchemaVersion"] != nil + } catch let error as ReminderPruneStoreError { + throw error } catch { throw ReminderPruneStoreError.checksumMismatch } - let payload: Data - do { - payload = try encoder.encode(envelope.payload) - } catch { - throw ReminderPruneStoreError.invalidBackup - } - guard envelope.checksum == checksum(for: payload) else { - throw ReminderPruneStoreError.checksumMismatch + let batch: ReminderPruneBackupBatch + if hasSchemaVersion { + let envelope: BackupEnvelope + do { + envelope = try decoder.decode(BackupEnvelope.self, from: data) + } catch { + throw ReminderPruneStoreError.checksumMismatch + } + let payload: Data + do { + payload = try encoder.encode(envelope.payload) + } catch { + throw ReminderPruneStoreError.invalidBackup + } + guard envelope.checksum == checksum(for: payload) else { + throw ReminderPruneStoreError.checksumMismatch + } + batch = envelope.payload + } else { + let legacy: LegacyBackupEnvelopeV1 + do { + legacy = try decoder.decode( + LegacyBackupEnvelopeV1.self, + from: data + ) + } catch { + throw ReminderPruneStoreError.checksumMismatch + } + let legacyPayload: Data + do { + legacyPayload = try encoder.encode(legacy.payload) + } catch { + throw ReminderPruneStoreError.invalidBackup + } + guard legacy.checksum == checksum(for: legacyPayload) else { + throw ReminderPruneStoreError.checksumMismatch + } + batch = legacy.payload.migrated() } - guard isValidBackup(envelope.payload) else { + guard isValidBackup(batch) else { throw ReminderPruneStoreError.invalidBackup } try ensurePrivateFile(url) - return envelope.payload + return batch } public func latestUnrestoredBackup() throws -> (URL, ReminderPruneBackupBatch)? { @@ -672,22 +711,66 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } public final class ReminderPruneOperationFileLock: @unchecked Sendable { - public init( - exclusive: Bool, - anchorURL: URL = URL( - fileURLWithPath: "/tmp", + public static func defaultAnchorURL() throws -> URL { + let fileManager = FileManager.default + let home = URL( + fileURLWithPath: NSHomeDirectory(), isDirectory: true ) + var candidates = [fileManager.temporaryDirectory] + if let caches = fileManager.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first { + candidates.append(caches) + } + candidates.append( + home.appendingPathComponent( + "Library/Caches", + isDirectory: true + ) + ) + candidates.append( + home.appendingPathComponent("Library", isDirectory: true) + ) + candidates.append(home) + for candidate in candidates { + if let validated = validatedAnchor(candidate) { + return validated + } + } + throw ReminderPruneStoreError.permissions + } + + public init( + exclusive: Bool, + anchorURL: URL? = nil ) throws { - let path = anchorURL.standardizedFileURL - .resolvingSymlinksInPath().path + let anchor: URL + if let anchorURL { + guard let validated = Self.validatedAnchor(anchorURL) else { + throw ReminderPruneStoreError.permissions + } + anchor = validated + } else { + anchor = try Self.defaultAnchorURL() + } let opened = open( - path, + anchor.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_DIRECTORY ) guard opened >= 0 else { throw ReminderPruneStoreError.permissions } + var status = stat() + guard + fstat(opened, &status) == 0, + status.st_uid == getuid(), + status.st_mode & S_IFMT == S_IFDIR + else { + _ = close(opened) + throw ReminderPruneStoreError.permissions + } let operation = exclusive ? LOCK_EX : LOCK_SH var result: Int32 repeat { @@ -714,9 +797,62 @@ public final class ReminderPruneOperationFileLock: @unchecked Sendable { } private var descriptor: Int32? + + private static func validatedAnchor(_ url: URL) -> URL? { + let resolved = url.standardizedFileURL.resolvingSymlinksInPath() + guard resolved.path != "/tmp", resolved.path != "/private/tmp" else { + return nil + } + var status = stat() + guard + lstat(resolved.path, &status) == 0, + status.st_uid == getuid(), + status.st_mode & S_IFMT == S_IFDIR + else { + return nil + } + return resolved + } } private struct BackupEnvelope: Codable { let checksum: String let payload: ReminderPruneBackupBatch } + +private struct LegacyBackupEnvelopeV1: Codable { + let checksum: String + let payload: LegacyReminderPruneBackupBatchV1 +} + +private struct LegacyReminderPruneBackupBatchV1: Codable { + let identifier: UUID + let createdAt: Date + let targetCalendarIdentifier: String + let targetCalendarTitle: String + let targetSourceIdentifier: String + let rulesVersion: Int + let items: [ReminderPruneBackupItem] + let actuallyDeletedIdentifiers: [String]? + let restoreAttemptIdentifier: UUID? + let restoredItemIdentifiers: [String: String] + let restoredAt: Date? + + func migrated() -> ReminderPruneBackupBatch { + ReminderPruneBackupBatch( + identifier: identifier, + createdAt: createdAt, + targetCalendarIdentifier: targetCalendarIdentifier, + targetCalendarTitle: targetCalendarTitle, + targetSourceIdentifier: targetSourceIdentifier, + backupSchemaVersion: + ReminderPruneRestorePolicy.currentBackupSchemaVersion, + rulesVersion: rulesVersion, + items: items, + actuallyDeletedIdentifiers: actuallyDeletedIdentifiers, + restoreAttemptIdentifier: restoreAttemptIdentifier, + restoredItemIdentifiers: restoredItemIdentifiers, + restoredAt: restoredAt + ) + } +} diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 4a63028..ce9ff3e 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -223,6 +223,12 @@ private func pruneBackupFixture() -> ReminderPruneBackupBatch { ) } +private let legacyPruneBackupEnvelopeFixture = Data( + """ + {"checksum":"386b6ec8c52714e00c16f65f69c8dbace2eff0f899613a43f6cbb467796235c1","payload":{"createdAt":-978307180,"identifier":"00000000-0000-0000-0000-0000000000A1","items":[],"restoredItemIdentifiers":{},"rulesVersion":0,"targetCalendarIdentifier":"legacy-calendar","targetCalendarTitle":"Legacy list","targetSourceIdentifier":"legacy-source"}} + """.utf8 +) + private let tests: [TestCase] = [ ("TaskForge v6 MessagePack store decodes task records", { let snapshot = try TaskForgeTaskStore.decode(taskStoreFixture()) @@ -1229,6 +1235,53 @@ private let tests: [TestCase] = [ ) try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") }), + ("legacy prune backup verifies old checksum before migration", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + try legacyPruneBackupEnvelopeFixture.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + let migrated = try store.loadBackup(at: url) + try require( + migrated.backupSchemaVersion + == ReminderPruneRestorePolicy.currentBackupSchemaVersion, + "legacy backup did not migrate to schema 1" + ) + try require( + migrated.rulesVersion == 0 + && migrated.targetCalendarTitle == "Legacy list", + "legacy payload fields changed during migration" + ) + + let tampered = String( + data: legacyPruneBackupEnvelopeFixture, + encoding: .utf8 + )!.replacingOccurrences( + of: "Legacy list", + with: "Tampered list" + ) + try Data(tampered.utf8).write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + do { + _ = try store.loadBackup(at: url) + throw TestFailure( + description: "tampered legacy backup was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .checksumMismatch, + "unexpected legacy checksum error" + ) + } + }), ("restore policy separates backup schema from candidate rules", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1271,6 +1324,49 @@ private let tests: [TestCase] = [ ) }), ("prune operation flock does not change the filesystem tree", { + let defaultAnchor = + try ReminderPruneOperationFileLock.defaultAnchorURL() + try require( + defaultAnchor.path != "/tmp" + && defaultAnchor.path != "/private/tmp", + "operation lock must use a per-user anchor" + ) + let defaultBefore = try FileManager.default.contentsOfDirectory( + atPath: defaultAnchor.path + ).sorted() + let defaultShared = try ReminderPruneOperationFileLock( + exclusive: false + ) + defaultShared.unlock() + let defaultExclusive = try ReminderPruneOperationFileLock( + exclusive: true + ) + defaultExclusive.unlock() + let defaultAfter = try FileManager.default.contentsOfDirectory( + atPath: defaultAnchor.path + ).sorted() + try require( + defaultBefore == defaultAfter, + "default per-user flock must not change its directory tree" + ) + do { + _ = try ReminderPruneOperationFileLock( + exclusive: false, + anchorURL: URL( + fileURLWithPath: "/tmp", + isDirectory: true + ) + ) + throw TestFailure( + description: "system temp anchor should be rejected" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected system temp anchor error" + ) + } + let anchor = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: anchor) } From 358ab512f7fd45dfbec77484366f7ccb8499a6be Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:14:12 +0800 Subject: [PATCH 19/34] =?UTF-8?q?fix(=E6=B8=85=E7=90=86):=20=E4=B8=A5?= =?UTF-8?q?=E6=A0=BC=E6=A0=A1=E9=AA=8C=E6=97=A7=E5=A4=87=E4=BB=BD=E5=B9=B6?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E9=94=81=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 78 ++++++--- Tests/TaskForgeReminderCoreTests/main.swift | 149 ++++++++++++++++-- 2 files changed, 195 insertions(+), 32 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 68e343f..791403d 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -261,6 +261,8 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } let data = try readData(at: url, error: .invalidBackup) + let rawRoot: [String: Any] + let rawPayload: [String: Any] let hasSchemaVersion: Bool do { guard @@ -270,6 +272,8 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { else { throw ReminderPruneStoreError.checksumMismatch } + rawRoot = root + rawPayload = payload hasSchemaVersion = payload["backupSchemaVersion"] != nil } catch let error as ReminderPruneStoreError { throw error @@ -296,6 +300,14 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } batch = envelope.payload } else { + guard + Set(rawRoot.keys) == LegacyBackupEnvelopeV1.envelopeKeys, + LegacyReminderPruneBackupBatchV1.hasExactKeys( + Set(rawPayload.keys) + ) + else { + throw ReminderPruneStoreError.checksumMismatch + } let legacy: LegacyBackupEnvelopeV1 do { legacy = try decoder.decode( @@ -712,28 +724,23 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { public final class ReminderPruneOperationFileLock: @unchecked Sendable { public static func defaultAnchorURL() throws -> URL { - let fileManager = FileManager.default - let home = URL( - fileURLWithPath: NSHomeDirectory(), - isDirectory: true - ) - var candidates = [fileManager.temporaryDirectory] - if let caches = fileManager.urls( - for: .cachesDirectory, - in: .userDomainMask - ).first { - candidates.append(caches) - } - candidates.append( + guard + let account = getpwuid(getuid()), + let homePath = String( + validatingUTF8: account.pointee.pw_dir + ) + else { + throw ReminderPruneStoreError.permissions + } + let home = URL(fileURLWithPath: homePath, isDirectory: true) + let candidates = [ home.appendingPathComponent( "Library/Caches", isDirectory: true - ) - ) - candidates.append( - home.appendingPathComponent("Library", isDirectory: true) - ) - candidates.append(home) + ), + home.appendingPathComponent("Library", isDirectory: true), + home + ] for candidate in candidates { if let validated = validatedAnchor(candidate) { return validated @@ -766,7 +773,8 @@ public final class ReminderPruneOperationFileLock: @unchecked Sendable { guard fstat(opened, &status) == 0, status.st_uid == getuid(), - status.st_mode & S_IFMT == S_IFDIR + status.st_mode & S_IFMT == S_IFDIR, + status.st_mode & 0o077 == 0 else { _ = close(opened) throw ReminderPruneStoreError.permissions @@ -807,7 +815,8 @@ public final class ReminderPruneOperationFileLock: @unchecked Sendable { guard lstat(resolved.path, &status) == 0, status.st_uid == getuid(), - status.st_mode & S_IFMT == S_IFDIR + status.st_mode & S_IFMT == S_IFDIR, + status.st_mode & 0o077 == 0 else { return nil } @@ -823,6 +832,11 @@ private struct BackupEnvelope: Codable { private struct LegacyBackupEnvelopeV1: Codable { let checksum: String let payload: LegacyReminderPruneBackupBatchV1 + + static let envelopeKeys: Set = [ + "checksum", + "payload" + ] } private struct LegacyReminderPruneBackupBatchV1: Codable { @@ -838,6 +852,11 @@ private struct LegacyReminderPruneBackupBatchV1: Codable { let restoredItemIdentifiers: [String: String] let restoredAt: Date? + static func hasExactKeys(_ keys: Set) -> Bool { + requiredKeys.isSubset(of: keys) + && keys.isSubset(of: requiredKeys.union(optionalKeys)) + } + func migrated() -> ReminderPruneBackupBatch { ReminderPruneBackupBatch( identifier: identifier, @@ -855,4 +874,21 @@ private struct LegacyReminderPruneBackupBatchV1: Codable { restoredAt: restoredAt ) } + + private static let requiredKeys: Set = [ + "identifier", + "createdAt", + "targetCalendarIdentifier", + "targetCalendarTitle", + "targetSourceIdentifier", + "rulesVersion", + "items", + "restoredItemIdentifiers" + ] + + private static let optionalKeys: Set = [ + "actuallyDeletedIdentifiers", + "restoreAttemptIdentifier", + "restoredAt" + ] } diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index ce9ff3e..674083f 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1,4 +1,5 @@ import Dispatch +import Darwin import Foundation import TaskForgeReminderCore @@ -229,6 +230,58 @@ private let legacyPruneBackupEnvelopeFixture = Data( """.utf8 ) +private let legacyPruneBackupUnknownPayloadFieldFixture = Data( + """ + {"checksum":"386b6ec8c52714e00c16f65f69c8dbace2eff0f899613a43f6cbb467796235c1","payload":{"createdAt":-978307180,"identifier":"00000000-0000-0000-0000-0000000000A1","items":[],"restoredItemIdentifiers":{},"rulesVersion":0,"targetCalendarIdentifier":"legacy-calendar","targetCalendarTitle":"Legacy list","targetSourceIdentifier":"legacy-source","unknownPayloadField":"must-not-be-ignored"}} + """.utf8 +) + +private func currentAccountHomeURL() throws -> URL { + guard + let account = getpwuid(getuid()), + let homePath = String( + validatingUTF8: account.pointee.pw_dir + ) + else { + throw TestFailure(description: "current account home is unavailable") + } + return URL(fileURLWithPath: homePath, isDirectory: true) + .standardizedFileURL + .resolvingSymlinksInPath() +} + +private func expectedOperationLockAnchorURL() throws -> URL { + let home = try currentAccountHomeURL() + let candidates = [ + home.appendingPathComponent("Library/Caches", isDirectory: true), + home.appendingPathComponent("Library", isDirectory: true), + home + ] + for candidate in candidates { + let resolved = candidate.standardizedFileURL + .resolvingSymlinksInPath() + var status = stat() + guard lstat(resolved.path, &status) == 0 else { + continue + } + if status.st_uid == getuid(), + status.st_mode & S_IFMT == S_IFDIR, + status.st_mode & 0o077 == 0 + { + return resolved + } + } + throw TestFailure( + description: "no deterministic private account anchor is available" + ) +} + +private func taskForgeEntries(at url: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: url.path) + .filter { $0.localizedCaseInsensitiveContains("taskforge") } + .sorted() +} + private let tests: [TestCase] = [ ("TaskForge v6 MessagePack store decodes task records", { let snapshot = try TaskForgeTaskStore.decode(taskStoreFixture()) @@ -1282,6 +1335,33 @@ private let tests: [TestCase] = [ ) } }), + ("legacy prune backup rejects an unknown payload field", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + try legacyPruneBackupUnknownPayloadFieldFixture.write( + to: url, + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + + do { + _ = try store.loadBackup(at: url) + throw TestFailure( + description: "unknown legacy payload field was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .checksumMismatch, + "unexpected unknown legacy field error" + ) + } + }), ("restore policy separates backup schema from candidate rules", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1326,14 +1406,24 @@ private let tests: [TestCase] = [ ("prune operation flock does not change the filesystem tree", { let defaultAnchor = try ReminderPruneOperationFileLock.defaultAnchorURL() + let expectedDefaultAnchor = try expectedOperationLockAnchorURL() try require( - defaultAnchor.path != "/tmp" - && defaultAnchor.path != "/private/tmp", - "operation lock must use a per-user anchor" + defaultAnchor == expectedDefaultAnchor, + "operation lock anchor must be deterministic from account home" ) - let defaultBefore = try FileManager.default.contentsOfDirectory( - atPath: defaultAnchor.path - ).sorted() + var defaultBeforeStatus = stat() + try require( + lstat(defaultAnchor.path, &defaultBeforeStatus) == 0, + "default operation lock anchor is unavailable" + ) + try require( + defaultBeforeStatus.st_uid == getuid() + && defaultBeforeStatus.st_mode & S_IFMT == S_IFDIR + && defaultBeforeStatus.st_mode & 0o077 == 0, + "default operation lock anchor must be a private owned directory" + ) + let defaultTaskForgeEntriesBefore = + try taskForgeEntries(at: defaultAnchor) let defaultShared = try ReminderPruneOperationFileLock( exclusive: false ) @@ -1342,12 +1432,20 @@ private let tests: [TestCase] = [ exclusive: true ) defaultExclusive.unlock() - let defaultAfter = try FileManager.default.contentsOfDirectory( - atPath: defaultAnchor.path - ).sorted() + var defaultAfterStatus = stat() try require( - defaultBefore == defaultAfter, - "default per-user flock must not change its directory tree" + lstat(defaultAnchor.path, &defaultAfterStatus) == 0 + && defaultAfterStatus.st_dev == defaultBeforeStatus.st_dev + && defaultAfterStatus.st_ino == defaultBeforeStatus.st_ino + && defaultAfterStatus.st_uid == defaultBeforeStatus.st_uid + && defaultAfterStatus.st_mode & 0o777 + == defaultBeforeStatus.st_mode & 0o777, + "default flock must retain its private anchor inode" + ) + try require( + try taskForgeEntries(at: defaultAnchor) + == defaultTaskForgeEntriesBefore, + "default flock must not create a TaskForge path" ) do { _ = try ReminderPruneOperationFileLock( @@ -1393,6 +1491,35 @@ private let tests: [TestCase] = [ ).sorted() try require(before == after, "flock must not create a lock path") }), + ("prune operation flock rejects a non-private owned anchor", { + let anchor = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: anchor) } + try FileManager.default.createDirectory( + at: anchor, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o750] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o750], + ofItemAtPath: anchor.path + ) + + do { + _ = try ReminderPruneOperationFileLock( + exclusive: false, + anchorURL: anchor + ) + throw TestFailure( + description: "non-private operation anchor was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected non-private anchor error" + ) + } + }), ("prune read-only ledger load never creates its root", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) From 6c630743df23255b2cfabe71ee5b79639222c902 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:21:47 +0800 Subject: [PATCH 20/34] =?UTF-8?q?feat(=E5=90=8C=E6=AD=A5):=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=E5=92=8C=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/TaskForgeReminderSync/Command.swift | 42 ++++++++++++++++++- .../TaskForgeReminderSync/SyncEngine.swift | 41 +++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/Sources/TaskForgeReminderSync/Command.swift b/Sources/TaskForgeReminderSync/Command.swift index f3cb598..398fea2 100644 --- a/Sources/TaskForgeReminderSync/Command.swift +++ b/Sources/TaskForgeReminderSync/Command.swift @@ -11,6 +11,9 @@ private enum RunMode { case sync case reverseDryRun case reverseOnce + case pruneDryRun + case pruneOnce + case restoreLastPrune case watch case help } @@ -45,6 +48,12 @@ private struct Options { options.mode = .reverseDryRun case "--reverse-once": options.mode = .reverseOnce + case "--prune-dry-run": + options.mode = .pruneDryRun + case "--prune-once": + options.mode = .pruneOnce + case "--restore-last-prune": + options.mode = .restoreLastPrune case "--watch": options.mode = .watch case "--help", "-h": @@ -151,7 +160,8 @@ private struct TaskForgeReminderSyncCommand { case .dryRun: try printPreview(options: options, calendar: calendar) case .audit, .deduplicateDryRun, .deduplicate, - .sync, .reverseDryRun, .reverseOnce, .watch: + .sync, .reverseDryRun, .reverseOnce, .pruneDryRun, + .pruneOnce, .restoreLastPrune, .watch: try await run(options: options, calendar: calendar) } } catch { @@ -228,11 +238,14 @@ private struct TaskForgeReminderSyncCommand { requireCandidate: options.taskIdentifier != nil ) let forward = try await engine.forward() + let prune = try await engine.prune(dryRun: false) print( "双向同步完成:反向写入 \(reverse.written)," + "正向新建 \(forward.created),更新 \(forward.updated)," + "重新关联 \(forward.relinked),无需变化 \(forward.unchanged)," - + "冲突 \(forward.conflicts)。" + + "冲突 \(forward.conflicts);" + + "清理首次 \(prune.firstSeen),等待 \(prune.waiting)," + + "删除 \(prune.deleted),失败 \(prune.failed)。" ) case .reverseDryRun: let counts = try await engine.reverse( @@ -248,6 +261,25 @@ private struct TaskForgeReminderSyncCommand { requireCandidate: true ) print("反向同步完成:写入 \(counts.written),失败 \(counts.failed)。") + case .pruneDryRun: + let counts = try await engine.prune(dryRun: true) + print( + "清理预演:扫描 \(counts.scanned),首次候选 \(counts.firstSeen)," + + "已满足二次确认 \(counts.ready)。" + ) + print("预演模式:没有写候选账本,没有删除提醒。") + case .pruneOnce: + let counts = try await engine.prune(dryRun: false) + print( + "清理推进:扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),删除 \(counts.deleted)," + + "失败 \(counts.failed)。" + ) + case .restoreLastPrune: + let counts = try await engine.restoreLastPrune() + print( + "清理恢复:恢复 \(counts.restored),失败 \(counts.failed)。" + ) case .watch: try await engine.watch() case .checkConfig, .dryRun, .help: @@ -313,6 +345,9 @@ private struct TaskForgeReminderSyncCommand { TaskForgeReminderSync --sync [--task-id ID] TaskForgeReminderSync --reverse-dry-run [--task-id ID] TaskForgeReminderSync --reverse-once --task-id ID + TaskForgeReminderSync --prune-dry-run + TaskForgeReminderSync --prune-once + TaskForgeReminderSync --restore-last-prune TaskForgeReminderSync --watch 选项: @@ -327,6 +362,9 @@ private struct TaskForgeReminderSyncCommand { --deduplicate 将冗余活跃提醒移到可恢复的归档列表 --reverse-dry-run 预览 Apple 完成状态的反向写入 --reverse-once 执行一次反向写入并等待 TaskForge 回读 + --prune-dry-run 只读预演提醒清理,不写候选账本 + --prune-once 推进一轮双扫描确认和清理 + --restore-last-prune 恢复最近一批已删除提醒 --sync 反向写入后再正向同步一次 --watch 常驻近实时双向同步 """ diff --git a/Sources/TaskForgeReminderSync/SyncEngine.swift b/Sources/TaskForgeReminderSync/SyncEngine.swift index 9f1b346..611a88a 100644 --- a/Sources/TaskForgeReminderSync/SyncEngine.swift +++ b/Sources/TaskForgeReminderSync/SyncEngine.swift @@ -3,6 +3,7 @@ import Darwin import EventKit import Foundation import TaskForgeReminderCore +import TaskForgeReminderEventKit struct SyncConfiguration { var listName: String @@ -172,15 +173,36 @@ enum TaskSourceWriter { final class SyncEngine { private let configuration: SyncConfiguration private var calendar: Calendar - private let store = EKEventStore() + private let store: EKEventStore + private let pruner: ReminderPruner private var reminderObserver: NSObjectProtocol? private var reminderDebounceTask: Task? private var isReconciling = false private var needsAnotherPass = false init(configuration: SyncConfiguration, calendar: Calendar) { + let store = EKEventStore() self.configuration = configuration self.calendar = calendar + self.store = store + self.pruner = ReminderPruner( + eventStore: store, + configuration: ReminderPruneConfiguration( + listName: configuration.listName, + localRoot: ReminderPruneLocalStore.defaultRoot, + confirmationInterval: 60, + restoreGraceInterval: 86_400 + ), + log: { message in + print("[\(ISO8601DateFormatter().string(from: Date()))] \(message)") + }, + logError: { message in + fputs( + "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n", + stderr + ) + } + ) } deinit { @@ -556,6 +578,17 @@ final class SyncEngine { return counts } + func prune(dryRun: Bool) async throws -> ReminderPruneCounts { + let snapshot = try loadSnapshotWithRetry() + return dryRun + ? try await pruner.dryRun(snapshot: snapshot) + : try await pruner.advance(snapshot: snapshot) + } + + func restoreLastPrune() async throws -> ReminderPruneCounts { + try await pruner.restoreLast() + } + func reconcile(reason: String) async { if isReconciling { needsAnotherPass = true @@ -578,6 +611,12 @@ final class SyncEngine { + "跳过 \(reverseCounts.skipped),失败 \(reverseCounts.failed)" ) _ = try await forward() + let pruneCounts = try await prune(dryRun: false) + log( + "清理同步:首次 \(pruneCounts.firstSeen)," + + "等待 \(pruneCounts.waiting),删除 \(pruneCounts.deleted)," + + "失败 \(pruneCounts.failed)" + ) } catch { logError("双向同步失败:\(error.localizedDescription)") } From aaa5bb24835f3e31b7a889ef333a7c519c273a3a Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:34:16 +0800 Subject: [PATCH 21/34] =?UTF-8?q?fix(=E5=90=8C=E6=AD=A5):=20=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E5=86=B2=E7=AA=81=E6=A8=A1=E5=BC=8F=E5=92=8C=E6=AD=A7?= =?UTF-8?q?=E4=B9=89=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 9 +- .../ReminderPruner.swift | 22 ++++- Sources/TaskForgeReminderSync/Command.swift | 57 +++++++++--- Tests/TaskForgeReminderCLITests/main.swift | 90 +++++++++++++++++++ Tests/TaskForgeReminderCoreTests/main.swift | 16 ++++ 5 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 Tests/TaskForgeReminderCLITests/main.swift diff --git a/Package.swift b/Package.swift index caeb88d..7e945e0 100644 --- a/Package.swift +++ b/Package.swift @@ -34,8 +34,15 @@ let package = Package( ), .executableTarget( name: "TaskForgeReminderCoreTests", - dependencies: ["TaskForgeReminderCore"], + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ], path: "Tests/TaskForgeReminderCoreTests" + ), + .executableTarget( + name: "TaskForgeReminderCLITests", + path: "Tests/TaskForgeReminderCLITests" ) ] ) diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index 7f96c6a..b6b4f93 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -130,7 +130,7 @@ public final class ReminderPruner { let operationLock = try operationFileLock(exclusive: false) defer { operationLock.unlock() } - guard let targetCalendar = targetCalendar() else { + guard let targetCalendar = try targetCalendar() else { return ReminderPruneCounts() } let reminders = try await fetchReminders(in: targetCalendar) @@ -170,7 +170,7 @@ public final class ReminderPruner { let operationLock = try operationFileLock(exclusive: true) defer { operationLock.unlock() } - guard let targetCalendar = targetCalendar() else { + guard let targetCalendar = try targetCalendar() else { return ReminderPruneCounts() } let initialReminders = try await fetchReminders(in: targetCalendar) @@ -507,10 +507,24 @@ public final class ReminderPruner { } } - private func targetCalendar() -> EKCalendar? { - eventStore.calendars(for: .reminder).first { + nonisolated static func uniqueTargetCalendarMatch( + _ matches: [Element] + ) throws -> Element? { + switch matches.count { + case 0: + return nil + case 1: + return matches[0] + default: + throw ReminderPrunerError.ambiguousTargetCalendar + } + } + + private func targetCalendar() throws -> EKCalendar? { + let matches = eventStore.calendars(for: .reminder).filter { $0.title == configuration.listName } + return try Self.uniqueTargetCalendarMatch(matches) } private func reconcileUnresolvedBackup( diff --git a/Sources/TaskForgeReminderSync/Command.swift b/Sources/TaskForgeReminderSync/Command.swift index 398fea2..d4a15f6 100644 --- a/Sources/TaskForgeReminderSync/Command.swift +++ b/Sources/TaskForgeReminderSync/Command.swift @@ -18,6 +18,14 @@ private enum RunMode { case help } +private enum CommandParsingError: Error, LocalizedError { + case conflictingRunModes + + var errorDescription: String? { + "不能同时指定多个运行模式。" + } +} + private struct Options { var mode: RunMode = .dryRun var listName = "TaskForge 今日" @@ -28,36 +36,37 @@ private struct Options { static func parse(_ arguments: [String], calendar: Calendar) throws -> Options { var options = Options() + var selectedMode: RunMode? var index = 0 while index < arguments.count { let argument = arguments[index] switch argument { case "--check-config": - options.mode = .checkConfig + try select(.checkConfig, into: &selectedMode) case "--dry-run": - options.mode = .dryRun + try select(.dryRun, into: &selectedMode) case "--audit": - options.mode = .audit + try select(.audit, into: &selectedMode) case "--deduplicate-dry-run": - options.mode = .deduplicateDryRun + try select(.deduplicateDryRun, into: &selectedMode) case "--deduplicate": - options.mode = .deduplicate + try select(.deduplicate, into: &selectedMode) case "--sync": - options.mode = .sync + try select(.sync, into: &selectedMode) case "--reverse-dry-run": - options.mode = .reverseDryRun + try select(.reverseDryRun, into: &selectedMode) case "--reverse-once": - options.mode = .reverseOnce + try select(.reverseOnce, into: &selectedMode) case "--prune-dry-run": - options.mode = .pruneDryRun + try select(.pruneDryRun, into: &selectedMode) case "--prune-once": - options.mode = .pruneOnce + try select(.pruneOnce, into: &selectedMode) case "--restore-last-prune": - options.mode = .restoreLastPrune + try select(.restoreLastPrune, into: &selectedMode) case "--watch": - options.mode = .watch + try select(.watch, into: &selectedMode) case "--help", "-h": - options.mode = .help + try select(.help, into: &selectedMode) case "--list-name": index += 1 options.listName = try value(after: argument, at: index, in: arguments) @@ -89,9 +98,20 @@ private struct Options { } index += 1 } + options.mode = selectedMode ?? .dryRun return options } + private static func select( + _ mode: RunMode, + into selectedMode: inout RunMode? + ) throws { + guard selectedMode == nil else { + throw CommandParsingError.conflictingRunModes + } + selectedMode = mode + } + private static func value( after argument: String, at index: Int, @@ -152,6 +172,17 @@ private struct TaskForgeReminderSyncCommand { calendar: calendar ) + #if DEBUG + if + ProcessInfo.processInfo.environment[ + "TASKFORGE_REMINDER_SYNC_TEST_PARSE_ONLY" + ] == "1" + { + print("参数解析完成") + return + } + #endif + switch options.mode { case .help: printHelp() diff --git a/Tests/TaskForgeReminderCLITests/main.swift b/Tests/TaskForgeReminderCLITests/main.swift new file mode 100644 index 0000000..8a814be --- /dev/null +++ b/Tests/TaskForgeReminderCLITests/main.swift @@ -0,0 +1,90 @@ +import Foundation + +private struct TestFailure: Error, CustomStringConvertible { + let description: String +} + +private func require( + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + guard condition() else { + throw TestFailure(description: message) + } +} + +private func runConflictingModes( + _ arguments: [String], + label: String +) throws { + let executable = URL(fileURLWithPath: CommandLine.arguments[0]) + .deletingLastPathComponent() + .appendingPathComponent("TaskForgeReminderSync") + let process = Process() + process.executableURL = executable + process.arguments = arguments + var environment = ProcessInfo.processInfo.environment + environment["TASKFORGE_REMINDER_SYNC_TEST_PARSE_ONLY"] = "1" + process.environment = environment + + let standardOutput = Pipe() + let standardError = Pipe() + process.standardOutput = standardOutput + process.standardError = standardError + try process.run() + process.waitUntilExit() + + let output = String( + data: standardOutput.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let error = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + + try require( + process.terminationStatus == 1, + "\(label): conflicting modes must fail before execution" + ) + try require( + error.contains("不能同时指定多个运行模式"), + "\(label): failure must use the anonymous conflict error" + ) + for argument in arguments { + try require( + !error.contains(argument), + "\(label): error must not echo a raw argument" + ) + } + try require( + !output.contains("提醒事项权限") + && !error.contains("提醒事项权限") + && !output.contains("EventKit") + && !error.contains("EventKit"), + "\(label): conflict must fail before EventKit access" + ) +} + +private let conflictPairs = [ + ["--prune-dry-run", "--prune-once"], + ["--restore-last-prune", "--sync"], + ["--dry-run", "--sync"], + ["--help", "--prune-once"], + ["--prune-once", "--prune-once"] +] + +do { + for (index, pair) in conflictPairs.enumerated() { + try runConflictingModes(pair, label: "pair \(index + 1) forward") + try runConflictingModes( + Array(pair.reversed()), + label: "pair \(index + 1) reverse" + ) + } + let testCount = conflictPairs.count * 2 + print("\(testCount)/\(testCount) CLI parser conflict tests passed") +} catch { + fputs("FAIL \(error)\n", stderr) + exit(1) +} diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 674083f..f72b1cc 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -2,6 +2,7 @@ import Dispatch import Darwin import Foundation import TaskForgeReminderCore +@testable import TaskForgeReminderEventKit private struct TestFailure: Error, CustomStringConvertible { let description: String @@ -283,6 +284,21 @@ private func taskForgeEntries(at url: URL) throws -> [String] { } private let tests: [TestCase] = [ + ("prune target calendar selection fails closed on ambiguity", { + let missing: Int? = try ReminderPruner.uniqueTargetCalendarMatch([]) + try require(missing == nil, "zero matching calendars should be absent") + let unique = try ReminderPruner.uniqueTargetCalendarMatch([7]) + try require(unique == 7, "one matching calendar should be selected") + + do { + let _: Int? = try ReminderPruner.uniqueTargetCalendarMatch([7, 8]) + throw TestFailure( + description: "multiple matching calendars must fail closed" + ) + } catch ReminderPrunerError.ambiguousTargetCalendar { + // Expected anonymous fail-closed error. + } + }), ("TaskForge v6 MessagePack store decodes task records", { let snapshot = try TaskForgeTaskStore.decode(taskStoreFixture()) try require(snapshot.version == 6, "unexpected task store version") From 37f55a1f9d76bf64d0c016798f663a24a5763bc8 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:48:49 +0800 Subject: [PATCH 22/34] =?UTF-8?q?test(EventKit):=20=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E6=B8=85=E7=90=86=E9=9A=94=E7=A6=BB=E5=92=8C=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 8 + .../TaskForgeReminderEventKitTests/main.swift | 926 ++++++++++++++++++ 2 files changed, 934 insertions(+) create mode 100644 Tests/TaskForgeReminderEventKitTests/main.swift diff --git a/Package.swift b/Package.swift index 7e945e0..d106e52 100644 --- a/Package.swift +++ b/Package.swift @@ -43,6 +43,14 @@ let package = Package( .executableTarget( name: "TaskForgeReminderCLITests", path: "Tests/TaskForgeReminderCLITests" + ), + .executableTarget( + name: "TaskForgeReminderEventKitTests", + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ], + path: "Tests/TaskForgeReminderEventKitTests" ) ] ) diff --git a/Tests/TaskForgeReminderEventKitTests/main.swift b/Tests/TaskForgeReminderEventKitTests/main.swift new file mode 100644 index 0000000..aa5c7cd --- /dev/null +++ b/Tests/TaskForgeReminderEventKitTests/main.swift @@ -0,0 +1,926 @@ +import Darwin +import Dispatch +import EventKit +import Foundation +import TaskForgeReminderCore +import TaskForgeReminderEventKit + +struct IntegrationFailure: Error, CustomStringConvertible { + let description: String +} + +func require( + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + guard condition() else { + throw IntegrationFailure(description: message) + } +} + +guard ProcessInfo.processInfo.environment[ + "TASKFORGE_RUN_EVENTKIT_TESTS" +] == "1" else { + print("SKIP EventKit integration tests require explicit opt-in") + exit(0) +} + +private final class AsyncResultGate: @unchecked Sendable { + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resolve(_ result: Result) { + lock.lock() + guard let continuation else { + lock.unlock() + return + } + self.continuation = nil + lock.unlock() + continuation.resume(with: result) + } + + private let lock = NSLock() + private var continuation: CheckedContinuation? +} + +private struct FixtureLabels { + let plain: String + let priority: String + let important: [String] + let completed: String + let current: String + let historical: String + let other: String +} + +private struct RestoreExpectation { + let title: String + let notes: String + let url: URL + let dueDateComponents: DateComponents + let startDateComponents: DateComponents + let alarmOffset: TimeInterval + let recurrenceInterval: Int + let recurrenceCount: Int +} + +private let operationTimeout: TimeInterval = 30 + +private func checked( + _ count: inout Int, + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + try require(condition(), message) + count += 1 +} + +private func requestReminderAccess( + eventStore: EKEventStore +) async throws { + let granted: Bool = try await withCheckedThrowingContinuation { + continuation in + let gate = AsyncResultGate(continuation) + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + operationTimeout + ) { + gate.resolve( + .failure( + IntegrationFailure( + description: "reminder access request timed out" + ) + ) + ) + } + + let completion: @Sendable (Bool, Error?) -> Void = { + granted, error in + if error != nil { + gate.resolve( + .failure( + IntegrationFailure( + description: "reminder access request failed" + ) + ) + ) + } else { + gate.resolve(.success(granted)) + } + } + if #available(macOS 14.0, *) { + eventStore.requestFullAccessToReminders(completion: completion) + } else { + eventStore.requestAccess(to: .reminder, completion: completion) + } + } + guard granted else { + throw IntegrationFailure(description: "reminder access was denied") + } +} + +private func fetchReminders( + eventStore: EKEventStore, + calendars: [EKCalendar] +) async throws -> [EKReminder] { + guard !calendars.isEmpty else { + throw IntegrationFailure( + description: "isolated reminder fetch requires a temporary calendar" + ) + } + let predicate = eventStore.predicateForReminders(in: calendars) + return try await withCheckedThrowingContinuation { continuation in + let gate = AsyncResultGate<[EKReminder]>(continuation) + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + operationTimeout + ) { + gate.resolve( + .failure( + IntegrationFailure( + description: "isolated reminder fetch timed out" + ) + ) + ) + } + _ = eventStore.fetchReminders(matching: predicate) { reminders in + guard let reminders else { + gate.resolve( + .failure( + IntegrationFailure( + description: "isolated reminder fetch failed" + ) + ) + ) + return + } + gate.resolve(.success(reminders)) + } + } +} + +private func exactTemporaryCalendars( + eventStore: EKEventStore, + names: Set +) -> [EKCalendar] { + eventStore.calendars(for: .reminder).filter { + names.contains($0.title) + } +} + +private func exactTemporaryCalendar( + eventStore: EKEventStore, + name: String +) throws -> EKCalendar { + let matches = exactTemporaryCalendars( + eventStore: eventStore, + names: [name] + ) + guard matches.count == 1, let calendar = matches.first else { + throw IntegrationFailure( + description: "temporary calendar lookup was not unique" + ) + } + return calendar +} + +private func createTemporaryCalendars( + eventStore: EKEventStore, + targetName: String, + otherName: String +) throws { + let exactNames: Set = [targetName, otherName] + guard exactTemporaryCalendars( + eventStore: eventStore, + names: exactNames + ).isEmpty else { + throw IntegrationFailure( + description: "temporary calendar UUID collision" + ) + } + let reminderSources = eventStore.sources.filter { + $0.sourceType == .local || $0.sourceType == .calDAV + } + guard let source = reminderSources.first else { + throw IntegrationFailure( + description: "no writable reminder source is available" + ) + } + + let target = EKCalendar(for: .reminder, eventStore: eventStore) + target.title = targetName + target.source = source + try eventStore.saveCalendar(target, commit: false) + + let other = EKCalendar(for: .reminder, eventStore: eventStore) + other.title = otherName + other.source = source + try eventStore.saveCalendar(other, commit: false) + + try eventStore.commit() + eventStore.reset() + guard exactTemporaryCalendars( + eventStore: eventStore, + names: exactNames + ).count == exactNames.count else { + throw IntegrationFailure( + description: "temporary calendars were not persisted" + ) + } +} + +private func cleanupTemporaryCalendars( + eventStore: EKEventStore, + names: Set +) -> IntegrationFailure? { + for _ in 0..<3 { + eventStore.reset() + let matches = exactTemporaryCalendars( + eventStore: eventStore, + names: names + ) + if matches.isEmpty { + return nil + } + for calendar in matches { + try? eventStore.removeCalendar(calendar, commit: true) + } + } + eventStore.reset() + guard exactTemporaryCalendars( + eventStore: eventStore, + names: names + ).isEmpty else { + return IntegrationFailure( + description: "temporary calendar cleanup failed" + ) + } + return nil +} + +private func createPrivateDirectory(_ url: URL) throws { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) +} + +private func stageReminder( + eventStore: EKEventStore, + calendar: EKCalendar, + title: String, + notes: String? = nil, + priority: Int = 0, + isCompleted: Bool = false, + configure: ((EKReminder) -> Void)? = nil +) throws { + let reminder = EKReminder(eventStore: eventStore) + reminder.calendar = calendar + reminder.title = title + reminder.notes = notes + reminder.priority = priority + reminder.isCompleted = isCompleted + configure?(reminder) + try eventStore.save(reminder, commit: false) +} + +private func stageFixtures( + eventStore: EKEventStore, + targetCalendar: EKCalendar, + otherCalendar: EKCalendar, + labels: FixtureLabels, + restore: RestoreExpectation, + currentMarker: String, + historicalReference: String +) throws { + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.plain, + notes: restore.notes + ) { reminder in + reminder.url = restore.url + reminder.dueDateComponents = restore.dueDateComponents + reminder.startDateComponents = restore.startDateComponents + reminder.addAlarm( + EKAlarm(relativeOffset: restore.alarmOffset) + ) + reminder.addRecurrenceRule( + EKRecurrenceRule( + recurrenceWith: .daily, + interval: restore.recurrenceInterval, + end: EKRecurrenceEnd( + occurrenceCount: restore.recurrenceCount + ) + ) + ) + } + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.priority, + priority: 1 + ) + for title in labels.important { + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: title + ) + } + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.completed, + isCompleted: true + ) + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.current, + notes: currentMarker + ) + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.historical, + notes: historicalReference + ) + try stageReminder( + eventStore: eventStore, + calendar: otherCalendar, + title: labels.other + ) + try eventStore.commit() + eventStore.reset() +} + +private func onlyReminder( + _ reminders: [EKReminder], + title: String +) throws -> EKReminder { + let matches = reminders.filter { $0.title == title } + guard matches.count == 1, let reminder = matches.first else { + throw IntegrationFailure( + description: "fixture reminder lookup was not unique" + ) + } + return reminder +} + +private func sameDateComponents( + _ lhs: DateComponents?, + _ rhs: DateComponents? +) -> Bool { + guard let lhs, let rhs else { + return lhs == nil && rhs == nil + } + return lhs.year == rhs.year + && lhs.month == rhs.month + && lhs.day == rhs.day + && lhs.hour == rhs.hour + && lhs.minute == rhs.minute +} + +private func latestBackupURL(localRoot: URL) throws -> URL { + let backupDirectory = localRoot.appendingPathComponent( + "PruneBackups", + isDirectory: true + ) + let backups = try FileManager.default.contentsOfDirectory( + at: backupDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == "json" } + guard backups.count == 1, let backup = backups.first else { + throw IntegrationFailure( + description: "exactly one prune backup was expected" + ) + } + return backup +} + +private func privateFilePermissions(at url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem( + atPath: url.path + ) + if let permissions = attributes[.posixPermissions] as? NSNumber { + return permissions.intValue & 0o777 + } + if let permissions = attributes[.posixPermissions] as? Int { + return permissions & 0o777 + } + throw IntegrationFailure( + description: "backup permissions were unavailable" + ) +} + +@MainActor +private func runIntegrationTests() async throws -> Int { + let eventStore = EKEventStore() + let targetName = "TaskForgeReminderSync Test \(UUID().uuidString)" + let otherName = "TaskForgeReminderSync Other \(UUID().uuidString)" + let exactNames: Set = [targetName, otherName] + let testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent( + "TaskForgeReminderEventKitTests-\(UUID().uuidString)", + isDirectory: true + ) + let vaultURL = testRoot.appendingPathComponent( + "Vault", + isDirectory: true + ) + let localRoot = testRoot.appendingPathComponent( + "PruneState", + isDirectory: true + ) + + var bodyError: Error? + var cleanupError: Error? + var passCount = 0 + + do { + defer { + let calendarError = cleanupTemporaryCalendars( + eventStore: eventStore, + names: exactNames + ) + var directoryError: IntegrationFailure? + if FileManager.default.fileExists(atPath: testRoot.path) { + do { + try FileManager.default.removeItem(at: testRoot) + } catch { + directoryError = IntegrationFailure( + description: "temporary test directory cleanup failed" + ) + } + } + cleanupError = calendarError ?? directoryError + } + + do { + try await requestReminderAccess(eventStore: eventStore) + try createPrivateDirectory(testRoot) + try createPrivateDirectory(vaultURL) + try createTemporaryCalendars( + eventStore: eventStore, + targetName: targetName, + otherName: otherName + ) + print("TEMP \(targetName)") + print("TEMP \(otherName)") + + let labelToken = UUID().uuidString + let labels = FixtureLabels( + plain: "plain-\(labelToken)", + priority: "priority-\(labelToken)", + important: ["!", "!", "❗", "‼️", "⭐", "📌"].map { + " \($0) protected-\(labelToken)" + }, + completed: "completed-\(labelToken)", + current: "current-\(labelToken)", + historical: "historical-\(labelToken)", + other: "other-\(labelToken)" + ) + + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = TimeZone(secondsFromGMT: 0)! + let dueDate = DateComponents( + calendar: gregorian, + timeZone: gregorian.timeZone, + year: 2030, + month: 7, + day: 30, + hour: 10, + minute: 45 + ) + let startDate = DateComponents( + calendar: gregorian, + timeZone: gregorian.timeZone, + year: 2030, + month: 7, + day: 30, + hour: 9, + minute: 30 + ) + let restore = RestoreExpectation( + title: labels.plain, + notes: "integration fixture notes", + url: URL( + string: + "taskforge-eventkit-test://restore/\(UUID().uuidString)" + )!, + dueDateComponents: dueDate, + startDateComponents: startDate, + alarmOffset: -1_800, + recurrenceInterval: 2, + recurrenceCount: 4 + ) + + let sourceURL = vaultURL.appendingPathComponent("history.md") + try "- [ ] 历史任务\n".write( + to: sourceURL, + atomically: true, + encoding: .utf8 + ) + let historicalTask = TaskForgeTask( + identifier: "historical-\(UUID().uuidString)", + title: "历史任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: sourceURL.path, + sourceType: "markdownInline", + originalLine: "- [ ] 历史任务", + lineNumber: 1 + ) + let currentTask = TaskForgeTask( + identifier: "current-\(UUID().uuidString)", + title: "当前任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: nil, + sourceType: nil, + originalLine: nil, + lineNumber: nil + ) + let snapshot = TaskForgeSnapshot( + version: 6, + vaultPath: vaultURL.path, + tasks: [currentTask] + ) + let currentMarker = TaskSyncMarker.make( + vaultPath: snapshot.vaultPath, + taskIdentifier: currentTask.identifier + ) + let historicalReference = TaskSourceReference( + task: historicalTask + ).encodedLine + + let targetCalendar = try exactTemporaryCalendar( + eventStore: eventStore, + name: targetName + ) + let otherCalendar = try exactTemporaryCalendar( + eventStore: eventStore, + name: otherName + ) + try stageFixtures( + eventStore: eventStore, + targetCalendar: targetCalendar, + otherCalendar: otherCalendar, + labels: labels, + restore: restore, + currentMarker: currentMarker, + historicalReference: historicalReference + ) + + let initialTarget = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + name: targetName + ) + ] + ) + let initialOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + name: otherName + ) + ] + ) + try checked( + &passCount, + initialTarget.count == 11, + "target fixture count was incorrect" + ) + try checked( + &passCount, + initialOther.count == 1, + "other-list fixture count was incorrect" + ) + + let plainIdentifier = try onlyReminder( + initialTarget, + title: labels.plain + ).calendarItemIdentifier + let priorityIdentifier = try onlyReminder( + initialTarget, + title: labels.priority + ).calendarItemIdentifier + let importantIdentifiers = try labels.important.map { + try onlyReminder(initialTarget, title: $0) + .calendarItemIdentifier + } + let completedIdentifier = try onlyReminder( + initialTarget, + title: labels.completed + ).calendarItemIdentifier + let currentIdentifier = try onlyReminder( + initialTarget, + title: labels.current + ).calendarItemIdentifier + let historicalIdentifier = try onlyReminder( + initialTarget, + title: labels.historical + ).calendarItemIdentifier + let otherIdentifier = try onlyReminder( + initialOther, + title: labels.other + ).calendarItemIdentifier + + let pruner = ReminderPruner( + eventStore: eventStore, + configuration: ReminderPruneConfiguration( + listName: targetName, + localRoot: localRoot, + confirmationInterval: 60, + restoreGraceInterval: 86_400 + ), + log: { _ in }, + logError: { _ in } + ) + let firstNow = Date(timeIntervalSince1970: 10_000) + let secondNow = Date(timeIntervalSince1970: 10_061) + let first = try await pruner.advance( + snapshot: snapshot, + now: firstNow + ) + try checked( + &passCount, + first.deleted == 0, + "first scan deleted immediately" + ) + try checked( + &passCount, + first.firstSeen == 1, + "first scan did not register exactly one candidate" + ) + + let afterFirst = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + name: targetName + ) + ] + ) + try checked( + &passCount, + afterFirst.contains { + $0.calendarItemIdentifier == plainIdentifier + }, + "first scan removed the plain external fixture" + ) + + let second = try await pruner.advance( + snapshot: snapshot, + now: secondNow + ) + try checked( + &passCount, + second.deleted == 1, + "second scan did not delete exactly one reminder" + ) + + let afterSecond = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + name: targetName + ) + ] + ) + let afterSecondIdentifiers = Set( + afterSecond.map(\.calendarItemIdentifier) + ) + try checked( + &passCount, + !afterSecondIdentifiers.contains(plainIdentifier), + "plain external fixture survived the second scan" + ) + try checked( + &passCount, + afterSecondIdentifiers.contains(priorityIdentifier), + "priority fixture was not protected" + ) + for identifier in importantIdentifiers { + try checked( + &passCount, + afterSecondIdentifiers.contains(identifier), + "important-prefix fixture was not protected" + ) + } + try checked( + &passCount, + afterSecondIdentifiers.contains(completedIdentifier), + "completed fixture was not protected" + ) + try checked( + &passCount, + afterSecondIdentifiers.contains(currentIdentifier), + "current snapshot fixture was not protected" + ) + try checked( + &passCount, + afterSecondIdentifiers.contains(historicalIdentifier), + "historical source fixture was not protected" + ) + + let afterSecondOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + name: otherName + ) + ] + ) + try checked( + &passCount, + afterSecondOther.contains { + $0.calendarItemIdentifier == otherIdentifier + }, + "other-list fixture was touched" + ) + + let backupURL = try latestBackupURL(localRoot: localRoot) + let backupPermissions = try privateFilePermissions( + at: backupURL + ) + try checked( + &passCount, + backupPermissions == 0o600, + "prune backup permissions were not 0600" + ) + + let restoreNow = Date(timeIntervalSince1970: 10_062) + let restoredCounts = try await pruner.restoreLast(now: restoreNow) + try checked( + &passCount, + restoredCounts.restored == 1, + "restore did not recreate exactly one reminder" + ) + + let afterRestore = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + name: targetName + ) + ] + ) + let restored = try onlyReminder( + afterRestore, + title: restore.title + ) + try checked( + &passCount, + restored.title == restore.title, + "restored title did not match" + ) + try checked( + &passCount, + restored.notes == restore.notes, + "restored notes did not match" + ) + try checked( + &passCount, + restored.url == restore.url, + "restored URL did not match" + ) + try checked( + &passCount, + restored.priority == 0, + "restored priority did not match" + ) + try checked( + &passCount, + !restored.isCompleted, + "restored completion state did not match" + ) + try checked( + &passCount, + sameDateComponents( + restored.dueDateComponents, + restore.dueDateComponents + ), + "restored due date did not match" + ) + try checked( + &passCount, + sameDateComponents( + restored.startDateComponents, + restore.startDateComponents + ), + "restored start date did not match" + ) + let restoredAlarms = restored.alarms ?? [] + try checked( + &passCount, + restoredAlarms.count == 1, + "restored alarm count did not match" + ) + try checked( + &passCount, + restoredAlarms.first?.absoluteDate == nil + && abs( + (restoredAlarms.first?.relativeOffset ?? 0) + - restore.alarmOffset + ) < 0.001, + "restored alarm fields did not match" + ) + let restoredRules = restored.recurrenceRules ?? [] + try checked( + &passCount, + restoredRules.count == 1, + "restored recurrence count did not match" + ) + try checked( + &passCount, + restoredRules.first?.frequency == .daily + && restoredRules.first?.interval + == restore.recurrenceInterval + && restoredRules.first?.recurrenceEnd?.occurrenceCount + == restore.recurrenceCount, + "restored recurrence fields did not match" + ) + + let withinGrace = try await pruner.advance( + snapshot: snapshot, + now: restoreNow.addingTimeInterval(61) + ) + try checked( + &passCount, + withinGrace.deleted == 0 && withinGrace.waiting == 1, + "24-hour restore grace did not block deletion" + ) + + let afterGraceFirst = try await pruner.advance( + snapshot: snapshot, + now: restoreNow.addingTimeInterval(86_401) + ) + try checked( + &passCount, + afterGraceFirst.deleted == 0 + && afterGraceFirst.firstSeen == 1, + "post-grace first scan did not re-register the candidate" + ) + + let afterGraceSecond = try await pruner.advance( + snapshot: snapshot, + now: restoreNow.addingTimeInterval(86_462) + ) + try checked( + &passCount, + afterGraceSecond.deleted == 1, + "post-grace second scan did not delete the candidate" + ) + + let finalOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + name: otherName + ) + ] + ) + try checked( + &passCount, + finalOther.contains { + $0.calendarItemIdentifier == otherIdentifier + }, + "other-list fixture was touched after restore" + ) + } catch { + bodyError = error + } + } + + if let cleanupError { + throw cleanupError + } + if let bodyError { + throw bodyError + } + return passCount +} + +do { + let passCount = try await runIntegrationTests() + print("PASS \(passCount) isolated EventKit checks") + print("EventKit integration tests passed") +} catch { + fputs("FAIL EventKit integration tests: \(error)\n", stderr) + exit(1) +} From fbd24c870a1ad0c9786685a99418d7d551f5753a Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:11:33 +0800 Subject: [PATCH 23/34] =?UTF-8?q?fix(EventKit):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E6=B5=8B=E8=AF=95=E5=92=8C=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPruner.swift | 66 ++- Tests/TaskForgeReminderCoreTests/main.swift | 216 +++++++++ .../TaskForgeReminderEventKitTests/main.swift | 449 +++++++++++------- 3 files changed, 562 insertions(+), 169 deletions(-) diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index b6b4f93..c3177fd 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -1,5 +1,6 @@ import CryptoKit import Darwin +import Dispatch import EventKit import Foundation import TaskForgeReminderCore @@ -102,6 +103,61 @@ public enum ReminderPrunerError: Error, LocalizedError { } } +private final class ReminderFetchResolution: @unchecked Sendable { + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resolve(_ result: Result) { + lock.lock() + guard let continuation else { + lock.unlock() + return + } + self.continuation = nil + lock.unlock() + continuation.resume(with: result) + } + + private let lock = NSLock() + private var continuation: CheckedContinuation? +} + +enum ReminderFetchWaiter { + typealias TimeoutScheduler = @Sendable ( + TimeInterval, + @escaping @Sendable () -> Void + ) -> Void + + static func wait( + timeout: TimeInterval = 30, + scheduleTimeout: @escaping TimeoutScheduler = { + interval, timeout in + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + max(interval, 0), + execute: timeout + ) + }, + start: ( + @escaping @Sendable (Result) -> Void + ) -> Void + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + let resolution = ReminderFetchResolution( + continuation + ) + scheduleTimeout(timeout) { + resolution.resolve( + .failure(ReminderPrunerError.reminderFetchFailed) + ) + } + start { result in + resolution.resolve(result) + } + } + } +} + @MainActor public final class ReminderPruner { public init( @@ -608,16 +664,16 @@ public final class ReminderPruner { in calendar: EKCalendar ) async throws -> [EKReminder] { let predicate = eventStore.predicateForReminders(in: [calendar]) - return try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation<[EKReminder], Error>) in + return try await ReminderFetchWaiter.wait(timeout: 30) { + completion in _ = eventStore.fetchReminders(matching: predicate) { reminders in guard let reminders else { - continuation.resume( - throwing: ReminderPrunerError.reminderFetchFailed + completion( + .failure(ReminderPrunerError.reminderFetchFailed) ) return } - continuation.resume(returning: reminders) + completion(.success(reminders)) } } } diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index f72b1cc..dc974d9 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1,5 +1,7 @@ +import CoreLocation import Dispatch import Darwin +import EventKit import Foundation import TaskForgeReminderCore @testable import TaskForgeReminderEventKit @@ -27,6 +29,45 @@ private func requireValue( return value } +private final class AsyncTestResultBox: @unchecked Sendable { + func store(_ result: Result) { + lock.lock() + self.result = result + lock.unlock() + } + + func take() -> Result? { + lock.lock() + defer { lock.unlock() } + return result + } + + private let lock = NSLock() + private var result: Result? +} + +private func waitForAsync( + _ operation: @escaping @Sendable () async throws -> Value +) throws -> Value { + let resultBox = AsyncTestResultBox() + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + do { + resultBox.store(.success(try await operation())) + } catch { + resultBox.store(.failure(error)) + } + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + 5) == .success else { + throw TestFailure(description: "async unit test timed out") + } + return try requireValue( + resultBox.take(), + "async unit test produced no result" + ).get() +} + private var shanghaiCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! @@ -284,6 +325,181 @@ private func taskForgeEntries(at url: URL) throws -> [String] { } private let tests: [TestCase] = [ + ("reminder fetch timeout resolves once and ignores a late callback", { + do { + let _: Int = try waitForAsync { + try await ReminderFetchWaiter.wait( + timeout: 0, + scheduleTimeout: { _, timeout in + timeout() + }, + start: { completion in + completion(.success(7)) + } + ) + } + throw TestFailure( + description: "fetch timeout unexpectedly succeeded" + ) + } catch ReminderPrunerError.reminderFetchFailed { + // Expected anonymous timeout after the late callback is ignored. + } + }), + ("reminder backup adapter round-trips every expressible field", { + let eventStore = EKEventStore() + let original = EKReminder(eventStore: eventStore) + original.title = "adapter fixture" + original.notes = "adapter notes" + original.url = URL(string: "taskforge-adapter-test://fixture") + original.priority = 5 + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 9 * 3_600)! + original.dueDateComponents = DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + era: 1, + year: 2031, + month: 8, + day: 9, + hour: 10, + minute: 11, + second: 12 + ) + original.startDateComponents = DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + era: 1, + year: 2031, + month: 8, + day: 9, + hour: 9, + minute: 10, + second: 11 + ) + + let absoluteAlarm = EKAlarm( + absoluteDate: Date(timeIntervalSince1970: 2_000_000_000) + ) + let location = EKStructuredLocation(title: "adapter location") + location.geoLocation = CLLocation( + latitude: 31.2304, + longitude: 121.4737 + ) + location.radius = 125 + absoluteAlarm.structuredLocation = location + absoluteAlarm.proximity = .enter + original.addAlarm(absoluteAlarm) + original.addAlarm(EKAlarm(relativeOffset: -1_800)) + + let recurrence = EKRecurrenceRule( + recurrenceWith: .yearly, + interval: 2, + daysOfTheWeek: [ + EKRecurrenceDayOfWeek( + dayOfTheWeek: .monday, + weekNumber: 2 + ) + ], + daysOfTheMonth: [1, -1].map(NSNumber.init(value:)), + monthsOfTheYear: [1, 12].map(NSNumber.init(value:)), + weeksOfTheYear: [1, -1].map(NSNumber.init(value:)), + daysOfTheYear: [100, -1].map(NSNumber.init(value:)), + setPositions: [1, -1].map(NSNumber.init(value:)), + end: EKRecurrenceEnd(occurrenceCount: 7) + ) + original.addRecurrenceRule(recurrence) + + let captured = ReminderBackupAdapter.capture( + original, + taskPresence: .sourceConfirmed + ) + let restored = EKReminder(eventStore: eventStore) + ReminderBackupAdapter.restore(captured, into: restored) + let roundTrip = ReminderBackupAdapter.capture( + restored, + taskPresence: .sourceConfirmed + ) + + try require( + roundTrip.title == captured.title + && roundTrip.notes == captured.notes + && roundTrip.url == captured.url + && roundTrip.priority == captured.priority, + "adapter changed scalar reminder fields" + ) + try require( + roundTrip.dueDateComponents == captured.dueDateComponents + && roundTrip.startDateComponents + == captured.startDateComponents, + "adapter changed date component fields" + ) + try require( + roundTrip.alarms.count == captured.alarms.count, + "adapter changed alarm count" + ) + let capturedAbsolute = try requireValue( + captured.alarms.first { $0.absoluteDate != nil }, + "absolute alarm fixture was not captured" + ) + let roundTripAbsolute = try requireValue( + roundTrip.alarms.first { $0.absoluteDate != nil }, + "absolute alarm was not restored" + ) + try require( + roundTripAbsolute.absoluteDate + == capturedAbsolute.absoluteDate, + "adapter changed absolute alarm date" + ) + try require( + roundTripAbsolute.structuredLocation + == capturedAbsolute.structuredLocation, + "adapter changed structured alarm location" + ) + try require( + roundTripAbsolute.proximityRawValue + == capturedAbsolute.proximityRawValue, + "adapter changed alarm proximity" + ) + let capturedRelative = try requireValue( + captured.alarms.first { $0.absoluteDate == nil }, + "relative alarm fixture was not captured" + ) + let roundTripRelative = try requireValue( + roundTrip.alarms.first { $0.absoluteDate == nil }, + "relative alarm was not restored" + ) + try require( + roundTripRelative == capturedRelative, + "adapter changed relative alarm fields" + ) + try require( + roundTrip.recurrenceRules == captured.recurrenceRules, + "adapter changed recurrence fields" + ) + try require( + roundTrip.taskPresence == .sourceConfirmed + && captured.priority == 5 + && captured.alarms.count == 2 + && captured.alarms.contains { + $0.absoluteDate != nil + && $0.structuredLocation != nil + && $0.proximityRawValue + == EKAlarmProximity.enter.rawValue + } + && captured.recurrenceRules.first?.daysOfMonth + == [1, -1] + && captured.recurrenceRules.first?.monthsOfYear + == [1, 12] + && captured.recurrenceRules.first?.weeksOfYear + == [1, -1] + && captured.recurrenceRules.first?.daysOfYear + == [100, -1] + && captured.recurrenceRules.first?.setPositions + == [1, -1], + "adapter fixture did not cover extended fields" + ) + }), ("prune target calendar selection fails closed on ambiguity", { let missing: Int? = try ReminderPruner.uniqueTargetCalendarMatch([]) try require(missing == nil, "zero matching calendars should be absent") diff --git a/Tests/TaskForgeReminderEventKitTests/main.swift b/Tests/TaskForgeReminderEventKitTests/main.swift index aa5c7cd..f0cc03c 100644 --- a/Tests/TaskForgeReminderEventKitTests/main.swift +++ b/Tests/TaskForgeReminderEventKitTests/main.swift @@ -66,6 +66,132 @@ private struct RestoreExpectation { let recurrenceCount: Int } +private struct TemporaryCalendarIdentifiers { + var target: String? + var other: String? + + var all: [String] { + [target, other].compactMap { $0 } + } +} + +private struct DateComponentsBaseline: Equatable { + init(_ components: DateComponents) { + calendarIdentifier = components.calendar?.identifier + timeZoneIdentifier = components.timeZone?.identifier + timeZoneSecondsFromGMT = + components.timeZone?.secondsFromGMT() + era = components.era + year = components.year + month = components.month + day = components.day + hour = components.hour + minute = components.minute + second = components.second + nanosecond = components.nanosecond + weekday = components.weekday + weekdayOrdinal = components.weekdayOrdinal + quarter = components.quarter + weekOfMonth = components.weekOfMonth + weekOfYear = components.weekOfYear + yearForWeekOfYear = components.yearForWeekOfYear + isLeapMonth = components.isLeapMonth + } + + let calendarIdentifier: Calendar.Identifier? + let timeZoneIdentifier: String? + let timeZoneSecondsFromGMT: Int? + let era: Int? + let year: Int? + let month: Int? + let day: Int? + let hour: Int? + let minute: Int? + let second: Int? + let nanosecond: Int? + let weekday: Int? + let weekdayOrdinal: Int? + let quarter: Int? + let weekOfMonth: Int? + let weekOfYear: Int? + let yearForWeekOfYear: Int? + let isLeapMonth: Bool? +} + +private struct ReminderFieldBaseline: Equatable { + init(_ reminder: EKReminder) { + title = reminder.title + notes = reminder.notes + url = reminder.url + priority = reminder.priority + isCompleted = reminder.isCompleted + dueDateComponents = reminder.dueDateComponents.map( + DateComponentsBaseline.init + ) + startDateComponents = reminder.startDateComponents.map( + DateComponentsBaseline.init + ) + alarms = (reminder.alarms ?? []).map { alarm in + let location = alarm.structuredLocation.map { + ReminderLocationBackup( + title: $0.title ?? "", + latitude: $0.geoLocation?.coordinate.latitude, + longitude: $0.geoLocation?.coordinate.longitude, + radius: $0.radius + ) + } + return ReminderAlarmBackup( + absoluteDate: alarm.absoluteDate, + relativeOffset: alarm.absoluteDate == nil + ? alarm.relativeOffset + : nil, + structuredLocation: location, + proximityRawValue: alarm.proximity.rawValue + ) + } + recurrenceRules = (reminder.recurrenceRules ?? []).map { rule in + let end = rule.recurrenceEnd + return ReminderRecurrenceBackup( + frequencyRawValue: rule.frequency.rawValue, + interval: rule.interval, + daysOfWeek: (rule.daysOfTheWeek ?? []).map { + ReminderWeekdayBackup( + dayOfTheWeekRawValue: + $0.dayOfTheWeek.rawValue, + weekNumber: $0.weekNumber + ) + }, + daysOfMonth: + (rule.daysOfTheMonth ?? []).map(\.intValue), + monthsOfYear: + (rule.monthsOfTheYear ?? []).map(\.intValue), + weeksOfYear: + (rule.weeksOfTheYear ?? []).map(\.intValue), + daysOfYear: + (rule.daysOfTheYear ?? []).map(\.intValue), + setPositions: + (rule.setPositions ?? []).map(\.intValue), + endDate: end?.endDate, + occurrenceCount: end.flatMap { + $0.occurrenceCount > 0 + ? Int($0.occurrenceCount) + : nil + } + ) + } + } + + let title: String? + let notes: String? + let url: URL? + let priority: Int + let isCompleted: Bool + let dueDateComponents: DateComponentsBaseline? + let startDateComponents: DateComponentsBaseline? + let alarms: [ReminderAlarmBackup] + let recurrenceRules: [ReminderRecurrenceBackup] +} + private let operationTimeout: TimeInterval = 30 private func checked( @@ -159,26 +285,17 @@ private func fetchReminders( } } -private func exactTemporaryCalendars( - eventStore: EKEventStore, - names: Set -) -> [EKCalendar] { - eventStore.calendars(for: .reminder).filter { - names.contains($0.title) - } -} - private func exactTemporaryCalendar( eventStore: EKEventStore, - name: String + identifier: String, + expectedName: String ) throws -> EKCalendar { - let matches = exactTemporaryCalendars( - eventStore: eventStore, - names: [name] - ) - guard matches.count == 1, let calendar = matches.first else { + guard + let calendar = eventStore.calendar(withIdentifier: identifier), + calendar.title == expectedName + else { throw IntegrationFailure( - description: "temporary calendar lookup was not unique" + description: "temporary calendar lookup failed" ) } return calendar @@ -187,17 +304,9 @@ private func exactTemporaryCalendar( private func createTemporaryCalendars( eventStore: EKEventStore, targetName: String, - otherName: String + otherName: String, + identifiers: inout TemporaryCalendarIdentifiers ) throws { - let exactNames: Set = [targetName, otherName] - guard exactTemporaryCalendars( - eventStore: eventStore, - names: exactNames - ).isEmpty else { - throw IntegrationFailure( - description: "temporary calendar UUID collision" - ) - } let reminderSources = eventStore.sources.filter { $0.sourceType == .local || $0.sourceType == .calDAV } @@ -210,47 +319,63 @@ private func createTemporaryCalendars( let target = EKCalendar(for: .reminder, eventStore: eventStore) target.title = targetName target.source = source - try eventStore.saveCalendar(target, commit: false) + try eventStore.saveCalendar(target, commit: true) + guard !target.calendarIdentifier.isEmpty else { + try? eventStore.removeCalendar(target, commit: true) + throw IntegrationFailure( + description: "target calendar identifier was unavailable" + ) + } + let targetIdentifier = target.calendarIdentifier + identifiers.target = targetIdentifier let other = EKCalendar(for: .reminder, eventStore: eventStore) other.title = otherName other.source = source - try eventStore.saveCalendar(other, commit: false) - - try eventStore.commit() - eventStore.reset() - guard exactTemporaryCalendars( - eventStore: eventStore, - names: exactNames - ).count == exactNames.count else { + try eventStore.saveCalendar(other, commit: true) + guard !other.calendarIdentifier.isEmpty else { + try? eventStore.removeCalendar(other, commit: true) throw IntegrationFailure( - description: "temporary calendars were not persisted" + description: "other calendar identifier was unavailable" ) } + let otherIdentifier = other.calendarIdentifier + identifiers.other = otherIdentifier + + eventStore.reset() + _ = try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetIdentifier, + expectedName: targetName + ) + _ = try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherIdentifier, + expectedName: otherName + ) } private func cleanupTemporaryCalendars( eventStore: EKEventStore, - names: Set + identifiers: [String] ) -> IntegrationFailure? { - for _ in 0..<3 { - eventStore.reset() - let matches = exactTemporaryCalendars( - eventStore: eventStore, - names: names - ) - if matches.isEmpty { - return nil - } - for calendar in matches { + for identifier in identifiers { + for _ in 0..<3 { + eventStore.reset() + guard + let calendar = eventStore.calendar( + withIdentifier: identifier + ) + else { + break + } try? eventStore.removeCalendar(calendar, commit: true) } } eventStore.reset() - guard exactTemporaryCalendars( - eventStore: eventStore, - names: names - ).isEmpty else { + guard identifiers.allSatisfy({ + eventStore.calendar(withIdentifier: $0) == nil + }) else { return IntegrationFailure( description: "temporary calendar cleanup failed" ) @@ -369,20 +494,6 @@ private func onlyReminder( return reminder } -private func sameDateComponents( - _ lhs: DateComponents?, - _ rhs: DateComponents? -) -> Bool { - guard let lhs, let rhs else { - return lhs == nil && rhs == nil - } - return lhs.year == rhs.year - && lhs.month == rhs.month - && lhs.day == rhs.day - && lhs.hour == rhs.hour - && lhs.minute == rhs.minute -} - private func latestBackupURL(localRoot: URL) throws -> URL { let backupDirectory = localRoot.appendingPathComponent( "PruneBackups", @@ -421,7 +532,6 @@ private func runIntegrationTests() async throws -> Int { let eventStore = EKEventStore() let targetName = "TaskForgeReminderSync Test \(UUID().uuidString)" let otherName = "TaskForgeReminderSync Other \(UUID().uuidString)" - let exactNames: Set = [targetName, otherName] let testRoot = FileManager.default.temporaryDirectory .appendingPathComponent( "TaskForgeReminderEventKitTests-\(UUID().uuidString)", @@ -439,12 +549,13 @@ private func runIntegrationTests() async throws -> Int { var bodyError: Error? var cleanupError: Error? var passCount = 0 + var calendarIdentifiers = TemporaryCalendarIdentifiers() do { defer { let calendarError = cleanupTemporaryCalendars( eventStore: eventStore, - names: exactNames + identifiers: calendarIdentifiers.all ) var directoryError: IntegrationFailure? if FileManager.default.fileExists(atPath: testRoot.path) { @@ -466,8 +577,19 @@ private func runIntegrationTests() async throws -> Int { try createTemporaryCalendars( eventStore: eventStore, targetName: targetName, - otherName: otherName + otherName: otherName, + identifiers: &calendarIdentifiers ) + guard + let targetCalendarIdentifier = + calendarIdentifiers.target, + let otherCalendarIdentifier = + calendarIdentifiers.other + else { + throw IntegrationFailure( + description: "temporary calendar identifiers missing" + ) + } print("TEMP \(targetName)") print("TEMP \(otherName)") @@ -561,11 +683,13 @@ private func runIntegrationTests() async throws -> Int { let targetCalendar = try exactTemporaryCalendar( eventStore: eventStore, - name: targetName + identifier: targetCalendarIdentifier, + expectedName: targetName ) let otherCalendar = try exactTemporaryCalendar( eventStore: eventStore, - name: otherName + identifier: otherCalendarIdentifier, + expectedName: otherName ) try stageFixtures( eventStore: eventStore, @@ -582,7 +706,8 @@ private func runIntegrationTests() async throws -> Int { calendars: [ try exactTemporaryCalendar( eventStore: eventStore, - name: targetName + identifier: targetCalendarIdentifier, + expectedName: targetName ) ] ) @@ -591,7 +716,8 @@ private func runIntegrationTests() async throws -> Int { calendars: [ try exactTemporaryCalendar( eventStore: eventStore, - name: otherName + identifier: otherCalendarIdentifier, + expectedName: otherName ) ] ) @@ -606,10 +732,21 @@ private func runIntegrationTests() async throws -> Int { "other-list fixture count was incorrect" ) - let plainIdentifier = try onlyReminder( + let initialTargetIdentifiers = Set( + initialTarget.map(\.calendarItemIdentifier) + ) + let initialOtherIdentifiers = Set( + initialOther.map(\.calendarItemIdentifier) + ) + let plainReminder = try onlyReminder( initialTarget, title: labels.plain - ).calendarItemIdentifier + ) + let plainIdentifier = + plainReminder.calendarItemIdentifier + let plainFieldBaseline = ReminderFieldBaseline( + plainReminder + ) let priorityIdentifier = try onlyReminder( initialTarget, title: labels.priority @@ -630,11 +767,6 @@ private func runIntegrationTests() async throws -> Int { initialTarget, title: labels.historical ).calendarItemIdentifier - let otherIdentifier = try onlyReminder( - initialOther, - title: labels.other - ).calendarItemIdentifier - let pruner = ReminderPruner( eventStore: eventStore, configuration: ReminderPruneConfiguration( @@ -668,16 +800,32 @@ private func runIntegrationTests() async throws -> Int { calendars: [ try exactTemporaryCalendar( eventStore: eventStore, - name: targetName + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + ] + ) + let afterFirstOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherCalendarIdentifier, + expectedName: otherName ) ] ) try checked( &passCount, - afterFirst.contains { - $0.calendarItemIdentifier == plainIdentifier - }, - "first scan removed the plain external fixture" + Set(afterFirst.map(\.calendarItemIdentifier)) + == initialTargetIdentifiers, + "first scan changed the target identifier set" + ) + try checked( + &passCount, + Set(afterFirstOther.map(\.calendarItemIdentifier)) + == initialOtherIdentifiers, + "first scan changed the other-list identifier set" ) let second = try await pruner.advance( @@ -695,7 +843,8 @@ private func runIntegrationTests() async throws -> Int { calendars: [ try exactTemporaryCalendar( eventStore: eventStore, - name: targetName + identifier: targetCalendarIdentifier, + expectedName: targetName ) ] ) @@ -740,15 +889,15 @@ private func runIntegrationTests() async throws -> Int { calendars: [ try exactTemporaryCalendar( eventStore: eventStore, - name: otherName + identifier: otherCalendarIdentifier, + expectedName: otherName ) ] ) try checked( &passCount, - afterSecondOther.contains { - $0.calendarItemIdentifier == otherIdentifier - }, + Set(afterSecondOther.map(\.calendarItemIdentifier)) + == initialOtherIdentifiers, "other-list fixture was touched" ) @@ -775,7 +924,8 @@ private func runIntegrationTests() async throws -> Int { calendars: [ try exactTemporaryCalendar( eventStore: eventStore, - name: targetName + identifier: targetCalendarIdentifier, + expectedName: targetName ) ] ) @@ -783,81 +933,18 @@ private func runIntegrationTests() async throws -> Int { afterRestore, title: restore.title ) + let restoredIdentifier = + restored.calendarItemIdentifier try checked( &passCount, - restored.title == restore.title, - "restored title did not match" - ) - try checked( - &passCount, - restored.notes == restore.notes, - "restored notes did not match" - ) - try checked( - &passCount, - restored.url == restore.url, - "restored URL did not match" - ) - try checked( - &passCount, - restored.priority == 0, - "restored priority did not match" - ) - try checked( - &passCount, - !restored.isCompleted, - "restored completion state did not match" - ) - try checked( - &passCount, - sameDateComponents( - restored.dueDateComponents, - restore.dueDateComponents - ), - "restored due date did not match" - ) - try checked( - &passCount, - sameDateComponents( - restored.startDateComponents, - restore.startDateComponents - ), - "restored start date did not match" - ) - let restoredAlarms = restored.alarms ?? [] - try checked( - &passCount, - restoredAlarms.count == 1, - "restored alarm count did not match" - ) - try checked( - &passCount, - restoredAlarms.first?.absoluteDate == nil - && abs( - (restoredAlarms.first?.relativeOffset ?? 0) - - restore.alarmOffset - ) < 0.001, - "restored alarm fields did not match" - ) - let restoredRules = restored.recurrenceRules ?? [] - try checked( - &passCount, - restoredRules.count == 1, - "restored recurrence count did not match" - ) - try checked( - &passCount, - restoredRules.first?.frequency == .daily - && restoredRules.first?.interval - == restore.recurrenceInterval - && restoredRules.first?.recurrenceEnd?.occurrenceCount - == restore.recurrenceCount, - "restored recurrence fields did not match" + ReminderFieldBaseline(restored) + == plainFieldBaseline, + "restored fields differ from the saved EventKit baseline" ) let withinGrace = try await pruner.advance( snapshot: snapshot, - now: restoreNow.addingTimeInterval(61) + now: restoreNow.addingTimeInterval(86_399) ) try checked( &passCount, @@ -886,20 +973,45 @@ private func runIntegrationTests() async throws -> Int { "post-grace second scan did not delete the candidate" ) + let finalTarget = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + ] + ) let finalOther = try await fetchReminders( eventStore: eventStore, calendars: [ try exactTemporaryCalendar( eventStore: eventStore, - name: otherName + identifier: otherCalendarIdentifier, + expectedName: otherName ) ] ) try checked( &passCount, - finalOther.contains { - $0.calendarItemIdentifier == otherIdentifier - }, + !Set(finalTarget.map(\.calendarItemIdentifier)) + .contains(restoredIdentifier) + && !finalTarget.contains { + $0.title == labels.plain + }, + "post-grace second scan retained the restored fixture" + ) + try checked( + &passCount, + Set(finalTarget.map(\.calendarItemIdentifier)) + == afterSecondIdentifiers, + "post-grace scans changed a protected fixture" + ) + try checked( + &passCount, + Set(finalOther.map(\.calendarItemIdentifier)) + == initialOtherIdentifiers, "other-list fixture was touched after restore" ) } catch { @@ -920,7 +1032,16 @@ do { let passCount = try await runIntegrationTests() print("PASS \(passCount) isolated EventKit checks") print("EventKit integration tests passed") +} catch let failure as IntegrationFailure { + fputs( + "FAIL EventKit integration tests: \(failure.description)\n", + stderr + ) + exit(1) } catch { - fputs("FAIL EventKit integration tests: \(error)\n", stderr) + fputs( + "FAIL EventKit integration tests: unexpected-stage-failure\n", + stderr + ) exit(1) } From 39862a24f29519e56da04ed90ca8fda7ac0a7ffc Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:27:20 +0800 Subject: [PATCH 24/34] =?UTF-8?q?fix(EventKit):=20=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E5=90=8E=E5=8F=96=E6=B6=88=E6=8F=90=E9=86=92=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPruner.swift | 129 ++++++++++-- Tests/TaskForgeReminderCoreTests/main.swift | 189 +++++++++++++++++- 2 files changed, 293 insertions(+), 25 deletions(-) diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index c3177fd..3e89579 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -103,24 +103,76 @@ public enum ReminderPrunerError: Error, LocalizedError { } } -private final class ReminderFetchResolution: @unchecked Sendable { - init(_ continuation: CheckedContinuation) { +private final class ReminderFetchResolution< + Value, + Handle: Sendable +>: @unchecked Sendable { + init( + _ continuation: CheckedContinuation, + cancel: @escaping @Sendable (Handle) -> Void + ) { self.continuation = continuation + self.cancel = cancel } func resolve(_ result: Result) { lock.lock() - guard let continuation else { + guard !settled, let continuation else { lock.unlock() return } + settled = true self.continuation = nil lock.unlock() continuation.resume(with: result) } + func timeout() { + lock.lock() + guard !settled, let continuation else { + lock.unlock() + return + } + settled = true + timeoutWon = true + self.continuation = nil + let handleToCancel = cancellationHandleLocked() + lock.unlock() + + if let handleToCancel { + cancel(handleToCancel) + } + continuation.resume( + throwing: ReminderPrunerError.reminderFetchFailed + ) + } + + func register(_ handle: Handle) { + lock.lock() + self.handle = handle + let handleToCancel = cancellationHandleLocked() + lock.unlock() + + if let handleToCancel { + cancel(handleToCancel) + } + } + + private func cancellationHandleLocked() -> Handle? { + guard timeoutWon, !cancelIssued, let handle else { + return nil + } + cancelIssued = true + return handle + } + private let lock = NSLock() private var continuation: CheckedContinuation? + private var handle: Handle? + private var settled = false + private var timeoutWon = false + private var cancelIssued = false + private let cancel: @Sendable (Handle) -> Void } enum ReminderFetchWaiter { @@ -129,7 +181,7 @@ enum ReminderFetchWaiter { @escaping @Sendable () -> Void ) -> Void - static func wait( + static func wait( timeout: TimeInterval = 30, scheduleTimeout: @escaping TimeoutScheduler = { interval, timeout in @@ -138,26 +190,48 @@ enum ReminderFetchWaiter { execute: timeout ) }, + cancel: @escaping @Sendable (Handle) -> Void, start: ( @escaping @Sendable (Result) -> Void - ) -> Void + ) -> Handle ) async throws -> Value { try await withCheckedThrowingContinuation { continuation in - let resolution = ReminderFetchResolution( - continuation + let resolution = ReminderFetchResolution( + continuation, + cancel: cancel ) scheduleTimeout(timeout) { - resolution.resolve( - .failure(ReminderPrunerError.reminderFetchFailed) - ) + resolution.timeout() } - start { result in + let handle = start { result in resolution.resolve(result) } + resolution.register(handle) } } } +private final class ReminderFetchRequestIdentifier: @unchecked Sendable { + init(_ rawValue: Any) { + self.rawValue = rawValue + } + + let rawValue: Any +} + +@MainActor +private final class ReminderFetchCanceller { + init(eventStore: EKEventStore) { + self.eventStore = eventStore + } + + func cancel(_ identifier: ReminderFetchRequestIdentifier) { + eventStore.cancelFetchRequest(identifier.rawValue) + } + + private let eventStore: EKEventStore +} + @MainActor public final class ReminderPruner { public init( @@ -664,18 +738,31 @@ public final class ReminderPruner { in calendar: EKCalendar ) async throws -> [EKReminder] { let predicate = eventStore.predicateForReminders(in: [calendar]) - return try await ReminderFetchWaiter.wait(timeout: 30) { - completion in - _ = eventStore.fetchReminders(matching: predicate) { reminders in - guard let reminders else { - completion( - .failure(ReminderPrunerError.reminderFetchFailed) - ) - return + let canceller = ReminderFetchCanceller(eventStore: eventStore) + return try await ReminderFetchWaiter.wait( + timeout: 30, + cancel: { identifier in + Task { @MainActor in + canceller.cancel(identifier) + } + }, + start: { completion in + let identifier = eventStore.fetchReminders( + matching: predicate + ) { reminders in + guard let reminders else { + completion( + .failure( + ReminderPrunerError.reminderFetchFailed + ) + ) + return + } + completion(.success(reminders)) } - completion(.success(reminders)) + return ReminderFetchRequestIdentifier(identifier) } - } + ) } private func restoreReadbackPairs( diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index dc974d9..34eaa43 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -46,6 +46,62 @@ private final class AsyncTestResultBox: @unchecked Sendable { private var result: Result? } +private final class LockedCounter: @unchecked Sendable { + func increment() { + lock.lock() + count += 1 + lock.unlock() + } + + func read() -> Int { + lock.lock() + defer { lock.unlock() } + return count + } + + private let lock = NSLock() + private var count = 0 +} + +private final class LockedValues: @unchecked Sendable { + func append(_ value: Value) { + lock.lock() + values.append(value) + lock.unlock() + } + + func read() -> [Value] { + lock.lock() + defer { lock.unlock() } + return values + } + + private let lock = NSLock() + private var values: [Value] = [] +} + +private final class ScheduledCallbackBox: @unchecked Sendable { + func store(_ callback: @escaping @Sendable () -> Void) { + lock.lock() + self.callback = callback + lock.unlock() + } + + func call() throws { + lock.lock() + let callback = self.callback + lock.unlock() + try require( + callback != nil, + "scheduled callback was not registered" + ) + callback?() + } + + private let lock = NSLock() + private var callback: (@Sendable () -> Void)? +} + private func waitForAsync( _ operation: @escaping @Sendable () async throws -> Value ) throws -> Value { @@ -325,25 +381,94 @@ private func taskForgeEntries(at url: URL) throws -> [String] { } private let tests: [TestCase] = [ - ("reminder fetch timeout resolves once and ignores a late callback", { + ("reminder fetch timeout cancels a registered request exactly once", { + let cancellations = LockedValues() + do { + let _: Int = try waitForAsync { + try await ReminderFetchWaiter.wait( + timeout: 0.05, + scheduleTimeout: { _, timeout in + DispatchQueue.global().asyncAfter( + deadline: .now() + 0.05 + ) { + timeout() + timeout() + } + }, + cancel: { identifier in + cancellations.append(identifier) + }, + start: { completion in + _ = completion + return 11 + } + ) + } + throw TestFailure( + description: "fetch timeout unexpectedly succeeded" + ) + } catch ReminderPrunerError.reminderFetchFailed { + // Expected anonymous timeout. + } + try require( + cancellations.read() == [11], + "timeout must cancel the registered request identifier once" + ) + }), + ("reminder fetch success ignores a late timeout without cancelling", { + let scheduledTimeout = ScheduledCallbackBox() + let cancellations = LockedCounter() + let value: Int = try waitForAsync { + try await ReminderFetchWaiter.wait( + scheduleTimeout: { _, timeout in + scheduledTimeout.store(timeout) + }, + cancel: { _ in + cancellations.increment() + }, + start: { completion in + completion(.success(7)) + return 12 + } + ) + } + try scheduledTimeout.call() + try scheduledTimeout.call() + try require(value == 7, "fetch success returned an unexpected value") + try require( + cancellations.read() == 0, + "a late timeout must not cancel a successful request" + ) + }), + ("reminder fetch timeout before handle registration cancels once", { + let cancellations = LockedValues() do { let _: Int = try waitForAsync { try await ReminderFetchWaiter.wait( timeout: 0, scheduleTimeout: { _, timeout in timeout() + timeout() + }, + cancel: { identifier in + cancellations.append(identifier) }, start: { completion in - completion(.success(7)) + completion(.success(9)) + return 13 } ) } throw TestFailure( - description: "fetch timeout unexpectedly succeeded" + description: "pre-registration timeout unexpectedly succeeded" ) } catch ReminderPrunerError.reminderFetchFailed { - // Expected anonymous timeout after the late callback is ignored. + // Expected; the callback arrives after timeout has already won. } + try require( + cancellations.read() == [13], + "a late request identifier must be cancelled exactly once" + ) }), ("reminder backup adapter round-trips every expressible field", { let eventStore = EKEventStore() @@ -500,6 +625,62 @@ private let tests: [TestCase] = [ "adapter fixture did not cover extended fields" ) }), + ("reminder backup adapter round-trips recurrence end date", { + let eventStore = EKEventStore() + let original = EKReminder(eventStore: eventStore) + original.title = "adapter end-date fixture" + original.addRecurrenceRule( + EKRecurrenceRule( + recurrenceWith: .daily, + interval: 3, + end: EKRecurrenceEnd( + end: Date(timeIntervalSince1970: 2_100_000_000) + ) + ) + ) + + let captured = ReminderBackupAdapter.capture( + original, + taskPresence: .absent + ) + let stableEndDate = try requireValue( + original.recurrenceRules?.first?.recurrenceEnd?.endDate, + "EventKit did not expose the recurrence end date" + ) + let capturedRule = try requireValue( + captured.recurrenceRules.first, + "end-date recurrence fixture was not captured" + ) + try require( + capturedRule.endDate == stableEndDate, + "adapter did not preserve EventKit's stable recurrence end date" + ) + let restored = EKReminder(eventStore: eventStore) + ReminderBackupAdapter.restore(captured, into: restored) + let restoredEndDate = try requireValue( + restored.recurrenceRules?.first?.recurrenceEnd?.endDate, + "adapter did not restore the recurrence end date" + ) + let roundTrip = ReminderBackupAdapter.capture( + restored, + taskPresence: .absent + ) + let roundTripRule = try requireValue( + roundTrip.recurrenceRules.first, + "end-date recurrence was not restored" + ) + + try require( + restoredEndDate == stableEndDate + && roundTripRule.endDate == stableEndDate, + "adapter changed EventKit's stable recurrence end date" + ) + try require( + capturedRule.occurrenceCount == nil + && roundTripRule.occurrenceCount == nil, + "date-bounded recurrence must not become count-bounded" + ) + }), ("prune target calendar selection fails closed on ambiguity", { let missing: Int? = try ReminderPruner.uniqueTargetCalendarMatch([]) try require(missing == nil, "zero matching calendars should be absent") From 94cf39b807dc4677b5c7e9f81490ff7e19cae88e Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:39:50 +0800 Subject: [PATCH 25/34] =?UTF-8?q?docs(=E6=B8=85=E7=90=86):=20=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E8=87=AA=E5=8A=A8=E5=88=A0=E9=99=A4=E5=92=8C=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++ CHANGELOG.md | 20 ++++++++ PRIVACY.md | 38 ++++++++++++++- README.md | 101 ++++++++++++++++++++++++++++++++++------ SECURITY.md | 27 +++++++++++ docs/ARCHITECTURE.md | 75 +++++++++++++++++++++++++++++ docs/TROUBLESHOOTING.md | 98 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 348 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 24b2ef4..8706ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,9 @@ __pycache__/ .env .env.* Backups/ +PruneCandidates.json +PruneBackups/ +PruneHashSalt +*.prune-test.json .worktrees/ .superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da9e40..654f079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project are documented here. +## Unreleased + +- Added automatic cleanup for unfinished, unimportant reminders that are + confirmed absent from both the TaskForge snapshot and durable Vault source. +- Added protection for completed reminders, EventKit priority and six leading + important-title markers. +- Added an unchanged two-scan confirmation window of at least 60 seconds, + private candidate persistence and fail-closed source resolution. +- Added checksummed `0600` pre-deletion backups and + `--restore-last-prune` with a minimum 24-hour restoration grace period. +- Added strict read-only `--prune-dry-run` and one-pass `--prune-once` + commands; `--sync` and `--watch` now advance the same pruning state machine + after reverse and forward synchronization. +- Scoped pruning fetches to exactly one configured reminders list, rejecting + same-name ambiguity and leaving all other lists untouched. +- Added a 30-second EventKit fetch timeout with request cancellation and + anonymous, salted pruning logs. +- Preserved the existing TaskForge boundary: reverse completion only marks + source tasks `done`; pruning never deletes TaskForge task lines or files. + ## 1.1.0 - 2026-07-29 - Added TaskForge-to-Reminders updates for linked title, date, time and status diff --git a/PRIVACY.md b/PRIVACY.md index de304e9..860352e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -6,7 +6,8 @@ TaskForge Reminder Sync is designed to run entirely on the local Mac. - TaskForge's local `tasks.v6.bin` cache; - titles, schedules, status and source metadata for TaskForge tasks; -- reminders in the configured Apple Reminders list; +- reminders in the configured Apple Reminders list only; pruning does not fetch + reminder contents from any other list; - Markdown or TaskNotes source files only when a linked reminder is completed. ## Data it writes @@ -15,8 +16,21 @@ TaskForge Reminder Sync is designed to run entirely on the local Mac. - completion markers in the matching Vault source task; - timestamped source-file backups under `~/Library/Application Support/TaskForgeReminderSync/Backups/`; +- the `0600` pruning candidate ledger at + `~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json`; +- checksummed `0600` reminder restore backups under + `~/Library/Application Support/TaskForgeReminderSync/PruneBackups/`; +- a `0600` local random hashing salt at + `~/Library/Application Support/TaskForgeReminderSync/PruneHashSalt`; - operational logs under `~/Library/Logs/`. +The pruning ledger stores raw EventKit and target-list identifiers, timestamps, +candidate fingerprints, rules versions and restore grace periods. Pruning +backups contain the reminder fields needed for restoration, including titles, +notes, dates, priority, alarms, recurrence data and original system +identifiers. These are sensitive local runtime data. Their parent application +support directory is restricted to the current user (`0700`). + ## Data it does not send The application contains no HTTP client, analytics SDK, telemetry, advertising, @@ -28,6 +42,18 @@ Apple Reminders may sync through iCloud according to the user's Apple account and system settings. That synchronization is performed by macOS, not by this project. +## Pruning logs + +Pruning output is limited to aggregate candidate, waiting, deletion, +restoration and failure counts; when item correlation is needed it uses a +truncated hash derived with the local random salt. Pruning logs do not contain +reminder titles or notes, Vault or source-file paths, raw TaskForge IDs or raw +EventKit IDs. Errors are reported by category without user content. + +An EventKit reminder fetch is scoped to the configured list and cancelled after +30 seconds. Timeout, permission, snapshot, source-resolution and I/O failures +are treated as indeterminate and preserve the reminder. + ## Metadata stored in reminders Linked reminders contain: @@ -52,4 +78,12 @@ location access. Generated build directories, TaskForge cache files, logs, backups, `.env` files and local plist overrides are excluded by `.gitignore`. Contributors -should still inspect staged files before every push. +should still inspect staged files before every push. `PruneCandidates.json`, +`PruneBackups/`, `PruneHashSalt` and `*.prune-test.json` are explicitly ignored +in case private debugging data is copied into a checkout. + +Never commit reminder titles, notes, Vault or source paths, raw TaskForge / +EventKit identifiers, candidate ledgers, pruning backups, salts or production +logs. Uninstalling the App intentionally preserves these private files so that +recovery remains possible; review and remove them separately only after any +needed `--restore-last-prune` operation. diff --git a/README.md b/README.md index 7869c1b..9b90b0c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ - **歧义时不新建**:同一 ID 或源位置对应多个提醒时,记录冲突并停止该任务,不会继续制造重复项。 - **持久源映射**:提醒中保存经过 Base64 编码的任务源引用。任务离开 TaskForge 当前缓存后,历史提醒仍能定位原笔记。 - **防止误重开**:任意一端已经完成时,正向同步不会把 Apple 提醒重新打开。 +- **可恢复自动清理**:只在配置的提醒列表中,把“未完成、不重要、且 TaskForge 当前缓存和持久源都确认不存在”的提醒经过至少相隔 60 秒的两次扫描后删除。 +- **重要提醒保护**:Apple 内建优先级大于零,或标题去除开头空白后以 `!`、`!`、`❗`、`‼️`、`⭐`、`📌` 开头的提醒永不进入清理候选。 +- **删除前本机备份**:每批清理先写入并回读校验权限为 `0600` 的备份;最近一个尚未恢复的批次可以一键恢复。 - **先备份再回写**:每次反向修改前保存源文件副本,并记录修改前后的 SHA-256。 - **保守拒绝**:重复任务、非 `keep` 完成策略、Vault 外路径、陈旧或歧义源行都不会被自动修改。 - **不删除源任务**: @@ -34,12 +37,8 @@ TaskForge tasks.v6.bin │ 读取今日未完成任务 ▼ Apple 提醒事项 / TaskForge 今日 - │ 完成状态 + 持久源引用 - ▼ -Vault Markdown / TaskNotes - │ TaskForge 重新索引 - ▼ -TaskForge done + ├─ 完成状态 + 持久源引用 ─▶ Vault Markdown / TaskNotes ─▶ TaskForge done + └─ 双源确认不存在 ─▶ 两次扫描 ─▶ 本机备份 ─▶ 删除 Apple 提醒 ``` 正向同步只**新建今天的任务**,避免把整个 Vault 导入提醒事项;已经关联的任务即使改到其他日期,仍会更新原提醒。反向同步则会检查所有已经建立关联的提醒,因此昨天或更早的任务在 Apple 端完成后仍可闭环。 @@ -117,6 +116,13 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 - 标准日志:`~/Library/Logs/TaskForgeReminderSync.log` - 错误日志:`~/Library/Logs/TaskForgeReminderSync.error.log` - 回写备份:`~/Library/Application Support/TaskForgeReminderSync/Backups/` +- 清理候选账本:`~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json` +- 清理备份:`~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` +- 清理日志匿名化盐:`~/Library/Application Support/TaskForgeReminderSync/PruneHashSalt` + +候选账本、清理备份和匿名化盐均为本机私有运行数据,文件权限为 `0600` +(父目录为 `0700`),不会进入 Git 仓库。备份可能包含恢复提醒所需的标题、 +笔记、日期、闹钟、重复规则和原始系统标识,请像保护 Vault 一样保护该目录。 ## 命令 @@ -129,8 +135,11 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 | `--deduplicate` | 保留权威提醒,把冗余活跃提醒移到可恢复的归档列表 | 是 | | `--reverse-dry-run` | 预览 Apple → TaskForge 的源文件修改 | 否 | | `--reverse-once` | 执行一次反向完成并等待 TaskForge 回读 | 是 | -| `--sync` | 先反向扫描,再执行一次今日任务正向同步 | 是 | -| `--watch` | 常驻近实时双向同步 | 是 | +| `--prune-dry-run` | 严格只读分类,报告首次候选和已满足二次确认的数量;不写账本、备份或盐,不修改提醒 | 否 | +| `--prune-once` | 推进一次候选状态;首次登记,至少 60 秒后的下一次扫描才可能备份并删除 | 是 | +| `--restore-last-prune` | 恢复最近一个尚未恢复的实际删除批次 | 是 | +| `--sync` | 依次反向扫描、正向同步并推进一次自动清理 | 是 | +| `--watch` | 常驻近实时双向同步,并在每轮末推进自动清理 | 是 | 常用参数: @@ -151,6 +160,58 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 --reverse-once --task-id TASK_ID ``` +## 自动清理与恢复 + +清理候选必须同时满足全部条件: + +1. 位于 `--list-name` 指定的列表(默认 `TaskForge 今日`); +2. 尚未完成; +3. EventKit 优先级为零,且标题没有六个受保护前缀; +4. 当前 TaskForge 快照没有对应任务; +5. 持久源引用也无法在 Vault 中确认任务存在。 + +第 4、5 项是“双源不存在”检查。源文件不可读、引用越出 Vault、源行同名或 +位置有歧义、TaskForge 快照解码失败、权限或 I/O 出错时,结果属于“无法判定” +而不是“不存在”,清理会失败关闭并保留提醒。旧版提醒若既不在当前缓存中, +也没有可验证的持久源引用,则可能成为候选。 + +清理只向 EventKit 请求目标列表中的提醒;其他 Apple 提醒列表、去重归档列表 +和已完成历史都不参与清理。同名目标列表若出现多个匹配,工具会拒绝猜测并 +整轮停止。目标列表不存在时,清理返回零,不会为了清理新建列表。 + +先运行严格只读预演: + +```bash +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --prune-dry-run +``` + +真实推进需要两次独立扫描,且两次至少相隔 60 秒: + +```bash +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --prune-once +# 至少 60 秒后,由 watcher / --sync / 第二次 --prune-once 再确认 +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --prune-once +``` + +期间只要提醒被完成、加入优先级或重要前缀、恢复 TaskForge 身份、移出目标 +列表或候选属性改变,旧候选就会撤销;属性改变后仍符合条件也要重新计时。 +删除前必须成功写入并校验整批备份,否则整批不删除。 + +恢复最近一个尚未恢复的删除批次: + +```bash +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --restore-last-prune +``` + +恢复会重建用户可编辑字段,但 EventKit 会生成新的系统 ID。恢复项享有至少 +24 小时的本机清理宽限;宽限结束后仍符合条件,也必须重新经历两次扫描。 +如果原列表已经不存在,恢复只会尝试在备份记录的原提醒事项账户中重建;账户 +缺失或同一账户中有多个同名列表时失败关闭,备份保持未消费。 + ## 同步与安全规则 1. 提醒必须带有本工具生成的稳定标记,才会参与反向同步。 @@ -161,8 +222,9 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 6. 写入后逐字节校验文件,并等待 TaskForge 任务库刷新。 7. TaskForge 可能从缓存中移除已完成的内联任务;这不等于源任务被删除。 8. 早期版本创建、没有持久源引用且已经离开 TaskForge 缓存的提醒会被安全跳过,不会猜测写入。 -9. TaskForge 删除任务时,本工具不会自动删除对应提醒;删除属于显式的非自动操作。 -10. 去重维护只移动冗余提醒到独立归档列表,不删除提醒或 TaskForge 源任务。 +9. 自动清理只删除符合上述五项条件并通过双扫描确认的 Apple 提醒;不会删除或移动 TaskForge 源任务。 +10. Apple → TaskForge 反向同步仍只把源任务改为 `done`,绝不会因清理删除任务行或 TaskNotes 文件。 +11. 去重维护只移动冗余提醒到独立归档列表,不删除提醒或 TaskForge 源任务;去重归档列表也不在自动清理范围内。 只读检查整个受管列表是否仍然无重复: @@ -172,6 +234,11 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 审计覆盖当前与历史受管提醒,只输出数量,不输出标题、笔记、Vault 路径或原始任务 ID。“历史源位置复用”是信息项:同一行在不同计划日期承载过不同任务,不等同于重复。 +清理日志也只输出首次候选、等待、撤销、删除、恢复和失败的数量,必要的单项 +关联使用本机随机盐生成的截断哈希。清理日志不会输出标题、笔记、Vault / +源文件路径、原始 TaskForge ID 或原始 EventKit ID。EventKit 读取若 30 秒 +未完成,会取消该次请求并以匿名错误失败关闭,不会继续删除。 + 修复早期版本已经产生的活跃重复项: ```bash @@ -203,7 +270,12 @@ launchctl print "gui/$(id -u)/local.codex.taskforge-reminder-sync" - Apple 提醒事项中已经创建的内容; - Vault 中的任何笔记或任务; -- 反向写入备份。 +- 反向写入备份; +- 清理候选账本、清理备份、匿名化盐和运行日志。 + +如果需要找回已清理提醒,请在删除这些本机运行数据前执行 +`--restore-last-prune`。App 已卸载时,可以重新构建后从 `dist` 运行恢复命令; +备份被恢复后仍会保留,并记录为已恢复。 ## 开发与测试 @@ -213,14 +285,17 @@ swift build ./scripts/build-app.sh ``` -测试覆盖 MessagePack v6 解码、今日任务筛选、稳定标记、ID 变化后的源位置复用、歧义去重、日期语义比较、正反向完成策略、历史源引用、Markdown / TaskNotes 回写和安全拒绝条件。 +测试覆盖 MessagePack v6 解码、今日任务筛选、稳定标记、ID 变化后的源位置 +复用、歧义去重、日期语义比较、正反向完成策略、历史源引用、Markdown / +TaskNotes 回写、清理判定、双扫描状态机、私有备份、恢复宽限、EventKit +跨列表隔离以及读取超时取消。 ## 限制 - TaskForge 改变内部缓存格式后,解码器可能需要更新。 - 同时改动内联任务的文件位置、行号和标题,且 TaskForge 也更换内部 ID 时,没有足够的稳定信息可安全认定为同一任务;工具宁可拒绝猜测。 - 当前不会反向处理重复任务或完成后会移动、归档、删除的任务。 -- 当前不会根据 TaskForge 删除操作自动删除 Apple 提醒事项。 +- 自动清理判断的是“当前缓存与真实源都确认不存在”,不会仅凭 TaskForge 缓存暂时缺席就删除 Apple 提醒;已完成、重要或其他列表中的提醒不清理。 - TaskForge 未运行时,源文件可能不会立即被重新索引;建议让 TaskForge 保持运行。 - 本项目不提供云服务、遥测或跨设备同步;Apple 提醒事项自身的 iCloud 同步由系统负责。 diff --git a/SECURITY.md b/SECURITY.md index ea9e0cf..f477b0f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,3 +30,30 @@ The application deliberately refuses to: - process tasks whose completion policy is not `keep`; - edit a stale or ambiguous Markdown task; - write directly to TaskForge's binary cache. + +Reminder pruning adds these fixed boundaries: + +- only the configured Apple Reminders list is fetched for pruning; other lists, + completed history and the deduplication archive are never prune targets; +- multiple same-named target lists fail closed instead of selecting one; +- only unfinished, unimportant reminders confirmed absent from both the + current TaskForge snapshot and their durable Vault source may be candidates; +- EventKit priority greater than zero and the six leading title markers `!`, + `!`, `❗`, `‼️`, `⭐`, `📌` protect a reminder; +- deletion requires two unchanged scans at least 60 seconds apart and a + checksummed `0600` backup verified before the EventKit commit; +- snapshot, source, permission, I/O, ledger, backup, EventKit timeout and + ambiguous restore errors preserve data and fail closed; +- `--prune-dry-run` never writes the ledger, backups, salt or reminders; +- TaskForge reverse completion only changes a task to `done`; pruning never + deletes or moves a TaskForge source task. + +EventKit target-list reads time out after 30 seconds and cancel the outstanding +request. Pruning logs use aggregate counts, anonymous error categories and +salted truncated identifiers; they exclude reminder content, local paths and +raw TaskForge or EventKit IDs. + +The private candidate ledger, restore backups and hash salt live under +`~/Library/Application Support/TaskForgeReminderSync/` with `0600` file +permissions. They are intentionally preserved by uninstall for recovery and +must never be attached to a public issue or committed to Git. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2211642..faee07f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,6 +11,8 @@ A Foundation-only library responsible for: - creating and decoding stable markers; - deriving stable source identities and selecting deduplicated reminder matches; - serializing durable source references; +- classifying prune candidates and advancing the two-scan state machine; +- encoding private prune ledgers and checksummed restore backups; - comparing reminder dates semantically; - validating and editing Markdown / TaskNotes completion state. @@ -22,6 +24,7 @@ The macOS executable coordinates: - `EKEventStore` access; - forward and reverse reconciliation; +- target-list-only pruning and restoration; - source backups and SHA-256 receipts; - TaskForge cache refresh verification; - EventKit notifications, polling and scheduled fallbacks. @@ -39,6 +42,11 @@ and otherwise the oldest item. Every redundant reminder is moved—not deleted—to a separate archive calendar before forward reconciliation refreshes the preserved item. +The pruning path is separate from deduplication. It selects exactly one +configured list by name, fails closed if that name is ambiguous, and builds an +EventKit predicate scoped to that one calendar. Reminders from other lists, +including the deduplication archive, are never fetched as prune inputs. + ### LaunchAgent The installed LaunchAgent keeps a small signed supervisor alive. The supervisor @@ -53,6 +61,14 @@ background. The watcher: 4. runs a full reconciliation every minute; 5. runs additional checks at 07:00, 11:00 and 15:00. +Each reconciliation is serialized and runs in this order: + +1. reverse-complete Apple reminders into TaskForge sources; +2. create or refresh today's TaskForge reminders in Apple Reminders; +3. advance the pruning state machine against the latest snapshot and sources. + +This preserves completion writeback before any candidate can be deleted. + ## Forward flow 1. Decode `tasks.v6.bin`. @@ -87,6 +103,63 @@ EventKit item, so a TaskForge reindex does not create a second reminder. 7. Apply the completion edit atomically. 8. Verify exact file bytes and wait for TaskForge's cache to refresh. +Reverse completion only changes the source task to `done`. It never deletes a +Markdown task line, TaskNotes file or any other TaskForge source item. + +## Prune flow + +1. Select exactly one configured calendar. No match means no prune work; + multiple same-named matches fail closed. +2. Fetch reminders with an EventKit predicate containing only that calendar. + A fetch is cancelled after 30 seconds and the pass fails closed. +3. Protect every completed reminder, every reminder with EventKit priority + greater than zero, and every title whose leading whitespace is followed by + `!`, `!`, `❗`, `‼️`, `⭐` or `📌`. +4. Resolve TaskForge presence from both the current snapshot and the durable + source reference. Only `.absent` is eligible; permission, I/O, out-of-Vault + and ambiguous-source results are indeterminate and protected. +5. On the first eligible scan, store the EventKit ID, calendar ID, candidate + fingerprint, first-seen time and rules version in the private ledger. +6. On a later scan at least 60 seconds after first sighting, refetch and + reclassify the reminder. Any identity, calendar or fingerprint change + revokes or restarts the candidate. +7. Before deleting a confirmed batch, write a checksummed private backup and + read it back. A write or verification failure rejects the whole batch. +8. Stage EventKit removals, commit once, then refetch actual state. Record and + report what was actually deleted rather than assuming atomic success. + +`--prune-dry-run` uses a read-only ledger load and never creates or modifies +the ledger, backup directory, hash salt or EventKit items. `--prune-once`, +`--sync` and each watcher pass advance the same state machine. + +Private pruning state defaults to: + +- `~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json`; +- `~/Library/Application Support/TaskForgeReminderSync/PruneBackups/`; +- `~/Library/Application Support/TaskForgeReminderSync/PruneHashSalt`. + +The parent directory is `0700`; ledger, backups and salt are `0600`. Writes use +temporary files and atomic replacement. The salt produces truncated hashes for +anonymous item correlation; logs contain counts, hashed identifiers and error +categories, never reminder content, source paths or raw identifiers. + +## Restore flow + +`--restore-last-prune` selects the newest backup with a verified, recorded +actual deletion result that has not been restored. It restores only items +confirmed deleted, preserving EventKit-readable user fields. EventKit assigns +new system IDs. + +The original calendar is matched by calendar and source identifiers first. If +it no longer exists, restoration may recreate it only in the recorded original +source. A missing source, multiple same-named calendars in that source, +unsupported backup schema, checksum mismatch or ambiguous readback fails +closed without consuming the backup. + +Restored reminders receive a ledger grace entry for at least 24 hours. When +the grace period expires, an item still satisfying the candidate policy starts +again at the first scan; restoration never skips the two-scan requirement. + ## Why the binary cache is read-only `tasks.v6.bin` is treated as an implementation detail and cache, not as a @@ -102,6 +175,8 @@ the Vault source and lets TaskForge re-index it. EventKit's calendar/time-zone metadata. - EventKit changes are debounced. - A second reconciliation is queued instead of running concurrently. +- Prune and restore operations are serialized in-process and with a private + operation lock. - A reminder is claimed by at most one TaskForge task in each reconciliation. - Duplicate IDs, duplicate source identities and ambiguous existing reminders are reported as conflicts and never cause a new reminder to be created. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a2ed165..342f00e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -19,6 +19,15 @@ After rebuilding, re-authorize the newly installed app. To preserve identity across builds, use a stable code-signing certificate via `TASKFORGE_SYNC_CODESIGN_IDENTITY`; the project never creates one automatically. +Pruning also needs private local state under: + +`~/Library/Application Support/TaskForgeReminderSync/` + +The directory must be accessible only to the current user (`0700`), while +`PruneCandidates.json`, files below `PruneBackups/` and `PruneHashSalt` must be +`0600`. If permissions are broader, the ledger is damaged, or a backup checksum +does not verify, pruning fails closed and keeps the reminders. + ## LaunchAgent is not running ```bash @@ -68,6 +77,79 @@ not select one arbitrarily and does not create another reminder. Use `--reverse-dry-run --task-id TASK_ID` before any manual retry. +Reverse sync only marks the TaskForge source task `done`; reminder pruning +never deletes TaskForge task lines or TaskNotes files. + +## A prune candidate is not deleted + +Run the strict read-only classification first: + +```bash +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --prune-dry-run +``` + +Common safe reasons for retaining a reminder are: + +- it is completed, has EventKit priority greater than zero, or starts with + `!`, `!`, `❗`, `‼️`, `⭐` or `📌` after leading whitespace; +- the TaskForge current snapshot still contains it; +- its durable source reference still resolves to a real Vault task; +- source presence is indeterminate because of permissions, I/O, an out-of-Vault + path or an ambiguous same-named source task; +- the second unchanged scan has not occurred at least 60 seconds later; +- the reminder changed, moved lists or regained TaskForge identity, which + revokes or restarts the candidate; +- it was restored less than 24 hours ago; +- the candidate ledger or deletion backup cannot be privately written and + verified. + +`--prune-dry-run` is strictly read-only: it does not create or change the +candidate ledger, backup directory, hash salt or reminders. To advance state, +use `--prune-once`, `--sync`, or let `--watch` complete another reconciliation. + +## Pruning reports an ambiguous list + +If more than one Apple Reminders list has the configured name, pruning and +name-based restoration refuse to select one. Rename the unintended duplicate, +then rerun `--prune-dry-run`. Do not delete either list until you have checked +which one contains the expected reminders and backups. + +Pruning never fetches reminder contents from other lists. If the configured +list does not exist, a prune pass returns zero and does not create it. + +## Reminder reading times out + +Each target-list EventKit fetch has a 30-second limit. On timeout, the tool +cancels that fetch request once, records an anonymous error category and skips +deletion. Check Reminders permission and whether the Reminders app is +responsive, then retry. A late callback from the cancelled request is ignored. + +## Restore the last pruned batch + +Run: + +```bash +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --restore-last-prune +``` + +The command restores the newest verified batch that has an actual deletion +result and has not already been restored. It restores user-editable fields, +but macOS assigns new EventKit IDs. Restored reminders receive a 24-hour +minimum grace period and must complete two new scans before any later deletion. + +If the original list is gone, the tool attempts to recreate it only in the +original reminders account recorded by the backup. A missing account, multiple +same-named lists in that account, unsupported or modified backup, or ambiguous +post-restore readback fails closed; the backup remains available for retry. +The command is idempotent after an interrupted restore and will not knowingly +duplicate an already read-back restored item. + +If no unrestored batch is found, the restored count is zero. Backups remain at: + +`~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` + ## Restore a source file Backups are stored below: @@ -77,6 +159,9 @@ Backups are stored below: Each run uses a timestamped directory. Compare the backup and current source before restoring. Stop the LaunchAgent first if manual restoration is needed. +This source-file backup is separate from `PruneBackups/`, which restores Apple +reminders deleted by the pruning feature. + ## Repeated sync loop Current versions compare EventKit date components semantically and should @@ -86,3 +171,16 @@ settle at `更新 0`. If logs show continuous updates: 2. save the relevant redacted logs; 3. report the macOS and TaskForge versions; 4. do not publish real reminder notes or Vault paths. + +## Uninstall and private pruning data + +```bash +./scripts/uninstall-daily-sync.sh +``` + +Uninstalling stops the LaunchAgent and removes the installed App. It does not +delete Apple reminders, Vault sources, the candidate ledger, pruning backups, +hash salt or logs. If you may need a deleted reminder, run +`--restore-last-prune` before removing those private runtime files. If the App +is already uninstalled, rebuild it and run the restore command from `dist`; +the preserved backup remains the source of truth. From c0e2ae2e7b8303a486ee4e173243b1e8cc08ff90 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:53:43 +0800 Subject: [PATCH 26/34] =?UTF-8?q?fix(=E6=81=A2=E5=A4=8D):=20=E5=8F=AA?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E7=9C=9F=E5=AE=9E=E5=88=A0=E9=99=A4=E6=89=B9?= =?UTF-8?q?=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CHANGELOG.md | 2 + PRIVACY.md | 17 +- README.md | 4 +- SECURITY.md | 5 +- .../ReminderPrunePersistence.swift | 21 +- Tests/TaskForgeReminderCoreTests/main.swift | 198 +++++++++++++++++- docs/ARCHITECTURE.md | 19 +- docs/TROUBLESHOOTING.md | 13 +- .../plans/2026-07-30-reminder-pruning.md | 19 +- 10 files changed, 270 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 8706ffd..f9d3011 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ Backups/ PruneCandidates.json PruneBackups/ PruneHashSalt +PruneHashSalt.lock *.prune-test.json .worktrees/ .superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 654f079..9e7ec16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to this project are documented here. private candidate persistence and fail-closed source resolution. - Added checksummed `0600` pre-deletion backups and `--restore-last-prune` with a minimum 24-hour restoration grace period. +- Restore selection skips unresolved, zero-deletion and already restored + backups, choosing the newest remaining real deletion batch. - Added strict read-only `--prune-dry-run` and one-pass `--prune-once` commands; `--sync` and `--watch` now advance the same pruning state machine after reverse and forward synchronization. diff --git a/PRIVACY.md b/PRIVACY.md index 860352e..eda1820 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -8,12 +8,20 @@ TaskForge Reminder Sync is designed to run entirely on the local Mac. - titles, schedules, status and source metadata for TaskForge tasks; - reminders in the configured Apple Reminders list only; pruning does not fetch reminder contents from any other list; -- Markdown or TaskNotes source files only when a linked reminder is completed. +- the matching Markdown or TaskNotes source file when reverse-completing a + linked reminder; +- for pruning classification of an unfinished reminder, only the Vault file + named by that reminder's durable source reference, to confirm whether the + source task still exists. ## Data it writes - linked reminders in the configured Apple Reminders list; -- completion markers in the matching Vault source task; +- completion state in the matching Vault source task during reverse completion; +- automatic deletion of target-list reminders only after they satisfy every + prune-candidate rule and pass the two-scan confirmation; +- restored reminders rebuilt under the original reminders source and list + semantics recorded by the verified backup; - timestamped source-file backups under `~/Library/Application Support/TaskForgeReminderSync/Backups/`; - the `0600` pruning candidate ledger at @@ -79,8 +87,9 @@ location access. Generated build directories, TaskForge cache files, logs, backups, `.env` files and local plist overrides are excluded by `.gitignore`. Contributors should still inspect staged files before every push. `PruneCandidates.json`, -`PruneBackups/`, `PruneHashSalt` and `*.prune-test.json` are explicitly ignored -in case private debugging data is copied into a checkout. +`PruneBackups/`, `PruneHashSalt`, `PruneHashSalt.lock` and +`*.prune-test.json` are explicitly ignored in case private debugging data is +copied into a checkout. Never commit reminder titles, notes, Vault or source paths, raw TaskForge / EventKit identifiers, candidate ledgers, pruning backups, salts or production diff --git a/README.md b/README.md index 9b90b0c..dff2d0b 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,9 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 列表或候选属性改变,旧候选就会撤销;属性改变后仍符合条件也要重新计时。 删除前必须成功写入并校验整批备份,否则整批不删除。 -恢复最近一个尚未恢复的删除批次: +恢复按创建时间选择最新一个“尚未恢复、实际删除结果已解析且非空”的批次。 +更新但删除结果尚未解析的备份,以及实际删除数为零的备份,会保留在磁盘上但 +不会阻塞更早的真实删除批次,也不会被错误标记为已恢复。 ```bash ./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ diff --git a/SECURITY.md b/SECURITY.md index f477b0f..6def064 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,7 +46,10 @@ Reminder pruning adds these fixed boundaries: ambiguous restore errors preserve data and fail closed; - `--prune-dry-run` never writes the ledger, backups, salt or reminders; - TaskForge reverse completion only changes a task to `done`; pruning never - deletes or moves a TaskForge source task. + deletes or moves a TaskForge source task; +- restoration skips unresolved, zero-deletion and already restored backups, + selecting the newest remaining verified batch with a non-empty actual + deletion result. EventKit target-list reads time out after 30 seconds and cancel the outstanding request. Pruning logs use aggregate counts, anonymous error categories and diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 791403d..27e7cc2 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -353,7 +353,15 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { (url, try loadBackup(at: url)) } return batches - .filter { $0.1.restoredAt == nil } + .filter { + guard + $0.1.restoredAt == nil, + let deletedItems = $0.1.actuallyDeletedItems + else { + return false + } + return !deletedItems.isEmpty + } .sorted { if $0.1.createdAt == $1.1.createdAt { return $0.0.lastPathComponent > $1.0.lastPathComponent @@ -365,7 +373,10 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { public func markRestored(at url: URL, date: Date) throws { var batch = try loadBackup(at: url) - guard batch.actuallyDeletedIdentifiers != nil else { + guard + let deletedItems = batch.actuallyDeletedItems, + !deletedItems.isEmpty + else { throw ReminderPruneStoreError.invalidBackup } batch.restoredAt = date @@ -396,7 +407,10 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { at url: URL ) throws -> ReminderPruneBackupBatch { var batch = try loadBackup(at: url) - guard batch.actuallyDeletedIdentifiers != nil else { + guard + let deletedItems = batch.actuallyDeletedItems, + !deletedItems.isEmpty + else { throw ReminderPruneStoreError.invalidBackup } if batch.restoreAttemptIdentifier == nil { @@ -414,6 +428,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { guard batch.restoreAttemptIdentifier != nil, let deleted = batch.actuallyDeletedIdentifiers, + !deleted.isEmpty, Set(identifiers.keys) == Set(deleted), identifiers.values.allSatisfy({ !$0.isEmpty }), Set(identifiers.values).count == identifiers.count diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 34eaa43..b5f32d3 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -285,10 +285,16 @@ private func taskStoreFixture() -> Data { ) } -private func pruneBackupFixture() -> ReminderPruneBackupBatch { +private func pruneBackupFixture( + identifier: UUID = UUID( + uuidString: "00000000-0000-0000-0000-000000000001" + )!, + createdAt: Date = Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: [String]? = nil +) -> ReminderPruneBackupBatch { ReminderPruneBackupBatch( - identifier: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, - createdAt: Date(timeIntervalSince1970: 20), + identifier: identifier, + createdAt: createdAt, targetCalendarIdentifier: "calendar", targetCalendarTitle: "TaskForge 今日", targetSourceIdentifier: "source", @@ -315,7 +321,7 @@ private func pruneBackupFixture() -> ReminderPruneBackupBatch { taskPresence: .absent ) ], - actuallyDeletedIdentifiers: nil, + actuallyDeletedIdentifiers: actuallyDeletedIdentifiers, restoreAttemptIdentifier: nil, restoredItemIdentifiers: [:], restoredAt: nil @@ -1960,6 +1966,190 @@ private let tests: [TestCase] = [ "read-only ledger load must not create the ledger" ) }), + ("restore selection skips a newer unresolved backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let older = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000101" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(older) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000102" + )!, + createdAt: Date(timeIntervalSince1970: 20) + ) + ) + + let selected = try requireValue( + try store.latestUnrestoredBackup(), + "older real deletion backup should remain restorable" + ) + try require( + selected.1.identifier == older.identifier, + "newer unresolved backup blocked the real deletion backup" + ) + }), + ("restore selection skips a newer empty deletion result", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let older = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000111" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(older) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000112" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: [] + ) + ) + + let selected = try requireValue( + try store.latestUnrestoredBackup(), + "older real deletion backup should remain restorable" + ) + try require( + selected.1.identifier == older.identifier, + "newer empty deletion result blocked the real deletion backup" + ) + }), + ("unresolved and empty backups are not restorable or markable", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let unresolvedURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000121" + )!, + createdAt: Date(timeIntervalSince1970: 10) + ) + ) + let emptyURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000122" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: [] + ) + ) + + try require( + try store.latestUnrestoredBackup() == nil, + "unresolved or empty deletion results are not restorable" + ) + for url in [unresolvedURL, emptyURL] { + do { + try store.markRestored( + at: url, + date: Date(timeIntervalSince1970: 30) + ) + throw TestFailure( + description: "non-restorable backup was marked restored" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected non-restorable mark error" + ) + } + try require( + try store.loadBackup(at: url).restoredAt == nil, + "failed mark must not modify the backup" + ) + } + }), + ("restore selection chooses the newest real deletion backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000131" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + ) + let newer = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000132" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(newer) + + let selected = try requireValue( + try store.latestUnrestoredBackup(), + "real deletion backup should be restorable" + ) + try require( + selected.1.identifier == newer.identifier, + "selection did not choose the newest real deletion backup" + ) + }), + ("restore selection skips a restored real deletion backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let older = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000141" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(older) + let newerURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000142" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.beginRestoreAttempt(at: newerURL) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: newerURL + ) + try store.markRestored( + at: newerURL, + date: Date(timeIntervalSince1970: 30) + ) + + let selected = try requireValue( + try store.latestUnrestoredBackup(), + "older unrestored deletion backup should remain available" + ) + try require( + selected.1.identifier == older.identifier, + "restored deletion backup was selected again" + ) + }), ("prune backup persists deletion and idempotent restore readback", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index faee07f..702a764 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,10 @@ ### TaskForgeReminderCore -A Foundation-only library responsible for: +The core library keeps EventKit out of policy and persistence code. Its pure +task, matching and pruning policy uses Foundation value types; private pruning +persistence additionally uses CryptoKit for checksums and hashing, plus Darwin +for POSIX permissions and file locking. The library is responsible for: - decoding TaskForge MessagePack v6 records; - selecting scheduled open tasks; @@ -16,7 +19,8 @@ A Foundation-only library responsible for: - comparing reminder dates semantically; - validating and editing Markdown / TaskNotes completion state. -Keeping these rules outside EventKit makes them deterministic and testable. +Keeping these boundaries outside EventKit makes policy deterministic and local +persistence independently testable. ### TaskForgeReminderSync @@ -145,10 +149,13 @@ categories, never reminder content, source paths or raw identifiers. ## Restore flow -`--restore-last-prune` selects the newest backup with a verified, recorded -actual deletion result that has not been restored. It restores only items -confirmed deleted, preserving EventKit-readable user fields. EventKit assigns -new system IDs. +`--restore-last-prune` selects by creation time the newest un-restored backup +whose actual-deletion identifiers have been parsed and are non-empty. A newer +backup with an unresolved result or an empty actual-deletion set remains on +disk but cannot block an older real deletion batch and cannot be marked +restored. Among multiple eligible batches, the newest wins; already restored +batches are skipped. Restoration preserves EventKit-readable user fields, and +EventKit assigns new system IDs. The original calendar is matched by calendar and source identifiers first. If it no longer exists, restoration may recreate it only in the recorded original diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 342f00e..a9dc795 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -134,10 +134,12 @@ Run: --restore-last-prune ``` -The command restores the newest verified batch that has an actual deletion -result and has not already been restored. It restores user-editable fields, -but macOS assigns new EventKit IDs. Restored reminders receive a 24-hour -minimum grace period and must complete two new scans before any later deletion. +The command restores the newest verified, un-restored batch whose parsed actual +deletion result is non-empty. Newer unresolved or zero-deletion backups remain +available for audit but do not block an older real deletion batch and are not +marked restored. It restores user-editable fields, but macOS assigns new +EventKit IDs. Restored reminders receive a 24-hour minimum grace period and +must complete two new scans before any later deletion. If the original list is gone, the tool attempts to recreate it only in the original reminders account recorded by the backup. A missing account, multiple @@ -146,7 +148,8 @@ post-restore readback fails closed; the backup remains available for retry. The command is idempotent after an interrupted restore and will not knowingly duplicate an already read-back restored item. -If no unrestored batch is found, the restored count is zero. Backups remain at: +If no eligible non-empty unrestored batch is found, the restored count is zero. +Unresolved, empty and restored backups remain at: `~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md index 72bb5ff..9a3d19d 100644 --- a/docs/superpowers/plans/2026-07-30-reminder-pruning.md +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -1463,6 +1463,7 @@ git commit -m "test(EventKit): 验证清理隔离和恢复闭环" PruneCandidates.json PruneBackups/ PruneHashSalt +PruneHashSalt.lock *.prune-test.json ``` @@ -1471,10 +1472,18 @@ PruneHashSalt - [ ] **步骤 3:执行文档矛盾和隐私扫描** ```bash +public_docs=( + README.md + docs/ARCHITECTURE.md + docs/TROUBLESHOOTING.md + PRIVACY.md + SECURITY.md + CHANGELOG.md +) rg -n "不会.*删除|不自动删除|其他.*列表|prune|清理|恢复" \ - README.md docs PRIVACY.md SECURITY.md CHANGELOG.md -rg -n "/Users/[^/]+/|/var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,}" \ - README.md docs PRIVACY.md SECURITY.md CHANGELOG.md + "${public_docs[@]}" +rg -n "[/]Users/[^/]+/|[/]var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,}" \ + "${public_docs[@]}" git diff --check ``` @@ -1575,8 +1584,8 @@ tail -80 "$HOME/Library/Logs/TaskForgeReminderSync.error.log" git status --short --branch git diff origin/main...HEAD --check git diff origin/main...HEAD --name-only -git diff origin/main...HEAD -- | \ - rg -n '(/Users/[^/]+/|/var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,})' +git diff origin/main...HEAD -- . ':(exclude)docs/superpowers/**' | \ + rg -n '([/]Users/[^/]+/|[/]var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,})' ``` 预期: From cf50f5a3ef16ff083143fc73620eb7b09457bdae Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:05:14 +0800 Subject: [PATCH 27/34] =?UTF-8?q?fix(=E6=B8=85=E7=90=86):=20=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E6=9C=AA=E8=A7=A3=E6=9E=90=E4=B8=8E=E5=8F=AF=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReminderPrunePersistence.swift | 53 ++++- .../ReminderPruner.swift | 13 +- Tests/TaskForgeReminderCoreTests/main.swift | 222 +++++++++++++++++- docs/ARCHITECTURE.md | 9 +- .../plans/2026-07-30-reminder-pruning.md | 12 +- 5 files changed, 278 insertions(+), 31 deletions(-) diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 27e7cc2..8a2d3d7 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -335,7 +335,32 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { return batch } - public func latestUnrestoredBackup() throws -> (URL, ReminderPruneBackupBatch)? { + public func latestUnresolvedDeletionBackup() throws + -> (URL, ReminderPruneBackupBatch)? + { + try latestBackup { + $0.restoredAt == nil + && $0.actuallyDeletedIdentifiers == nil + } + } + + public func latestRestorableBackup() throws + -> (URL, ReminderPruneBackupBatch)? + { + try latestBackup { + guard + $0.restoredAt == nil, + let deletedItems = $0.actuallyDeletedItems + else { + return false + } + return !deletedItems.isEmpty + } + } + + private func latestBackup( + where isEligible: (ReminderPruneBackupBatch) -> Bool + ) throws -> (URL, ReminderPruneBackupBatch)? { try ensureDirectory(rootURL) let backups = try backupsURL() let urls: [URL] @@ -353,15 +378,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { (url, try loadBackup(at: url)) } return batches - .filter { - guard - $0.1.restoredAt == nil, - let deletedItems = $0.1.actuallyDeletedItems - else { - return false - } - return !deletedItems.isEmpty - } + .filter { isEligible($0.1) } .sorted { if $0.1.createdAt == $1.1.createdAt { return $0.0.lastPathComponent > $1.0.lastPathComponent @@ -374,8 +391,11 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { public func markRestored(at url: URL, date: Date) throws { var batch = try loadBackup(at: url) guard - let deletedItems = batch.actuallyDeletedItems, - !deletedItems.isEmpty + batch.restoredAt == nil, + batch.restoreAttemptIdentifier != nil, + let deleted = batch.actuallyDeletedIdentifiers, + !deleted.isEmpty, + Set(batch.restoredItemIdentifiers.keys) == Set(deleted) else { throw ReminderPruneStoreError.invalidBackup } @@ -388,6 +408,13 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { at url: URL ) throws { var batch = try loadBackup(at: url) + guard + batch.restoredAt == nil, + batch.restoreAttemptIdentifier == nil, + batch.restoredItemIdentifiers.isEmpty + else { + throw ReminderPruneStoreError.invalidBackup + } let original = Set(batch.items.map(\.originalItemIdentifier)) let deleted = Set(identifiers) guard deleted.isSubset(of: original) else { @@ -408,6 +435,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { ) throws -> ReminderPruneBackupBatch { var batch = try loadBackup(at: url) guard + batch.restoredAt == nil, let deletedItems = batch.actuallyDeletedItems, !deletedItems.isEmpty else { @@ -426,6 +454,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { ) throws { var batch = try loadBackup(at: url) guard + batch.restoredAt == nil, batch.restoreAttemptIdentifier != nil, let deleted = batch.actuallyDeletedIdentifiers, !deleted.isEmpty, diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index 3e89579..4cf467a 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -463,7 +463,7 @@ public final class ReminderPruner { defer { operationLock.unlock() } guard let (backupURL, originalBackup) = - try localStore.latestUnrestoredBackup() + try localStore.latestRestorableBackup() else { return ReminderPruneCounts() } @@ -475,14 +475,11 @@ public final class ReminderPruner { throw ReminderPrunerError.backupSchemaVersionUnsupported } guard - let items = originalBackup.actuallyDeletedItems + let items = originalBackup.actuallyDeletedItems, + !items.isEmpty else { throw ReminderPrunerError.deletionOutcomeUnresolved } - if items.isEmpty { - try localStore.markRestored(at: backupURL, date: now) - return ReminderPruneCounts() - } let backup = try localStore.beginRestoreAttempt(at: backupURL) guard let attemptIdentifier = backup.restoreAttemptIdentifier else { @@ -662,8 +659,8 @@ public final class ReminderPruner { reminders: [EKReminder] ) throws { guard - let (url, backup) = try localStore.latestUnrestoredBackup(), - backup.actuallyDeletedIdentifiers == nil + let (url, backup) = + try localStore.latestUnresolvedDeletionBackup() else { return } diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index b5f32d3..49471a4 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1966,6 +1966,141 @@ private let tests: [TestCase] = [ "read-only ledger load must not create the ledger" ) }), + ("unresolved selection chooses the newest unresolved outcome", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000201" + )!, + createdAt: Date(timeIntervalSince1970: 10) + ) + ) + let newerUnresolved = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000202" + )!, + createdAt: Date(timeIntervalSince1970: 20) + ) + _ = try store.saveBackup(newerUnresolved) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000203" + )!, + createdAt: Date(timeIntervalSince1970: 30), + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000204" + )!, + createdAt: Date(timeIntervalSince1970: 40), + actuallyDeletedIdentifiers: [] + ) + ) + let restoredURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000205" + )!, + createdAt: Date(timeIntervalSince1970: 50), + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.beginRestoreAttempt(at: restoredURL) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: restoredURL + ) + try store.markRestored( + at: restoredURL, + date: Date(timeIntervalSince1970: 60) + ) + + let selected = try requireValue( + try store.latestUnresolvedDeletionBackup(), + "unresolved deletion backup should be selected" + ) + try require( + selected.1.identifier == newerUnresolved.identifier, + "selection did not choose the newest unresolved outcome" + ) + }), + ("unresolved selection ignores empty resolved and restorable outcomes", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000211" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: [] + ) + ) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000212" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: ["item"] + ) + ) + + try require( + try store.latestUnresolvedDeletionBackup() == nil, + "resolved outcomes must not be selected as unresolved" + ) + }), + ("unresolved outcome settles into a preserved restorable backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let batch = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000221" + )!, + createdAt: Date(timeIntervalSince1970: 10) + ) + let url = try store.saveBackup(batch) + let unresolved = try requireValue( + try store.latestUnresolvedDeletionBackup(), + "unresolved deletion outcome should be discoverable" + ) + try require( + unresolved.1.identifier == batch.identifier, + "unresolved selection returned another backup" + ) + + try store.recordActuallyDeletedIdentifiers(["item"], at: url) + + try require( + try store.latestUnresolvedDeletionBackup() == nil, + "settled outcome remained unresolved" + ) + let restorable = try requireValue( + try store.latestRestorableBackup(), + "settled non-empty deletion should become restorable" + ) + try require( + restorable.1.identifier == batch.identifier, + "settlement replaced or lost the original backup" + ) + try require( + FileManager.default.fileExists(atPath: url.path), + "settlement must preserve the backup file" + ) + }), ("restore selection skips a newer unresolved backup", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1989,7 +2124,7 @@ private let tests: [TestCase] = [ ) let selected = try requireValue( - try store.latestUnrestoredBackup(), + try store.latestRestorableBackup(), "older real deletion backup should remain restorable" ) try require( @@ -2021,7 +2156,7 @@ private let tests: [TestCase] = [ ) let selected = try requireValue( - try store.latestUnrestoredBackup(), + try store.latestRestorableBackup(), "older real deletion backup should remain restorable" ) try require( @@ -2053,7 +2188,7 @@ private let tests: [TestCase] = [ ) try require( - try store.latestUnrestoredBackup() == nil, + try store.latestRestorableBackup() == nil, "unresolved or empty deletion results are not restorable" ) for url in [unresolvedURL, emptyURL] { @@ -2101,7 +2236,7 @@ private let tests: [TestCase] = [ _ = try store.saveBackup(newer) let selected = try requireValue( - try store.latestUnrestoredBackup(), + try store.latestRestorableBackup(), "real deletion backup should be restorable" ) try require( @@ -2142,7 +2277,7 @@ private let tests: [TestCase] = [ ) let selected = try requireValue( - try store.latestUnrestoredBackup(), + try store.latestRestorableBackup(), "older unrestored deletion backup should remain available" ) try require( @@ -2212,6 +2347,79 @@ private let tests: [TestCase] = [ ) } }), + ("restored backup rejects every further state mutation", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup( + pruneBackupFixture( + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.beginRestoreAttempt(at: url) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: url + ) + let restoredAt = Date(timeIntervalSince1970: 30) + try store.markRestored(at: url, date: restoredAt) + + do { + try store.recordActuallyDeletedIdentifiers(["item"], at: url) + throw TestFailure( + description: "restored deletion outcome was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restored outcome mutation error" + ) + } + do { + _ = try store.beginRestoreAttempt(at: url) + throw TestFailure( + description: "restored backup began another attempt" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restored begin mutation error" + ) + } + do { + try store.recordRestoreReadback( + ["item": "restored-item"], + at: url + ) + throw TestFailure( + description: "restored backup accepted another readback" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restored readback mutation error" + ) + } + do { + try store.markRestored( + at: url, + date: Date(timeIntervalSince1970: 40) + ) + throw TestFailure( + description: "restored backup was marked twice" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected repeated restored mutation error" + ) + } + try require( + try store.loadBackup(at: url).restoredAt == restoredAt, + "rejected mutations changed the restored backup" + ) + }), ("prune local store writes private ledger and verified backup", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -2369,7 +2577,7 @@ private let tests: [TestCase] = [ ofItemAtPath: backupURL.path ) do { - _ = try store.latestUnrestoredBackup() + _ = try store.latestRestorableBackup() throw TestFailure(description: "corrupt backup was ignored") } catch let error as ReminderPruneStoreError { try require(error == .checksumMismatch, "unexpected corrupt backup error") @@ -2394,7 +2602,7 @@ private let tests: [TestCase] = [ "restored backup must still verify" ) try require( - try store.latestUnrestoredBackup() == nil, + try store.latestRestorableBackup() == nil, "restored backup must not remain latest unrestored" ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 702a764..4577c07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -132,6 +132,12 @@ Markdown task line, TaskNotes file or any other TaskForge source item. 8. Stage EventKit removals, commit once, then refetch actual state. Record and report what was actually deleted rather than assuming atomic success. +If a process stops after EventKit commit but before the actual outcome is +persisted, the next advance pass uses +`latestUnresolvedDeletionBackup()` to select the newest unresolved batch, +refetches the target list and records the observed result. An empty result is +preserved but is neither unresolved nor restorable afterward. + `--prune-dry-run` uses a read-only ledger load and never creates or modifies the ledger, backup directory, hash salt or EventKit items. `--prune-once`, `--sync` and each watcher pass advance the same state machine. @@ -149,7 +155,8 @@ categories, never reminder content, source paths or raw identifiers. ## Restore flow -`--restore-last-prune` selects by creation time the newest un-restored backup +`--restore-last-prune` uses `latestRestorableBackup()` to select by creation +time the newest un-restored backup whose actual-deletion identifiers have been parsed and are non-empty. A newer backup with an unresolved result or an empty actual-deletion set remains on disk but cannot block an older real deletion batch and cannot be marked diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md index 9a3d19d..82a6028 100644 --- a/docs/superpowers/plans/2026-07-30-reminder-pruning.md +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -945,7 +945,10 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { public func saveLedger(_ ledger: ReminderPruneLedger) throws public func saveBackup(_ batch: ReminderPruneBackupBatch) throws -> URL public func loadBackup(at url: URL) throws -> ReminderPruneBackupBatch - public func latestUnrestoredBackup() throws -> (URL, ReminderPruneBackupBatch)? + public func latestUnresolvedDeletionBackup() throws + -> (URL, ReminderPruneBackupBatch)? + public func latestRestorableBackup() throws + -> (URL, ReminderPruneBackupBatch)? public func markRestored(at url: URL, date: Date) throws public func loadOrCreateHashSalt() throws -> Data } @@ -1138,13 +1141,16 @@ public final class ReminderPruner { 再次原子保存;仍存在的 ready entry 保留以便下轮重试。 10. commit 抛错时同样重新读取目标列表,按实际存在情况统计,不能假定批次 全成或全败;备份始终保留。 -11. 日志只用本机盐对 EventKit ID 做 SHA-256 后截取前 12 位。 +11. 下轮 `advance` 用 `latestUnresolvedDeletionBackup()` 找到尚未记录实际结果 + 的最新备份,回读目标列表并固化实际删除 ID;空结果保留但不进入恢复。 +12. 日志只用本机盐对 EventKit ID 做 SHA-256 后截取前 12 位。 - [ ] **步骤 4:实现恢复与 24 小时宽限** `restoreLast`: -1. 读取 `latestUnrestoredBackup()`; +1. 读取 `latestRestorableBackup()`;只选择尚未恢复且实际删除结果非空的最新 + 批次,跳过 unresolved、空结果和已恢复备份; 2. 查找目标列表;不存在时用同一 EventKit source 创建; 3. 为每个备份项目创建 `EKReminder` 并调用 adapter; 4. commit 成功后重新读取恢复项,按与 advance 相同的函数计算 fingerprint, From a90a9a1a9eee993be70ef5924230670575b961cd Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:46:47 +0800 Subject: [PATCH 28/34] =?UTF-8?q?fix(=E8=BF=90=E8=A1=8C=E6=97=B6):=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E8=BF=81=E7=A7=BB=E7=A7=81=E6=9C=89=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PRIVACY.md | 3 +- README.md | 9 +- SECURITY.md | 6 + Sources/TaskForgeReminderCore/Core.swift | 7 +- .../PrivateRuntimeDirectory.swift | 571 ++++++++++++++++++ .../ReminderPrunePersistence.swift | 82 +-- .../TaskForgeReminderSync/SyncEngine.swift | 37 +- Tests/TaskForgeReminderCoreTests/main.swift | 362 +++++++++++ docs/ARCHITECTURE.md | 6 + docs/TROUBLESHOOTING.md | 16 +- .../plans/2026-07-30-reminder-pruning.md | 18 + 11 files changed, 1032 insertions(+), 85 deletions(-) create mode 100644 Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift diff --git a/PRIVACY.md b/PRIVACY.md index eda1820..243df9f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -37,7 +37,8 @@ candidate fingerprints, rules versions and restore grace periods. Pruning backups contain the reminder fields needed for restoration, including titles, notes, dates, priority, alarms, recurrence data and original system identifiers. These are sensitive local runtime data. Their parent application -support directory is restricted to the current user (`0700`). +support directory, source-backup tree and batch directories are restricted to +the current user (`0700`); backup and state files use `0600`. ## Data it does not send diff --git a/README.md b/README.md index dff2d0b..e127dc0 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,12 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 - 清理备份:`~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` - 清理日志匿名化盐:`~/Library/Application Support/TaskForgeReminderSync/PruneHashSalt` -候选账本、清理备份和匿名化盐均为本机私有运行数据,文件权限为 `0600` -(父目录为 `0700`),不会进入 Git 仓库。备份可能包含恢复提醒所需的标题、 -笔记、日期、闹钟、重复规则和原始系统标识,请像保护 Vault 一样保护该目录。 +候选账本、清理备份、源文件回写备份和匿名化盐均为本机私有运行数据: +运行时根目录、`Backups/` 及批次目录为 `0700`,备份和状态文件为 `0600`, +且不会进入 Git 仓库。普通写路径可把本人拥有、非 symlink、无扩展 ACL、 +group/world 不可写的旧 `0755` / `0644` 备份树安全收紧;其他异常权限会 +失败关闭。备份可能包含恢复提醒所需的标题、笔记、日期、闹钟、重复规则和 +原始系统标识,请像保护 Vault 一样保护该目录。 ## 命令 diff --git a/SECURITY.md b/SECURITY.md index 6def064..e26c52a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,6 +45,12 @@ Reminder pruning adds these fixed boundaries: - snapshot, source, permission, I/O, ledger, backup, EventKit timeout and ambiguous restore errors preserve data and fail closed; - `--prune-dry-run` never writes the ledger, backups, salt or reminders; +- normal write paths only migrate an exposed legacy runtime root or known + `Backups/` tree after verifying current-user ownership, real directory / + regular-file types, no extended ACL and no group/world write bits; directories + become `0700`, files become `0600`, and descriptor metadata is rechecked; +- symlinks, a different owner, ACLs and group/world-writable nodes are never + repaired automatically and remain fail-closed; - TaskForge reverse completion only changes a task to `done`; pruning never deletes or moves a TaskForge source task; - restoration skips unresolved, zero-deletion and already restored backups, diff --git a/Sources/TaskForgeReminderCore/Core.swift b/Sources/TaskForgeReminderCore/Core.swift index 71dfd18..e41c73c 100644 --- a/Sources/TaskForgeReminderCore/Core.swift +++ b/Sources/TaskForgeReminderCore/Core.swift @@ -729,12 +729,15 @@ public enum TaskSourcePresenceInspector { ) -> TaskSourcePresence { switch task.sourceType?.lowercased() { case "markdowninline": - guard let originalLine = task.originalLine else { + guard + let originalLine = task.originalLine, + let lineNumber = task.lineNumber, + lineNumber > 0 + else { return .indeterminate } let lines = contents.components(separatedBy: "\n") if - let lineNumber = task.lineNumber, lines.indices.contains(lineNumber - 1), lines[lineNumber - 1] == originalLine { diff --git a/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift b/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift new file mode 100644 index 0000000..b2da5ba --- /dev/null +++ b/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift @@ -0,0 +1,571 @@ +import Darwin +import Foundation + +public enum PrivateRuntimeNodeKind: Equatable, Sendable { + case directory + case regularFile + case other +} + +public struct PrivateRuntimeNodeSecurity: Equatable, Sendable { + public var ownerUID: UInt32 + public var permissions: Int + public var kind: PrivateRuntimeNodeKind + public var hasExtendedACL: Bool + + public init( + ownerUID: UInt32, + permissions: Int, + kind: PrivateRuntimeNodeKind, + hasExtendedACL: Bool + ) { + self.ownerUID = ownerUID + self.permissions = permissions + self.kind = kind + self.hasExtendedACL = hasExtendedACL + } +} + +public enum PrivateRuntimeDirectoryPolicy { + public static func canMigrate( + _ security: PrivateRuntimeNodeSecurity, + currentUserUID: UInt32, + expectedKind: PrivateRuntimeNodeKind + ) -> Bool { + security.ownerUID == currentUserUID + && security.kind == expectedKind + && security.kind != .other + && !security.hasExtendedACL + && (0...0o777).contains(security.permissions) + && security.permissions & 0o022 == 0 + } +} + +public enum PrivateRuntimeDirectoryError: Error, Equatable { + case unsafeNode +} + +public enum PrivateRuntimeDirectory { + public static func prepareRoot(at url: URL) throws { + if let snapshot = try snapshotIfPresent(at: url) { + try validateMigratable( + snapshot, + expectedKind: .directory + ) + try migrate(snapshot, to: 0o700) + return + } + + try createDirectoryIncludingParents(at: url) + try validatePrivateDirectory(at: url) + } + + public static func prepareRootAndKnownTree( + rootURL: URL, + treeURL: URL + ) throws { + let root = rootURL.standardizedFileURL + let tree = treeURL.standardizedFileURL + guard tree.deletingLastPathComponent() == root else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + + guard let rootSnapshot = try snapshotIfPresent(at: root) else { + try createDirectoryIncludingParents(at: root) + try createPrivateDirectory(at: tree) + return + } + try validateMigratable(rootSnapshot, expectedKind: .directory) + + let treeSnapshots: [NodeSnapshot] + if let treeSnapshot = try snapshotIfPresent(at: tree) { + treeSnapshots = try collectKnownTree( + at: tree, + rootSnapshot: treeSnapshot + ) + for snapshot in treeSnapshots { + try validateMigratable( + snapshot, + expectedKind: snapshot.security.kind + ) + } + } else { + treeSnapshots = [] + } + + // Validate the complete known tree before changing any existing mode. + try migrate(rootSnapshot, to: 0o700) + if treeSnapshots.isEmpty { + try createPrivateDirectory(at: tree) + } else { + for snapshot in treeSnapshots.sorted(by: migrationOrder) { + let permissions = snapshot.security.kind == .directory + ? 0o700 + : 0o600 + try migrate(snapshot, to: permissions) + } + } + } + + public static func validatePrivateRootReadOnly( + at url: URL + ) throws -> Bool { + guard let snapshot = try snapshotIfPresent(at: url) else { + return false + } + try validatePrivate( + snapshot, + expectedKind: .directory, + permissions: 0o700 + ) + return true + } + + public static func validatePrivateDirectory(at url: URL) throws { + guard let snapshot = try snapshotIfPresent(at: url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try validatePrivate( + snapshot, + expectedKind: .directory, + permissions: 0o700 + ) + } + + public static func validatePrivateFile(at url: URL) throws { + guard let snapshot = try snapshotIfPresent(at: url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try validatePrivate( + snapshot, + expectedKind: .regularFile, + permissions: 0o600 + ) + } + + public static func createPrivateDirectory(at url: URL) throws { + let result = mkdir(url.path, mode_t(0o700)) + if result != 0 { + guard errno == EEXIST else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + try validatePrivateDirectory(at: url) + } + + public static func writePrivateFile( + _ data: Data, + to url: URL + ) throws { + try validatePrivateDirectory( + at: url.deletingLastPathComponent() + ) + let descriptor = open( + url.path, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + + var shouldRemove = true + defer { + _ = close(descriptor) + if shouldRemove { + _ = unlink(url.path) + } + } + guard fchmod(descriptor, mode_t(0o600)) == 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + guard let baseAddress = bytes.baseAddress else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let count = Darwin.write( + descriptor, + baseAddress.advanced(by: offset), + bytes.count - offset + ) + if count < 0 && errno == EINTR { + continue + } + guard count > 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + offset += count + } + } + guard fsync(descriptor) == 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + + var status = stat() + guard + fstat(descriptor, &status) == 0, + metadata(for: status, hasExtendedACL: false) + == PrivateRuntimeNodeSecurity( + ownerUID: getuid(), + permissions: 0o600, + kind: .regularFile, + hasExtendedACL: false + ), + try !hasExtendedACL(at: url) + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyPathStillMatches( + url, + device: status.st_dev, + inode: status.st_ino, + expectedKind: .regularFile, + permissions: 0o600 + ) + shouldRemove = false + } + + private struct NodeSnapshot { + let url: URL + let security: PrivateRuntimeNodeSecurity + let device: dev_t + let inode: ino_t + } + + private static func createDirectoryIncludingParents(at url: URL) throws { + do { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + guard try snapshotIfPresent(at: url) != nil else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + guard let snapshot = try snapshotIfPresent(at: url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try validateMigratable(snapshot, expectedKind: .directory) + try migrate(snapshot, to: 0o700) + } + + private static func collectKnownTree( + at url: URL, + rootSnapshot: NodeSnapshot + ) throws -> [NodeSnapshot] { + guard rootSnapshot.security.kind == .directory else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let names: [String] + do { + names = try FileManager.default.contentsOfDirectory( + atPath: url.path + ) + } catch { + throw PrivateRuntimeDirectoryError.unsafeNode + } + var snapshots = [rootSnapshot] + for name in names { + let childURL = url.appendingPathComponent(name) + guard let child = try snapshotIfPresent(at: childURL) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + switch child.security.kind { + case .directory: + snapshots.append( + contentsOf: try collectKnownTree( + at: childURL, + rootSnapshot: child + ) + ) + case .regularFile: + snapshots.append(child) + case .other: + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + return snapshots + } + + private static func validateMigratable( + _ snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind + ) throws { + guard + PrivateRuntimeDirectoryPolicy.canMigrate( + snapshot.security, + currentUserUID: getuid(), + expectedKind: expectedKind + ) + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + + private static func validatePrivate( + _ snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind, + permissions: Int + ) throws { + guard + PrivateRuntimeDirectoryPolicy.canMigrate( + snapshot.security, + currentUserUID: getuid(), + expectedKind: expectedKind + ), + snapshot.security.permissions == permissions + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let descriptor = try openNode( + snapshot, + expectedKind: expectedKind, + writable: false + ) + defer { _ = close(descriptor) } + try verifyOpenedNode( + descriptor, + matches: snapshot, + expectedKind: expectedKind, + permissions: permissions + ) + guard try !hasExtendedACL(at: snapshot.url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyPathStillMatches( + snapshot.url, + device: snapshot.device, + inode: snapshot.inode, + expectedKind: expectedKind, + permissions: permissions + ) + } + + private static func migrate( + _ snapshot: NodeSnapshot, + to permissions: Int + ) throws { + let expectedKind = snapshot.security.kind + let descriptor = try openNode( + snapshot, + expectedKind: expectedKind, + writable: false + ) + defer { _ = close(descriptor) } + + try verifyOpenedNode( + descriptor, + matches: snapshot, + expectedKind: expectedKind, + permissions: snapshot.security.permissions + ) + guard + try !hasExtendedACL(at: snapshot.url), + fchmod(descriptor, mode_t(permissions)) == 0 + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyOpenedNode( + descriptor, + matches: snapshot, + expectedKind: expectedKind, + permissions: permissions + ) + guard try !hasExtendedACL(at: snapshot.url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyPathStillMatches( + snapshot.url, + device: snapshot.device, + inode: snapshot.inode, + expectedKind: expectedKind, + permissions: permissions + ) + } + + private static func openNode( + _ snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind, + writable: Bool + ) throws -> Int32 { + var flags = (writable ? O_RDWR : O_RDONLY) | O_NOFOLLOW | O_CLOEXEC + if expectedKind == .directory { + flags |= O_DIRECTORY + } + let descriptor = open(snapshot.url.path, flags) + guard descriptor >= 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return descriptor + } + + private static func verifyOpenedNode( + _ descriptor: Int32, + matches snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind, + permissions: Int + ) throws { + var status = stat() + guard + fstat(descriptor, &status) == 0, + status.st_dev == snapshot.device, + status.st_ino == snapshot.inode + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let security = metadata( + for: status, + hasExtendedACL: snapshot.security.hasExtendedACL + ) + guard + security.ownerUID == getuid(), + security.kind == expectedKind, + security.permissions == permissions + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + + private static func snapshotIfPresent( + at url: URL + ) throws -> NodeSnapshot? { + var status = stat() + guard lstat(url.path, &status) == 0 else { + guard errno == ENOENT else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return nil + } + let acl = try hasExtendedACL(at: url) + return NodeSnapshot( + url: url, + security: metadata(for: status, hasExtendedACL: acl), + device: status.st_dev, + inode: status.st_ino + ) + } + + private static func verifyPathStillMatches( + _ url: URL, + device: dev_t, + inode: ino_t, + expectedKind: PrivateRuntimeNodeKind, + permissions: Int + ) throws { + guard + let current = try snapshotIfPresent(at: url), + current.device == device, + current.inode == inode, + current.security.ownerUID == getuid(), + current.security.kind == expectedKind, + current.security.permissions == permissions, + !current.security.hasExtendedACL + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + + private static func metadata( + for status: stat, + hasExtendedACL: Bool + ) -> PrivateRuntimeNodeSecurity { + let kind: PrivateRuntimeNodeKind + switch status.st_mode & S_IFMT { + case S_IFDIR: + kind = .directory + case S_IFREG: + kind = .regularFile + default: + kind = .other + } + return PrivateRuntimeNodeSecurity( + ownerUID: status.st_uid, + permissions: Int(status.st_mode & 0o777), + kind: kind, + hasExtendedACL: hasExtendedACL + ) + } + + private static func hasExtendedACL(at url: URL) throws -> Bool { + errno = 0 + guard let acl = acl_get_file(url.path, ACL_TYPE_EXTENDED) else { + guard errno == ENOENT else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return false + } + acl_free(UnsafeMutableRawPointer(acl)) + return true + } + + private static func migrationOrder( + _ lhs: NodeSnapshot, + _ rhs: NodeSnapshot + ) -> Bool { + if lhs.security.kind != rhs.security.kind { + return lhs.security.kind == .regularFile + } + return lhs.url.pathComponents.count > rhs.url.pathComponents.count + } +} + +public struct TaskSourceBackupStore: Sendable { + public let backupsRootURL: URL + + public init(backupsRootURL: URL) { + self.backupsRootURL = backupsRootURL.standardizedFileURL + } + + public func save( + _ data: Data, + fileName: String, + batchName: String? = nil + ) throws -> URL { + guard + isSinglePathComponent(fileName), + batchName.map(isSinglePathComponent) ?? true + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let runtimeRoot = backupsRootURL.deletingLastPathComponent() + try PrivateRuntimeDirectory.prepareRootAndKnownTree( + rootURL: runtimeRoot, + treeURL: backupsRootURL + ) + + let resolvedBatchName = batchName ?? defaultBatchName() + let batchURL = backupsRootURL.appendingPathComponent( + resolvedBatchName, + isDirectory: true + ) + try PrivateRuntimeDirectory.createPrivateDirectory(at: batchURL) + let fileURL = batchURL.appendingPathComponent(fileName) + try PrivateRuntimeDirectory.writePrivateFile(data, to: fileURL) + guard + try Data(contentsOf: fileURL) == data + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return fileURL + } + + private func defaultBatchName() -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .autoupdatingCurrent + formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" + return "\(formatter.string(from: Date()))-\(UUID().uuidString)" + } + + private func isSinglePathComponent(_ value: String) -> Bool { + !value.isEmpty + && value != "." + && value != ".." + && !value.contains("/") + && !value.contains("\0") + } +} diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index 8a2d3d7..b3ac617 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -220,10 +220,17 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } public func loadLedgerReadOnly() throws -> ReminderPruneLedger { - guard try attributesIfItemExists(at: rootURL) != nil else { - return ReminderPruneLedger() + do { + guard + try PrivateRuntimeDirectory.validatePrivateRootReadOnly( + at: rootURL + ) + else { + return ReminderPruneLedger() + } + } catch { + throw ReminderPruneStoreError.permissions } - try ensurePermissions(of: rootURL, expected: 0o700) guard try itemExists(at: ledgerURL) else { return ReminderPruneLedger() } @@ -517,6 +524,7 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } private func backupsURL() throws -> URL { + try ensureDirectory(rootURL) let url = rootURL.appendingPathComponent("PruneBackups", isDirectory: true) try ensureDirectory(url) return url.standardizedFileURL @@ -600,68 +608,31 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { } private func ensureDirectory(_ url: URL) throws { - if let attributes = try attributesIfItemExists(at: url) { - guard - attributes[.type] as? String - == FileAttributeType.typeDirectory.rawValue - else { - throw ReminderPruneStoreError.permissions - } - } else { - do { - try fileManager.createDirectory( - at: url, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - } catch { - guard try attributesIfItemExists(at: url) != nil else { - throw ReminderPruneStoreError.permissions - } + do { + if url.standardizedFileURL == rootURL.standardizedFileURL { + try PrivateRuntimeDirectory.prepareRoot(at: url) + } else if try attributesIfItemExists(at: url) == nil { + try PrivateRuntimeDirectory.createPrivateDirectory(at: url) + } else { + try PrivateRuntimeDirectory.validatePrivateDirectory(at: url) } - } - try ensurePermissions(of: url, expected: 0o700) - } - - private func ensurePrivateFile(_ url: URL) throws { - try ensurePermissions(of: url, expected: 0o600) - } - - private func ensurePermissions(of url: URL, expected: Int) throws { - guard let attributes = try attributesIfItemExists(at: url) else { - throw ReminderPruneStoreError.permissions - } - let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue - ?? (attributes[.posixPermissions] as? Int) - guard let permissions, permissions & 0o777 == expected else { + } catch { throw ReminderPruneStoreError.permissions } - try ensureOwnedByCurrentUser(url) - try ensureNoExtendedACL(url) } - private func ensureOwnedByCurrentUser(_ url: URL) throws { - var status = stat() - guard lstat(url.path, &status) == 0, status.st_uid == getuid() else { + private func ensurePrivateFile(_ url: URL) throws { + do { + try PrivateRuntimeDirectory.validatePrivateFile(at: url) + } catch { throw ReminderPruneStoreError.permissions } } - private func ensureNoExtendedACL(_ url: URL) throws { - guard let acl = acl_get_file(url.path, ACL_TYPE_EXTENDED) else { - guard errno == ENOENT else { - throw ReminderPruneStoreError.permissions - } - return - } - acl_free(UnsafeMutableRawPointer(acl)) - throw ReminderPruneStoreError.permissions - } - private func openSaltLock(at url: URL) throws -> Int32 { let descriptor = open( url.path, - O_RDWR | O_CREAT | O_EXCL, + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode_t(0o600) ) if descriptor >= 0 { @@ -675,7 +646,10 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { throw ReminderPruneStoreError.permissions } - let existingDescriptor = open(url.path, O_RDWR) + let existingDescriptor = open( + url.path, + O_RDWR | O_NOFOLLOW | O_CLOEXEC + ) guard existingDescriptor >= 0 else { throw ReminderPruneStoreError.permissions } diff --git a/Sources/TaskForgeReminderSync/SyncEngine.swift b/Sources/TaskForgeReminderSync/SyncEngine.swift index 611a88a..94401c6 100644 --- a/Sources/TaskForgeReminderSync/SyncEngine.swift +++ b/Sources/TaskForgeReminderSync/SyncEngine.swift @@ -123,15 +123,26 @@ enum TaskSourceWriter { throw SyncError.sourceEncodingInvalid(sourceURL.path) } - let backupDirectory = try makeBackupDirectory(root: backupRoot) let safeName = sourceURL.lastPathComponent.replacingOccurrences(of: "/", with: "_") - let backupURL = backupDirectory.appendingPathComponent( - "\(task.identifier)-\(safeName).bak" - ) + let safeIdentifier = task.identifier.map { character in + character.isLetter || character.isNumber + || character == "-" || character == "_" + ? character + : "_" + } + let backupURL: URL do { - try originalData.write(to: backupURL, options: [.atomic]) + backupURL = try TaskSourceBackupStore( + backupsRootURL: URL( + fileURLWithPath: backupRoot, + isDirectory: true + ) + ).save( + originalData, + fileName: "\(String(safeIdentifier))-\(safeName).bak" + ) } catch { - throw SyncError.backupFailed(backupURL.path) + throw SyncError.backupFailed(backupRoot) } try updatedData.write(to: sourceURL, options: [.atomic]) @@ -150,20 +161,6 @@ enum TaskSourceWriter { ) } - private static func makeBackupDirectory(root: String) throws -> URL { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = .autoupdatingCurrent - formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" - let directory = URL(fileURLWithPath: root, isDirectory: true) - .appendingPathComponent(formatter.string(from: Date()), isDirectory: true) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - return directory - } - private static func sha256(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 49471a4..bc88423 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -124,6 +124,35 @@ private func waitForAsync( ).get() } +private func addReadOnlyExtendedACL(to url: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/chmod") + process.arguments = ["+a", "everyone allow read", url.path] + let standardError = Pipe() + process.standardError = standardError + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + throw TestFailure( + description: "could not create ACL fixture: \(message)" + ) + } +} + +private func permissions(at url: URL) throws -> Int { + let value = try requireValue( + FileManager.default.attributesOfItem( + atPath: url.path + )[.posixPermissions] as? NSNumber, + "permissions missing for \(url.lastPathComponent)" + ) + return value.intValue & 0o777 +} + private var shanghaiCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! @@ -1416,6 +1445,28 @@ private let tests: [TestCase] = [ ) } }), + ("source presence rejects non-positive and overflowing line numbers", { + for lineNumber in [Int.min, 0, -1] { + let task = TaskForgeTask( + identifier: "invalid-line-\(lineNumber)", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: lineNumber + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n" + ) == .indeterminate, + "\(lineNumber) must fail closed without index arithmetic" + ) + } + }), ("prune policy protects non-target, completed and important reminders", { let target = "calendar-target" let protected = [ @@ -1584,6 +1635,47 @@ private let tests: [TestCase] = [ "changed item should receive a new firstSeen" ) }), + ("prune state restarts when the calendar identifier changes", { + let now = Date(timeIntervalSince1970: 2_250) + let prior = ReminderPruneLedger(entries: [ + "external": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-before", + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: nil + ) + ]) + let moved = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-after", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let plan = ReminderPruneStateMachine.plan( + observations: [moved], + prior: prior, + targetCalendarIdentifier: "calendar-after", + now: now + ) + + try require( + plan.firstSeenIdentifiers == ["external"], + "calendar move must start a fresh confirmation window" + ) + try require( + plan.readyIdentifiers.isEmpty, + "calendar move must not reuse the old ready state" + ) + try require( + plan.nextLedger.entries["external"]?.calendarIdentifier + == "calendar-after", + "fresh state must record the current calendar" + ) + }), ("prune state clamps custom confirmation intervals to sixty seconds", { let now = Date(timeIntervalSince1970: 2_500) let observation = ReminderPruneObservation( @@ -1966,6 +2058,61 @@ private let tests: [TestCase] = [ "read-only ledger load must not create the ledger" ) }), + ("prune dry-run rejects an exposed root without changing it", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: root.path + ) + + do { + _ = try ReminderPruneLocalStore(rootURL: root) + .loadLedgerReadOnly() + throw TestFailure( + description: "read-only load accepted an unmigrated root" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected read-only exposed-root error" + ) + } + try require( + try permissions(at: root) == 0o755, + "dry-run changed the root permissions" + ) + }), + ("prune mutating load safely migrates an exposed root", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: root.path + ) + + try require( + try ReminderPruneLocalStore(rootURL: root).loadLedger() + == ReminderPruneLedger(), + "normal load should preserve an absent ledger" + ) + try require( + try permissions(at: root) == 0o700, + "normal load did not migrate the root to 0700" + ) + }), ("unresolved selection chooses the newest unresolved outcome", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -2476,6 +2623,221 @@ private let tests: [TestCase] = [ "backup task presence round-trip failed" ) }), + ("runtime backup migration preserves content and normalizes permissions", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let backups = root.appendingPathComponent( + "Backups", + isDirectory: true + ) + let legacyBatch = backups.appendingPathComponent( + "legacy-batch", + isDirectory: true + ) + let sentinel = legacyBatch.appendingPathComponent("sentinel.bak") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: legacyBatch, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + for directory in [root, backups, legacyBatch] { + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: directory.path + ) + } + let sentinelData = Data("legacy sentinel".utf8) + try sentinelData.write(to: sentinel) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: sentinel.path + ) + + let backupStore = TaskSourceBackupStore(backupsRootURL: backups) + let freshData = Data("fresh backup".utf8) + let fresh = try backupStore.save( + freshData, + fileName: "fresh.bak", + batchName: "fresh-batch" + ) + let ledger = try ReminderPruneLocalStore(rootURL: root).loadLedger() + + try require( + ledger == ReminderPruneLedger(), + "prune store could not load after source backup initialization" + ) + try require( + try Data(contentsOf: sentinel) == sentinelData, + "legacy backup content changed during migration" + ) + try require( + try Data(contentsOf: fresh) == freshData, + "new source backup content changed" + ) + for directory in [ + root, + backups, + legacyBatch, + fresh.deletingLastPathComponent() + ] { + try require( + try permissions(at: directory) == 0o700, + "\(directory.lastPathComponent) must be 0700" + ) + } + for file in [sentinel, fresh] { + try require( + try permissions(at: file) == 0o600, + "\(file.lastPathComponent) must be 0600" + ) + } + }), + ("runtime migration policy rejects a different owner", { + let metadata = PrivateRuntimeNodeSecurity( + ownerUID: 502, + permissions: 0o755, + kind: .directory, + hasExtendedACL: false + ) + try require( + !PrivateRuntimeDirectoryPolicy.canMigrate( + metadata, + currentUserUID: 501, + expectedKind: .directory + ), + "a different owner must fail closed" + ) + }), + ("runtime root migration rejects symlinks and writable modes", { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let target = container.appendingPathComponent( + "target", + isDirectory: true + ) + let symlink = container.appendingPathComponent( + "runtime-link", + isDirectory: true + ) + let writable = container.appendingPathComponent( + "runtime-writable", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: container) } + try FileManager.default.createDirectory( + at: target, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.createSymbolicLink( + at: symlink, + withDestinationURL: target + ) + try FileManager.default.createDirectory( + at: writable, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o770] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o770], + ofItemAtPath: writable.path + ) + + for root in [symlink, writable] { + do { + _ = try ReminderPruneLocalStore(rootURL: root).loadLedger() + throw TestFailure( + description: "\(root.lastPathComponent) was migrated" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected unsafe-root error" + ) + } + } + try require( + try permissions(at: target) == 0o755, + "symlink target permissions changed" + ) + try require( + try permissions(at: writable) == 0o770, + "writable root permissions changed" + ) + }), + ("runtime backup migration rejects a symlink entry", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let backups = root.appendingPathComponent( + "Backups", + isDirectory: true + ) + let target = root.appendingPathComponent("outside.bak") + let linked = backups.appendingPathComponent("linked.bak") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: backups, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + try Data("outside".utf8).write(to: target) + try FileManager.default.createSymbolicLink( + at: linked, + withDestinationURL: target + ) + + do { + _ = try TaskSourceBackupStore(backupsRootURL: backups).save( + Data("new".utf8), + fileName: "new.bak", + batchName: "new-batch" + ) + throw TestFailure( + description: "symlinked legacy backup entry was accepted" + ) + } catch let error as PrivateRuntimeDirectoryError { + try require( + error == .unsafeNode, + "unexpected backup-tree symlink error" + ) + } + try require( + try Data(contentsOf: target) == Data("outside".utf8), + "symlink target content changed" + ) + }), + ("runtime root migration rejects an extended ACL", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: root.path + ) + try addReadOnlyExtendedACL(to: root) + + do { + _ = try ReminderPruneLocalStore(rootURL: root).loadLedger() + throw TestFailure( + description: "extended ACL runtime root was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected ACL-root error" + ) + } + try require( + try permissions(at: root) == 0o755, + "ACL rejection changed root permissions" + ) + }), ("prune local store rejects a modified backup", { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4577c07..6be0ad9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -141,6 +141,12 @@ preserved but is neither unresolved nor restorable afterward. `--prune-dry-run` uses a read-only ledger load and never creates or modifies the ledger, backup directory, hash salt or EventKit items. `--prune-once`, `--sync` and each watcher pass advance the same state machine. +Read-only loading requires an already-private `0700` runtime root and never +changes an exposed legacy mode. Mutating paths share a Core initializer that +can tighten a current-user-owned, non-symlink, ACL-free and non-group/world- +writable root from `0755` to `0700`, then reopen and verify it with `fstat`. +The same two-phase policy migrates the known source `Backups/` tree without +changing file contents; directories become `0700` and regular files `0600`. Private pruning state defaults to: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a9dc795..24291d5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -24,9 +24,12 @@ Pruning also needs private local state under: `~/Library/Application Support/TaskForgeReminderSync/` The directory must be accessible only to the current user (`0700`), while -`PruneCandidates.json`, files below `PruneBackups/` and `PruneHashSalt` must be -`0600`. If permissions are broader, the ledger is damaged, or a backup checksum -does not verify, pruning fails closed and keeps the reminders. +`PruneCandidates.json`, source backup files, files below `PruneBackups/` and +`PruneHashSalt` must be `0600`. A normal mutating run may safely tighten an old +user-owned `0755` runtime root and the known `Backups/` tree when every node is +real, has no extended ACL and is not group/world writable. A symlink, different +owner, ACL, group/world-writable mode, damaged ledger or checksum failure stays +fail-closed and preserves the reminder. ## LaunchAgent is not running @@ -107,6 +110,8 @@ Common safe reasons for retaining a reminder are: `--prune-dry-run` is strictly read-only: it does not create or change the candidate ledger, backup directory, hash salt or reminders. To advance state, use `--prune-once`, `--sync`, or let `--watch` complete another reconciliation. +This also means dry-run never fixes an old `0755` runtime root: it reports an +anonymous permission failure until a normal mutating run safely migrates it. ## Pruning reports an ambiguous list @@ -159,8 +164,9 @@ Backups are stored below: `~/Library/Application Support/TaskForgeReminderSync/Backups/` -Each run uses a timestamped directory. Compare the backup and current source -before restoring. Stop the LaunchAgent first if manual restoration is needed. +Each run uses a private `0700` timestamped/unique directory and writes `0600` +files. Compare the backup and current source before restoring. Stop the +LaunchAgent first if manual restoration is needed. This source-file backup is separate from `PruneBackups/`, which restores Apple reminders deleted by the pruning feature. diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md index 82a6028..eac64bf 100644 --- a/docs/superpowers/plans/2026-07-30-reminder-pruning.md +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -1514,6 +1514,24 @@ git commit -m "docs(清理): 说明自动删除和恢复边界" - 验证:全部代码、测试、文档和运行时安装 - 不创建包含私人数据的新仓库文件 +### 最终审查修复说明 + +- Core 提供共享的私有运行时目录初始化:普通写路径只迁移本人拥有、非 + symlink、无扩展 ACL、且 group/world 不可写的旧目录;目录和文件分别 + 收紧为 `0700` / `0600`,并在 descriptor 上 `fstat` 复核。反向源备份与 + prune store 使用同一套策略。 +- `--prune-dry-run` 不负责迁移:目录不存在时按空账本处理;既有目录不是 + 严格 `0700` 时匿名失败,并且不创建文件、不 `chmod`。普通同步、watcher + 的 prune 推进或反向备份写入会在写操作前完成安全迁移。 +- 现有 `Backups` 只按已知树迁移;必须全部是本人拥有的真实目录或普通 + 文件、无扩展 ACL、且 group/world 不可写。整棵树先验证再改权限,异常 + 一律失败关闭,不删除或改写备份内容。 +- watcher 无限循环本轮不新增伪造的直接测试:协调顺序由 + `reconcile(reason:)` 固定为 reverse → forward → prune,prune 的候选撤销、 + 日历变化、双扫描与恢复宽限由状态机测试直接覆盖,运行模式由进程级 CLI + 测试覆盖。真实 watcher 生命周期仍保留在部署验收步骤 3–5,本轮禁止启动 + production EventKit 或 LaunchAgent。 + - [ ] **步骤 1:运行完整本地验证** ```bash From c421d635521eff892e64fca41f720129c3db9528 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:46:52 +0800 Subject: [PATCH 29/34] =?UTF-8?q?test(=E5=91=BD=E4=BB=A4):=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=85=A8=E9=83=A8=E8=A7=A3=E6=9E=90=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 1 + Sources/TaskForgeReminderSync/Command.swift | 33 +++++++- Tests/TaskForgeReminderCLITests/main.swift | 90 ++++++++++++++++++--- scripts/build-app.sh | 3 +- 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/Package.swift b/Package.swift index d106e52..84fca50 100644 --- a/Package.swift +++ b/Package.swift @@ -42,6 +42,7 @@ let package = Package( ), .executableTarget( name: "TaskForgeReminderCLITests", + dependencies: ["TaskForgeReminderSync"], path: "Tests/TaskForgeReminderCLITests" ), .executableTarget( diff --git a/Sources/TaskForgeReminderSync/Command.swift b/Sources/TaskForgeReminderSync/Command.swift index d4a15f6..2358634 100644 --- a/Sources/TaskForgeReminderSync/Command.swift +++ b/Sources/TaskForgeReminderSync/Command.swift @@ -16,6 +16,37 @@ private enum RunMode { case restoreLastPrune case watch case help + + var parseOnlyLabel: String { + switch self { + case .checkConfig: + return "check-config" + case .dryRun: + return "dry-run" + case .audit: + return "audit" + case .deduplicateDryRun: + return "deduplicate-dry-run" + case .deduplicate: + return "deduplicate" + case .sync: + return "sync" + case .reverseDryRun: + return "reverse-dry-run" + case .reverseOnce: + return "reverse-once" + case .pruneDryRun: + return "prune-dry-run" + case .pruneOnce: + return "prune-once" + case .restoreLastPrune: + return "restore-last-prune" + case .watch: + return "watch" + case .help: + return "help" + } + } } private enum CommandParsingError: Error, LocalizedError { @@ -178,7 +209,7 @@ private struct TaskForgeReminderSyncCommand { "TASKFORGE_REMINDER_SYNC_TEST_PARSE_ONLY" ] == "1" { - print("参数解析完成") + print("parse-mode=\(options.mode.parseOnlyLabel)") return } #endif diff --git a/Tests/TaskForgeReminderCLITests/main.swift b/Tests/TaskForgeReminderCLITests/main.swift index 8a814be..4eb7c59 100644 --- a/Tests/TaskForgeReminderCLITests/main.swift +++ b/Tests/TaskForgeReminderCLITests/main.swift @@ -13,10 +13,13 @@ private func require( } } -private func runConflictingModes( - _ arguments: [String], - label: String -) throws { +private struct ParserResult { + let status: Int32 + let output: String + let error: String +} + +private func runParser(_ arguments: [String]) throws -> ParserResult { let executable = URL(fileURLWithPath: CommandLine.arguments[0]) .deletingLastPathComponent() .appendingPathComponent("TaskForgeReminderSync") @@ -43,29 +46,66 @@ private func runConflictingModes( encoding: .utf8 ) ?? "" + return ParserResult( + status: process.terminationStatus, + output: output, + error: error + ) +} + +private func runConflictingModes( + _ arguments: [String], + label: String +) throws { + let result = try runParser(arguments) try require( - process.terminationStatus == 1, + result.status == 1, "\(label): conflicting modes must fail before execution" ) try require( - error.contains("不能同时指定多个运行模式"), + result.error.contains("不能同时指定多个运行模式"), "\(label): failure must use the anonymous conflict error" ) for argument in arguments { try require( - !error.contains(argument), + !result.error.contains(argument), "\(label): error must not echo a raw argument" ) } try require( - !output.contains("提醒事项权限") - && !error.contains("提醒事项权限") - && !output.contains("EventKit") - && !error.contains("EventKit"), + !result.output.contains("提醒事项权限") + && !result.error.contains("提醒事项权限") + && !result.output.contains("EventKit") + && !result.error.contains("EventKit"), "\(label): conflict must fail before EventKit access" ) } +private func requireMode( + _ arguments: [String], + expected: String, + label: String +) throws { + let result = try runParser(arguments) + try require( + result.status == 0, + "\(label): one mode should parse successfully" + ) + try require( + result.output == "parse-mode=\(expected)\n", + "\(label): unexpected anonymous mode label \(result.output)" + ) + try require( + result.error.isEmpty, + "\(label): parse-only mode wrote stderr" + ) + try require( + !result.output.contains("提醒事项权限") + && !result.output.contains("EventKit"), + "\(label): parse-only mode reached EventKit" + ) +} + private let conflictPairs = [ ["--prune-dry-run", "--prune-once"], ["--restore-last-prune", "--sync"], @@ -74,6 +114,23 @@ private let conflictPairs = [ ["--prune-once", "--prune-once"] ] +private let singleModes: [([String], String)] = [ + ([], "dry-run"), + (["--check-config"], "check-config"), + (["--dry-run"], "dry-run"), + (["--audit"], "audit"), + (["--deduplicate-dry-run"], "deduplicate-dry-run"), + (["--deduplicate"], "deduplicate"), + (["--sync"], "sync"), + (["--reverse-dry-run"], "reverse-dry-run"), + (["--reverse-once"], "reverse-once"), + (["--prune-dry-run"], "prune-dry-run"), + (["--prune-once"], "prune-once"), + (["--restore-last-prune"], "restore-last-prune"), + (["--watch"], "watch"), + (["--help"], "help") +] + do { for (index, pair) in conflictPairs.enumerated() { try runConflictingModes(pair, label: "pair \(index + 1) forward") @@ -82,8 +139,15 @@ do { label: "pair \(index + 1) reverse" ) } - let testCount = conflictPairs.count * 2 - print("\(testCount)/\(testCount) CLI parser conflict tests passed") + for (arguments, expected) in singleModes { + try requireMode( + arguments, + expected: expected, + label: arguments.first ?? "no mode" + ) + } + let testCount = conflictPairs.count * 2 + singleModes.count + print("\(testCount)/\(testCount) CLI parser tests passed") } catch { fputs("FAIL \(error)\n", stderr) exit(1) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index d697bce..d95a231 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -9,7 +9,8 @@ TASKFORGE_SYNC_SIGNING_IDENTITY=${TASKFORGE_SYNC_CODESIGN_IDENTITY:--} swift build \ --package-path "$PROJECT_ROOT" \ - --configuration release + --configuration release \ + --product TaskForgeReminderSync BIN_DIR=$(swift build \ --package-path "$PROJECT_ROOT" \ From e29db3f67e6602f9c62025bc1fa236b64039d28f Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:08:58 +0800 Subject: [PATCH 30/34] =?UTF-8?q?fix(=E6=B8=85=E7=90=86):=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=BF=81=E7=A7=BB=E6=97=A2=E6=9C=89=E6=BA=90=E5=A4=87?= =?UTF-8?q?=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + .../PrivateRuntimeDirectory.swift | 29 +- .../ReminderPrunePersistence.swift | 20 +- Tests/TaskForgeReminderCoreTests/main.swift | 301 ++++++++++++++++++ docs/ARCHITECTURE.md | 2 + docs/TROUBLESHOOTING.md | 2 + .../plans/2026-07-30-reminder-pruning.md | 3 + 7 files changed, 356 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e127dc0..4148f27 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ macOS 会请求“提醒事项”访问权限。工具不需要“日历”权 group/world 不可写的旧 `0755` / `0644` 备份树安全收紧;其他异常权限会 失败关闭。备份可能包含恢复提醒所需的标题、笔记、日期、闹钟、重复规则和 原始系统标识,请像保护 Vault 一样保护该目录。 +`PruneHashSalt` 按需生成;只登记首次候选且尚不需要匿名项目关联时,它可以 +暂时不存在。 ## 命令 diff --git a/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift b/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift index b2da5ba..f46457a 100644 --- a/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift +++ b/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift @@ -63,6 +63,29 @@ public enum PrivateRuntimeDirectory { public static func prepareRootAndKnownTree( rootURL: URL, treeURL: URL + ) throws { + try prepareRootAndKnownTree( + rootURL: rootURL, + treeURL: treeURL, + createTreeIfMissing: true + ) + } + + public static func prepareRootAndExistingKnownTree( + rootURL: URL, + treeURL: URL + ) throws { + try prepareRootAndKnownTree( + rootURL: rootURL, + treeURL: treeURL, + createTreeIfMissing: false + ) + } + + private static func prepareRootAndKnownTree( + rootURL: URL, + treeURL: URL, + createTreeIfMissing: Bool ) throws { let root = rootURL.standardizedFileURL let tree = treeURL.standardizedFileURL @@ -72,7 +95,9 @@ public enum PrivateRuntimeDirectory { guard let rootSnapshot = try snapshotIfPresent(at: root) else { try createDirectoryIncludingParents(at: root) - try createPrivateDirectory(at: tree) + if createTreeIfMissing { + try createPrivateDirectory(at: tree) + } return } try validateMigratable(rootSnapshot, expectedKind: .directory) @@ -95,7 +120,7 @@ public enum PrivateRuntimeDirectory { // Validate the complete known tree before changing any existing mode. try migrate(rootSnapshot, to: 0o700) - if treeSnapshots.isEmpty { + if treeSnapshots.isEmpty && createTreeIfMissing { try createPrivateDirectory(at: tree) } else { for snapshot in treeSnapshots.sorted(by: migrationOrder) { diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift index b3ac617..595d832 100644 --- a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -512,6 +512,8 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { private let fileManager = FileManager.default private static let saltSetupLock = NSLock() + private let runtimePreparationLock = NSLock() + private var runtimePrepared = false private var encoder: JSONEncoder { let encoder = JSONEncoder() @@ -610,7 +612,23 @@ public final class ReminderPruneLocalStore: @unchecked Sendable { private func ensureDirectory(_ url: URL) throws { do { if url.standardizedFileURL == rootURL.standardizedFileURL { - try PrivateRuntimeDirectory.prepareRoot(at: url) + runtimePreparationLock.lock() + defer { runtimePreparationLock.unlock() } + if runtimePrepared { + try PrivateRuntimeDirectory.validatePrivateDirectory( + at: rootURL + ) + } else { + try PrivateRuntimeDirectory + .prepareRootAndExistingKnownTree( + rootURL: rootURL, + treeURL: rootURL.appendingPathComponent( + "Backups", + isDirectory: true + ) + ) + runtimePrepared = true + } } else if try attributesIfItemExists(at: url) == nil { try PrivateRuntimeDirectory.createPrivateDirectory(at: url) } else { diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index bc88423..31b573d 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1,4 +1,5 @@ import CoreLocation +import CryptoKit import Dispatch import Darwin import EventKit @@ -153,6 +154,112 @@ private func permissions(at url: URL) throws -> Int { return value.intValue & 0o777 } +private struct LegacySourceBackupFixture { + let backupsRoot: URL + let directories: [URL] + let files: [URL] +} + +private struct RuntimeTreeEvidence: Equatable { + var directoryCount = 0 + var fileCount = 0 + var fileHashes: [String: String] = [:] +} + +private func makeLegacySourceBackupFixture( + root: URL, + rootPermissions: Int +) throws -> LegacySourceBackupFixture { + let backups = root.appendingPathComponent("Backups", isDirectory: true) + let batchA = backups.appendingPathComponent("batch-a", isDirectory: true) + let nested = batchA.appendingPathComponent("nested", isDirectory: true) + let batchB = backups.appendingPathComponent("batch-b", isDirectory: true) + let batchC = backups.appendingPathComponent("batch-c", isDirectory: true) + let directories = [root, backups, batchA, nested, batchB, batchC] + try FileManager.default.createDirectory( + at: nested, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: batchB, + withIntermediateDirectories: false + ) + try FileManager.default.createDirectory( + at: batchC, + withIntermediateDirectories: false + ) + for directory in directories { + try FileManager.default.setAttributes( + [ + .posixPermissions: + directory == root ? rootPermissions : 0o755 + ], + ofItemAtPath: directory.path + ) + } + + let files = [ + backups.appendingPathComponent("manifest.bak"), + batchA.appendingPathComponent("one.bak"), + nested.appendingPathComponent("two.bak"), + batchB.appendingPathComponent("three.bak"), + batchC.appendingPathComponent("four.bak") + ] + for (index, file) in files.enumerated() { + try Data("legacy-\(index)".utf8).write(to: file) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: file.path + ) + } + return LegacySourceBackupFixture( + backupsRoot: backups, + directories: directories, + files: files + ) +} + +private func runtimeTreeEvidence(at root: URL) throws + -> RuntimeTreeEvidence +{ + var evidence = RuntimeTreeEvidence() + + func visit(_ url: URL) throws { + var status = stat() + guard lstat(url.path, &status) == 0 else { + throw TestFailure( + description: "could not inspect \(url.lastPathComponent)" + ) + } + switch status.st_mode & S_IFMT { + case S_IFDIR: + evidence.directoryCount += 1 + let children = try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil + ) + for child in children.sorted(by: { $0.path < $1.path }) { + try visit(child) + } + case S_IFREG: + evidence.fileCount += 1 + let relative = String( + url.path.dropFirst(root.path.count) + ) + evidence.fileHashes[relative] = SHA256.hash( + data: try Data(contentsOf: url) + ).map { String(format: "%02x", $0) }.joined() + default: + throw TestFailure( + description: "unexpected node in evidence tree" + ) + } + } + + try visit(root) + return evidence +} + private var shanghaiCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! @@ -2112,6 +2219,12 @@ private let tests: [TestCase] = [ try permissions(at: root) == 0o700, "normal load did not migrate the root to 0700" ) + try require( + !FileManager.default.fileExists( + atPath: root.appendingPathComponent("Backups").path + ), + "prune initialization created an unused source backup tree" + ) }), ("unresolved selection chooses the newest unresolved outcome", { let root = FileManager.default.temporaryDirectory @@ -2693,6 +2806,194 @@ private let tests: [TestCase] = [ ) } }), + ("prune store migrates an existing backup tree under a private root", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = try makeLegacySourceBackupFixture( + root: root, + rootPermissions: 0o700 + ) + let before = try runtimeTreeEvidence(at: root) + try require( + before.directoryCount == 6 && before.fileCount == 5, + "legacy fixture does not match production tree shape" + ) + + try require( + try ReminderPruneLocalStore(rootURL: root).loadLedger() + == ReminderPruneLedger(), + "normal prune load should preserve an absent ledger" + ) + let after = try runtimeTreeEvidence(at: root) + + try require( + after == before, + "prune initialization changed backup count or content hashes" + ) + for directory in fixture.directories { + try require( + try permissions(at: directory) == 0o700, + "\(directory.lastPathComponent) was not migrated to 0700" + ) + } + for file in fixture.files { + try require( + try permissions(at: file) == 0o600, + "\(file.lastPathComponent) was not migrated to 0600" + ) + } + }), + ("prune store migrates an exposed root and its complete backup tree", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = try makeLegacySourceBackupFixture( + root: root, + rootPermissions: 0o755 + ) + let before = try runtimeTreeEvidence(at: fixture.backupsRoot) + let store = ReminderPruneLocalStore(rootURL: root) + + try store.saveLedger(ReminderPruneLedger()) + try require( + try store.loadLedger() == ReminderPruneLedger(), + "normal prune save did not preserve the ledger" + ) + let after = try runtimeTreeEvidence(at: fixture.backupsRoot) + + try require( + after == before, + "full migration changed backup count or content hashes" + ) + for directory in fixture.directories { + try require( + try permissions(at: directory) == 0o700, + "\(directory.lastPathComponent) was not private" + ) + } + for file in fixture.files { + try require( + try permissions(at: file) == 0o600, + "\(file.lastPathComponent) was not private" + ) + } + try require( + try permissions(at: store.ledgerURL) == 0o600, + "prune save did not create a private ledger" + ) + }), + ("prune store fails closed for an unsafe existing backup tree", { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: container) } + + let writableRoot = container.appendingPathComponent( + "writable", + isDirectory: true + ) + let writableFixture = try makeLegacySourceBackupFixture( + root: writableRoot, + rootPermissions: 0o700 + ) + let writableBefore = try runtimeTreeEvidence(at: writableRoot) + let writableFile = writableFixture.files[0] + try FileManager.default.setAttributes( + [.posixPermissions: 0o660], + ofItemAtPath: writableFile.path + ) + + do { + _ = try ReminderPruneLocalStore(rootURL: writableRoot) + .loadLedger() + throw TestFailure( + description: "group-writable backup tree was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected writable-tree error" + ) + } + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: writableFile.path + ) + try require( + try runtimeTreeEvidence(at: writableRoot) == writableBefore, + "failed migration changed writable-tree content" + ) + try require( + try permissions(at: writableFixture.backupsRoot) == 0o755, + "failed migration partially changed directory modes" + ) + + let symlinkRoot = container.appendingPathComponent( + "symlink", + isDirectory: true + ) + let symlinkFixture = try makeLegacySourceBackupFixture( + root: symlinkRoot, + rootPermissions: 0o700 + ) + let external = container.appendingPathComponent("external.bak") + let linked = symlinkFixture.backupsRoot + .appendingPathComponent("linked.bak") + try Data("external".utf8).write(to: external) + try FileManager.default.createSymbolicLink( + at: linked, + withDestinationURL: external + ) + + do { + _ = try ReminderPruneLocalStore(rootURL: symlinkRoot).loadLedger() + throw TestFailure( + description: "symlinked backup tree was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected symlink-tree error" + ) + } + try require( + try Data(contentsOf: external) == Data("external".utf8), + "failed migration changed the symlink target" + ) + try require( + try permissions(at: symlinkFixture.backupsRoot) == 0o755, + "symlink rejection partially changed directory modes" + ) + }), + ("prune dry-run leaves an exposed backup tree unchanged", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = try makeLegacySourceBackupFixture( + root: root, + rootPermissions: 0o700 + ) + let before = try runtimeTreeEvidence(at: root) + + try require( + try ReminderPruneLocalStore(rootURL: root).loadLedgerReadOnly() + == ReminderPruneLedger(), + "dry-run should ignore an unrelated source backup tree" + ) + + try require( + try runtimeTreeEvidence(at: root) == before, + "dry-run changed backup count or content hashes" + ) + try require( + try permissions(at: fixture.backupsRoot) == 0o755, + "dry-run changed source backup directory permissions" + ) + try require( + try permissions(at: fixture.files[0]) == 0o644, + "dry-run changed source backup file permissions" + ) + }), ("runtime migration policy rejects a different owner", { let metadata = PrivateRuntimeNodeSecurity( ownerUID: 502, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6be0ad9..9323a03 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -158,6 +158,8 @@ The parent directory is `0700`; ledger, backups and salt are `0600`. Writes use temporary files and atomic replacement. The salt produces truncated hashes for anonymous item correlation; logs contain counts, hashed identifiers and error categories, never reminder content, source paths or raw identifiers. +The salt is created lazily when per-item anonymous correlation is first needed; +a successful first candidate-registration pass may only create the ledger. ## Restore flow diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 24291d5..4201b46 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -112,6 +112,8 @@ candidate ledger, backup directory, hash salt or reminders. To advance state, use `--prune-once`, `--sync`, or let `--watch` complete another reconciliation. This also means dry-run never fixes an old `0755` runtime root: it reports an anonymous permission failure until a normal mutating run safely migrates it. +`PruneHashSalt` is lazy state and may still be absent after a successful first +candidate-registration pass; it is not an installation-health check by itself. ## Pruning reports an ambiguous list diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md index eac64bf..6d10e82 100644 --- a/docs/superpowers/plans/2026-07-30-reminder-pruning.md +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -1526,6 +1526,9 @@ git commit -m "docs(清理): 说明自动删除和恢复边界" - 现有 `Backups` 只按已知树迁移;必须全部是本人拥有的真实目录或普通 文件、无扩展 ACL、且 group/world 不可写。整棵树先验证再改权限,异常 一律失败关闭,不删除或改写备份内容。 +- 所有非只读 prune store 初始化都会迁移已经存在的 `Backups` 已知树; + `Backups` 不存在时不为 prune 空跑创建它。`PruneHashSalt` 同样按需生成, + 首次只登记候选的成功扫描不要求它存在。 - watcher 无限循环本轮不新增伪造的直接测试:协调顺序由 `reconcile(reason:)` 固定为 reverse → forward → prune,prune 的候选撤销、 日历变化、双扫描与恢复宽限由状态机测试直接覆盖,运行模式由进程级 CLI From 39fdced719be1cd3b915dd1040691766e797ae85 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:03:49 +0800 Subject: [PATCH 31/34] =?UTF-8?q?feat(sync):=20=E5=A2=9E=E5=8A=A0=20TaskFo?= =?UTF-8?q?rge=20Kanban=20=E7=8A=B6=E6=80=81=E5=8F=8C=E5=90=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PRIVACY.md | 116 +- README.md | 399 ++---- Sources/TaskForgeReminderCore/Core.swift | 175 ++- .../TaskForgeKanban.swift | 949 +++++++++++++ .../ReminderPruner.swift | 18 +- Sources/TaskForgeReminderSync/Command.swift | 177 ++- .../KanbanSyncEngine.swift | 1255 +++++++++++++++++ .../TaskForgeReminderSync/SyncEngine.swift | 9 + Tests/TaskForgeReminderCLITests/main.swift | 27 +- Tests/TaskForgeReminderCoreTests/main.swift | 245 +++- docs/ARCHITECTURE.md | 298 ++-- docs/TROUBLESHOOTING.md | 226 +-- 12 files changed, 3210 insertions(+), 684 deletions(-) create mode 100644 Sources/TaskForgeReminderCore/TaskForgeKanban.swift create mode 100644 Sources/TaskForgeReminderSync/KanbanSyncEngine.swift diff --git a/PRIVACY.md b/PRIVACY.md index 243df9f..62e11a7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,95 +5,55 @@ TaskForge Reminder Sync is designed to run entirely on the local Mac. ## Data it reads - TaskForge's local `tasks.v6.bin` cache; -- titles, schedules, status and source metadata for TaskForge tasks; -- reminders in the configured Apple Reminders list only; pruning does not fetch - reminder contents from any other list; -- the matching Markdown or TaskNotes source file when reverse-completing a - linked reminder; -- for pruning classification of an unfinished reminder, only the Vault file - named by that reminder's durable source reference, to confirm whether the - source task still exists. +- the preferences plist entry `flutter.ctl_`; +- titles, status, priority, original schedule and source metadata needed for selected custom-list members; +- reminders only when they carry this tool's marker or match the private index; +- the exact Markdown or TaskNotes source file when an approved reverse write is requested; +- the Vault source referenced by the private index when pruning confirms historical presence. ## Data it writes -- linked reminders in the configured Apple Reminders list; -- completion state in the matching Vault source task during reverse completion; -- automatic deletion of target-list reminders only after they satisfy every - prune-candidate rule and pass the two-scan confirmation; -- restored reminders rebuilt under the original reminders source and list - semantics recorded by the verified backup; -- timestamped source-file backups under - `~/Library/Application Support/TaskForgeReminderSync/Backups/`; -- the `0600` pruning candidate ledger at - `~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json`; -- checksummed `0600` reminder restore backups under - `~/Library/Application Support/TaskForgeReminderSync/PruneBackups/`; -- a `0600` local random hashing salt at - `~/Library/Application Support/TaskForgeReminderSync/PruneHashSalt`; -- operational logs under `~/Library/Logs/`. - -The pruning ledger stores raw EventKit and target-list identifiers, timestamps, -candidate fingerprints, rules versions and restore grace periods. Pruning -backups contain the reminder fields needed for restoration, including titles, -notes, dates, priority, alarms, recurrence data and original system -identifiers. These are sensitive local runtime data. Their parent application -support directory, source-backup tree and batch directories are restricted to -the current user (`0700`); backup and state files use `0600`. +- only the tool-managed Apple status lists and their managed reminders; +- the status of a matching Vault source task during reverse sync; +- verified source-file backups and verified reminder deletion backups; +- private configuration, learned symbols, source references, state history and hashes; +- aggregate operational logs. -## Data it does not send - -The application contains no HTTP client, analytics SDK, telemetry, advertising, -crash-reporting service or hosted backend. It does not upload task titles, -Vault paths, reminder contents or source files to this repository or to a -third-party service. - -Apple Reminders may sync through iCloud according to the user's Apple account -and system settings. That synchronization is performed by macOS, not by this -project. - -## Pruning logs +The default private root is: -Pruning output is limited to aggregate candidate, waiting, deletion, -restoration and failure counts; when item correlation is needed it uses a -truncated hash derived with the local random salt. Pruning logs do not contain -reminder titles or notes, Vault or source-file paths, raw TaskForge IDs or raw -EventKit IDs. Errors are reported by category without user content. +```text +~/Library/Application Support/TaskForgeReminderSync/ +``` -An EventKit reminder fetch is scoped to the configured list and cancelled after -30 seconds. Timeout, permission, snapshot, source-resolution and I/O failures -are treated as indeterminate and preserve the reminder. +The root and directories are `0700`; configuration, index, ledger, source backups, reminder +backups and salts are `0600`. The list ID, full source references and reminder mappings are +never committed to Git and are not printed in logs. If permissions, ownership, symlinks, +extended ACLs, schema or hashes are unsafe, the operation fails closed. -## Metadata stored in reminders - -Linked reminders contain: - -- a stable, Base64-encoded TaskForge marker; -- a Base64-encoded source reference used for historical reverse completion; -- a human-readable source type, file path and line number. +## Data it does not send -Anyone who can read the reminder can therefore see its title and source path. -Use a dedicated reminder list and an Apple account you trust. +The project has no HTTP client, hosted backend, analytics SDK, telemetry, advertising or +crash-reporting service. It does not upload task titles, notes, Vault paths, source files, +personal profiles or list IDs. Apple may sync reminders through iCloud according to the user's +Apple account and macOS settings; that is performed by Apple, not this project. -## Permissions +The tool does not use Computer Use and does not request Calendar, Contacts, Photos, microphone, +camera or location access. -- **Reminders full access** is required to create, update and observe reminders. -- **Full Disk Access** may be required by macOS to read TaskForge's sandbox - container or a protected Vault location. +## Reminder notes -The tool does not request Calendar, Contacts, Photos, microphone, camera or -location access. +Managed reminder notes contain only a stable TaskForge tool marker and a short source marker. +The marker is intended for management, not secrecy. Full source references, previous status, +source hashes and EventKit mappings remain in the private `0600` index rather than being copied +into reminder notes. -## Public repository hygiene +## Logs and recovery -Generated build directories, TaskForge cache files, logs, backups, `.env` -files and local plist overrides are excluded by `.gitignore`. Contributors -should still inspect staged files before every push. `PruneCandidates.json`, -`PruneBackups/`, `PruneHashSalt`, `PruneHashSalt.lock` and -`*.prune-test.json` are explicitly ignored in case private debugging data is -copied into a checkout. +Logs contain status counts, timing and anonymous error categories. They do not contain raw task +IDs, EventKit IDs, titles, notes, list IDs, Vault paths or source paths. Backups are intentionally +sensitive local data because they can contain the fields required to restore a source or +reminder; stop the watcher before manual recovery and preserve their permissions. -Never commit reminder titles, notes, Vault or source paths, raw TaskForge / -EventKit identifiers, candidate ledgers, pruning backups, salts or production -logs. Uninstalling the App intentionally preserves these private files so that -recovery remains possible; review and remove them separately only after any -needed `--restore-last-prune` operation. +Reverse completion changes a source task to `done` and never deletes a TaskForge task. Cleanup +deletes only a managed, low-priority, unfinished reminder after the two-scan gate and verified +backup; ordinary Apple reminders and all TaskForge source tasks are protected. diff --git a/README.md b/README.md index 4148f27..f134136 100644 --- a/README.md +++ b/README.md @@ -1,315 +1,216 @@ -# TaskForge Reminder Sync - -一个完全在本机运行的 macOS 双向同步工具:把 TaskForge 日历视图中的今日任务写入 Apple 提醒事项,并把 Apple 端的完成状态安全回写为 TaskForge `done`。 - -> 非 TaskForge 或 Apple 官方项目。当前版本针对 TaskForge `tasks.v6.bin` 数据格式开发。 - -## 功能 - -- **今日任务正向同步**:只为本地当天、状态不是 `done` / `cancelled` 的 TaskForge 任务创建提醒。 -- **历史完成反向同步**:扫描目标列表中所有已完成的关联提醒,不受提醒日期限制。 -- **近实时响应**: - - Apple 提醒事项变化通知经过约 0.75 秒防抖后触发; - - 每秒检查 TaskForge 任务库修改时间; - - 每分钟主动核对,弥补系统通知漏失; - - 每天 07:00、11:00、15:00 再做定时兜底。 -- **时间保真**:全天任务保持为日期提醒;有时间的任务保留小时和分钟。 -- **TaskForge 变动正向推送**:已关联任务的标题、日期、时间和完成状态发生变化后,会更新原来的提醒事项。 -- **分层稳定去重**:先按 Vault + TaskForge 任务 ID 精确匹配;TaskForge 重新索引导致 ID 改变时,再按活跃任务源位置复用原提醒,并刷新其 ID 标记。 -- **历史实例隔离**:同一笔记行可被不同日期的任务先后复用;已完成提醒只有在源位置和任务日期都相同时才会参与二级匹配。 -- **歧义时不新建**:同一 ID 或源位置对应多个提醒时,记录冲突并停止该任务,不会继续制造重复项。 -- **持久源映射**:提醒中保存经过 Base64 编码的任务源引用。任务离开 TaskForge 当前缓存后,历史提醒仍能定位原笔记。 -- **防止误重开**:任意一端已经完成时,正向同步不会把 Apple 提醒重新打开。 -- **可恢复自动清理**:只在配置的提醒列表中,把“未完成、不重要、且 TaskForge 当前缓存和持久源都确认不存在”的提醒经过至少相隔 60 秒的两次扫描后删除。 -- **重要提醒保护**:Apple 内建优先级大于零,或标题去除开头空白后以 `!`、`!`、`❗`、`‼️`、`⭐`、`📌` 开头的提醒永不进入清理候选。 -- **删除前本机备份**:每批清理先写入并回读校验权限为 `0600` 的备份;最近一个尚未恢复的批次可以一键恢复。 -- **先备份再回写**:每次反向修改前保存源文件副本,并记录修改前后的 SHA-256。 -- **保守拒绝**:重复任务、非 `keep` 完成策略、Vault 外路径、陈旧或歧义源行都不会被自动修改。 -- **不删除源任务**: - - Markdown 内联任务:`- [ ]` 改为 `- [x]`,并追加完成日期; - - TaskNotes 文件:frontmatter 改为 `status: done` 并更新 `completedDate`; - - 工具不会删除任务行或 TaskNotes 文件。 - -## 工作原理 +# TaskForge Kanban ↔ Apple 提醒事项 -```text -TaskForge tasks.v6.bin - │ 读取今日未完成任务 - ▼ -Apple 提醒事项 / TaskForge 今日 - ├─ 完成状态 + 持久源引用 ─▶ Vault Markdown / TaskNotes ─▶ TaskForge done - └─ 双源确认不存在 ─▶ 两次扫描 ─▶ 本机备份 ─▶ 删除 Apple 提醒 -``` - -正向同步只**新建今天的任务**,避免把整个 Vault 导入提醒事项;已经关联的任务即使改到其他日期,仍会更新原提醒。反向同步则会检查所有已经建立关联的提醒,因此昨天或更早的任务在 Apple 端完成后仍可闭环。 - -去重身份按以下顺序选择: - -1. 精确的 TaskForge 任务 ID; -2. 未完成提醒使用稳定源位置: - - Markdown 内联任务:“标准化文件路径 + 行号”; - - TaskNotes 任务:“标准化文件路径”; -3. 已完成提醒还必须与当前任务的计划日期相同,避免历史任务占用今天的新实例。 - -第二层命中时不会创建新提醒,而是更新同一个 EventKit 项目,并把提醒中的任务 ID 与源引用替换为 TaskForge 的最新值。如果任一层出现多个候选,工具会保守停止而不是猜测。 - -详细设计见 [架构说明](docs/ARCHITECTURE.md),数据与权限边界见 [隐私说明](PRIVACY.md)。 +一个完全在本机运行的 macOS 双向同步工具:把固定绑定的 TaskForge 自定义 +Kanban `Today` 列表映射为多个彩色 Apple 提醒事项列表,并把 Apple 的状态变化 +安全回写 TaskForge 源任务。 -## 系统要求 +它不是 TaskForge 或 Apple 官方项目。TaskForge 2.6.1 没有可依赖的官方 CLI/API; +本工具只读取本机 `tasks.v6.bin` 和自定义列表配置,不使用 Computer Use,也不向 +网络上传任务内容、路径、列表 ID 或个人资料。 -- macOS 13 或更高版本; -- 已安装 TaskForge; -- TaskForge 使用 `tasks.v6.bin` 数据格式; -- Xcode Command Line Tools / Swift 5.9 或更高版本; -- Apple 提醒事项账户; -- 建议 TaskForge 保持运行,以便反向写入后及时重新索引。 +## 同步边界 -## 安装 +默认源是 `custom-list`,不是按日期筛选。成员集合由下面两份本机数据按 TaskForge +真实规则计算: -```bash -git clone https://github.com/ezbug/taskforge-reminder-sync.git -cd taskforge-reminder-sync -./scripts/build-app.sh +```text +tasks.v6.bin + flutter.ctl_<固定列表 ID> + │ 组内 all/any、组间 all/any、字段和操作符 + ▼ +TaskForge Today 当前成员 ``` -`build-app.sh` 默认使用本机 ad hoc 签名,适合自行构建和首次使用。ad hoc -签名只标识当前这一版二进制;重新构建后,macOS 可能要求重新授予提醒事项和文件 -访问权限。如果钥匙串中已有稳定的代码签名身份,可在构建时指定: +列表 ID 首次可通过 `--taskforge-list-id` 提供,成功写入时只保存到权限为 `0600` +的私有配置;代码、日志、Git 和 README 不包含真实 ID。列表配置缺失、JSON 损坏、 +任务库损坏、未知字段/操作符或未知逻辑时整轮失败关闭,不猜测成员。 -```bash -TASKFORGE_SYNC_CODESIGN_IDENTITY="Apple Development: Your Name" \ - ./scripts/build-app.sh -``` +`--source scheduled-day` 和旧的 `--date`、`--list-name` 只保留兼容模式;custom-list +模式使用这些旧参数会直接拒绝。 -项目不会自动创建证书、修改钥匙串或重置 TCC 权限。 +## Apple 状态列表 -先做完全无写入的配置检查与今日任务预览: +活动状态按需创建,前缀默认为 `TaskForge`,可用 `--list-prefix` 修改。首次创建 +`TaskForge · 待办` 时会把旧的 `TaskForge 今日` 列表原地重命名并复用;不会因重命名 +更换源列表。 -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --check-config -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --dry-run -``` +| TaskForge 状态 | Apple 列表 | 颜色 | 说明 | +|---|---|---|---| +| `todo` | `TaskForge · 待办` | 蓝 | 默认活动状态 | +| `scheduled` | `TaskForge · 已计划` | 紫 | 活动列表 | +| `ready` | `TaskForge · 就绪` | 绿 | 活动列表 | +| `inProgress` | `TaskForge · 进行中` | 青 | 活动列表 | +| `onHold` | `TaskForge · 暂停` | 橙 | 首次出现时创建 | +| `deferred` | `TaskForge · 已推迟` | 灰 | 首次出现时创建 | +| `blocked` | `TaskForge · 已阻塞` | 红 | 首次出现时创建 | +| `someday` | `TaskForge · 将来某天` | 靛蓝 | 首次出现时创建 | +| `done` | 无活动列表 | — | 完成提醒,不删除 TaskForge 任务 | +| `cancelled` | 无活动列表 | — | 完成提醒,标题额外标注“已取消” | -第一次真实同步: +未知但合法的 TaskForge 状态会以同前缀的灰色列表显示;未知状态不会被猜测写回。 +列表 ID 被删除或配置丢失时停止同步。 -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --sync -``` +## 双向语义 -macOS 会请求“提醒事项”访问权限。工具不需要“日历”权限。根据系统隐私设置,读取 TaskForge 容器或 Vault 时可能还需要为 App 授予“完全磁盘访问权限”。 +- TaskForge 标题、原始计划日期/时间和优先级正向更新对应提醒;不会人为添加“今天”。 +- Apple 状态列表移动会回写源状态;移动到普通 Apple 列表的受管提醒会被移回正确状态。 +- TaskForge 开放状态变化与 Apple 开放状态变化冲突时,TaskForge 优先。 +- Apple 新发生的完成操作优先,写回 TaskForge `done`;绝不删除 TaskForge 任务行或文件。 +- 受管提醒只由工具标记或私有索引识别;普通 Apple 列表永不扫描、删除或批量修改。 +- 已不属于 Today、未完成且无优先级的受管提醒,必须经过至少 60 秒间隔的双扫描、 + 备份和回读校验后才可能删除;重要、已完成、无法判断来源的提醒受保护。 +- `TaskForge 今日 · 去重归档` 中确认属于历史重复项的提醒可统一完成,但不会触发 + TaskForge 反向完成;完成后退出 Apple “今天”视图由用户自行操作。 -确认一次性同步正常后,安装常驻 LaunchAgent: +### 反向符号学习 -```bash -./scripts/install-daily-sync.sh -``` +首期只允许已确认符号:待办 `[ ]`、已计划 `[>]`、进行中 `[/]`、完成 `[x]`。 +工具从真实 TaskForge 记录学习映射并保存到权限为 `0600` 的状态字典。尚未学会、 +同一状态冲突或一个符号被多个状态占用时,拒绝写源文件并把提醒移回 TaskForge +当前状态。Markdown/TaskNotes 写回都先校验源身份和原始内容,再备份、原子写入、 +SHA-256 校验并等待 TaskForge 回读。 -安装器使用一个随 App 签名的小型监督进程,再通过 macOS LaunchServices 启动 -同步 App,而不是把 App 包内的主二进制当作普通后台命令执行。这样常驻同步进程 -与手动运行使用同一个 bundle 身份,系统授予的“提醒事项”权限才能在后台正确 -生效;若同步进程退出,LaunchAgent 会重新拉起它。 - -安装位置: - -- App:`~/Applications/TaskForgeReminderSync.app` -- LaunchAgent:`~/Library/LaunchAgents/local.codex.taskforge-reminder-sync.plist` -- 标准日志:`~/Library/Logs/TaskForgeReminderSync.log` -- 错误日志:`~/Library/Logs/TaskForgeReminderSync.error.log` -- 回写备份:`~/Library/Application Support/TaskForgeReminderSync/Backups/` -- 清理候选账本:`~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json` -- 清理备份:`~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` -- 清理日志匿名化盐:`~/Library/Application Support/TaskForgeReminderSync/PruneHashSalt` - -候选账本、清理备份、源文件回写备份和匿名化盐均为本机私有运行数据: -运行时根目录、`Backups/` 及批次目录为 `0700`,备份和状态文件为 `0600`, -且不会进入 Git 仓库。普通写路径可把本人拥有、非 symlink、无扩展 ACL、 -group/world 不可写的旧 `0755` / `0644` 备份树安全收紧;其他异常权限会 -失败关闭。备份可能包含恢复提醒所需的标题、笔记、日期、闹钟、重复规则和 -原始系统标识,请像保护 Vault 一样保护该目录。 -`PruneHashSalt` 按需生成;只登记首次候选且尚不需要匿名项目关联时,它可以 -暂时不存在。 +提醒备注只保留简洁工具标记和来源标记;完整源引用、上次状态、哈希和提醒映射只 +保存在私有索引中。 -## 命令 +## 近实时监听 -| 命令 | 作用 | 是否写入 | -|---|---|---| -| `--check-config` | 检查 TaskForge、数据版本、Vault 和今日任务数量 | 否 | -| `--dry-run` | 列出今天将被同步的任务 | 否 | -| `--audit` | 区分重复活跃任务、重复历史实例、正常历史源复用和缺失映射;不输出任务内容 | 否 | -| `--deduplicate-dry-run` | 预演重复组、保留数和归档数 | 否 | -| `--deduplicate` | 保留权威提醒,把冗余活跃提醒移到可恢复的归档列表 | 是 | -| `--reverse-dry-run` | 预览 Apple → TaskForge 的源文件修改 | 否 | -| `--reverse-once` | 执行一次反向完成并等待 TaskForge 回读 | 是 | -| `--prune-dry-run` | 严格只读分类,报告首次候选和已满足二次确认的数量;不写账本、备份或盐,不修改提醒 | 否 | -| `--prune-once` | 推进一次候选状态;首次登记,至少 60 秒后的下一次扫描才可能备份并删除 | 是 | -| `--restore-last-prune` | 恢复最近一个尚未恢复的实际删除批次 | 是 | -| `--sync` | 依次反向扫描、正向同步并推进一次自动清理 | 是 | -| `--watch` | 常驻近实时双向同步,并在每轮末推进自动清理 | 是 | +- `EKEventStoreChanged` 触发约 0.75 秒防抖同步; +- 每秒轮询任务库和自定义列表配置的修改时间; +- 每 60 秒做一次全量校准,恢复漏事件; +- 07:00、11:00、15:00 仅作为额外兜底校准。 -常用参数: +这表示本机事件驱动的近实时语义,不是 TaskForge 官方推送。验收目标是本地变化 +通常 5 秒内完成,漏事件最迟 60 秒修复。 -```text ---list-name NAME 目标提醒事项列表,默认“TaskForge 今日” ---task-store PATH 自定义 tasks.v6.bin 路径 ---date YYYY-MM-DD 指定正向预览/同步日期 ---task-id ID 只处理一个 TaskForge 任务 ---backup-root PATH 自定义反向写入备份目录 -``` +## 系统要求与安装 -定向预览或完成一个任务: +- macOS 13 或更高版本; +- 已安装 TaskForge,并使用 `tasks.v6.bin`; +- Swift 5.9 / Xcode Command Line Tools; +- 一个可用的 Apple 提醒事项账户。 ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --reverse-dry-run --task-id TASK_ID -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --reverse-once --task-id TASK_ID +git clone https://github.com/ezbug/taskforge-reminder-sync.git +cd taskforge-reminder-sync +./scripts/build-app.sh ``` -## 自动清理与恢复 - -清理候选必须同时满足全部条件: - -1. 位于 `--list-name` 指定的列表(默认 `TaskForge 今日`); -2. 尚未完成; -3. EventKit 优先级为零,且标题没有六个受保护前缀; -4. 当前 TaskForge 快照没有对应任务; -5. 持久源引用也无法在 Vault 中确认任务存在。 - -第 4、5 项是“双源不存在”检查。源文件不可读、引用越出 Vault、源行同名或 -位置有歧义、TaskForge 快照解码失败、权限或 I/O 出错时,结果属于“无法判定” -而不是“不存在”,清理会失败关闭并保留提醒。旧版提醒若既不在当前缓存中, -也没有可验证的持久源引用,则可能成为候选。 - -清理只向 EventKit 请求目标列表中的提醒;其他 Apple 提醒列表、去重归档列表 -和已完成历史都不参与清理。同名目标列表若出现多个匹配,工具会拒绝猜测并 -整轮停止。目标列表不存在时,清理返回零,不会为了清理新建列表。 - -先运行严格只读预演: +第一次只读配置和成员预演(不会请求提醒事项权限,也不会创建私有状态文件): ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --prune-dry-run +APP=./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync +"$APP" --check-config --taskforge-list-id LIST_ID +"$APP" --dry-run --taskforge-list-id LIST_ID ``` -真实推进需要两次独立扫描,且两次至少相隔 60 秒: +预演输出的状态数量必须与 TaskForge Today 当前界面动态数量一致,才能进入写入门槛。 +确认后执行一次同步,系统会请求提醒事项完整访问权限: ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --prune-once -# 至少 60 秒后,由 watcher / --sync / 第二次 --prune-once 再确认 -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --prune-once +"$APP" --sync --taskforge-list-id LIST_ID +"$APP" --watch ``` -期间只要提醒被完成、加入优先级或重要前缀、恢复 TaskForge 身份、移出目标 -列表或候选属性改变,旧候选就会撤销;属性改变后仍符合条件也要重新计时。 -删除前必须成功写入并校验整批备份,否则整批不删除。 +也可以先使用 `--sync --list-prefix NAME`;后续省略前缀时会复用私有配置。macOS +隐私设置可能还需要允许该 App 读取 TaskForge 容器和 Vault。工具只需要“提醒事项” +权限,不需要“日历”权限;不会自动重置 TCC,也不会自动批准权限。 -恢复按创建时间选择最新一个“尚未恢复、实际删除结果已解析且非空”的批次。 -更新但删除结果尚未解析的备份,以及实际删除数为零的备份,会保留在磁盘上但 -不会阻塞更早的真实删除批次,也不会被错误标记为已恢复。 +建议确认一次性同步和专用验收任务后,再安装监督进程: ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --restore-last-prune +./scripts/install-daily-sync.sh ``` -恢复会重建用户可编辑字段,但 EventKit 会生成新的系统 ID。恢复项享有至少 -24 小时的本机清理宽限;宽限结束后仍符合条件,也必须重新经历两次扫描。 -如果原列表已经不存在,恢复只会尝试在备份记录的原提醒事项账户中重建;账户 -缺失或同一账户中有多个同名列表时失败关闭,备份保持未消费。 - -## 同步与安全规则 - -1. 提醒必须带有本工具生成的稳定标记,才会参与反向同步。 -2. 源文件必须位于当前 TaskForge Vault 内。 -3. 任务必须是非重复任务,且 `onCompletion=keep`。 -4. 源行必须与保存的引用一致,或能在文件中唯一找到。 -5. 写入前创建带时间戳的完整文件备份。 -6. 写入后逐字节校验文件,并等待 TaskForge 任务库刷新。 -7. TaskForge 可能从缓存中移除已完成的内联任务;这不等于源任务被删除。 -8. 早期版本创建、没有持久源引用且已经离开 TaskForge 缓存的提醒会被安全跳过,不会猜测写入。 -9. 自动清理只删除符合上述五项条件并通过双扫描确认的 Apple 提醒;不会删除或移动 TaskForge 源任务。 -10. Apple → TaskForge 反向同步仍只把源任务改为 `done`,绝不会因清理删除任务行或 TaskNotes 文件。 -11. 去重维护只移动冗余提醒到独立归档列表,不删除提醒或 TaskForge 源任务;去重归档列表也不在自动清理范围内。 - -只读检查整个受管列表是否仍然无重复: +安装器不会把真实列表 ID 写入 plist;watcher 从权限为 `0600` 的私有配置读取。卸载: ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --audit +./scripts/uninstall-daily-sync.sh ``` -审计覆盖当前与历史受管提醒,只输出数量,不输出标题、笔记、Vault 路径或原始任务 ID。“历史源位置复用”是信息项:同一行在不同计划日期承载过不同任务,不等同于重复。 +## 命令 -清理日志也只输出首次候选、等待、撤销、删除、恢复和失败的数量,必要的单项 -关联使用本机随机盐生成的截断哈希。清理日志不会输出标题、笔记、Vault / -源文件路径、原始 TaskForge ID 或原始 EventKit ID。EventKit 读取若 30 秒 -未完成,会取消该次请求并以匿名错误失败关闭,不会继续删除。 +| 命令 | custom-list 行为 | 写入 | +|---|---|---| +| `--check-config` | 检查 v6、固定列表和状态计数 | 否 | +| `--dry-run` | 匿名输出成员及各状态数量 | 否 | +| `--sync` | 反向冲突处理、正向同步、双扫描推进 | 是 | +| `--reverse-dry-run` | 预览 Apple → TaskForge 候选 | 否(需权限) | +| `--reverse-once` | 执行一次反向状态写回 | 是 | +| `--deduplicate-dry-run` | 预览当前受管重复组 | 否(需权限) | +| `--deduplicate` | 将确认的历史重复提醒移到归档并完成 | 是 | +| `--prune-dry-run` | 只读清理分类,不写账本 | 否(需权限) | +| `--prune-once` | 推进双扫描、备份并按门槛清理 | 是 | +| `--watch` | 常驻近实时双向同步 | 是 | + +custom-list 的 `--deduplicate` 只把确认的重复提醒移到 +`TaskForge 今日 · 去重归档` 并标记完成;归档标记会阻止它触发 TaskForge 反向完成。 +`--restore-last-prune` 仍仅在 `scheduled-day` 兼容模式提供。兼容模式保留旧的 +`--audit`、`--deduplicate-*` 等维护命令,但不会改变 custom-list 的源语义。 -修复早期版本已经产生的活跃重复项: +常用参数: -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --deduplicate-dry-run -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --deduplicate +```text +--source custom-list|scheduled-day 同步源,默认 custom-list +--taskforge-list-id LIST_ID 固定绑定的自定义 Kanban 列表 ID +--list-prefix NAME Apple 状态列表前缀 +--task-store PATH 自定义 tasks.v6.bin 路径 +--task-id ID 专用任务验收时只处理一个任务 +--backup-root PATH 源文件反向写入备份目录 +--date YYYY-MM-DD 仅 scheduled-day 兼容模式 +--list-name NAME 仅 scheduled-day 兼容模式 ``` -去重优先保留仍与当前 TaskForge ID 精确匹配的提醒;没有精确匹配时保留最早创建项。冗余项不会被删除,而是移到“TaskForge 今日 · 去重归档”,随后保留项会重新关联当前 TaskForge 记录。需要恢复时可在 Apple 提醒事项中手动移回。 +## 私有数据、备份与回滚 -## 日志与排障 +默认私有运行根目录: -```bash -tail -f ~/Library/Logs/TaskForgeReminderSync.log -tail -f ~/Library/Logs/TaskForgeReminderSync.error.log -launchctl print "gui/$(id -u)/local.codex.taskforge-reminder-sync" +```text +~/Library/Application Support/TaskForgeReminderSync/ +├── KanbanSyncConfig.json 0600:列表 ID、前缀、学到的符号 +├── KanbanSyncIndex.json 0600:提醒映射、状态、源引用和哈希 +├── Backups/ 0700:反向写入前的源文件备份 +└── PruneBackups/ 0700:清理删除前的提醒事项备份 ``` -常见问题和恢复方式见 [排障指南](docs/TROUBLESHOOTING.md)。 +运行根目录和子目录为 `0700`,状态、配置和备份文件为 `0600`。写入前记录涉及 +文件的权限与哈希;写入后逐字节回读。发现私有目录、权限、符号链接、扩展 ACL、 +配置或索引异常时整轮失败关闭。 -## 卸载 - -```bash -./scripts/uninstall-daily-sync.sh -``` +回滚顺序: -卸载会停止 LaunchAgent 并移除安装的 App,不会删除: +1. 停止 watcher,避免回滚过程中再次同步; +2. 用 `--restore-last-prune` 恢复最近实际删除的提醒事项批次; +3. 从 `Backups/` 恢复对应 Vault 源文件,保留原权限并重新计算哈希; +4. 检查 TaskForge 回读后,再运行 `--dry-run` 和 `--prune-dry-run`; +5. 必要时移走私有索引后重新用明确的列表 ID做只读预演,不要手动删除普通提醒。 -- Apple 提醒事项中已经创建的内容; -- Vault 中的任何笔记或任务; -- 反向写入备份; -- 清理候选账本、清理备份、匿名化盐和运行日志。 +清理不会删除 TaskForge 源任务。若源身份、符号、回读或权限无法确认,工具宁可 +把 Apple 提醒移回当前 TaskForge 状态并拒绝写入。 -如果需要找回已清理提醒,请在删除这些本机运行数据前执行 -`--restore-last-prune`。App 已卸载时,可以重新构建后从 `dist` 运行恢复命令; -备份被恢复后仍会保留,并记录为已恢复。 +## 验证与生产门槛 -## 开发与测试 +本项目使用可执行测试入口而不是 XCTest 自动发现: ```bash -swift run TaskForgeReminderCoreTests swift build -./scripts/build-app.sh +swift run TaskForgeReminderCoreTests +swift run TaskForgeReminderCLITests +swift run TaskForgeReminderEventKitTests ``` -测试覆盖 MessagePack v6 解码、今日任务筛选、稳定标记、ID 变化后的源位置 -复用、歧义去重、日期语义比较、正反向完成策略、历史源引用、Markdown / -TaskNotes 回写、清理判定、双扫描状态机、私有备份、恢复宽限、EventKit -跨列表隔离以及读取超时取消。 - -## 限制 +EventKit 隔离测试默认跳过,只有明确设置环境变量才会访问真实提醒事项: -- TaskForge 改变内部缓存格式后,解码器可能需要更新。 -- 同时改动内联任务的文件位置、行号和标题,且 TaskForge 也更换内部 ID 时,没有足够的稳定信息可安全认定为同一任务;工具宁可拒绝猜测。 -- 当前不会反向处理重复任务或完成后会移动、归档、删除的任务。 -- 自动清理判断的是“当前缓存与真实源都确认不存在”,不会仅凭 TaskForge 缓存暂时缺席就删除 Apple 提醒;已完成、重要或其他列表中的提醒不清理。 -- TaskForge 未运行时,源文件可能不会立即被重新索引;建议让 TaskForge 保持运行。 -- 本项目不提供云服务、遥测或跨设备同步;Apple 提醒事项自身的 iCloud 同步由系统负责。 - -## 参与贡献 +```bash +TASKFORGE_RUN_EVENTKIT_TESTS=1 swift run TaskForgeReminderEventKitTests +``` -请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。安全或隐私问题请按 [SECURITY.md](SECURITY.md) 私下报告。 +生产验收至少包括:专用任务“TaskForge同步状态测试”完成 +`待办 → 已计划 → 进行中 → Apple 完成`,再测试 Apple 侧状态移动;watcher 连续 +运行至少 61 秒,确认事件、防抖、双扫描、重启恢复和 07:00/11:00/15:00 校准。 +生产写入前还必须备份提醒事项迁移数据、私有索引及涉及源文件,并记录权限和哈希。 -## License +当前代码不会自动执行权限批准、真实写入或合并 `main`;这些是用户可见的验收门槛。 -[MIT](LICENSE) +更多架构和排障说明见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)、 +[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)、[PRIVACY.md](PRIVACY.md) +和 [SECURITY.md](SECURITY.md)。 diff --git a/Sources/TaskForgeReminderCore/Core.swift b/Sources/TaskForgeReminderCore/Core.swift index e41c73c..3cf37c0 100644 --- a/Sources/TaskForgeReminderCore/Core.swift +++ b/Sources/TaskForgeReminderCore/Core.swift @@ -53,6 +53,38 @@ public struct TaskForgeTask: Codable, Equatable, Sendable { public let lineNumber: Int? public let onCompletion: String? public let recurrence: String? + public let tags: [String] + public let contexts: [String] + public let projects: [String] + public let due: TaskForgeScheduledDate? + public let start: TaskForgeScheduledDate? + public let completionDay: TaskForgeDay? + public let cancelledDay: TaskForgeDay? + public let isBlocked: Bool + public let fileName: String? + + private enum CodingKeys: String, CodingKey { + case identifier + case title + case status + case priority + case scheduled + case filePath + case sourceType + case originalLine + case lineNumber + case onCompletion + case recurrence + case tags + case contexts + case projects + case due + case start + case completionDay + case cancelledDay + case isBlocked + case fileName + } public init( identifier: String, @@ -65,7 +97,16 @@ public struct TaskForgeTask: Codable, Equatable, Sendable { originalLine: String?, lineNumber: Int?, onCompletion: String? = nil, - recurrence: String? = nil + recurrence: String? = nil, + tags: [String] = [], + contexts: [String] = [], + projects: [String] = [], + due: TaskForgeScheduledDate? = nil, + start: TaskForgeScheduledDate? = nil, + completionDay: TaskForgeDay? = nil, + cancelledDay: TaskForgeDay? = nil, + isBlocked: Bool = false, + fileName: String? = nil ) { self.identifier = identifier self.title = title @@ -78,10 +119,69 @@ public struct TaskForgeTask: Codable, Equatable, Sendable { self.lineNumber = lineNumber self.onCompletion = onCompletion self.recurrence = recurrence + self.tags = tags + self.contexts = contexts + self.projects = projects + self.due = due + self.start = start + self.completionDay = completionDay + self.cancelledDay = cancelledDay + self.isBlocked = isBlocked + self.fileName = fileName + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + identifier: try container.decode(String.self, forKey: .identifier), + title: try container.decode(String.self, forKey: .title), + status: try container.decode(String.self, forKey: .status), + priority: try container.decodeIfPresent(String.self, forKey: .priority), + scheduled: try container.decodeIfPresent( + TaskForgeScheduledDate.self, + forKey: .scheduled + ), + filePath: try container.decodeIfPresent(String.self, forKey: .filePath), + sourceType: try container.decodeIfPresent(String.self, forKey: .sourceType), + originalLine: try container.decodeIfPresent( + String.self, + forKey: .originalLine + ), + lineNumber: try container.decodeIfPresent(Int.self, forKey: .lineNumber), + onCompletion: try container.decodeIfPresent( + String.self, + forKey: .onCompletion + ), + recurrence: try container.decodeIfPresent(String.self, forKey: .recurrence), + tags: try container.decodeIfPresent([String].self, forKey: .tags) ?? [], + contexts: try container.decodeIfPresent([String].self, forKey: .contexts) ?? [], + projects: try container.decodeIfPresent([String].self, forKey: .projects) ?? [], + due: try container.decodeIfPresent( + TaskForgeScheduledDate.self, + forKey: .due + ), + start: try container.decodeIfPresent( + TaskForgeScheduledDate.self, + forKey: .start + ), + completionDay: try container.decodeIfPresent( + TaskForgeDay.self, + forKey: .completionDay + ), + cancelledDay: try container.decodeIfPresent( + TaskForgeDay.self, + forKey: .cancelledDay + ), + isBlocked: try container.decodeIfPresent(Bool.self, forKey: .isBlocked) + ?? false, + fileName: try container.decodeIfPresent(String.self, forKey: .fileName) + ) } public var isCompleted: Bool { - status == "done" || status == "cancelled" + let canonical = TaskForgeKanbanStatus.canonical(status) + return canonical == TaskForgeKanbanStatus.done.rawValue + || canonical == TaskForgeKanbanStatus.cancelled.rawValue } } @@ -147,7 +247,34 @@ public enum TaskForgeTaskStore { throw TaskForgeTaskStoreError.unsupportedVersion(version) } - let tasks = records.compactMap(decodeTaskRecord) + var allRecords = records + while decoder.hasRemaining { + let trailing = try decoder.decodeValue() + guard trailing.arrayValue?.count == 33 else { + throw TaskForgeTaskStoreError.malformed( + "任务库尾部包含未知记录" + ) + } + allRecords.append(trailing) + } + + var tasks: [TaskForgeTask] = [] + tasks.reserveCapacity(allRecords.count) + for record in allRecords { + // TaskForge leaves a scalar `1` tombstone in this array after an + // indexed record is removed. It is not a task and is present in + // the live v6 store, so it must not make an otherwise valid store + // fail closed or become a synthetic task. + if record.intValue == 1 { + continue + } + guard let task = decodeTaskRecord(record) else { + throw TaskForgeTaskStoreError.malformed( + "任务记录不完整或字段类型无效" + ) + } + tasks.append(task) + } return TaskForgeSnapshot(version: version, vaultPath: vaultPath, tasks: tasks) } @@ -175,10 +302,39 @@ public enum TaskForgeTaskStore { originalLine: fields[31].stringValue, lineNumber: fields[32].intValue, onCompletion: fields[25].stringValue, - recurrence: fields[30].stringValue + recurrence: fields[30].stringValue, + tags: decodeStrings(fields[5]), + contexts: decodeStrings(fields[6]), + projects: decodeStrings(fields[7]), + due: decodeScheduledDate(fields[14]), + start: decodeScheduledDate(fields[13]), + completionDay: decodeDay(fields[15]), + cancelledDay: decodeDay(fields[16]), + isBlocked: fields[26].boolValue ?? false, + fileName: fields[18].stringValue.map { + URL(fileURLWithPath: $0).lastPathComponent + } ) } + private static func decodeStrings(_ value: MessagePackValue) -> [String] { + value.arrayValue?.compactMap(\.stringValue) ?? [] + } + + private static func decodeDay(_ value: MessagePackValue) -> TaskForgeDay? { + guard + let fields = value.arrayValue, + let date = fields.first?.arrayValue, + date.count >= 3, + let year = date[0].intValue, + let month = date[1].intValue, + let day = date[2].intValue + else { + return nil + } + return TaskForgeDay(year: year, month: month, day: day) + } + private static func decodeScheduledDate( _ value: MessagePackValue ) -> TaskForgeScheduledDate? { @@ -1007,6 +1163,13 @@ private indirect enum MessagePackValue { return value } + var boolValue: Bool? { + guard case let .bool(value) = self else { + return nil + } + return value + } + var arrayValue: [MessagePackValue]? { guard case let .array(value) = self else { return nil @@ -1019,6 +1182,10 @@ private struct MessagePackDecoder { private let data: Data private var offset = 0 + var hasRemaining: Bool { + offset < data.count + } + init(data: Data) { self.data = data } diff --git a/Sources/TaskForgeReminderCore/TaskForgeKanban.swift b/Sources/TaskForgeReminderCore/TaskForgeKanban.swift new file mode 100644 index 0000000..81a75bc --- /dev/null +++ b/Sources/TaskForgeReminderCore/TaskForgeKanban.swift @@ -0,0 +1,949 @@ +import Foundation + +public enum TaskForgeKanbanStatus: String, Codable, CaseIterable, Sendable { + case todo + case scheduled + case ready + case inProgress + case onHold + case deferred + case blocked + case someday + case done + case cancelled + + public static func canonical(_ value: String) -> String { + var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.hasPrefix("TaskStatus.") { + normalized = String(normalized.dropFirst("TaskStatus.".count)) + } + normalized = normalized.replacingOccurrences(of: "-", with: "_") + switch normalized.lowercased() { + case "todo", "open": + return Self.todo.rawValue + case "scheduled": + return Self.scheduled.rawValue + case "ready": + return Self.ready.rawValue + case "inprogress", "in_progress": + return Self.inProgress.rawValue + case "onhold", "on_hold", "hold": + return Self.onHold.rawValue + case "deferred", "postponed": + return Self.deferred.rawValue + case "blocked": + return Self.blocked.rawValue + case "someday", "someday_maybe": + return Self.someday.rawValue + case "done", "completed", "complete": + return Self.done.rawValue + case "cancelled", "canceled": + return Self.cancelled.rawValue + default: + return normalized + } + } + + public static func fromTask(_ task: TaskForgeTask) -> Self? { + Self(rawValue: canonical(task.status)) + } + + public var isTerminal: Bool { + self == .done || self == .cancelled + } +} + +public enum TaskForgeFilterConfigurationError: Error, LocalizedError, Equatable { + case missingList(String) + case malformedJSON + case unsupportedField(String) + case unsupportedOperator(String) + case invalidMatchMode(String) + case invalidCondition(String) + case invalidPrivateState + + public var errorDescription: String? { + switch self { + case let .missingList(id): + _ = id + return "找不到 TaskForge 自定义列表配置,已停止同步。" + case .malformedJSON: + return "TaskForge 自定义列表配置不是有效 JSON。" + case let .unsupportedField(field): + return "TaskForge 自定义列表包含未知过滤字段:\(field)" + case let .unsupportedOperator(value): + return "TaskForge 自定义列表包含未知过滤操作符:\(value)" + case let .invalidMatchMode(value): + return "TaskForge 自定义列表包含无效组逻辑:\(value)" + case let .invalidCondition(value): + return "TaskForge 自定义列表过滤条件无效:\(value)" + case .invalidPrivateState: + return "TaskForge 同步私有状态损坏,已拒绝写入。" + } + } +} + +public enum TaskForgeFilterField: String, CaseIterable, Sendable { + case dueDate = "due_date" + case scheduledDate = "scheduled_date" + case startDate = "start_date" + case completionDate = "completion_date" + case cancelledDate = "cancelled_date" + case status + case priority + case tag + case context + case project + case filePath = "file_path" + case fileName = "file_name" + case taskSourceType = "task_source_type" + case title + case isBlocked = "is_blocked" +} + +public enum TaskForgeFilterOperator: String, CaseIterable, Sendable { + case equals + case notEquals = "not_equals" + case contains + case notContains = "not_contains" + case isNull = "is_null" + case isNotNull = "is_not_null" + case today + case beforeToday = "before_today" + case afterToday = "after_today" + case inNextDays = "in_next_days" + case inLastDays = "in_last_days" + case onOrBefore = "on_or_before" + case onOrAfter = "on_or_after" + case anyOf = "any_of" + case notAnyOf = "not_any_of" +} + +public struct TaskForgeFilterCondition: Codable, Equatable, Sendable { + public let type: String + public let `operator`: String + public let value: String? + public let days: Int? + public let propertyName: String? + public let useInCalendarView: Bool + public let useInDistributionView: Bool + + public init( + type: String, + operator: String, + value: String? = nil, + days: Int? = nil, + propertyName: String? = nil, + useInCalendarView: Bool = true, + useInDistributionView: Bool = true + ) throws { + guard TaskForgeFilterField(rawValue: type) != nil else { + throw TaskForgeFilterConfigurationError.unsupportedField(type) + } + guard TaskForgeFilterOperator(rawValue: `operator`) != nil else { + throw TaskForgeFilterConfigurationError.unsupportedOperator(`operator`) + } + self.type = type + self.operator = `operator` + self.value = value + self.days = days + self.propertyName = propertyName + self.useInCalendarView = useInCalendarView + self.useInDistributionView = useInDistributionView + } + + private enum CodingKeys: String, CodingKey { + case type + case `operator` + case value + case days + case propertyName + case useInCalendarView + case useInDistributionView + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + let op = try container.decode(String.self, forKey: .operator) + try self.init( + type: type, + operator: op, + value: try container.decodeIfPresent(String.self, forKey: .value), + days: try container.decodeIfPresent(Int.self, forKey: .days), + propertyName: try container.decodeIfPresent( + String.self, + forKey: .propertyName + ), + useInCalendarView: try container.decodeIfPresent( + Bool.self, + forKey: .useInCalendarView + ) ?? true, + useInDistributionView: try container.decodeIfPresent( + Bool.self, + forKey: .useInDistributionView + ) ?? true + ) + } +} + +public struct TaskForgeFilterGroup: Codable, Equatable, Sendable { + public let conditions: [TaskForgeFilterCondition] + public let matchMode: String + + public init( + conditions: [TaskForgeFilterCondition], + matchMode: String + ) throws { + guard matchMode == "all" || matchMode == "any" else { + throw TaskForgeFilterConfigurationError.invalidMatchMode(matchMode) + } + guard !conditions.isEmpty else { + throw TaskForgeFilterConfigurationError.invalidCondition("empty group") + } + self.conditions = conditions + self.matchMode = matchMode + } + + private enum CodingKeys: String, CodingKey { + case conditions + case matchMode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + conditions: container.decode([TaskForgeFilterCondition].self, forKey: .conditions), + matchMode: container.decode(String.self, forKey: .matchMode) + ) + } +} + +public struct TaskForgeCustomList: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let filterGroups: [TaskForgeFilterGroup] + public let filterGroupsMatchMode: String + public let kanbanMode: Bool + + public init( + id: String, + name: String, + filterGroups: [TaskForgeFilterGroup], + filterGroupsMatchMode: String, + kanbanMode: Bool + ) throws { + guard !id.isEmpty, !name.isEmpty else { + throw TaskForgeFilterConfigurationError.invalidCondition("list identity") + } + guard filterGroupsMatchMode == "all" || filterGroupsMatchMode == "any" else { + throw TaskForgeFilterConfigurationError.invalidMatchMode( + filterGroupsMatchMode + ) + } + guard !filterGroups.isEmpty else { + throw TaskForgeFilterConfigurationError.invalidCondition("empty list") + } + self.id = id + self.name = name + self.filterGroups = filterGroups + self.filterGroupsMatchMode = filterGroupsMatchMode + self.kanbanMode = kanbanMode + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case filterGroups + case filterGroupsMatchMode + case kanbanMode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + id: container.decode(String.self, forKey: .id), + name: container.decode(String.self, forKey: .name), + filterGroups: container.decode( + [TaskForgeFilterGroup].self, + forKey: .filterGroups + ), + filterGroupsMatchMode: container.decode( + String.self, + forKey: .filterGroupsMatchMode + ), + kanbanMode: container.decodeIfPresent(Bool.self, forKey: .kanbanMode) + ?? false + ) + } +} + +public enum TaskForgeListConfigurationStore { + public static let defaultPreferencesPath = + "\(NSHomeDirectory())/Library/Containers/com.azhard.taskforge/Data/Library/Preferences/com.azhard.taskforge.plist" + + public static func load( + listID: String, + preferencesPath: String = defaultPreferencesPath + ) throws -> TaskForgeCustomList { + let url = URL(fileURLWithPath: preferencesPath) + guard + let data = try? Data(contentsOf: url), + let propertyList = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ), + let plist = propertyList as? [String: Any], + let rawJSON = plist["flutter.ctl_\(listID)"] as? String + else { + throw TaskForgeFilterConfigurationError.missingList(listID) + } + guard let jsonData = rawJSON.data(using: .utf8) else { + throw TaskForgeFilterConfigurationError.malformedJSON + } + do { + return try JSONDecoder().decode(TaskForgeCustomList.self, from: jsonData) + } catch let error as TaskForgeFilterConfigurationError { + throw error + } catch { + throw TaskForgeFilterConfigurationError.malformedJSON + } + } + + public static func decode(jsonData: Data) throws -> TaskForgeCustomList { + do { + return try JSONDecoder().decode(TaskForgeCustomList.self, from: jsonData) + } catch let error as TaskForgeFilterConfigurationError { + throw error + } catch { + throw TaskForgeFilterConfigurationError.malformedJSON + } + } +} + +public enum TaskForgeFilterEvaluator { + public static func select( + tasks: [TaskForgeTask], + list: TaskForgeCustomList, + calendar: Calendar, + now: Date = Date() + ) throws -> [TaskForgeTask] { + try tasks.filter { task in + try evaluate(task: task, list: list, calendar: calendar, now: now) + } + } + + public static func evaluate( + task: TaskForgeTask, + list: TaskForgeCustomList, + calendar: Calendar, + now: Date = Date() + ) throws -> Bool { + let groupResults = try list.filterGroups.map { group in + let conditionResults = try group.conditions.map { + try evaluate( + condition: $0, + task: task, + calendar: calendar, + now: now + ) + } + return group.matchMode == "all" + ? conditionResults.allSatisfy { $0 } + : conditionResults.contains { $0 } + } + return list.filterGroupsMatchMode == "all" + ? groupResults.allSatisfy { $0 } + : groupResults.contains { $0 } + } + + private static func evaluate( + condition: TaskForgeFilterCondition, + task: TaskForgeTask, + calendar: Calendar, + now: Date + ) throws -> Bool { + guard let field = TaskForgeFilterField(rawValue: condition.type) else { + throw TaskForgeFilterConfigurationError.unsupportedField(condition.type) + } + guard let op = TaskForgeFilterOperator(rawValue: condition.operator) else { + throw TaskForgeFilterConfigurationError.unsupportedOperator( + condition.operator + ) + } + switch field { + case .dueDate, .scheduledDate, .startDate, .completionDate, .cancelledDate: + let day: TaskForgeDay? + switch field { + case .dueDate: day = task.due?.day + case .scheduledDate: day = task.scheduled?.day + case .startDate: day = task.start?.day + case .completionDate: day = task.completionDay + case .cancelledDate: day = task.cancelledDay + default: day = nil + } + return evaluateDate( + day: day, + operator: op, + value: condition.value, + days: condition.days, + calendar: calendar, + now: now + ) + case .isBlocked: + return evaluateScalar( + task.isBlocked ? "true" : "false", + isPresent: true, + operator: op, + value: condition.value + ) + case .status: + return evaluateScalar( + TaskForgeKanbanStatus.canonical(task.status), + isPresent: true, + operator: op, + value: condition.value.map(TaskForgeKanbanStatus.canonical) + ) + case .priority: + return evaluateScalar( + canonicalEnum(task.priority), + isPresent: task.priority != nil, + operator: op, + value: condition.value.map(canonicalEnum) + ) + case .tag: + return evaluateCollection( + task.tags, + operator: op, + value: condition.value + ) + case .context: + return evaluateCollection( + task.contexts, + operator: op, + value: condition.value + ) + case .project: + return evaluateCollection( + task.projects, + operator: op, + value: condition.value + ) + case .filePath: + return evaluateScalar( + task.filePath ?? "", + isPresent: task.filePath != nil, + operator: op, + value: condition.value + ) + case .fileName: + return evaluateScalar( + task.fileName ?? task.filePath.map { + URL(fileURLWithPath: $0).lastPathComponent + } ?? "", + isPresent: task.fileName != nil || task.filePath != nil, + operator: op, + value: condition.value + ) + case .taskSourceType: + return evaluateScalar( + canonicalEnum(task.sourceType), + isPresent: task.sourceType != nil, + operator: op, + value: condition.value.map(canonicalEnum) + ) + case .title: + return evaluateScalar( + task.title, + isPresent: !task.title.isEmpty, + operator: op, + value: condition.value + ) + } + } + + private static func evaluateScalar( + _ actual: String, + isPresent: Bool, + operator op: TaskForgeFilterOperator, + value: String? + ) -> Bool { + let expected = value ?? "" + switch op { + case .equals: + return isPresent && actual.caseInsensitiveCompare(expected) == .orderedSame + case .notEquals: + return !isPresent || actual.caseInsensitiveCompare(expected) != .orderedSame + case .contains: + return isPresent && actual.range(of: expected, options: .caseInsensitive) != nil + case .notContains: + return !isPresent || actual.range(of: expected, options: .caseInsensitive) == nil + case .isNull: + return !isPresent + case .isNotNull: + return isPresent + case .anyOf: + return valueList(value).contains { + actual.caseInsensitiveCompare($0) == .orderedSame + } + case .notAnyOf: + return !valueList(value).contains { + actual.caseInsensitiveCompare($0) == .orderedSame + } + default: + return false + } + } + + private static func evaluateCollection( + _ actual: [String], + operator op: TaskForgeFilterOperator, + value: String? + ) -> Bool { + let expected = value ?? "" + let matches: (String) -> Bool = { + $0.range(of: expected, options: .caseInsensitive) != nil + } + switch op { + case .equals, .contains: + return actual.contains(where: matches) + case .notEquals, .notContains: + return !actual.contains(where: matches) + case .isNull: + return actual.isEmpty + case .isNotNull: + return !actual.isEmpty + case .anyOf: + return valueList(value).contains { candidate in + actual.contains { $0.caseInsensitiveCompare(candidate) == .orderedSame } + } + case .notAnyOf: + return !valueList(value).contains { candidate in + actual.contains { $0.caseInsensitiveCompare(candidate) == .orderedSame } + } + default: + return false + } + } + + private static func evaluateDate( + day: TaskForgeDay?, + operator op: TaskForgeFilterOperator, + value: String?, + days: Int?, + calendar: Calendar, + now: Date + ) -> Bool { + guard let day else { + return op == .isNull || op == .notEquals + } + if op == .isNotNull { return true } + if op == .isNull { return false } + let today = TaskForgeDay(containing: now, calendar: calendar) + let target = parseDay(value) ?? today + guard let dayDate = date(day, calendar: calendar), + let targetDate = date(target, calendar: calendar), + let todayDate = date(today, calendar: calendar) + else { return false } + switch op { + case .equals, .today: + return dayDate == targetDate + case .notEquals: + return dayDate != targetDate + case .beforeToday: + return dayDate < todayDate + case .afterToday: + return dayDate > todayDate + case .inNextDays: + let upper = calendar.date(byAdding: .day, value: max(days ?? 0, 0), to: todayDate) ?? todayDate + return dayDate >= todayDate && dayDate <= upper + case .inLastDays: + let lower = calendar.date(byAdding: .day, value: -max(days ?? 0, 0), to: todayDate) ?? todayDate + return dayDate >= lower && dayDate <= todayDate + case .onOrBefore: + return dayDate <= targetDate + case .onOrAfter: + return dayDate >= targetDate + case .anyOf: + return valueList(value).contains { parseDay($0) == day } + case .notAnyOf: + return !valueList(value).contains { parseDay($0) == day } + default: + return false + } + } + + private static func canonicalEnum(_ value: String?) -> String { + guard let value else { return "" } + if let dot = value.lastIndex(of: ".") { + return String(value[value.index(after: dot)...]).lowercased() + } + return value.lowercased().replacingOccurrences(of: "-", with: "_") + } + + private static func valueList(_ value: String?) -> [String] { + value?.split(separator: ",").map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } ?? [] + } + + private static func parseDay(_ value: String?) -> TaskForgeDay? { + guard let value else { return nil } + let parts = value.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + return TaskForgeDay(year: year, month: month, day: day) + } + + private static func date(_ day: TaskForgeDay, calendar: Calendar) -> Date? { + calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: day.year, + month: day.month, + day: day.day + )) + } +} + +public enum TaskForgeStatusSymbolError: Error, LocalizedError, Equatable { + case conflict(String) + case unknownStatus(String) + case sourceLineNotUnique + case sourceLineMismatch + case unsupportedSourceType + case statusFieldNotFound + + public var errorDescription: String? { + switch self { + case let .conflict(status): + return "TaskForge 状态符号存在冲突:\(status)" + case let .unknownStatus(status): + return "尚未学习 TaskForge 状态的写回符号:\(status)" + case .sourceLineNotUnique: + return "TaskForge 源任务行不唯一,已拒绝写回。" + case .sourceLineMismatch: + return "TaskForge 源任务原始内容已变化,已拒绝写回。" + case .unsupportedSourceType: + return "暂不支持该 TaskForge 源类型的状态写回。" + case .statusFieldNotFound: + return "TaskNotes frontmatter 中找不到 status 字段。" + } + } +} + +public enum TaskForgeStatusSymbolLearner { + public static let supportedSymbols = Set(["[ ]", "[>]", "[/]", "[x]"]) + + public static func learn( + tasks: [TaskForgeTask], + existing: [String: String] = [:] + ) throws -> [String: String] { + var result = existing + for task in tasks { + guard task.sourceType?.lowercased() == "markdowninline", + let line = task.originalLine, + let symbol = symbol(in: line), + supportedSymbols.contains(symbol) + else { continue } + let status = TaskForgeKanbanStatus.canonical(task.status) + if let previous = result[status], previous != symbol { + throw TaskForgeStatusSymbolError.conflict(status) + } + if let otherStatus = result.first(where: { + $0.value == symbol && $0.key != status + })?.key { + throw TaskForgeStatusSymbolError.conflict(otherStatus) + } + result[status] = symbol + } + return result + } + + public static func symbol(in line: String) -> String? { + guard let range = line.range(of: #"\[[^\]]\]"#, options: .regularExpression) else { + return nil + } + return String(line[range]) + } +} + +public struct TaskForgeStatusEdit: Equatable, Sendable { + public let updatedContents: String + public let lineNumber: Int + public let originalLine: String + public let updatedLine: String + + public init( + updatedContents: String, + lineNumber: Int, + originalLine: String, + updatedLine: String + ) { + self.updatedContents = updatedContents + self.lineNumber = lineNumber + self.originalLine = originalLine + self.updatedLine = updatedLine + } +} + +public enum TaskForgeStatusSourceEditor { + public static func update( + task: TaskForgeTask, + contents: String, + targetStatus: String, + symbols: [String: String] + ) throws -> TaskForgeStatusEdit { + let canonicalStatus = TaskForgeKanbanStatus.canonical(targetStatus) + switch task.sourceType?.lowercased() { + case "markdowninline": + guard let expectedLine = task.originalLine else { + throw TaskForgeStatusSymbolError.sourceLineMismatch + } + var lines = contents.components(separatedBy: "\n") + let expectedIndex = task.lineNumber.map { $0 - 1 } + let lineIndex: Int + if let expectedIndex, + lines.indices.contains(expectedIndex), + lines[expectedIndex] == expectedLine + { + lineIndex = expectedIndex + } else { + let matches = lines.indices.filter { lines[$0] == expectedLine } + guard matches.count == 1, let match = matches.first else { + throw matches.isEmpty + ? TaskForgeStatusSymbolError.sourceLineMismatch + : TaskForgeStatusSymbolError.sourceLineNotUnique + } + lineIndex = match + } + guard let symbol = symbols[canonicalStatus], supported(symbol) else { + throw TaskForgeStatusSymbolError.unknownStatus(canonicalStatus) + } + let pattern = #"^(\s*(?:[-*+]|\d+\.)\s+)\[[^\]]\]"# + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(lines[lineIndex].startIndex..., in: lines[lineIndex]) + guard regex.firstMatch(in: lines[lineIndex], range: range) != nil else { + throw TaskForgeStatusSymbolError.sourceLineMismatch + } + let updatedLine = regex.stringByReplacingMatches( + in: lines[lineIndex], + range: range, + withTemplate: "$1\(symbol)" + ) + lines[lineIndex] = updatedLine + return TaskForgeStatusEdit( + updatedContents: lines.joined(separator: "\n"), + lineNumber: lineIndex + 1, + originalLine: expectedLine, + updatedLine: updatedLine + ) + case "tasknotes": + var lines = contents.components(separatedBy: "\n") + guard lines.first?.trimmingCharacters(in: .whitespaces) == "---", + let closing = lines.dropFirst().firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces) == "---" + }) + else { + throw TaskForgeStatusSymbolError.statusFieldNotFound + } + guard let statusIndex = (1.. Bool { + TaskForgeStatusSymbolLearner.supportedSymbols.contains(symbol) + } +} + +public struct TaskForgeSyncIndexEntry: Codable, Equatable, Sendable { + public var reminderIdentifier: String + public var calendarIdentifier: String + public var status: String + public var sourceHash: String? + public var sourceReference: TaskForgeTask? + public var lastSyncAt: Date + + public init( + reminderIdentifier: String, + calendarIdentifier: String, + status: String, + sourceHash: String?, + sourceReference: TaskForgeTask? = nil, + lastSyncAt: Date + ) { + self.reminderIdentifier = reminderIdentifier + self.calendarIdentifier = calendarIdentifier + self.status = status + self.sourceHash = sourceHash + self.sourceReference = sourceReference + self.lastSyncAt = lastSyncAt + } +} + +public struct TaskForgeSyncIndex: Codable, Equatable, Sendable { + public var schemaVersion: Int + public var listID: String + public var entries: [String: TaskForgeSyncIndexEntry] + + public init( + schemaVersion: Int = 1, + listID: String, + entries: [String: TaskForgeSyncIndexEntry] = [:] + ) { + self.schemaVersion = schemaVersion + self.listID = listID + self.entries = entries + } +} + +public struct TaskForgeSyncPrivateConfiguration: Codable, Equatable, Sendable { + public var schemaVersion: Int + public var taskForgeListID: String? + public var listPrefix: String + public var learnedSymbols: [String: String] + + public init( + schemaVersion: Int = 1, + taskForgeListID: String? = nil, + listPrefix: String = "TaskForge", + learnedSymbols: [String: String] = [:] + ) { + self.schemaVersion = schemaVersion + self.taskForgeListID = taskForgeListID + self.listPrefix = listPrefix + self.learnedSymbols = learnedSymbols + } +} + +public final class TaskForgeSyncPrivateStore: @unchecked Sendable { + public static let defaultRoot = URL( + fileURLWithPath: "\(NSHomeDirectory())/Library/Application Support/TaskForgeReminderSync", + isDirectory: true + ) + + public let rootURL: URL + + public init(rootURL: URL = defaultRoot) { + self.rootURL = rootURL.standardizedFileURL + } + + public var configurationURL: URL { + rootURL.appendingPathComponent("KanbanSyncConfig.json") + } + + public var indexURL: URL { + rootURL.appendingPathComponent("KanbanSyncIndex.json") + } + + public func loadConfigurationReadOnly() throws -> TaskForgeSyncPrivateConfiguration? { + guard try PrivateRuntimeDirectory.validatePrivateRootReadOnly(at: rootURL) else { + return nil + } + guard FileManager.default.fileExists(atPath: configurationURL.path) else { + return nil + } + try PrivateRuntimeDirectory.validatePrivateFile(at: configurationURL) + let configuration = try decode( + TaskForgeSyncPrivateConfiguration.self, + at: configurationURL + ) + guard configuration.schemaVersion == 1, + !configuration.listPrefix.isEmpty, + !configuration.listPrefix.contains("\n"), + !configuration.listPrefix.contains("\r"), + configuration.learnedSymbols.allSatisfy({ key, value in + !key.isEmpty + && TaskForgeStatusSymbolLearner.supportedSymbols.contains(value) + }) + else { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + return configuration + } + + public func loadIndexReadOnly() throws -> TaskForgeSyncIndex? { + guard try PrivateRuntimeDirectory.validatePrivateRootReadOnly(at: rootURL) else { + return nil + } + guard FileManager.default.fileExists(atPath: indexURL.path) else { + return nil + } + try PrivateRuntimeDirectory.validatePrivateFile(at: indexURL) + let index = try decode(TaskForgeSyncIndex.self, at: indexURL) + guard index.schemaVersion == 1, !index.listID.isEmpty, + index.entries.allSatisfy({ key, entry in + !key.isEmpty && !entry.reminderIdentifier.isEmpty + && !entry.calendarIdentifier.isEmpty + && !entry.status.isEmpty + }) + else { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + return index + } + + public func saveConfiguration( + _ configuration: TaskForgeSyncPrivateConfiguration + ) throws { + try save(configuration, to: configurationURL) + } + + public func saveIndex(_ index: TaskForgeSyncIndex) throws { + try save(index, to: indexURL) + } + + private func save(_ value: Value, to url: URL) throws { + try PrivateRuntimeDirectory.prepareRoot(at: rootURL) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(value) + let temporary = rootURL.appendingPathComponent( + ".\(url.lastPathComponent).\(UUID().uuidString).tmp" + ) + try PrivateRuntimeDirectory.writePrivateFile(data, to: temporary) + if FileManager.default.fileExists(atPath: url.path) { + try PrivateRuntimeDirectory.validatePrivateFile(at: url) + _ = try FileManager.default.replaceItemAt(url, withItemAt: temporary) + } else { + try FileManager.default.moveItem(at: temporary, to: url) + } + try PrivateRuntimeDirectory.validatePrivateFile(at: url) + } + + private func decode(_ type: Value.Type, at url: URL) throws -> Value { + do { + return try JSONDecoder().decode(Value.self, from: Data(contentsOf: url)) + } catch { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + } +} diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift index 4cf467a..8eef154 100644 --- a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -10,17 +10,20 @@ public struct ReminderPruneConfiguration: Sendable { public var localRoot: URL public var confirmationInterval: TimeInterval public var restoreGraceInterval: TimeInterval + public var managedIndex: TaskForgeSyncIndex? public init( listName: String, localRoot: URL, confirmationInterval: TimeInterval = 60, - restoreGraceInterval: TimeInterval = 86_400 + restoreGraceInterval: TimeInterval = 86_400, + managedIndex: TaskForgeSyncIndex? = nil ) { self.listName = listName self.localRoot = localRoot self.confirmationInterval = confirmationInterval self.restoreGraceInterval = restoreGraceInterval + self.managedIndex = managedIndex } } @@ -58,6 +61,8 @@ private final class ReminderPrunerOperationGate { } public struct ReminderPruneCounts: Equatable, Sendable { + public init() {} + public var scanned = 0 public var firstSeen = 0 public var waiting = 0 @@ -869,9 +874,14 @@ public final class ReminderPruner { { return .currentSnapshot } - guard - let reference = TaskSourceReference.decode(from: notes), - let path = reference.task.filePath + let markerTaskIdentifier = TaskSyncMarker.extract(from: notes) + .flatMap(TaskSyncMarker.decode)?.taskIdentifier + let reference = TaskSourceReference.decode(from: notes) + ?? markerTaskIdentifier.flatMap { + configuration.managedIndex?.entries[$0]?.sourceReference + .map(TaskSourceReference.init(task:)) + } + guard let reference, let path = reference.task.filePath else { return .absent } diff --git a/Sources/TaskForgeReminderSync/Command.swift b/Sources/TaskForgeReminderSync/Command.swift index 2358634..183f487 100644 --- a/Sources/TaskForgeReminderSync/Command.swift +++ b/Sources/TaskForgeReminderSync/Command.swift @@ -49,6 +49,11 @@ private enum RunMode { } } +private enum SyncSource: String { + case customList = "custom-list" + case scheduledDay = "scheduled-day" +} + private enum CommandParsingError: Error, LocalizedError { case conflictingRunModes @@ -59,7 +64,10 @@ private enum CommandParsingError: Error, LocalizedError { private struct Options { var mode: RunMode = .dryRun + var source: SyncSource = .customList var listName = "TaskForge 今日" + var taskForgeListID: String? + var listPrefix: String? var taskStorePath = TaskForgeTaskStore.defaultPath var requestedDay: TaskForgeDay? var taskIdentifier: String? @@ -98,6 +106,27 @@ private struct Options { try select(.watch, into: &selectedMode) case "--help", "-h": try select(.help, into: &selectedMode) + case "--source": + index += 1 + let value = try value(after: argument, at: index, in: arguments) + guard let source = SyncSource(rawValue: value) else { + throw SyncError.invalidSource(value) + } + options.source = source + case "--taskforge-list-id": + index += 1 + options.taskForgeListID = try value( + after: argument, + at: index, + in: arguments + ) + case "--list-prefix": + index += 1 + options.listPrefix = try value( + after: argument, + at: index, + in: arguments + ) case "--list-name": index += 1 options.listName = try value(after: argument, at: index, in: arguments) @@ -129,6 +158,14 @@ private struct Options { } index += 1 } + if options.source == .customList, + options.requestedDay != nil + || options.listName != "TaskForge 今日" + { + throw SyncError.legacyOptionRequiresScheduledDay( + "--date/--list-name" + ) + } options.mode = selectedMode ?? .dryRun return options } @@ -232,7 +269,33 @@ private struct TaskForgeReminderSyncCommand { } } + @MainActor private static func checkConfig(options: Options, calendar: Calendar) throws { + if options.source == .customList { + let engine = KanbanSyncEngine( + configuration: kanbanConfiguration(options: options), + calendar: calendar + ) + let preview = try engine.preview() + print( + "TaskForge:" + + (FileManager.default.fileExists( + atPath: "/Applications/TaskForge.app" + ) ? "已安装" : "未在 /Applications 找到") + ) + print("任务库:\(options.taskStorePath)") + print("自定义列表:\(preview.listName)") + print("列表成员:\(preview.totalMembers)") + printStatusCounts(preview.statusCounts) + print( + "配置 ID:" + + (preview.taskForgeListIDWasConfigured + ? "已配置到私有状态" + : "仅使用本次参数") + ) + print("配置检查不会请求提醒事项权限,也不会写入任何内容。") + return + } let snapshot = try loadSnapshotWithRetry(at: options.taskStorePath) let requestedDay = options.requestedDay ?? TaskForgeDay(containing: Date(), calendar: calendar) @@ -254,6 +317,10 @@ private struct TaskForgeReminderSyncCommand { @MainActor private static func run(options: Options, calendar: Calendar) async throws { + if options.source == .customList { + try await runCustomList(options: options, calendar: calendar) + return + } let configuration = SyncConfiguration( listName: options.listName, taskStorePath: options.taskStorePath, @@ -349,7 +416,86 @@ private struct TaskForgeReminderSyncCommand { } } + @MainActor + private static func runCustomList( + options: Options, + calendar: Calendar + ) async throws { + let engine = KanbanSyncEngine( + configuration: kanbanConfiguration(options: options), + calendar: calendar + ) + switch options.mode { + case .audit: + let preview = try engine.preview() + print("自定义列表成员:\(preview.totalMembers)") + printStatusCounts(preview.statusCounts) + case .sync: + let counts = try await engine.sync() + print( + "Kanban 双向同步:新建 \(counts.created),更新 \(counts.updated)," + + "移动 \(counts.moved),完成 \(counts.completed)," + + "反向候选 \(counts.reverseCandidates)," + + "反向写入 \(counts.reverseWritten)," + + "拒绝 \(counts.reverseSkipped),冲突 \(counts.conflicts)。" + ) + case .reverseDryRun: + let counts = try await engine.reverse(dryRun: true) + print( + "Kanban 反向预演:候选 \(counts.reverseCandidates)," + + "没有写入源文件或私有状态。" + ) + case .reverseOnce: + let counts = try await engine.reverse(dryRun: false) + print( + "Kanban 反向同步:候选 \(counts.reverseCandidates)," + + "写入 \(counts.reverseWritten),拒绝 \(counts.reverseSkipped)。" + ) + case .pruneDryRun, .pruneOnce: + let dryRun = options.mode == .pruneDryRun + let counts = try await engine.prune(dryRun: dryRun) + print( + dryRun + ? "清理预演:扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),可删除 \(counts.ready)。" + : "清理推进:扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),删除 \(counts.deleted)," + + "失败 \(counts.failed)。" + ) + case .watch: + try await engine.watch() + case .deduplicateDryRun, .deduplicate: + let counts = try await engine.deduplicate( + dryRun: options.mode == .deduplicateDryRun + ) + print( + "Kanban 去重\(options.mode == .deduplicateDryRun ? "预演" : "完成"):" + + "重复组 \(counts.duplicateGroups),保留 \(counts.preserved)," + + "归档并完成 \(counts.archived)。" + ) + case .restoreLastPrune: + throw SyncError.legacyOptionRequiresScheduledDay("--restore-last-prune") + case .checkConfig, .dryRun, .help: + break + } + } + + @MainActor private static func printPreview(options: Options, calendar: Calendar) throws { + if options.source == .customList { + let engine = KanbanSyncEngine( + configuration: kanbanConfiguration(options: options), + calendar: calendar + ) + let preview = try engine.preview() + print("自定义列表:\(preview.listName)") + print("列表成员:\(preview.totalMembers)") + printStatusCounts(preview.statusCounts) + print("计划操作:按状态同步到对应彩色列表;done/cancelled 只完成提醒,不删除 TaskForge 源任务。") + print("计划操作:Apple 事件防抖、1 秒轮询和 60 秒全量校准;不读取提醒事项。") + print("\n匿名只读预演:没有读取提醒事项,也没有写入任何内容。") + return + } let snapshot = try loadSnapshotWithRetry(at: options.taskStorePath) let requestedDay = options.requestedDay ?? TaskForgeDay(containing: Date(), calendar: calendar) @@ -413,9 +559,12 @@ private struct TaskForgeReminderSyncCommand { TaskForgeReminderSync --watch 选项: - --list-name NAME 目标提醒事项列表(默认:TaskForge 今日) + --source MODE custom-list(默认)或 scheduled-day 兼容模式 + --taskforge-list-id ID 固定绑定的 TaskForge Kanban 列表 ID(写入私有配置) + --list-prefix NAME Apple 状态列表前缀(默认:TaskForge) + --list-name NAME 兼容模式目标提醒事项列表(默认:TaskForge 今日) --task-store PATH TaskForge tasks.v6.bin 路径 - --date DATE 指定要同步的本地日期 + --date DATE scheduled-day 兼容模式日期 --task-id ID 只处理一个 TaskForge 任务 --backup-root PATH 反向写入前的备份目录 --dry-run 只列出今日任务,不请求权限(默认) @@ -448,4 +597,28 @@ private struct TaskForgeReminderSyncCommand { print("缺少可审计源身份:\(report.missingSourceIdentityCount)") print("去重状态:\(report.isDuplicateFree ? "通过" : "发现冲突")") } + + private static func kanbanConfiguration( + options: Options + ) -> KanbanSyncConfiguration { + KanbanSyncConfiguration( + taskStorePath: options.taskStorePath, + taskForgeListID: options.taskForgeListID, + listPrefix: options.listPrefix, + taskIdentifier: options.taskIdentifier, + backupRoot: options.backupRoot + ) + } + + private static func printStatusCounts(_ counts: [String: Int]) { + for status in TaskForgeKanbanStatus.allCases { + if let count = counts[status.rawValue] { + print("- \(status.rawValue):\(count)") + } + } + let known = Set(TaskForgeKanbanStatus.allCases.map(\.rawValue)) + for key in counts.keys.sorted() where !known.contains(key) { + print("- \(key):\(counts[key] ?? 0)") + } + } } diff --git a/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift b/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift new file mode 100644 index 0000000..eb63800 --- /dev/null +++ b/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift @@ -0,0 +1,1255 @@ +import CoreGraphics +import CryptoKit +import Darwin +import EventKit +import Foundation +import TaskForgeReminderCore +import TaskForgeReminderEventKit + +private struct KanbanFetchHandle: @unchecked Sendable { + let rawValue: Any +} + +private final class KanbanFetchResolution: @unchecked Sendable { + init( + _ continuation: CheckedContinuation<[EKReminder], Error>, + cancel: @escaping @Sendable (KanbanFetchHandle) -> Void + ) { + self.continuation = continuation + self.cancel = cancel + } + + func resolve(_ result: Result<[EKReminder], Error>) { + lock.lock() + guard !settled, let continuation else { + lock.unlock() + return + } + settled = true + self.continuation = nil + lock.unlock() + continuation.resume(with: result) + } + + func register(_ handle: KanbanFetchHandle) { + lock.lock() + self.handle = handle + let shouldCancel = timedOut && !cancelIssued + if shouldCancel { cancelIssued = true } + lock.unlock() + if shouldCancel { cancel(handle) } + } + + func timeout() { + lock.lock() + guard !settled, let continuation else { + lock.unlock() + return + } + settled = true + timedOut = true + self.continuation = nil + let handleToCancel: KanbanFetchHandle? + if let handle, !cancelIssued { + cancelIssued = true + handleToCancel = handle + } else { + handleToCancel = nil + } + lock.unlock() + if let handleToCancel { cancel(handleToCancel) } + continuation.resume(throwing: SyncError.reminderFetchFailed) + } + + private let lock = NSLock() + private var continuation: CheckedContinuation<[EKReminder], Error>? + private var handle: KanbanFetchHandle? + private var settled = false + private var timedOut = false + private var cancelIssued = false + private let cancel: @Sendable (KanbanFetchHandle) -> Void +} + +struct KanbanSyncConfiguration { + var taskStorePath: String + var taskForgeListID: String? + var listPrefix: String? + var taskIdentifier: String? + var backupRoot: String + var preferencesPath: String + var privateRoot: URL + + init( + taskStorePath: String, + taskForgeListID: String?, + listPrefix: String? = nil, + taskIdentifier: String?, + backupRoot: String, + preferencesPath: String = TaskForgeListConfigurationStore.defaultPreferencesPath, + privateRoot: URL = TaskForgeSyncPrivateStore.defaultRoot + ) { + self.taskStorePath = taskStorePath + self.taskForgeListID = taskForgeListID + self.listPrefix = listPrefix + self.taskIdentifier = taskIdentifier + self.backupRoot = backupRoot + self.preferencesPath = preferencesPath + self.privateRoot = privateRoot + } +} + +struct KanbanPreview { + var listName: String + var totalMembers: Int + var statusCounts: [String: Int] + var taskForgeListIDWasConfigured: Bool +} + +struct KanbanSyncCounts { + var created = 0 + var updated = 0 + var moved = 0 + var completed = 0 + var reverseCandidates = 0 + var reverseWritten = 0 + var reverseSkipped = 0 + var conflicts = 0 + var failed = 0 +} + +@MainActor +final class KanbanSyncEngine { + private let configuration: KanbanSyncConfiguration + private let store: EKEventStore + private let privateStore: TaskForgeSyncPrivateStore + private var calendar: Calendar + private var reminderObserver: NSObjectProtocol? + private var debounceTask: Task? + private var isReconciling = false + private var needsAnotherPass = false + private var resolvedListPrefix = "TaskForge" + + init(configuration: KanbanSyncConfiguration, calendar: Calendar) { + self.configuration = configuration + self.calendar = calendar + self.store = EKEventStore() + self.privateStore = TaskForgeSyncPrivateStore( + rootURL: configuration.privateRoot + ) + } + + deinit { + if let reminderObserver { + NotificationCenter.default.removeObserver(reminderObserver) + } + debounceTask?.cancel() + } + + func requestReminderAccess() async throws { + let status = EKEventStore.authorizationStatus(for: .reminder) + if #available(macOS 14.0, *), status == .fullAccess { + return + } + if #unavailable(macOS 14.0), status == .authorized { + return + } + let granted: Bool + if #available(macOS 14.0, *) { + granted = try await withCheckedThrowingContinuation { continuation in + store.requestFullAccessToReminders { granted, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + } else { + granted = try await withCheckedThrowingContinuation { continuation in + store.requestAccess(to: .reminder) { granted, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + } + guard granted else { + throw SyncError.accessDenied + } + } + + func preview() throws -> KanbanPreview { + let snapshot = try loadSnapshotWithRetry() + let (list, _) = try loadList(readOnly: true) + let privateConfiguration = try privateStore.loadConfigurationReadOnly() + let configured = privateConfiguration?.taskForgeListID != nil + let tasks = try TaskForgeFilterEvaluator.select( + tasks: snapshot.tasks, + list: list, + calendar: calendar + ) + var counts: [String: Int] = [:] + for task in tasks { + let status = TaskForgeKanbanStatus.canonical(task.status) + counts[status, default: 0] += 1 + } + return KanbanPreview( + listName: list.name, + totalMembers: tasks.count, + statusCounts: counts, + taskForgeListIDWasConfigured: configured + ) + } + + func sync() async throws -> KanbanSyncCounts { + try await requestReminderAccess() + return try await reconcileOnce(reason: "手动同步") + } + + func reverse(dryRun: Bool) async throws -> KanbanSyncCounts { + try await requestReminderAccess() + let snapshot = try loadSnapshotWithRetry() + let (list, listID) = try loadList(readOnly: dryRun) + let members = try TaskForgeFilterEvaluator.select( + tasks: snapshot.tasks, + list: list, + calendar: calendar + ) + let symbols = try learnedSymbols( + snapshot: snapshot, + listID: listID, + persist: !dryRun + ) + return try await reverse( + snapshot: snapshot, + members: members, + listID: listID, + symbols: symbols, + dryRun: dryRun + ) + } + + func deduplicate(dryRun: Bool) async throws -> DeduplicationCounts { + try await requestReminderAccess() + let snapshot = try loadSnapshotWithRetry() + let (_, listID) = try loadList(readOnly: dryRun) + let index = try privateStore.loadIndexReadOnly() + let reminders = try await fetchReminders(in: nil) + let records = reminders.enumerated().compactMap { + key, reminder -> TaskReminderDeduplicationRecord? in + guard isManaged(reminder, snapshot: snapshot, listID: listID), + !reminder.isCompleted, + let marker = TaskSyncMarker.extract(from: reminder.notes), + let decoded = TaskSyncMarker.decode(marker), + decoded.vaultPath == snapshot.vaultPath + else { return nil } + let sourceReference = index?.entries[decoded.taskIdentifier]?.sourceReference + ?? TaskSourceReference.decode(from: reminder.notes)?.task + ?? snapshot.tasks.first(where: { + $0.identifier == decoded.taskIdentifier + }) + return TaskReminderDeduplicationRecord( + key: key, + taskIdentifier: decoded.taskIdentifier, + sourceIdentity: sourceReference.flatMap(TaskSourceIdentity.init), + creationTimestamp: reminder.creationDate?.timeIntervalSince1970 + ?? .greatestFiniteMagnitude + ) + } + let plan = TaskReminderDeduplicationPolicy.plan( + records: records, + currentTaskIdentifiers: Set(snapshot.tasks.map(\.identifier)) + ) + let counts = DeduplicationCounts( + duplicateGroups: plan.duplicateGroups, + preserved: plan.preservedKeys.count, + archived: plan.archiveKeys.count + ) + guard !dryRun, !plan.archiveKeys.isEmpty else { return counts } + guard let source = store.defaultCalendarForNewReminders()?.source + ?? store.calendars(for: .reminder).first?.source + else { throw SyncError.noReminderSource } + let archiveTitle = "TaskForge 今日 · 去重归档" + let matching = store.calendars(for: .reminder).filter { + $0.title == archiveTitle + } + guard matching.count <= 1 else { throw SyncError.noReminderSource } + let archive: EKCalendar + if let existing = matching.first { + archive = existing + } else { + archive = EKCalendar(for: .reminder, eventStore: store) + archive.title = archiveTitle + archive.source = source + try store.saveCalendar(archive, commit: true) + } + for key in plan.archiveKeys { + let reminder = reminders[key] + reminder.isCompleted = true + reminder.calendar = archive + reminder.notes = (reminder.notes ?? "") + + "\nTaskForge-Dedup-Archived: 1" + try store.save(reminder, commit: false) + } + try store.commit() + return counts + } + + func prune(dryRun: Bool) async throws -> ReminderPruneCounts { + try await requestReminderAccess() + let (_, listID) = try loadList(readOnly: dryRun) + let snapshot = try loadSnapshotWithRetry() + return try await prune( + snapshot: snapshot, + listID: listID, + dryRun: dryRun + ) + } + + private func prune( + snapshot: TaskForgeSnapshot, + listID: String, + dryRun: Bool + ) async throws -> ReminderPruneCounts { + let baseCalendar = try stateCalendar( + status: .todo, + create: !dryRun + ) + guard let baseCalendar else { + return ReminderPruneCounts() + } + let pruner = ReminderPruner( + eventStore: store, + configuration: ReminderPruneConfiguration( + listName: baseCalendar.title, + localRoot: privateStore.rootURL, + confirmationInterval: 60, + restoreGraceInterval: 86_400, + managedIndex: try privateStore.loadIndexReadOnly() + ), + log: { _ in }, + logError: { _ in } + ) + _ = listID + return dryRun + ? try await pruner.dryRun(snapshot: snapshot) + : try await pruner.advance(snapshot: snapshot) + } + + func watch() async throws { + try await requestReminderAccess() + _ = try loadList(readOnly: true) + var lastTaskStoreModification = modificationDate( + at: configuration.taskStorePath + ) + var lastPreferencesModification = modificationDate( + at: configuration.preferencesPath + ) + var lastFullReconciliation = Date.distantPast + var lastScheduleKey = "" + + reminderObserver = NotificationCenter.default.addObserver( + forName: .EKEventStoreChanged, + object: store, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.debounceTask?.cancel() + self.debounceTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 750_000_000) + guard !Task.isCancelled else { return } + await self?.reconcile(reason: "Apple 提醒事项变化") + } + } + } + + await reconcile(reason: "启动") + while true { + try await Task.sleep(nanoseconds: 1_000_000_000) + let currentTaskStoreModification = modificationDate( + at: configuration.taskStorePath + ) + let currentPreferencesModification = modificationDate( + at: configuration.preferencesPath + ) + if currentTaskStoreModification != lastTaskStoreModification + || currentPreferencesModification != lastPreferencesModification + { + lastTaskStoreModification = currentTaskStoreModification + lastPreferencesModification = currentPreferencesModification + await reconcile(reason: "TaskForge 任务库或列表规则变化") + } + + let now = Date() + if now.timeIntervalSince(lastFullReconciliation) >= 60 { + lastFullReconciliation = now + await reconcile(reason: "60 秒漏事件兜底") + } + let components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute], + from: now + ) + if let hour = components.hour, + components.minute == 0, + [7, 11, 15].contains(hour) + { + let key = String( + format: "%04d-%02d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0, + hour + ) + if key != lastScheduleKey { + lastScheduleKey = key + await reconcile(reason: "\(hour):00 全量校准") + } + } + } + } + + private func reconcile(reason: String) async { + if isReconciling { + needsAnotherPass = true + return + } + isReconciling = true + defer { isReconciling = false } + repeat { + needsAnotherPass = false + do { + _ = try await reconcileOnce(reason: reason) + } catch { + fputs("TaskForge 同步失败:\(error.localizedDescription)\n", stderr) + } + } while needsAnotherPass + } + + private func reconcileOnce(reason: String) async throws -> KanbanSyncCounts { + let snapshot = try loadSnapshotWithRetry() + let (list, listID) = try loadList(readOnly: false) + let members = try TaskForgeFilterEvaluator.select( + tasks: snapshot.tasks, + list: list, + calendar: calendar + ) + let symbols = try learnedSymbols( + snapshot: snapshot, + listID: listID, + persist: true + ) + var counts = try await reverse( + snapshot: snapshot, + members: members, + listID: listID, + symbols: symbols, + dryRun: false + ) + let refreshedSnapshot = (try? loadSnapshotWithRetry()) ?? snapshot + let refreshedMembers = (try? TaskForgeFilterEvaluator.select( + tasks: refreshedSnapshot.tasks, + list: list, + calendar: calendar + )) ?? members + let forwardCounts = try await forward( + snapshot: refreshedSnapshot, + members: refreshedMembers, + listID: listID, + dryRun: false + ) + counts.created += forwardCounts.created + counts.updated += forwardCounts.updated + counts.moved += forwardCounts.moved + counts.completed += forwardCounts.completed + counts.conflicts += forwardCounts.conflicts + counts.failed += forwardCounts.failed + _ = try await prune( + snapshot: refreshedSnapshot, + listID: listID, + dryRun: false + ) + _ = reason + return counts + } + + private func loadList(readOnly: Bool) throws -> (TaskForgeCustomList, String) { + let existing = try privateStore.loadConfigurationReadOnly() + if let configured = existing?.taskForgeListID, + let requested = configuration.taskForgeListID, + configured != requested + { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + let listID = configuration.taskForgeListID ?? existing?.taskForgeListID + guard let listID, + !listID.isEmpty, + listID == listID.trimmingCharacters(in: .whitespacesAndNewlines), + !listID.contains("\n"), + !listID.contains("\r") + else { + throw TaskForgeFilterConfigurationError.missingList("未配置") + } + resolvedListPrefix = configuration.listPrefix + ?? existing?.listPrefix + ?? "TaskForge" + guard !resolvedListPrefix.isEmpty, + !resolvedListPrefix.contains("\n"), + !resolvedListPrefix.contains("\r"), + resolvedListPrefix.count <= 80 + else { + throw TaskForgeFilterConfigurationError.invalidCondition( + "列表前缀" + ) + } + let list = try TaskForgeListConfigurationStore.load( + listID: listID, + preferencesPath: configuration.preferencesPath + ) + guard list.kanbanMode else { + throw TaskForgeFilterConfigurationError.invalidCondition("目标列表不是 Kanban") + } + if !readOnly { + var state = existing ?? TaskForgeSyncPrivateConfiguration() + state.taskForgeListID = listID + state.listPrefix = resolvedListPrefix + try privateStore.saveConfiguration(state) + } + return (list, listID) + } + + private func learnedSymbols( + snapshot: TaskForgeSnapshot, + listID: String, + persist: Bool + ) throws -> [String: String] { + let existing = try privateStore.loadConfigurationReadOnly() + let learned = try TaskForgeStatusSymbolLearner.learn( + tasks: snapshot.tasks, + existing: existing?.learnedSymbols ?? [:] + ) + guard persist else { return learned } + var state = existing ?? TaskForgeSyncPrivateConfiguration() + state.taskForgeListID = listID + state.listPrefix = resolvedListPrefix + state.learnedSymbols = learned + try privateStore.saveConfiguration(state) + return learned + } + + private func forward( + snapshot: TaskForgeSnapshot, + members: [TaskForgeTask], + listID: String, + dryRun: Bool + ) async throws -> KanbanSyncCounts { + let calendars = try stateCalendars( + statusKeys: Set(members.map { + TaskForgeKanbanStatus.canonical($0.status) + }), + create: !dryRun + ) + let reminders = try await fetchReminders(in: nil) + let managed = reminders.filter { + isManaged($0, snapshot: snapshot, listID: listID) + } + var counts = KanbanSyncCounts() + var claimed = Set() + var index = try privateStore.loadIndexReadOnly() + ?? TaskForgeSyncIndex(listID: listID) + var seenTaskIDs = Set() + for task in members { + if let taskIdentifier = configuration.taskIdentifier, + taskIdentifier != task.identifier + { + continue + } + guard seenTaskIDs.insert(task.identifier).inserted else { + counts.conflicts += 1 + continue + } + let statusKey = TaskForgeKanbanStatus.canonical(task.status) + let candidates = managed.filter { reminder in + TaskSyncMarker.decode( + TaskSyncMarker.extract(from: reminder.notes) ?? "" + )?.taskIdentifier == task.identifier + } + guard candidates.count <= 1 else { + counts.conflicts += 1 + continue + } + let reminder: EKReminder + let isNew: Bool + if let current = candidates.first { + reminder = current + isNew = false + claimed.insert(current.calendarItemIdentifier) + } else if let entry = index.entries[task.identifier], + let current = managed.first(where: { + $0.calendarItemIdentifier == entry.reminderIdentifier + }), + !claimed.contains(current.calendarItemIdentifier) + { + reminder = current + isNew = false + claimed.insert(current.calendarItemIdentifier) + } else { + reminder = EKReminder(eventStore: store) + isNew = true + } + if (statusKey == TaskForgeKanbanStatus.done.rawValue + || statusKey == TaskForgeKanbanStatus.cancelled.rawValue) + && isNew + { + continue + } + let desiredCalendar: EKCalendar? + if statusKey == TaskForgeKanbanStatus.done.rawValue + || statusKey == TaskForgeKanbanStatus.cancelled.rawValue + { + desiredCalendar = nil + } else { + desiredCalendar = calendars[statusKey] + } + if let desiredCalendar, reminder.calendar?.calendarIdentifier + != desiredCalendar.calendarIdentifier + { + if !dryRun { + reminder.calendar = desiredCalendar + } + counts.moved += 1 + } + if isNew, let desiredCalendar, !dryRun { + reminder.calendar = desiredCalendar + } + let changed = apply( + task: task, + vaultPath: snapshot.vaultPath, + to: reminder + ) + if !dryRun && (isNew || changed) { + try store.save(reminder, commit: false) + if isNew { counts.created += 1 } else { counts.updated += 1 } + if statusKey == TaskForgeKanbanStatus.done.rawValue + || statusKey == TaskForgeKanbanStatus.cancelled.rawValue + { + counts.completed += 1 + } + } + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: desiredCalendar?.calendarIdentifier + ?? reminder.calendar?.calendarIdentifier ?? "completed", + status: statusKey, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + let baseCalendar = try stateCalendar( + status: .todo, + create: !dryRun + ) + for reminder in managed where !claimed.contains( + reminder.calendarItemIdentifier + ) { + guard let marker = TaskSyncMarker.extract(from: reminder.notes), + let decoded = TaskSyncMarker.decode(marker), + decoded.vaultPath == snapshot.vaultPath, + configuration.taskIdentifier == nil + || configuration.taskIdentifier == decoded.taskIdentifier, + let task = snapshot.tasks.first(where: { + $0.identifier == decoded.taskIdentifier + }) + else { continue } + let status = TaskForgeKanbanStatus.canonical(task.status) + var unclaimedChanged = false + if status == TaskForgeKanbanStatus.done.rawValue + || status == TaskForgeKanbanStatus.cancelled.rawValue + { + unclaimedChanged = apply( + task: task, + vaultPath: snapshot.vaultPath, + to: reminder + ) + if !reminder.isCompleted { + reminder.isCompleted = true + unclaimedChanged = true + } + } + if let baseCalendar, + reminder.calendar?.calendarIdentifier != baseCalendar.calendarIdentifier + { + if !dryRun { + reminder.calendar = baseCalendar + try store.save(reminder, commit: false) + unclaimedChanged = false + } + counts.moved += 1 + } + if !dryRun && unclaimedChanged { + try store.save(reminder, commit: false) + } + if !dryRun { + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: status, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + } + if !dryRun { + try store.commit() + index.listID = listID + try privateStore.saveIndex(index) + } + return counts + } + + private func reverse( + snapshot: TaskForgeSnapshot, + members: [TaskForgeTask], + listID: String, + symbols: [String: String], + dryRun: Bool + ) async throws -> KanbanSyncCounts { + let reminders = try await fetchReminders(in: nil) + let memberIDs = Set(members.map(\.identifier)) + var counts = KanbanSyncCounts() + var index = try privateStore.loadIndexReadOnly() + ?? TaskForgeSyncIndex(listID: listID) + let stateCalendarNames = try stateCalendarNameMap() + for reminder in reminders where isManaged( + reminder, + snapshot: snapshot, + listID: listID + ) { + guard let decoded = TaskSyncMarker.decode( + TaskSyncMarker.extract(from: reminder.notes) ?? "" + ) else { continue } + guard let task = snapshot.tasks.first(where: { + $0.identifier == decoded.taskIdentifier + }) else { continue } + if configuration.taskIdentifier != nil, + configuration.taskIdentifier != task.identifier + { + continue + } + let currentStatus = TaskForgeKanbanStatus.canonical(task.status) + let calendarStatus: String? + if let title = reminder.calendar?.title { + calendarStatus = stateCalendarNames[title] + } else { + calendarStatus = nil + } + var targetStatus = calendarStatus ?? currentStatus + let lastStatus = index.entries[task.identifier]?.status + let taskStatusChanged = lastStatus.map { + $0 != currentStatus + } ?? false + let appleStatusChanged: Bool + if let calendarStatus { + appleStatusChanged = lastStatus.map { + calendarStatus != $0 + } ?? (calendarStatus != currentStatus) + } else { + appleStatusChanged = false + } + var sourceHash: String? + if !reminder.isCompleted, + memberIDs.contains(task.identifier), + calendarStatus == nil + { + if !dryRun { + try moveBack(reminder: reminder, status: currentStatus) + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: currentStatus, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + continue + } + if reminder.isCompleted && !task.isCompleted { + counts.reverseCandidates += 1 + if dryRun { + continue + } + do { + sourceHash = try writeSourceStatus( + task: task, + snapshot: snapshot, + targetStatus: TaskForgeKanbanStatus.done.rawValue, + symbols: symbols + ) + try await verifyTaskForgeStatus( + task: task, + targetStatus: TaskForgeKanbanStatus.done.rawValue + ) + counts.reverseWritten += 1 + targetStatus = TaskForgeKanbanStatus.done.rawValue + } catch { + counts.reverseSkipped += 1 + try moveBack( + reminder: reminder, + status: currentStatus + ) + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: currentStatus, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + continue + } + } else if !reminder.isCompleted, + !task.isCompleted, + appleStatusChanged, + !taskStatusChanged, + memberIDs.contains(task.identifier) + { + counts.reverseCandidates += 1 + if dryRun { + continue + } + do { + sourceHash = try writeSourceStatus( + task: task, + snapshot: snapshot, + targetStatus: targetStatus, + symbols: symbols + ) + try await verifyTaskForgeStatus( + task: task, + targetStatus: targetStatus + ) + counts.reverseWritten += 1 + } catch { + counts.reverseSkipped += 1 + try moveBack( + reminder: reminder, + status: currentStatus + ) + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: currentStatus, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + continue + } + } + if !dryRun { + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier ?? "completed", + status: targetStatus, + sourceHash: sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + } + if !dryRun { + index.listID = listID + try privateStore.saveIndex(index) + } + return counts + } + + private func apply( + task: TaskForgeTask, + vaultPath: String, + to reminder: EKReminder + ) -> Bool { + let status = TaskForgeKanbanStatus.canonical(task.status) + let title = task.title.trimmingCharacters(in: .whitespacesAndNewlines) + let desiredTitle = status == TaskForgeKanbanStatus.cancelled.rawValue + ? "已取消 · \(title.isEmpty ? "(无标题 TaskForge 任务)" : title)" + : (title.isEmpty ? "(无标题 TaskForge 任务)" : title) + let desiredNotes = [ + TaskSyncMarker.make( + vaultPath: vaultPath, + taskIdentifier: task.identifier + ), + "来源:TaskForge" + ].joined(separator: "\n") + let desiredDueDate = task.scheduled.map { + TaskReminderTiming.dueDateComponents(for: $0, calendar: calendar) + } + var changed = false + if reminder.title != desiredTitle { + reminder.title = desiredTitle + changed = true + } + if !ReminderDueDatePolicy.isEquivalent( + reminder.dueDateComponents, + desiredDueDate + ) { + reminder.dueDateComponents = desiredDueDate + changed = true + } + if reminder.priority != applePriority(task.priority) { + reminder.priority = applePriority(task.priority) + changed = true + } + if reminder.notes != desiredNotes { + reminder.notes = desiredNotes + changed = true + } + let shouldBeCompleted = task.isCompleted || reminder.isCompleted + if reminder.isCompleted != shouldBeCompleted { + reminder.isCompleted = shouldBeCompleted + changed = true + } + if reminder.url != nil { + reminder.url = nil + changed = true + } + return changed + } + + private func writeSourceStatus( + task: TaskForgeTask, + snapshot: TaskForgeSnapshot, + targetStatus: String, + symbols: [String: String] + ) throws -> String { + guard let sourcePath = task.filePath else { + throw TaskForgeStatusSymbolError.sourceLineMismatch + } + let sourceURL = URL(fileURLWithPath: sourcePath) + .standardizedFileURL + .resolvingSymlinksInPath() + let vaultURL = URL(fileURLWithPath: snapshot.vaultPath) + .standardizedFileURL + .resolvingSymlinksInPath() + let prefix = vaultURL.path.hasSuffix("/") + ? vaultURL.path + : vaultURL.path + "/" + guard sourceURL.path.hasPrefix(prefix) else { + throw SyncError.sourceOutsideVault(sourceURL.path) + } + let originalData = try Data(contentsOf: sourceURL) + guard let contents = String(data: originalData, encoding: .utf8) else { + throw SyncError.sourceEncodingInvalid(sourceURL.path) + } + let edit = try TaskForgeStatusSourceEditor.update( + task: task, + contents: contents, + targetStatus: targetStatus, + symbols: symbols + ) + guard let updatedData = edit.updatedContents.data(using: .utf8) else { + throw SyncError.sourceEncodingInvalid(sourceURL.path) + } + guard updatedData != originalData else { + return sha256(originalData) + } + let safeIdentifier = task.identifier.map { character in + character.isLetter || character.isNumber + || character == "-" || character == "_" + ? character + : "_" + } + _ = try TaskSourceBackupStore( + backupsRootURL: URL( + fileURLWithPath: configuration.backupRoot, + isDirectory: true + ) + ).save( + originalData, + fileName: "\(String(safeIdentifier))-\(sourceURL.lastPathComponent).bak" + ) + try updatedData.write(to: sourceURL, options: [.atomic]) + guard try Data(contentsOf: sourceURL) == updatedData else { + throw SyncError.writeVerificationFailed(sourceURL.path) + } + return sha256(updatedData) + } + + private func verifyTaskForgeStatus( + task: TaskForgeTask, + targetStatus: String, + timeout: TimeInterval = 15 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let refreshed = try? loadSnapshotWithRetry(), + let current = refreshed.tasks.first(where: { + $0.identifier == task.identifier + }) + { + if TaskForgeKanbanStatus.canonical(current.status) + == TaskForgeKanbanStatus.canonical(targetStatus) + { + return + } + } else if targetStatus == TaskForgeKanbanStatus.done.rawValue, + let sourcePath = task.filePath, + let data = try? Data(contentsOf: URL(fileURLWithPath: sourcePath)), + let contents = String(data: data, encoding: .utf8), + TaskCompletionSourceInspector.isCompleted( + task: task, + contents: contents + ) + { + return + } + try await Task.sleep(nanoseconds: 250_000_000) + } + throw SyncError.taskForgeVerificationTimedOut("受管任务") + } + + private func sha256(_ data: Data) -> String { + SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } + + private func moveBack(reminder: EKReminder, status: String) throws { + guard let calendar = try stateCalendar(rawStatus: status, create: true) + else { return } + reminder.isCompleted = false + reminder.calendar = calendar + try store.save(reminder, commit: true) + } + + private func stateCalendars( + statuses: [TaskForgeKanbanStatus], + create: Bool + ) throws -> [String: EKCalendar] { + var result: [String: EKCalendar] = [:] + for status in Set(statuses) where !status.isTerminal { + if let calendar = try stateCalendar(status: status, create: create) { + result[status.rawValue] = calendar + } + } + return result + } + + private func stateCalendars( + statusKeys: Set, + create: Bool + ) throws -> [String: EKCalendar] { + var result: [String: EKCalendar] = [:] + for statusKey in statusKeys + where statusKey != TaskForgeKanbanStatus.done.rawValue + && statusKey != TaskForgeKanbanStatus.cancelled.rawValue + { + if let calendar = try stateCalendar( + rawStatus: statusKey, + create: create + ) { + result[statusKey] = calendar + } + } + return result + } + + private func stateCalendarNameMap() throws -> [String: String] { + var result: [String: String] = [:] + for status in TaskForgeKanbanStatus.allCases where !status.isTerminal { + result[stateTitle(status)] = status.rawValue + } + return result + } + + private func stateCalendar( + status: TaskForgeKanbanStatus?, + create: Bool + ) throws -> EKCalendar? { + guard let status, !status.isTerminal else { return nil } + return try stateCalendar(rawStatus: status.rawValue, create: create) + } + + private func stateCalendar( + rawStatus: String, + create: Bool + ) throws -> EKCalendar? { + let canonicalStatus = TaskForgeKanbanStatus.canonical(rawStatus) + guard canonicalStatus != TaskForgeKanbanStatus.done.rawValue, + canonicalStatus != TaskForgeKanbanStatus.cancelled.rawValue + else { return nil } + let status = TaskForgeKanbanStatus(rawValue: canonicalStatus) + let title = stateTitle(rawStatus: canonicalStatus) + let matching = store.calendars(for: .reminder).filter { + $0.title == title + } + guard matching.count <= 1 else { + throw SyncError.noReminderSource + } + if let existing = matching.first { + try setColor(existing, status: status, save: true) + return existing + } + guard create else { return nil } + if canonicalStatus == TaskForgeKanbanStatus.todo.rawValue, + let legacy = store.calendars(for: .reminder).first(where: { + $0.title == "TaskForge 今日" + }) + { + legacy.title = title + try setColor(legacy, status: status, save: true) + return legacy + } + guard let source = store.defaultCalendarForNewReminders()?.source + ?? store.calendars(for: .reminder).first?.source + else { + throw SyncError.noReminderSource + } + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = title + calendar.source = source + try setColor(calendar, status: status, save: false) + try store.saveCalendar(calendar, commit: true) + return calendar + } + + private func setColor( + _ calendar: EKCalendar, + status: TaskForgeKanbanStatus?, + save: Bool + ) throws { + let color: (CGFloat, CGFloat, CGFloat) + switch status { + case nil: color = (0.56, 0.56, 0.58) + case .todo: color = (0.20, 0.47, 0.96) + case .scheduled: color = (0.69, 0.32, 0.86) + case .ready: color = (0.20, 0.78, 0.35) + case .inProgress: color = (0.00, 0.78, 0.75) + case .onHold: color = (1.00, 0.58, 0.00) + case .deferred: color = (0.56, 0.56, 0.58) + case .blocked: color = (1.00, 0.23, 0.19) + case .someday: color = (0.35, 0.34, 0.84) + case .done, .cancelled: return + } + calendar.cgColor = CGColor( + srgbRed: color.0, + green: color.1, + blue: color.2, + alpha: 1 + ) + if save { + try store.saveCalendar(calendar, commit: true) + } + } + + private func stateTitle(_ status: TaskForgeKanbanStatus) -> String { + stateTitle(rawStatus: status.rawValue) + } + + private func stateTitle(rawStatus: String) -> String { + let status = TaskForgeKanbanStatus(rawValue: rawStatus) + let name: String + switch status { + case .todo: name = "待办" + case .scheduled: name = "已计划" + case .ready: name = "就绪" + case .inProgress: name = "进行中" + case .onHold: name = "暂停" + case .deferred: name = "已推迟" + case .blocked: name = "已阻塞" + case .someday: name = "将来某天" + case .done: name = "完成" + case .cancelled: name = "取消" + case nil: name = rawStatus + } + return "\(resolvedListPrefix) · \(name)" + } + + private func isManaged( + _ reminder: EKReminder, + snapshot: TaskForgeSnapshot, + listID: String + ) -> Bool { + guard !(reminder.notes ?? "").contains("TaskForge-Dedup-Archived: 1") + else { return false } + if let marker = TaskSyncMarker.extract(from: reminder.notes), + let decoded = TaskSyncMarker.decode(marker), + decoded.vaultPath == snapshot.vaultPath + { + return true + } + guard let index = try? privateStore.loadIndexReadOnly(), + index.listID == listID + else { return false } + return index.entries.values.contains { + $0.reminderIdentifier == reminder.calendarItemIdentifier + } + } + + private func fetchReminders(in calendars: [EKCalendar]?) async throws -> [EKReminder] { + let predicate = store.predicateForReminders(in: calendars) + return try await withCheckedThrowingContinuation { continuation in + let resolution = KanbanFetchResolution( + continuation, + cancel: { [store] handle in + Task { @MainActor in + store.cancelFetchRequest(handle.rawValue) + } + } + ) + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + 30 + ) { + resolution.timeout() + } + let request = store.fetchReminders(matching: predicate) { reminders in + guard let reminders else { + resolution.resolve(.failure(SyncError.reminderFetchFailed)) + return + } + resolution.resolve(.success(reminders)) + } + resolution.register(KanbanFetchHandle(rawValue: request)) + } + } + + private func loadSnapshotWithRetry() throws -> TaskForgeSnapshot { + var lastError: Error? + for attempt in 1...3 { + do { + return try TaskForgeTaskStore.load(at: configuration.taskStorePath) + } catch { + lastError = error + if attempt < 3 { usleep(200_000) } + } + } + throw lastError ?? TaskForgeTaskStoreError.truncated + } + + private func applePriority(_ value: String?) -> Int { + switch value?.lowercased() { + case "highest", "high", "taskpriority.highest", "taskpriority.high": + return 1 + case "medium", "taskpriority.medium": + return 5 + case "low", "lowest", "taskpriority.low", "taskpriority.lowest": + return 9 + default: + return 0 + } + } + + private func modificationDate(at path: String) -> Date? { + (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] + as? Date + } +} diff --git a/Sources/TaskForgeReminderSync/SyncEngine.swift b/Sources/TaskForgeReminderSync/SyncEngine.swift index 94401c6..5e78e9a 100644 --- a/Sources/TaskForgeReminderSync/SyncEngine.swift +++ b/Sources/TaskForgeReminderSync/SyncEngine.swift @@ -26,6 +26,9 @@ enum SyncError: Error, LocalizedError { case backupFailed(String) case writeVerificationFailed(String) case taskForgeVerificationTimedOut(String) + case reminderFetchFailed + case invalidSource(String) + case legacyOptionRequiresScheduledDay(String) var errorDescription: String? { switch self { @@ -53,6 +56,12 @@ enum SyncError: Error, LocalizedError { return "写入后内容校验失败:\(path)" case let .taskForgeVerificationTimedOut(title): return "源文件已写入,但等待 TaskForge 确认完成超时:\(title)" + case .reminderFetchFailed: + return "提醒事项读取超时或失败,已按失败关闭。" + case let .invalidSource(value): + return "同步源必须是 custom-list 或 scheduled-day:\(value)" + case let .legacyOptionRequiresScheduledDay(value): + return "参数 \(value) 仅能在 --source scheduled-day 兼容模式使用。" } } } diff --git a/Tests/TaskForgeReminderCLITests/main.swift b/Tests/TaskForgeReminderCLITests/main.swift index 4eb7c59..b65f7d7 100644 --- a/Tests/TaskForgeReminderCLITests/main.swift +++ b/Tests/TaskForgeReminderCLITests/main.swift @@ -128,9 +128,33 @@ private let singleModes: [([String], String)] = [ (["--prune-once"], "prune-once"), (["--restore-last-prune"], "restore-last-prune"), (["--watch"], "watch"), - (["--help"], "help") + (["--help"], "help"), + (["--source", "custom-list"], "dry-run"), + (["--source", "scheduled-day", "--date", "2026-08-04"], "dry-run") ] +private func requireSourceCompatibilityRules() throws { + let result = try runParser([ + "--source", "custom-list", "--date", "2026-08-04" + ]) + try require( + result.status == 1 + && result.error.contains("scheduled-day"), + "custom-list must reject legacy date selection" + ) + try require( + !result.error.contains("2026-08-04"), + "parser error must not echo a date value" + ) + let unknown = try runParser(["--source", "not-a-source"]) + try require( + unknown.status == 1 + && unknown.error.contains("custom-list") + && unknown.error.contains("scheduled-day"), + "unknown source must fail closed" + ) +} + do { for (index, pair) in conflictPairs.enumerated() { try runConflictingModes(pair, label: "pair \(index + 1) forward") @@ -146,6 +170,7 @@ do { label: arguments.first ?? "no mode" ) } + try requireSourceCompatibilityRules() let testCount = conflictPairs.count * 2 + singleModes.count print("\(testCount)/\(testCount) CLI parser tests passed") } catch { diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 31b573d..609426c 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -384,8 +384,11 @@ private func taskRecord( return .array(fields) } -private func taskStoreFixture() -> Data { - encodeFixture( +private func taskStoreFixture( + extraRecord: FixtureValue? = nil, + trailingRecord: FixtureValue? = nil +) -> Data { + let root = encodeFixture( .array([ .int(6), .map([]), @@ -415,10 +418,14 @@ private func taskStoreFixture() -> Data { title: "明天任务", status: "todo", scheduled: taskDate(2026, 7, 27) - ) - ]) + ), + // A live v6 store can retain scalar `1` tombstones. + .int(1) + ] + (extraRecord.map { [$0] } ?? [])) ]) ) + guard let trailingRecord else { return root } + return root + encodeFixture(trailingRecord) } private func pruneBackupFixture( @@ -522,7 +529,212 @@ private func taskForgeEntries(at url: URL) throws -> [String] { .sorted() } +private func kanbanTask( + identifier: String, + status: String, + line: String, + fileName: String = "Today.md", + tags: [String] = [] +) -> TaskForgeTask { + TaskForgeTask( + identifier: identifier, + title: identifier, + status: status, + priority: "medium", + scheduled: nil, + filePath: "/vault/\(fileName)", + sourceType: "markdownInline", + originalLine: line, + lineNumber: 1, + tags: tags, + fileName: fileName + ) +} + private let tests: [TestCase] = [ + ("custom Kanban filters preserve group and condition logic", { + let statusCondition = try TaskForgeFilterCondition( + type: "status", + operator: "not_equals", + value: "TaskStatus.done" + ) + let fileCondition = try TaskForgeFilterCondition( + type: "file_name", + operator: "contains", + value: "Today" + ) + let tagCondition = try TaskForgeFilterCondition( + type: "tag", + operator: "contains", + value: "keep" + ) + let list = try TaskForgeCustomList( + id: "list", + name: "Today", + filterGroups: [ + try TaskForgeFilterGroup( + conditions: [statusCondition, fileCondition], + matchMode: "all" + ), + try TaskForgeFilterGroup( + conditions: [tagCondition], + matchMode: "all" + ) + ], + filterGroupsMatchMode: "any", + kanbanMode: true + ) + let tasks = [ + kanbanTask( + identifier: "first", + status: "todo", + line: "- [ ] first" + ), + kanbanTask( + identifier: "second", + status: "done", + line: "- [x] second" + ), + kanbanTask( + identifier: "third", + status: "todo", + line: "- [ ] third", + fileName: "Other.md", + tags: ["keep"] + ) + ] + let selected = try TaskForgeFilterEvaluator.select( + tasks: tasks, + list: list, + calendar: Calendar(identifier: .gregorian) + ) + try require( + selected.map(\.identifier) == ["first", "third"], + "custom list group logic selected the wrong tasks" + ) + }), + ("custom Kanban rejects unknown fields and operators", { + let unknownField = Data( + """ + {"id":"list","name":"Today","filterGroups":[{"conditions":[{"type":"unknown","operator":"equals"}],"matchMode":"all"}],"filterGroupsMatchMode":"all","kanbanMode":true} + """.utf8 + ) + do { + _ = try TaskForgeListConfigurationStore.decode(jsonData: unknownField) + throw TestFailure(description: "unknown field was accepted") + } catch let error as TaskForgeFilterConfigurationError { + try require( + error == .unsupportedField("unknown"), + "unexpected unknown field error" + ) + } + + let unknownOperator = Data( + """ + {"id":"list","name":"Today","filterGroups":[{"conditions":[{"type":"status","operator":"guess"}],"matchMode":"all"}],"filterGroupsMatchMode":"all","kanbanMode":true} + """.utf8 + ) + do { + _ = try TaskForgeListConfigurationStore.decode(jsonData: unknownOperator) + throw TestFailure(description: "unknown operator was accepted") + } catch let error as TaskForgeFilterConfigurationError { + try require( + error == .unsupportedOperator("guess"), + "unexpected unknown operator error" + ) + } + }), + ("TaskForge status symbol learning rejects conflicts", { + let tasks = [ + kanbanTask( + identifier: "one", + status: "todo", + line: "- [ ] one" + ), + kanbanTask( + identifier: "two", + status: "todo", + line: "- [>] two" + ) + ] + do { + _ = try TaskForgeStatusSymbolLearner.learn(tasks: tasks) + throw TestFailure(description: "conflicting symbols were learned") + } catch let error as TaskForgeStatusSymbolError { + try require( + error == .conflict("todo"), + "unexpected symbol conflict error" + ) + } + }), + ("TaskForge status editor changes only the checkbox symbol", { + let task = kanbanTask( + identifier: "one", + status: "todo", + line: "- [ ] one" + ) + let edit = try TaskForgeStatusSourceEditor.update( + task: task, + contents: "- [ ] one\n", + targetStatus: "inProgress", + symbols: ["inProgress": "[/]"] + ) + try require( + edit.updatedContents == "- [/ ] one\n" + || edit.updatedContents == "- [/] one\n", + "status editor changed the source unexpectedly" + ) + try require( + !edit.updatedContents.contains("✅"), + "status editor added an artificial completion date" + ) + }), + ("TaskForge status aliases cover every Kanban state", { + let aliases = [ + "todo", "scheduled", "ready", "inProgress", "on-hold", + "deferred", "blocked", "someday", "done", "cancelled" + ] + let expected = Set(TaskForgeKanbanStatus.allCases.map(\.rawValue)) + try require( + Set(aliases.map(TaskForgeKanbanStatus.canonical)) == expected, + "status aliases do not cover the locked status matrix" + ) + }), + ("Kanban private state is read-only until an explicit save", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = TaskForgeSyncPrivateStore(rootURL: root) + try require( + try store.loadConfigurationReadOnly() == nil, + "read-only private load should not create a root" + ) + try require( + !FileManager.default.fileExists(atPath: root.path), + "read-only private load created a root" + ) + try store.saveConfiguration( + TaskForgeSyncPrivateConfiguration(taskForgeListID: "list") + ) + try store.saveIndex(TaskForgeSyncIndex(listID: "list")) + let rootMode = try requireValue( + FileManager.default.attributesOfItem(atPath: root.path)[.posixPermissions] + as? NSNumber, + "private root mode missing" + ) + let configMode = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.configurationURL.path + )[.posixPermissions] as? NSNumber, + "private config mode missing" + ) + try require(rootMode.intValue & 0o777 == 0o700, "private root is not 0700") + try require(configMode.intValue & 0o777 == 0o600, "private config is not 0600") + try require( + try store.loadConfigurationReadOnly()?.taskForgeListID == "list", + "private config did not round-trip" + ) + }), ("reminder fetch timeout cancels a registered request exactly once", { let cancellations = LockedValues() do { @@ -846,6 +1058,31 @@ private let tests: [TestCase] = [ try require(snapshot.tasks[0].title == "示例任务", "unexpected first task title") try require(snapshot.tasks[0].sourceType == "markdownInline", "unexpected source type") }), + ("TaskForge v6 store rejects unknown scalar records", { + do { + _ = try TaskForgeTaskStore.decode( + taskStoreFixture(extraRecord: .int(2)) + ) + throw TestFailure(description: "unknown scalar record was accepted") + } catch TaskForgeTaskStoreError.malformed { + // Expected fail-closed behavior. + } + }), + ("TaskForge v6 store includes a complete trailing task record", { + let trailing = taskRecord( + id: "trailing-task", + title: "追加任务", + status: "todo", + scheduled: .null + ) + let snapshot = try TaskForgeTaskStore.decode( + taskStoreFixture(trailingRecord: trailing) + ) + try require( + snapshot.tasks.contains { $0.identifier == "trailing-task" }, + "complete trailing task record was dropped" + ) + }), ("today selection matches TaskForge calendar open tasks", { let snapshot = try TaskForgeTaskStore.decode(taskStoreFixture()) let today = TaskForgeDay(year: 2026, month: 7, day: 26) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9323a03..a8d2d84 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,201 +4,121 @@ ### TaskForgeReminderCore -The core library keeps EventKit out of policy and persistence code. Its pure -task, matching and pruning policy uses Foundation value types; private pruning -persistence additionally uses CryptoKit for checksums and hashing, plus Darwin -for POSIX permissions and file locking. The library is responsible for: - -- decoding TaskForge MessagePack v6 records; -- selecting scheduled open tasks; -- creating and decoding stable markers; -- deriving stable source identities and selecting deduplicated reminder matches; -- serializing durable source references; -- classifying prune candidates and advancing the two-scan state machine; -- encoding private prune ledgers and checksummed restore backups; -- comparing reminder dates semantically; -- validating and editing Markdown / TaskNotes completion state. - -Keeping these boundaries outside EventKit makes policy deterministic and local -persistence independently testable. +Core is pure Foundation policy and private persistence. It is responsible for: + +- strict decoding of TaskForge v6 records, including the live scalar tombstone; +- decoding `flutter.ctl_` from the TaskForge preferences plist; +- validating known fields/operators and evaluating nested `all`/`any` filter groups; +- canonicalizing TaskForge statuses and learning the four approved Markdown symbols; +- editing Markdown/TaskNotes source content only after identity and original-line checks; +- storing the private Kanban configuration/index with `0700`/`0600` verification; +- the existing pruning, backup, restore and source-presence policies. + +Unknown filter schema, malformed private state and unknown status symbols are errors, not empty +matches. This keeps an incomplete TaskForge reverse-engineering result from becoming a broad +destructive query. ### TaskForgeReminderSync -The macOS executable coordinates: - -- `EKEventStore` access; -- forward and reverse reconciliation; -- target-list-only pruning and restoration; -- source backups and SHA-256 receipts; -- TaskForge cache refresh verification; -- EventKit notifications, polling and scheduled fallbacks. - -The `--audit` path reads every managed reminder in the target list and -separately reports duplicate task identifiers, duplicate active source -identities, duplicate completed occurrences, normal historical source reuse and -missing source identities. It emits aggregate counts only, including for -historical reminders no longer present in the current TaskForge cache. - -The explicit deduplication path builds connected duplicate groups from active -reminders that share either a TaskForge ID or a source identity. It preserves a -single canonical reminder, preferring an ID still present in the current cache -and otherwise the oldest item. Every redundant reminder is moved—not -deleted—to a separate archive calendar before forward reconciliation refreshes -the preserved item. - -The pruning path is separate from deduplication. It selects exactly one -configured list by name, fails closed if that name is ambiguous, and builds an -EventKit predicate scoped to that one calendar. Reminders from other lists, -including the deduplication archive, are never fetched as prune inputs. +The executable owns `EKEventStore`, calendar/list creation, forward and reverse reconciliation, +source backups, TaskForge readback verification, and the near-real-time watcher. Its custom +source path never writes `tasks.v6.bin`; it writes only the Vault source file and lets TaskForge +re-index it. + +The old scheduled-day engine remains behind `--source scheduled-day` for compatibility. It is +not used by the default custom-list path. ### LaunchAgent -The installed LaunchAgent keeps a small signed supervisor alive. The supervisor -starts the app through LaunchServices, monitors the exact `--watch` process and -terminates it when the LaunchAgent is unloaded. Launching the bundle, rather -than its inner Mach-O directly, preserves the EventKit/TCC app identity in the -background. The watcher: - -1. reconciles at startup; -2. debounces `EKEventStoreChanged` notifications for 750 ms; -3. checks the TaskForge cache mtime every second; -4. runs a full reconciliation every minute; -5. runs additional checks at 07:00, 11:00 and 15:00. - -Each reconciliation is serialized and runs in this order: - -1. reverse-complete Apple reminders into TaskForge sources; -2. create or refresh today's TaskForge reminders in Apple Reminders; -3. advance the pruning state machine against the latest snapshot and sources. - -This preserves completion writeback before any candidate can be deleted. - -## Forward flow - -1. Decode `tasks.v6.bin`. -2. Select tasks scheduled today and not completed/cancelled. -3. Find or create the configured reminder list. -4. Match reminders by a stable marker derived from Vault path and task ID. -5. If the ID changed, fall back to a durable source identity: - - inline Markdown: standardized file path and line number; - - TaskNotes: standardized file path. -6. Prefer an uncompleted reminder at that source. A completed reminder also - needs the same scheduled day, so an older task that reused the line cannot - capture a new occurrence. -7. Reject ambiguous ID/source/occurrence matches instead of creating another - reminder. -8. Create/update title, due date, notes, marker and durable source reference. -9. Preserve completion if either side is already completed. - -Only today's open tasks create new reminders. Existing linked reminders may -still be refreshed so title, date, time, completion and durable source mapping -stay current. A source-identity fallback rewrites the old marker on the same -EventKit item, so a TaskForge reindex does not create a second reminder. - -## Reverse flow - -1. Fetch all reminders from the configured list, including completed history. -2. Keep only completed reminders carrying this tool's marker. -3. Resolve the task from the current TaskForge cache or the durable reminder - source reference. -4. Reject recurring, non-`keep`, out-of-Vault or ambiguous tasks. -5. Detect and skip a source that is already completed. -6. Back up the complete source file. -7. Apply the completion edit atomically. -8. Verify exact file bytes and wait for TaskForge's cache to refresh. - -Reverse completion only changes the source task to `done`. It never deletes a -Markdown task line, TaskNotes file or any other TaskForge source item. - -## Prune flow - -1. Select exactly one configured calendar. No match means no prune work; - multiple same-named matches fail closed. -2. Fetch reminders with an EventKit predicate containing only that calendar. - A fetch is cancelled after 30 seconds and the pass fails closed. -3. Protect every completed reminder, every reminder with EventKit priority - greater than zero, and every title whose leading whitespace is followed by - `!`, `!`, `❗`, `‼️`, `⭐` or `📌`. -4. Resolve TaskForge presence from both the current snapshot and the durable - source reference. Only `.absent` is eligible; permission, I/O, out-of-Vault - and ambiguous-source results are indeterminate and protected. -5. On the first eligible scan, store the EventKit ID, calendar ID, candidate - fingerprint, first-seen time and rules version in the private ledger. -6. On a later scan at least 60 seconds after first sighting, refetch and - reclassify the reminder. Any identity, calendar or fingerprint change - revokes or restarts the candidate. -7. Before deleting a confirmed batch, write a checksummed private backup and - read it back. A write or verification failure rejects the whole batch. -8. Stage EventKit removals, commit once, then refetch actual state. Record and - report what was actually deleted rather than assuming atomic success. - -If a process stops after EventKit commit but before the actual outcome is -persisted, the next advance pass uses -`latestUnresolvedDeletionBackup()` to select the newest unresolved batch, -refetches the target list and records the observed result. An empty result is -preserved but is neither unresolved nor restorable afterward. - -`--prune-dry-run` uses a read-only ledger load and never creates or modifies -the ledger, backup directory, hash salt or EventKit items. `--prune-once`, -`--sync` and each watcher pass advance the same state machine. -Read-only loading requires an already-private `0700` runtime root and never -changes an exposed legacy mode. Mutating paths share a Core initializer that -can tighten a current-user-owned, non-symlink, ACL-free and non-group/world- -writable root from `0755` to `0700`, then reopen and verify it with `fstat`. -The same two-phase policy migrates the known source `Backups/` tree without -changing file contents; directories become `0700` and regular files `0600`. - -Private pruning state defaults to: - -- `~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json`; -- `~/Library/Application Support/TaskForgeReminderSync/PruneBackups/`; -- `~/Library/Application Support/TaskForgeReminderSync/PruneHashSalt`. - -The parent directory is `0700`; ledger, backups and salt are `0600`. Writes use -temporary files and atomic replacement. The salt produces truncated hashes for -anonymous item correlation; logs contain counts, hashed identifiers and error -categories, never reminder content, source paths or raw identifiers. -The salt is created lazily when per-item anonymous correlation is first needed; -a successful first candidate-registration pass may only create the ledger. - -## Restore flow - -`--restore-last-prune` uses `latestRestorableBackup()` to select by creation -time the newest un-restored backup -whose actual-deletion identifiers have been parsed and are non-empty. A newer -backup with an unresolved result or an empty actual-deletion set remains on -disk but cannot block an older real deletion batch and cannot be marked -restored. Among multiple eligible batches, the newest wins; already restored -batches are skipped. Restoration preserves EventKit-readable user fields, and -EventKit assigns new system IDs. - -The original calendar is matched by calendar and source identifiers first. If -it no longer exists, restoration may recreate it only in the recorded original -source. A missing source, multiple same-named calendars in that source, -unsupported backup schema, checksum mismatch or ambiguous readback fails -closed without consuming the backup. - -Restored reminders receive a ledger grace entry for at least 24 hours. When -the grace period expires, an item still satisfying the candidate policy starts -again at the first scan; restoration never skips the two-scan requirement. +The supervisor starts the signed App bundle so the background process has the same Reminders TCC +identity as a manually launched App. It restarts the watcher if it exits. The watcher: + +1. reconciles once at startup; +2. debounces `EKEventStoreChanged` for 750 ms; +3. checks the task store and preferences plist every second; +4. performs a full pass every 60 seconds; +5. performs extra passes at 07:00, 11:00 and 15:00. + +Each pass is serialized. Its order is reverse conflict resolution, forward status reconciliation, +then the protected two-scan cleanup. A TaskForge source change is re-read before the forward +phase when reverse writing occurred in the same pass. + +## Custom-list forward flow + +1. Read `tasks.v6.bin`; a truncated or unknown record fails the pass closed. +2. Read the fixed private list ID and the matching `flutter.ctl_` JSON. +3. Validate and evaluate TaskForge's group and condition logic against all v6 tasks. +4. Learn only non-conflicting symbols from real source records and persist them privately. +5. Find or create only the state lists needed by current statuses. Reuse/rename the legacy + `TaskForge 今日` list as `TaskForge · 待办`. +6. Match managed reminders by the stable marker or private index. Duplicate candidates are a + conflict and never cause a new reminder. +7. Move the reminder to the canonical status list, update title, original scheduled date/time, + priority and concise marker notes, and preserve completion. +8. Completed/cancelled tasks are completed in EventKit and are not put in an active state list. +9. Managed reminders whose source task left the fixed list are moved to the base `待办` list + so the protected prune state machine can classify low-priority absent items. + +No date filter is added by this path. An existing task's original schedule is the only date +information sent to EventKit. + +## Reverse flow and conflict precedence + +All state lists are read for managed reminders, including completed reminders. For an open task, +the previous private index status distinguishes a TaskForge change from an Apple list move: + +| Situation | Decision | +|---|---| +| TaskForge open status changed | TaskForge wins; forward phase moves Apple reminder | +| Apple open list changed only | Write that approved status to source, then verify readback | +| Both open sides changed | TaskForge wins; Apple reminder is moved back | +| Apple completion is newly observed | Apple completion wins; source becomes `done` | +| Apple reminder was moved to an ordinary list | Move it back to current TaskForge state | +| Symbol unknown/conflicting or source stale | Refuse source write and move reminder back | + +Source write checks the resolved source path is inside the Vault, reads the exact original bytes, +backs them up, applies only the status edit, atomically replaces the file, hashes and reads it +back, then waits up to 15 seconds for TaskForge to expose the target state. A completion never +deletes a TaskForge line or file. + +## State list policy + +Known active states map to the names and colors documented in the README. Unknown active states +use the configured prefix and a gray list; terminal states have no active list. Existing exact +state-list duplicates are ambiguous and stop the pass. A normal Apple list is never treated as a +status merely because its title resembles one. + +## Pruning and recovery + +The custom path scopes pruning to `TaskForge · 待办`. It protects completed, important, +priority-bearing, indeterminate and ordinary reminders. An absent low-priority managed item is +registered in a private ledger, re-fetched after at least 60 seconds, backed up, removed, and +verified. Fetches time out after 30 seconds; timeout, permission, source and I/O errors preserve +the reminder. Other Apple lists are never fetched as prune inputs. + +Source references used for historical presence checks are stored in the private index, not in +reminder notes. The private index is passed into the pruning policy so historical managed items +remain protected without exposing full paths in EventKit notes. + +All deletion and source-write backups are private, checksum-verified and recoverable. Restore +never consumes a backup when account, calendar, schema, checksum or readback is ambiguous. + +The custom deduplication command builds duplicate groups from all active marked reminders using +the private source references, moves confirmed extras to `TaskForge 今日 · 去重归档`, marks those +extras complete, and adds an archive marker that excludes them from reverse completion. It never +deletes the reminder or its TaskForge source. ## Why the binary cache is read-only -`tasks.v6.bin` is treated as an implementation detail and cache, not as a -public database. Writing it could race with TaskForge, corrupt the cache or -bypass TaskForge's own source-of-truth rules. Reverse sync therefore changes -the Vault source and lets TaskForge re-index it. - -## Loop prevention - -- Apple completion is monotonic: forward sync never reopens an already - completed reminder. -- Date components are compared by year/month/day/hour/minute, ignoring - EventKit's calendar/time-zone metadata. -- EventKit changes are debounced. -- A second reconciliation is queued instead of running concurrently. -- Prune and restore operations are serialized in-process and with a private - operation lock. -- A reminder is claimed by at most one TaskForge task in each reconciliation. -- Duplicate IDs, duplicate source identities and ambiguous existing reminders - are reported as conflicts and never cause a new reminder to be created. +`tasks.v6.bin` is a TaskForge cache and implementation detail. Writing it could race with +TaskForge or bypass the source-of-truth rules. Reverse sync changes the Markdown/TaskNotes +source, then waits for TaskForge to re-index it. + +## Loop prevention and safety gates + +- EventKit notifications are debounced and passes cannot run concurrently. +- A reminder is claimed by at most one source task per pass. +- Completion is monotonic; forward sync never reopens an Apple-completed reminder. +- Date comparisons use only year/month/day/hour/minute, not EventKit metadata. +- Private configuration/index permission or schema failures stop writes. +- No production permission request, write, cleanup or watcher acceptance is automated by tests. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4201b46..5936f69 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,197 +1,117 @@ # Troubleshooting -## Permission denied - -Open: - -`System Settings → Privacy & Security → Reminders` +## Start with a read-only custom-list preview -Enable **TaskForge Reminder Sync**. If the TaskForge cache or Vault cannot be -read, also check **Full Disk Access**. +```bash +APP=./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync +"$APP" --check-config --taskforge-list-id LIST_ID +"$APP" --dry-run --taskforge-list-id LIST_ID +``` -If a manual `--sync` works but the LaunchAgent does not write a startup log, -re-run `./scripts/install-daily-sync.sh`. Current releases start the bundle -through LaunchServices so the background process retains the same Reminders -permission identity as the app. +The preview reads TaskForge only. It must report the same dynamic member count as the current +TaskForge Today list before any command that requests Reminders permission. A missing list ID, +missing `flutter.ctl_`, malformed JSON, unknown filter field/operator, or malformed v6 store +is a fail-closed configuration error. -Locally rebuilt ad hoc-signed apps receive a version-specific code identity. -After rebuilding, re-authorize the newly installed app. To preserve identity -across builds, use a stable code-signing certificate via -`TASKFORGE_SYNC_CODESIGN_IDENTITY`; the project never creates one automatically. +Once accepted, configure once with `--sync --taskforge-list-id LIST_ID`; later runs can omit the +ID and read the private `0600` configuration. Never place a real ID in shell history shared with +others, logs, tests or repository files. -Pruning also needs private local state under: +## Permission denied -`~/Library/Application Support/TaskForgeReminderSync/` +Open `System Settings → Privacy & Security → Reminders` and grant full access to the built App. +The TaskForge container or Vault may additionally require Full Disk Access. Rebuilt ad hoc-signed +Apps can receive a new TCC identity and may need re-authorization. The project never resets TCC +or approves permissions automatically. -The directory must be accessible only to the current user (`0700`), while -`PruneCandidates.json`, source backup files, files below `PruneBackups/` and -`PruneHashSalt` must be `0600`. A normal mutating run may safely tighten an old -user-owned `0755` runtime root and the known `Backups/` tree when every node is -real, has no extended ACL and is not group/world writable. A symlink, different -owner, ACL, group/world-writable mode, damaged ledger or checksum failure stays -fail-closed and preserves the reminder. +## A list or reminder is missing -## LaunchAgent is not running +Check the source first: ```bash -launchctl print "gui/$(id -u)/local.codex.taskforge-reminder-sync" -tail -n 100 ~/Library/Logs/TaskForgeReminderSync.error.log +"$APP" --dry-run ``` -Reinstall: +Confirm the task is a member of the fixed custom Kanban list, not terminal, and that its status +is a known or displayable active status. A status list is created only when needed. If the old +`TaskForge 今日` list exists, it is renamed in place to `TaskForge · 待办`. -```bash -./scripts/uninstall-daily-sync.sh -./scripts/install-daily-sync.sh -``` - -## A reminder is not created +If a managed reminder was moved to a normal Apple list, the next reverse pass moves it back. A +normal reminder without the marker/index is never imported or modified. -Run: - -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --check-config -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --dry-run -``` - -Confirm the task is scheduled for today and is not already done or cancelled. +## Apple completion is not reflected in TaskForge -## A TaskForge edit reports a deduplication conflict +1. Keep TaskForge running so it can re-index the source. +2. Run `--reverse-dry-run --task-id TEST_TASK_ID`. +3. Check that the reminder still has the tool marker and is a managed task. +4. Check the source is inside the current Vault and has not changed at the expected line. +5. Confirm the source type is Markdown inline or TaskNotes and that symbol learning is not conflicting. -The forward log includes `冲突 N`. A conflict means more than one TaskForge -record or managed reminder carries the same ID/source identity. The tool does -not select one arbitrarily and does not create another reminder. +An unknown or conflicting symbol intentionally refuses the source write and moves the reminder +back. Completion writes `done`; it never deletes the source task. -1. Stop editing the affected task briefly and let TaskForge finish re-indexing. -2. Run `--sync` again and confirm the conflict settles to `0`. -3. If it remains, inspect only redacted reminder metadata and source locations. -4. Do not delete reminders automatically; decide which existing item is - authoritative before manual cleanup. +## Open-state move was rejected -## Apple completion is not reflected in TaskForge +The source status must be learned from real records. Current Markdown writeback symbols are +`[ ]`, `[>]`, `[/]` and `[x]`. A symbol not learned for the target status, a symbol conflict, +stale original line, ambiguous match, non-UTF-8 source, or Vault escape is a deliberate stop. +TaskForge wins an open/open conflict; Apple completion wins an open/completed conflict. -1. Keep TaskForge running. -2. Check the standard and error logs. -3. Confirm the reminder was created by this tool and still contains its marker. -4. Confirm the source remains inside the current Vault. -5. Confirm the task is non-recurring and uses `onCompletion=keep`. +## TaskForge list was deleted or changed -Use `--reverse-dry-run --task-id TASK_ID` before any manual retry. +Stop the watcher. Restore or recreate the original fixed list without silently choosing a new +ID, then run the anonymous preview again. A renamed list keeps its ID; a deleted or missing ID +stops synchronization rather than falling back to a date query. -Reverse sync only marks the TaskForge source task `done`; reminder pruning -never deletes TaskForge task lines or TaskNotes files. +## Prune candidate is not deleted -## A prune candidate is not deleted - -Run the strict read-only classification first: +Use the strict read-only classification: ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --prune-dry-run +"$APP" --prune-dry-run ``` -Common safe reasons for retaining a reminder are: - -- it is completed, has EventKit priority greater than zero, or starts with - `!`, `!`, `❗`, `‼️`, `⭐` or `📌` after leading whitespace; -- the TaskForge current snapshot still contains it; -- its durable source reference still resolves to a real Vault task; -- source presence is indeterminate because of permissions, I/O, an out-of-Vault - path or an ambiguous same-named source task; -- the second unchanged scan has not occurred at least 60 seconds later; -- the reminder changed, moved lists or regained TaskForge identity, which - revokes or restarts the candidate; -- it was restored less than 24 hours ago; -- the candidate ledger or deletion backup cannot be privately written and - verified. +Retention is expected when the reminder is completed, important, priority-bearing, in an +ordinary list, still present in the current snapshot, source-confirmed by the private index, +indeterminate due to I/O/permissions/ambiguity, or has not passed two unchanged scans separated +by at least 60 seconds. A move, edit, completion or source reappearance restarts the gate. -`--prune-dry-run` is strictly read-only: it does not create or change the -candidate ledger, backup directory, hash salt or reminders. To advance state, -use `--prune-once`, `--sync`, or let `--watch` complete another reconciliation. -This also means dry-run never fixes an old `0755` runtime root: it reports an -anonymous permission failure until a normal mutating run safely migrates it. -`PruneHashSalt` is lazy state and may still be absent after a successful first -candidate-registration pass; it is not an installation-health check by itself. +`--prune-dry-run` never creates or changes the ledger, backup directory, salt or reminders. A +normal mutating pass may tighten a safe user-owned private root to `0700`; an exposed symlink, +wrong owner, ACL or damaged state remains fail-closed. -## Pruning reports an ambiguous list +## EventKit reading times out -If more than one Apple Reminders list has the configured name, pruning and -name-based restoration refuse to select one. Rename the unintended duplicate, -then rerun `--prune-dry-run`. Do not delete either list until you have checked -which one contains the expected reminders and backups. +Kanban fetches and prune fetches have a 30-second limit. The request is cancelled once and the +pass fails closed; a late callback is ignored. Check Reminders permission and retry only after +Reminders is responsive. A timeout must never be treated as an empty list. -Pruning never fetches reminder contents from other lists. If the configured -list does not exist, a prune pass returns zero and does not create it. +## Roll back a cleanup or source write -## Reminder reading times out +Stop the watcher first. Use `--restore-last-prune` for the newest verified reminder deletion +batch. Source-file backups are below: -Each target-list EventKit fetch has a 30-second limit. On timeout, the tool -cancels that fetch request once, records an anonymous error category and skips -deletion. Check Reminders permission and whether the Reminders app is -responsive, then retry. A late callback from the cancelled request is ignored. +```text +~/Library/Application Support/TaskForgeReminderSync/Backups/ +``` -## Restore the last pruned batch +Compare hashes before restoring. Keep the private index and backups until TaskForge re-reads the +source and a fresh `--dry-run`/`--prune-dry-run` passes. -Run: +## Watcher and LaunchAgent ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --restore-last-prune +launchctl print "gui/$(id -u)/local.codex.taskforge-reminder-sync" +tail -n 100 ~/Library/Logs/TaskForgeReminderSync.error.log ``` -The command restores the newest verified, un-restored batch whose parsed actual -deletion result is non-empty. Newer unresolved or zero-deletion backups remain -available for audit but do not block an older real deletion batch and are not -marked restored. It restores user-editable fields, but macOS assigns new -EventKit IDs. Restored reminders receive a 24-hour minimum grace period and -must complete two new scans before any later deletion. - -If the original list is gone, the tool attempts to recreate it only in the -original reminders account recorded by the backup. A missing account, multiple -same-named lists in that account, unsupported or modified backup, or ambiguous -post-restore readback fails closed; the backup remains available for retry. -The command is idempotent after an interrupted restore and will not knowingly -duplicate an already read-back restored item. - -If no eligible non-empty unrestored batch is found, the restored count is zero. -Unresolved, empty and restored backups remain at: - -`~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` - -## Restore a source file - -Backups are stored below: - -`~/Library/Application Support/TaskForgeReminderSync/Backups/` - -Each run uses a private `0700` timestamped/unique directory and writes `0600` -files. Compare the backup and current source before restoring. Stop the -LaunchAgent first if manual restoration is needed. - -This source-file backup is separate from `PruneBackups/`, which restores Apple -reminders deleted by the pruning feature. - -## Repeated sync loop - -Current versions compare EventKit date components semantically and should -settle at `更新 0`. If logs show continuous updates: - -1. stop the LaunchAgent; -2. save the relevant redacted logs; -3. report the macOS and TaskForge versions; -4. do not publish real reminder notes or Vault paths. - -## Uninstall and private pruning data +Verify the private list ID exists before reinstalling. The watcher should be left running for at +least 61 seconds during acceptance so both the event path and the full-scan fallback are observed. +To reinstall: ```bash ./scripts/uninstall-daily-sync.sh +./scripts/install-daily-sync.sh ``` -Uninstalling stops the LaunchAgent and removes the installed App. It does not -delete Apple reminders, Vault sources, the candidate ledger, pruning backups, -hash salt or logs. If you may need a deleted reminder, run -`--restore-last-prune` before removing those private runtime files. If the App -is already uninstalled, rebuild it and run the restore command from `dist`; -the preserved backup remains the source of truth. +The installer does not delete reminders, TaskForge sources, private indexes or backups. From f77596ba7020d3d7ad6bd34092ba1ed40b7d2486 Mon Sep 17 00:00:00 2001 From: ezbug <174582976+ezbug@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:13:31 +0800 Subject: [PATCH 32/34] =?UTF-8?q?fix(sync):=20=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=20planned=20=E7=8A=B6=E6=80=81=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TaskForgeReminderCore/TaskForgeKanban.swift | 16 ++++++++++++++-- Tests/TaskForgeReminderCoreTests/main.swift | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Sources/TaskForgeReminderCore/TaskForgeKanban.swift b/Sources/TaskForgeReminderCore/TaskForgeKanban.swift index 81a75bc..f32627c 100644 --- a/Sources/TaskForgeReminderCore/TaskForgeKanban.swift +++ b/Sources/TaskForgeReminderCore/TaskForgeKanban.swift @@ -21,7 +21,7 @@ public enum TaskForgeKanbanStatus: String, Codable, CaseIterable, Sendable { switch normalized.lowercased() { case "todo", "open": return Self.todo.rawValue - case "scheduled": + case "scheduled", "planned": return Self.scheduled.rawValue case "ready": return Self.ready.rawValue @@ -643,7 +643,19 @@ public enum TaskForgeStatusSymbolLearner { tasks: [TaskForgeTask], existing: [String: String] = [:] ) throws -> [String: String] { - var result = existing + var result: [String: String] = [:] + for (rawStatus, symbol) in existing { + let status = TaskForgeKanbanStatus.canonical(rawStatus) + if let previous = result[status], previous != symbol { + throw TaskForgeStatusSymbolError.conflict(status) + } + if let otherStatus = result.first(where: { + $0.value == symbol && $0.key != status + })?.key { + throw TaskForgeStatusSymbolError.conflict(otherStatus) + } + result[status] = symbol + } for task in tasks { guard task.sourceType?.lowercased() == "markdowninline", let line = task.originalLine, diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index 609426c..c13234c 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -689,9 +689,24 @@ private let tests: [TestCase] = [ "status editor added an artificial completion date" ) }), + ("TaskForge status symbol learning migrates planned aliases", { + let task = kanbanTask( + identifier: "planned-task", + status: "planned", + line: "- [>] planned-task" + ) + let learned = try TaskForgeStatusSymbolLearner.learn( + tasks: [task], + existing: ["planned": "[>]"] + ) + try require( + learned["scheduled"] == "[>]" && learned["planned"] == nil, + "planned symbol alias was not migrated to scheduled" + ) + }), ("TaskForge status aliases cover every Kanban state", { let aliases = [ - "todo", "scheduled", "ready", "inProgress", "on-hold", + "todo", "scheduled", "planned", "ready", "inProgress", "on-hold", "deferred", "blocked", "someday", "done", "cancelled" ] let expected = Set(TaskForgeKanbanStatus.allCases.map(\.rawValue)) From e8ccb696fdd0a84af99f981dddc0e19adf14f888 Mon Sep 17 00:00:00 2001 From: ezbug Date: Wed, 5 Aug 2026 01:00:55 +0800 Subject: [PATCH 33/34] =?UTF-8?q?fix(sync):=20=E8=AE=B0=E5=BD=95=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E5=86=99=E5=9B=9E=E5=A4=B1=E8=B4=A5=E7=B1=BB=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KanbanSyncEngine.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift b/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift index eb63800..45fc62b 100644 --- a/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift +++ b/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift @@ -800,6 +800,10 @@ final class KanbanSyncEngine { targetStatus = TaskForgeKanbanStatus.done.rawValue } catch { counts.reverseSkipped += 1 + logError( + "反向写回拒绝:\(reverseFailureCategory(error));" + + "提醒已恢复到当前 TaskForge 状态。" + ) try moveBack( reminder: reminder, status: currentStatus @@ -839,6 +843,10 @@ final class KanbanSyncEngine { counts.reverseWritten += 1 } catch { counts.reverseSkipped += 1 + logError( + "反向写回拒绝:\(reverseFailureCategory(error));" + + "提醒已恢复到当前 TaskForge 状态。" + ) try moveBack( reminder: reminder, status: currentStatus @@ -1252,4 +1260,20 @@ final class KanbanSyncEngine { (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date } + + private func reverseFailureCategory(_ error: Error) -> String { + switch error { + case is TaskForgeStatusSymbolError: + return "source-status-validation" + case is SyncError: + return "source-write-or-readback" + default: + return "unknown-write-failure" + } + } + + private func logError(_ message: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + fputs("[\(timestamp)] \(message)\n", stderr) + } } From fa4fa23e8e156d6f84756f3aa78c914b83a4824f Mon Sep 17 00:00:00 2001 From: ezbug Date: Wed, 5 Aug 2026 01:02:34 +0800 Subject: [PATCH 34/34] =?UTF-8?q?ci:=20=E4=BB=85=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E4=BA=A7=E5=93=81=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33bba3d..14d1015 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Run core tests run: swift run TaskForgeReminderCoreTests - name: Build release - run: swift build --configuration release + run: swift build --configuration release --product TaskForgeReminderSync - name: Validate property lists run: | plutil -lint Resources/Info.plist