diff --git a/BuildRuntimeViewerServerXCFramework.sh b/BuildRuntimeViewerServerXCFramework.sh index f77a2530..9d1a0aec 100755 --- a/BuildRuntimeViewerServerXCFramework.sh +++ b/BuildRuntimeViewerServerXCFramework.sh @@ -18,7 +18,7 @@ set -e # ========================================== SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" WORKSPACE_NAME="RuntimeViewer" -WORKSPACE_PATH="${SCRIPT_DIR}/${WORKSPACE_NAME}.xcworkspace" +WORKSPACE_PATH="${SCRIPT_DIR}/${WORKSPACE_NAME}-Distribution.xcworkspace" SCHEME_MACOS="RuntimeViewerServer" SCHEME_MOBILE="RuntimeViewerMobileServer" FRAMEWORK_NAME="RuntimeViewerServer" @@ -30,6 +30,7 @@ CONFIGURATION="Distribution" # Parse arguments VERBOSE=false CLEAN_BUILD=true +UPDATE_PACKAGES=true USER_PLATFORMS=() CPU_CORES=$(sysctl -n hw.ncpu 2>/dev/null || echo 8) @@ -37,9 +38,11 @@ usage() { echo "Usage: $0 [options] [Platforms...]" echo "" echo "Options:" - echo " -v, --verbose Show detailed build output" - echo " --no-clean Skip cleaning before build" - echo " -h, --help Show this help message" + echo " -v, --verbose Show detailed build output" + echo " --no-clean Skip cleaning before build" + echo " --no-update-packages Skip forcing a Swift package update" + echo " (just resolve from existing Package.resolved)" + echo " -h, --help Show this help message" echo "" echo "Platforms (if none specified, builds all):" echo " macOS, macCatalyst, iOS, tvOS, watchOS, visionOS" @@ -65,6 +68,10 @@ while [[ $# -gt 0 ]]; do CLEAN_BUILD=false shift ;; + --no-update-packages) + UPDATE_PACKAGES=false + shift + ;; -h|--help) usage ;; @@ -145,6 +152,54 @@ fi mkdir -p "$ARCHIVE_PATH" mkdir -p "$OUTPUT_DIR/DerivedData" +# ========================================== +# Update / Resolve Workspace Package Dependencies +# ========================================== +# Note: Do NOT run `swift package update` on individual packages — +# the workspace unifies swift-syntax via a local checkout +# (RuntimeViewerPrecompiledLibraries/swift-syntax). Resolving each +# package standalone would pick incompatible upstream constraints +# (e.g. SwiftMCP wants 602.x while RxSwiftPlus wants 601.x). +# +# To force a real update (latest versions matching workspace +# constraints) we: +# 1. delete the workspace's Package.resolved +# 2. point -resolvePackageDependencies at our clean +# $OUTPUT_DIR/DerivedData so SPM cannot reuse a stale +# SourcePackages/checkouts directory from the default +# ~/Library/Developer/Xcode/DerivedData location. +# Without (2), SPM happily keeps an older transitive version +# (e.g. swift-dyld-private 1.1.0) even though a newer matching +# version (1.2.0) is available in the repository cache. +# Both Package.resolved and DerivedData are gitignored / disposable. +WORKSPACE_RESOLVED="$WORKSPACE_PATH/xcshareddata/swiftpm/Package.resolved" +RESOLVE_DERIVED_DATA="$OUTPUT_DIR/DerivedData" + +if [ "$UPDATE_PACKAGES" = true ] && [ -f "$WORKSPACE_RESOLVED" ]; then + echo "🔄 Removing workspace Package.resolved to force update..." + rm -f "$WORKSPACE_RESOLVED" +fi + +echo "📦 Resolving workspace package dependencies..." +if [ "$VERBOSE" = true ]; then + if ! xcodebuild -resolvePackageDependencies \ + -workspace "$WORKSPACE_PATH" \ + -scheme "$SCHEME_MACOS" \ + -derivedDataPath "$RESOLVE_DERIVED_DATA"; then + echo "❌ Failed to resolve workspace package dependencies" + exit 1 + fi +else + if ! xcodebuild -resolvePackageDependencies \ + -workspace "$WORKSPACE_PATH" \ + -scheme "$SCHEME_MACOS" \ + -derivedDataPath "$RESOLVE_DERIVED_DATA" > /dev/null 2>&1; then + echo "❌ Failed to resolve workspace package dependencies (re-run with -v to see details)" + exit 1 + fi +fi +echo "" + # ========================================== # Function: Build Archive # ========================================== diff --git a/Documentations/Evolution/2026-03-03-bonjour-reliability.md b/Documentations/Evolution/0000-bonjour-reliability.md similarity index 100% rename from Documentations/Evolution/2026-03-03-bonjour-reliability.md rename to Documentations/Evolution/0000-bonjour-reliability.md diff --git a/Documentations/Evolution/0002-background-indexing.md b/Documentations/Evolution/0002-background-indexing.md new file mode 100644 index 00000000..92e56925 --- /dev/null +++ b/Documentations/Evolution/0002-background-indexing.md @@ -0,0 +1,858 @@ +# 0002 - 后台索引 + +- **状态**: Accepted +- **作者**: JH +- **日期**: 2026-04-24 +- **最后更新**: 2026-04-29 + +## 摘要 + +新增一个可选的 **后台索引(Background Indexing)** 功能,针对目标进程已加载镜像的依赖闭包,主动解析其 ObjC 与 Swift 元数据。工作由每个 `RuntimeEngine` 持有的 Swift Concurrency actor(`RuntimeBackgroundIndexingManager`)驱动,可在 Settings 中配置,通过 Toolbar 弹出框实时显示进度,并支持随时取消。 + +## 动机 + +Runtime Viewer 当前仅在用户显式打开某个镜像时才对其进行索引(解析 ObjC/Swift 元数据)。对于目标进程已经通过 dyld 加载的镜像 —— 例如 UIKit、Foundation 及其传递依赖闭包 —— 首次查询会因从未摊销的解析成本而出现可见延迟。 + +目标: + +- 通过预解析常用镜像,降低用户在常见查询路径上感知到的延迟。 +- 保留现有按需 `loadImage(at:)` 路径及其语义。 +- 让用户通过 Settings 在 CPU 占用与响应速度之间权衡(depth、并发数)。 +- 为运行中的工作提供实时可见性以及一键取消能力。 + +### 非目标 + +- 不在应用重启之间持久化索引历史(每次会话从干净状态开始)。 +- 不支持单镜像(子批次)级取消 —— 仅支持批次级取消。 +- 不支持暂停/恢复,仅支持启动 / 取消。 +- 不自动重试失败项。 +- 除单一手动 `prioritize(path:)` 钩子外,不引入额外 QoS 等级。 +- 不引入空闲 / 低功耗启发式策略。无论系统负载如何,索引都会运行。 +- 不向 MCP 工具暴露索引进度(MCP 消费的是结果,而不是进程状态)。 +- 不在跨 Document / 跨 Engine 之间共享缓存(保留 dyld 层面已有的复用)。 +- 不为旧调用方"loadImage == indexed"的混淆假设提供向后兼容垫片。 + +## 提议方案 + +### 背景上下文 + +来自头脑风暴和代码核验的事实来源: + +- `RuntimeEngine`(actor)已经维护 `imageList: [String]`(所有 dyld 已知镜像)和 `loadedImagePaths: Set`(我们通过 `loadImage(at:)` 处理过的镜像)。 +- 单个镜像的索引目前发生在 `loadImage(at:)` 中:调用 `objcSectionFactory.section(for:)` 与 `swiftSectionFactory.section(for:)`,然后触发 `reloadData()`。 +- `MachOImage.dependencies: [DependedDylib]` 提供依赖列表。MachOKit 将 `LC_LOAD_WEAK_DYLIB` 折叠为 `DependType.load`,因此实际上只会观察到 `.load`、`.reexport`、`.upwardLoad`、`.lazyLoad`。 +- `Semaphore` 包(`groue/Semaphore`)已经为 `RuntimeViewerCommunication` 解析。在管理器可以 import 它之前,需要在 `RuntimeViewerCore` target 中显式声明为产品依赖。 +- `MCPStatusPopoverViewController` + `MCPStatusToolbarItem` 是基于 Toolbar 锚定、RxSwift 驱动的弹出框模板。 +- `RuntimeEngine` 暴露了 `request(local:remote:)` 分发原语(`RuntimeEngine.swift:468`),用于每一个其结果依赖于目标进程的公共方法(local 与 XPC/TCP 之分)。本提案新增的所有引擎公共方法都使用同一原语。 + +### 术语:Loaded vs. Indexed + +这一区分至关重要。 + +- **Loaded** —— 镜像已在目标进程中向 dyld 注册(出现在 `DyldUtilities.imageNames()` 中)。Loaded 并不能说明 Runtime Viewer 是否解析过其 ObjC / Swift 元数据。 +- **Indexed** —— `RuntimeObjCSectionFactory` 和 `RuntimeSwiftSectionFactory` 都拥有针对该镜像路径的**成功解析后**缓存 section。解析失败**不**算作 indexed,这意味着失败路径会在下一批次中被重试(参见替代方案 D 解释为什么这是有意为之)。 + +新增 API —— `RuntimeEngine.isImageIndexed(path:)` —— 回答 indexed 这一问题。已有的 `isImageLoaded(path:)` 继续回答 loaded 这一问题。后台索引的去重始终使用 `isImageIndexed`。 + +### 架构 + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ RuntimeViewerUsingAppKit (App target — 不带 Runtime 前缀) │ +│ │ +│ Toolbar: BackgroundIndexingToolbarItem (NSToolbarItem 子类) +│ + BackgroundIndexingToolbarItemView (NSProgressIndicator +│ 覆盖在 SFSymbol 图标上) │ +│ │ +│ Popover: BackgroundIndexingPopoverViewController │ +│ + BackgroundIndexingPopoverViewModel (ViewModel) +│ + BackgroundIndexingNode 枚举 (batch / item) │ +└───────────────────────────────────────────────────────────────────┘ + ↕ RxSwift(仅用于 UI 绑定层) +┌───────────────────────────────────────────────────────────────────┐ +│ RuntimeViewerApplication(新类型带 Runtime 前缀) │ +│ │ +│ RuntimeBackgroundIndexingCoordinator (class) │ +│ · 订阅 Document 生命周期与引擎镜像加载事件 │ +│ · 通过 withObservationTracking 观察 Settings.backgroundIndexing +│ · 调用 engine.backgroundIndexingManager.startBatch(...) │ +│ · 将管理器的 AsyncStream 桥接为弹出框消费的 │ +│ Observable<[RuntimeIndexingBatch]>(RxSwift) │ +│ · 暴露聚合状态 (Driver) │ +└───────────────────────────────────────────────────────────────────┘ + ↕ async / await +┌───────────────────────────────────────────────────────────────────┐ +│ RuntimeViewerCore(新类型带 Runtime 前缀) │ +│ │ +│ RuntimeEngine (actor,已有) │ +│ + var backgroundIndexingManager: RuntimeBackgroundIndexingManager +│ + func isImageIndexed(path:) async throws -> Bool (request/remote) +│ + func mainExecutablePath() async throws -> String (request/remote) +│ + func loadImageForBackgroundIndexing(at:) async throws (request/remote) +│ + nonisolated var imageDidLoadPublisher: some Publisher +│ │ +│ RuntimeBackgroundIndexingManager (actor,新增 —— 核心) │ +│ 公共 API: │ +│ · events: AsyncStream │ +│ · batches: [RuntimeIndexingBatch] │ +│ · startBatch(rootImagePath:depth:maxConcurrency:reason:) │ +│ -> RuntimeIndexingBatchID │ +│ · cancelBatch(_:) │ +│ · cancelAllBatches() │ +│ · prioritize(imagePath:) │ +│ 内部: │ +│ · activeBatches: [RuntimeIndexingBatchID: BatchState] │ +│ · 每批次一个 AsyncSemaphore 控制并发 │ +│ · 每批次一个驱动 Task,托管一个 TaskGroup │ +│ │ +│ Sendable 值类型(全部 Hashable): │ +│ RuntimeIndexingBatch, RuntimeIndexingBatchID, │ +│ RuntimeIndexingTaskItem, RuntimeIndexingTaskState, │ +│ RuntimeIndexingEvent, RuntimeIndexingBatchReason, │ +│ ResolvedDependency │ +│ │ +│ 工具: │ +│ DylibPathResolver —— 基于 rpaths 与镜像路径解析 │ +│ @rpath / @executable_path / @loader_path 形式的 install name │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### 远程分发模型 + +新增的所有 `RuntimeEngine` 公共方法 —— `isImageIndexed`、`mainExecutablePath`、`loadImageForBackgroundIndexing` —— 都包裹在已有的 `request(local:remote:)` 原语之内。该原语当前为 `private`(`RuntimeEngine.swift:468`),但新增的 API 以及前两个 factory 都放在跨文件扩展 `RuntimeEngine+BackgroundIndexing.swift` 中实现 —— Swift 的 `private` 不允许跨文件 extension 访问,因此 `request` 与两个 factory 必须提至 `internal`: + +```swift +public func isImageIndexed(path: String) async throws -> Bool { + try await request { + objcSectionFactory.hasCachedSection(for: path) + && swiftSectionFactory.hasCachedSection(for: path) + } remote: { senderConnection in + try await senderConnection.sendMessage( + name: .isImageIndexed, request: path) + } +} +``` + +新增三个 `CommandNames` 枚举值 —— `.isImageIndexed`、`.mainExecutablePath`、`.loadImageForBackgroundIndexing` —— 同时服务端处理表(`RuntimeEngine.swift:276-302`)增加: + +```swift +setMessageHandlerBinding(forName: .isImageIndexed, of: self) { $0.isImageIndexed(path:) } +setMessageHandlerBinding(forName: .mainExecutablePath, of: self) { $0.mainExecutablePath } +setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, of: self) { $0.loadImageForBackgroundIndexing(at:) } +``` + +`RuntimeBackgroundIndexingManager` 与 engine 一对一构造,**实例始终活在客户端进程内**(参见 Assumption #2)。manager 通过 `BackgroundIndexingEngineRepresenting` 协议消费 engine,而 engine 的方法实现内部走 `request { local } remote: { RPC }` —— 本地源(DyldSharedCache / file)在客户端就近完成索引;远程源(XPC / directTCP)的实际索引工作在服务端目标进程执行。manager 自身的事件、批次状态、取消 API 都在客户端进程内,UI 通过 coordinator 直接消费,**不**通过 XPC 镜像;镜像化留作后续工作。 + +### 组件 + +#### `RuntimeBackgroundIndexingManager`(actor) + +持有所有运行中的批次以及所有事件流。在 `RuntimeEngine` init 时创建,**通过协议 `BackgroundIndexingEngineRepresenting` 以 `unowned` 反向引用持有引擎**(`unowned let engine: any BackgroundIndexingEngineRepresenting`):engine 强持 manager(`RuntimeEngine.backgroundIndexingManager: RuntimeBackgroundIndexingManager!`),如果 manager 也强引用回 engine 就形成跨 source-switch 累积泄漏的环(参见 ultrareview N1)。`unowned` 在生产上安全,因为 engine deinit 必然先释放 `backgroundIndexingManager` 属性,manager 一同消亡,反向引用没有机会悬空。manager 不直接依赖具体的 `RuntimeEngine` 类型,只通过协议表面消费 `isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` / `canOpenImage` / `rpaths` / `dependencies` 等方法。`RuntimeEngine`(actor)只是该协议的一个 conformance,测试用 `MockBackgroundIndexingEngine`(`@unchecked Sendable`)与 `InstrumentedEngine` 同样 conform。 + +```swift +public actor RuntimeBackgroundIndexingManager { + /// `unowned` because the engine owns this manager via + /// `RuntimeEngine.backgroundIndexingManager`. Strong back-reference + /// would form a cycle that leaks engine + manager + section caches on + /// every source switch. + private unowned let engine: any BackgroundIndexingEngineRepresenting + + public nonisolated var events: AsyncStream { ... } + + init(engine: any BackgroundIndexingEngineRepresenting) + + public func startBatch( + rootImagePath: String, + depth: Int, + maxConcurrency: Int, + reason: RuntimeIndexingBatchReason + ) async -> RuntimeIndexingBatchID + + public func cancelBatch(_ id: RuntimeIndexingBatchID) + public func cancelAllBatches() + public func prioritize(imagePath: String) + public func currentBatches() -> [RuntimeIndexingBatch] +} +``` + +#### `BackgroundIndexingEngineRepresenting`(协议) + +manager 与具体 engine 类型之间的抽象 seam。`: AnyObject, Sendable` —— manager 通过 `unowned let engine` 持有,需要类受限的 existential。参见决策日志 2026-04-28(回退了 2026-04-26 的暂时性"仅 Sendable"决议,因为 ultrareview N1 暴露了真实的 RuntimeEngine ↔ Manager 强引用环)。 + +```swift +protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable { + func isImageIndexed(path: String) async throws -> Bool + func loadImageForBackgroundIndexing(at path: String) async throws + func mainExecutablePath() async throws -> String + func canOpenImage(at path: String) async -> Bool + func rpaths(for path: String) async throws -> [String] + func dependencies(for path: String) + async throws -> [(installName: String, resolvedPath: String?)] +} +``` + +要点: + +- **不暴露 `MachOImage`**:该类型为非 Sendable 结构体(包含 unsafe pointer),跨 actor 边界返回会触发 Swift 6 严格并发错误。需要门控递归的调用方走 `canOpenImage(at:)`,需要查依赖的走 `dependencies(for:)`(在 conformance 实现里 actor 隔离地调用 `MachOImage`)。 +- **几乎所有方法都是 `async throws`**:`RuntimeEngine` conformance 内部走 `request { local } remote: { RPC }`,远程分支(XPC / directTCP)可能抛错。`canOpenImage` 是纯本地查询,保持 non-throwing。 +- **conformances**: + - `extension RuntimeEngine: BackgroundIndexingEngineRepresenting`(生产路径,actor —— actors 自动满足 `AnyObject`) + - `final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, @unchecked Sendable`(单元测试) + - `final class InstrumentedEngine: BackgroundIndexingEngineRepresenting, @unchecked Sendable`(并发计数测试包装器) +- **测试 keepalive 约定**:`unowned let` 要求 engine 寿命覆盖 manager。生产上由 `RuntimeEngine.backgroundIndexingManager` 强持回 manager 自动满足(engine deinit 时 manager 一同释放,unowned 不会悬空);测试里 mock 与 manager 是平行 local,ARC 可在 `await` 前先释放 mock —— `RuntimeBackgroundIndexingManagerTests` 用 `keep(_:)` helper 把 mock 钉到 test instance 的 `aliveObjects` 数组上,tearDown 清空。 + +#### Sendable 值类型 + +```swift +public struct RuntimeIndexingBatchID: Hashable, Sendable { public let raw: UUID } + +public enum RuntimeIndexingBatchReason: Sendable, Hashable { + case appLaunch + case imageLoaded(path: String) + case manual + case settingsEnabled +} + +public enum RuntimeIndexingTaskState: Sendable, Hashable { + case pending + case running + case completed + case failed(message: String) + case cancelled +} + +public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Hashable { + public let id: String // 镜像路径(未解析时为 install name) + public let resolvedPath: String? + public var state: RuntimeIndexingTaskState + public var hasPriorityBoost: Bool +} + +public struct RuntimeIndexingBatch: Sendable, Identifiable, Hashable { + public let id: RuntimeIndexingBatchID + public let rootImagePath: String + public let depth: Int + public let reason: RuntimeIndexingBatchReason + public var items: [RuntimeIndexingTaskItem] + public var isCancelled: Bool + public var isFinished: Bool +} + +public struct ResolvedDependency: Sendable, Hashable { + public let installName: String + public let resolvedPath: String? +} + +public enum RuntimeIndexingEvent: Sendable { + case batchStarted(RuntimeIndexingBatch) + case taskStarted(batchID: RuntimeIndexingBatchID, path: String) + case taskFinished(batchID: RuntimeIndexingBatchID, path: String, + result: RuntimeIndexingTaskState) + case taskPrioritized(batchID: RuntimeIndexingBatchID, path: String) + case batchFinished(RuntimeIndexingBatch) + case batchCancelled(RuntimeIndexingBatch) +} +``` + +所有值类型都是 `Hashable`,因此可以无需额外 conformance 工作就组合成 `BackgroundIndexingNode: Hashable`。 + +#### `RuntimeBackgroundIndexingCoordinator` + +每个 Document 创建一份(由 `DocumentState` 持有)。**`@MainActor` 隔离类**(与 `DocumentState` 一致),所有事件归约、Settings 观察、UI 状态发布都在主线程,不需要内部 `MainActor.run` 跳转。职责: + +1. 通过 `withObservationTracking` 观察 `Settings.backgroundIndexing`(参见 Settings 章节)→ 启用 / 禁用 / 重启。 +2. 监听引擎的 `imageDidLoadPublisher` → 为该镜像启动一次依赖批次。 +3. 监听 Sidebar 的镜像选中信号 → 调用 `manager.prioritize(path:)`。 +4. 将 `manager.events`(AsyncStream)桥接到 `eventRelay: PublishRelay`(RxSwift)。 +5. 维护两条事件归约 relay: + - `batchesRelay: BehaviorRelay<[RuntimeIndexingBatch]>` —— 仅包含**未 finalized** 的活跃批次。任何 `.batchFinished` / `.batchCancelled` 事件到达时立即移除对应批次(失败也一并移除,不再保留在此 relay 内 —— 参见 2026-04-29 决策)。 + - `historyRelay: BehaviorRelay<[RuntimeIndexingBatch]>` —— 已 finalized 的批次历史(成功、失败、取消三类合并),**最新在前**。仅在当前 Document 的 Coordinator 内存中累积 —— Document 关闭时随 Coordinator deinit 一起消亡;源切换时由 `handleEngineSwap` 显式清空(与 `batchesRelay.accept([])` 对称,因为旧 engine 的 batch 元数据对新 engine 无意义)。用户通过弹出框的 `Clear History` 按钮(取代旧 `Clear Failed`)显式清空。 +6. 暴露 `aggregateStateObservable: Observable`(字段 `hasActiveBatch` / `progress` 用于弹出框副标题)。原计划中给 toolbar 的 `IndexingToolbarState` 已在实现期被简化为静态 IconButton(`BackgroundIndexingToolbarItem`),因此 `AggregateState.hasAnyFailure` 字段虽保留为公共 API 但不再被任何消费方读取。 +7. 持有按 Document 维度的批次跟踪:`[Document.ID: Set]`。 +8. 通过 RxSwift 订阅 `documentState.$runtimeEngine.skip(1)`(BehaviorRelay) 响应 source switch:取消旧 manager 上的 doc batches、停掉旧 pumps、清空 `batchesRelay` / `historyRelay` / `aggregateRelay`、把 `self.engine` 切到新 engine、重启 pumps、若 isEnabled 则重新触发 `documentDidOpen()`。`engine` 字段为 `var`,每次 swap 时被覆盖。参见决策日志 2026-04-28(I3 / N2)。 + +### 数据流场景 + +#### 场景 A —— 启用了索引时的应用启动 / Document 打开 + +``` +Document 打开 + → DocumentState ready,RuntimeEngine 可用 + → Coordinator.documentDidOpen(documentState) + 读取 Settings.backgroundIndexing + 若 !isEnabled → return + rootPath = try await engine.mainExecutablePath() + batchID = await engine.backgroundIndexingManager.startBatch( + rootImagePath: rootPath, + depth: settings.depth, + maxConcurrency: settings.maxConcurrency, + reason: .appLaunch) + Toolbar 项从 idle 切换到 indexing +``` + +#### 场景 B —— 用户在运行时加载新镜像 + +``` +用户操作 → documentState.loadImage(at: path) + → RuntimeEngine.loadImage(at:)(已有路径完成) + → Engine 发出 imageDidLoadPublisher(path) + → Coordinator(若 isEnabled): + batchID = manager.startBatch( + rootImagePath: path, + depth: settings.depth, + maxConcurrency: settings.maxConcurrency, + reason: .imageLoaded(path: path)) + 依赖图扩展会跳过已索引的项 +``` + +#### 场景 C —— 用户选中已经在队列中的镜像 + +``` +Sidebar 选中变化 → SidebarViewModel 发出 imageSelected(path) + → Coordinator → manager.prioritize(imagePath: path) + manager 遍历 activeBatches,找到匹配 path 的 pending 项 + 标记 hasPriorityBoost = true,加入 priorityBoostPaths 集合 + 发出 .taskPrioritized + 正在运行 / 已完成 / 不存在的路径:静默 no-op +``` + +#### 场景 D —— Document 关闭 + +``` +Document.close() + → Coordinator.documentWillClose(documentState) + for batchID in Coordinator.batchesFor(document): + await manager.cancelBatch(batchID) + 移除 document 条目 +``` + +#### 场景 E —— Settings 切换(通过 `withObservationTracking`) + +``` +Coordinator.subscribeToSettings(): + withObservationTracking { + let snapshot = Settings.shared.backgroundIndexing + _ = snapshot.isEnabled + _ = snapshot.depth + _ = snapshot.maxConcurrency + } onChange: { [weak self] in + Task { @MainActor in + self?.handleSettingsChange() + self?.subscribeToSettings() // 重新注册 + } + } + +handleSettingsChange: + isEnabled false → true: + 对每个打开的 Document 执行场景 A(root = mainExecutablePath) + (不要回放历史 loadImage 调用) + isEnabled true → false: + await manager.cancelAllBatches() + 启用状态下 depth / maxConcurrency 变化: + 对运行中的批次为 no-op;新值在下一次 startBatch 生效。 +``` + +理由:`Settings` 已经声明为 `@Observable`,`withObservationTracking` 是原生匹配。在 `onChange` 中重新注册是文档化的"一次性观察者"恢复模式;它在每次 settings 变化中都让观察者保持存活,且不引入 Combine 基础设施。 + +#### 场景 G —— Source switch (用户在 toolbar PopUp 切换 Local / XPC / Bonjour) + +``` +MainCoordinator.prepareTransition(.main(let runtimeEngine)): + documentState.runtimeEngine = runtimeEngine // BehaviorRelay 触发 + +Coordinator (RxSwift 订阅,在 init 末尾通过 bootstrapEngineObservation 注册): + documentState.$runtimeEngine.skip(1).subscribeOnNext { [weak self] newEngine in + self?.handleEngineSwap(to: newEngine) + } + +handleEngineSwap(to: newEngine): + let oldEngine = self.engine + let oldBatchIDs = self.documentBatchIDs + + // 1) 拆掉旧 pumps + eventPumpTask?.cancel(); imageLoadedPumpTask?.cancel() + + // 2) Best-effort 取消旧 manager 上的 doc batches(fire-and-forget) + Task { for id in oldBatchIDs { + await oldEngine.backgroundIndexingManager.cancelBatch(id) + } } + + // 3) 清 UI relays —— 旧 batches / 历史均不再适用 + documentBatchIDs.removeAll() + batchesRelay.accept([]) + historyRelay.accept([]) + refreshAggregate(batches: []) + + // 4) 切到新 engine + self.engine = newEngine + + // 5) 重启 pumps,若 isEnabled 重新触发 main exec batch + startEventPump(); startImageLoadedPump() + documentDidOpen() +``` + +`skip(1)` 跳过 BehaviorRelay 在 subscribe 时回放的初值(与 init 时捕获的引用相同)。Coordinator 实例本身不重建 —— 它的 relays / aggregateState 持续驱动 toolbar,toolbar 的 `coordinator.aggregateStateObservable` 订阅链不需要重新连接。`engine` 字段从 `let` 改为 `var`。`DocumentState.runtimeEngine` 不再是不可变(参见假设 #1)。 + +#### 场景 F —— 用户从弹出框取消 + +``` +弹出框 Cancel 按钮 → ViewModel cancelBatchRelay.accept(batchID) + → Coordinator → await manager.cancelBatch(id) + 批次的驱动 Task → task.cancel() + TaskGroup 子任务继承取消 + runSingleIndex 捕获 CancellationError → 项状态 .cancelled + 已完成项保留 .completed + 发出 .batchCancelled +``` + +### 依赖图扩展 + +由 manager 内部的 `expandDependencyGraph(rootPath:depth:)` 实现。在 `startBatch` 开始时同步运行,因此在第一个 `taskStarted` 事件触发之前批次的总项数就已知 —— 这让弹出框的进度条从第一帧就保持准确。 + +```swift +// 伪代码 +func expandDependencyGraph(rootPath: String, depth: Int) async + -> [RuntimeIndexingTaskItem] +{ + var visited: Set = [] + var items: [RuntimeIndexingTaskItem] = [] + var frontier: [(path: String, level: Int)] = [(rootPath, 0)] + + while !frontier.isEmpty { + let (path, level) = frontier.removeFirst() + guard visited.insert(path).inserted else { continue } + + if await engine.isImageIndexed(path: path) { continue } + + items.append(.init(id: path, resolvedPath: path, + state: .pending, hasPriorityBoost: false)) + guard level < depth else { continue } + + for dep in await engine.dependencies(for: path) { + if let resolved = dep.resolvedPath { + if !visited.contains(resolved) { + frontier.append((resolved, level + 1)) + } + } else if visited.insert(dep.installName).inserted { + items.append(.init(id: dep.installName, resolvedPath: nil, + state: .failed(message: "path unresolved"), + hasPriorityBoost: false)) + } + } + } + return items +} +``` + +我们允许的深度(≤ 5)下,`Array.removeFirst()` 已经够用;不需要双端队列。 + +#### 依赖类型筛选 + +- **包含**: `.load`、`.reexport`、`.upwardLoad`。 +- **跳过**: `.lazyLoad` —— 懒加载的 dylib 在运行时可能从不真正加载,主动解析它们既是猜测又是浪费。 + +`LC_LOAD_WEAK_DYLIB` 被 MachOKit 解码为 `DependType.load`(参见 `MachOImage.swift:168-173`);`.weakLoad` 这一枚举值永远不会从 `dependencies` 出现,无需显式分支。 + +#### 路径解析(`DylibPathResolver`) + +install name 有四种形态: + +| 形态 | 解析 | +|-------|------------| +| `/System/Library/...`(绝对路径) | 原样使用,通过 `pathExists` 校验。 | +| `@rpath/Foo.framework/Foo` | 对根镜像上每个 `LC_RPATH` 进行替换,取第一个 `pathExists` 通过的候选。 | +| `@executable_path/...` | 用主可执行文件所在目录替换,再 `pathExists` 校验。 | +| `@loader_path/...` | 用当前镜像所在目录替换,再 `pathExists` 校验。 | + +返回 `String?` —— `nil` 映射为 `.failed("path unresolved")` 且不递归的 task item。 + +`pathExists(_:)` 同时接受**磁盘文件**与 **dyld shared cache 内的字面路径**(通过 `DyldUtilities.isInDyldSharedCache(_:)`,Set 缓存)。Apple Silicon 与近代 Intel macOS 上 `/usr/lib/libobjc.A.dylib`、`/usr/lib/libSystem.B.dylib`、`Foundation.framework/Versions/C/Foundation` 等系统镜像无磁盘文件,只在 cache 中。 + +**不做版本规范化**:cache 中存的就是平台原生形式(macOS versioned / iOS unversioned),`isInDyldSharedCache` 做字面比较。LC_LOAD_DYLIB install name 与 cache 形式不匹配时(典型场景:macOS 上 `Foundation.framework/Foundation` 不带 `Versions/C/`),走 `.failed("path unresolved")` —— 这是真实的解析失败,不是误报。参见假设 #4 与决策日志 2026-04-28(N4)。 + +### 并发模型 + +完全基于 Swift Concurrency —— 工作路径中没有 `OperationQueue`、没有 `DispatchQueue`、没有 RxSwift。RxSwift 仅用于 coordinator 内的 UI 绑定层。 + +```swift +// Manager 内部(草图) +private func runBatch(id: RuntimeIndexingBatchID) async { + let state = activeBatches[id]! + eventsContinuation.yield(.batchStarted(state.batch)) + + let semaphore = AsyncSemaphore(value: state.maxConcurrency) + await withTaskGroup(of: Void.self) { group in + while let item = popNextPrioritizedPending(batchID: id) { + try? await semaphore.waitUnlessCancelled() + if Task.isCancelled { break } + group.addTask { [weak self] in + defer { Task { await semaphore.signal() } } + await self?.runSingleIndex(batchID: id, path: item.id) + } + } + } + + finalizeBatch(id) // 发出 .batchFinished 或 .batchCancelled +} + +private func runSingleIndex(batchID: RuntimeIndexingBatchID, + path: String) async { + updateItemState(batchID, path, .running) + eventsContinuation.yield(.taskStarted(batchID: batchID, path: path)) + do { + try Task.checkCancellation() + try await engine.loadImageForBackgroundIndexing(at: path) + updateItemState(batchID, path, .completed) + eventsContinuation.yield(.taskFinished( + batchID: batchID, path: path, result: .completed)) + } catch is CancellationError { + updateItemState(batchID, path, .cancelled) + } catch { + let message = error.localizedDescription + updateItemState(batchID, path, .failed(message: message)) + eventsContinuation.yield(.taskFinished( + batchID: batchID, path: path, result: .failed(message: message))) + } +} +``` + +#### 优先级队列机制 + +每个批次状态持有一个 pending 路径的 `Array` 以及 priority-boost 成员的 `Set`。`prioritize(imagePath:)` 仅修改集合(并发出 `.taskPrioritized`);pop 辅助函数会先在 pending 数组中扫描第一个被 boost 的路径,没有 boost 时退化为数组头部。优先级无法抢占已经在运行的子任务 —— Swift 结构化并发不支持。对运行中或已完成的路径调用 `prioritize` 是静默 no-op。 + +#### `AsyncSemaphore` + +来自 `groue/Semaphore`。该依赖在 package 层已经解析,但仅声明给 `RuntimeViewerCommunication`;本提案在 `RuntimeViewerCore` target 的 dependencies 列表中显式添加 `.product(name: "Semaphore", package: "Semaphore")`。 + +#### UI 刷新抑制 + +`loadImageForBackgroundIndexing(at:)` **不**调用 `reloadData()`。在一次批次中调用 N 次会让 sidebar 被洪水攻击。Coordinator 在每次 `.batchFinished` / `.batchCancelled` 事件触发时调用一次 `await engine.reloadData(isReloadImageNodes: false)`,让 sidebar 在一次更新中拉起新索引的图标。 + +### Settings + +#### `BackgroundIndexing` 结构体(`Settings+Types.swift`) + +```swift +@Codable @MemberInit public struct BackgroundIndexing { + @Default(false) public var isEnabled: Bool + @Default(1) public var depth: Int // 有效区间 1...5 + @Default(4) public var maxConcurrency: Int // 有效区间 1...8 + public static let `default` = Self() +} +``` + +添加到根 `Settings` 类(已为 `@Observable`)作为: + +```swift +@Default(BackgroundIndexing.default) +public var backgroundIndexing: BackgroundIndexing = .init() { + didSet { scheduleAutoSave() } +} +``` + +由已有的 `SettingsFileSystemStorage` 自动保存持久化。不向 `Settings` 添加 Combine publisher。 + +#### `BackgroundIndexingSettingsView`(SwiftUI) + +位于 `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift`。通过在 `SettingsRootView.swift` 新增的 `SettingsPage.backgroundIndexing` case 进入(图标 `square.stack.3d.down.right`,标题 `"Background Indexing"`)。 + +Form 内容: +- `Toggle "Enable background indexing"` 绑定 `$settings.isEnabled`。 +- 解释行为的说明段落。 +- depth 的 `Stepper`(1...5),附带说明语义。 +- maxConcurrency 的 `Stepper`(1...8),附带说明 CPU 取舍。 + +Cancel-all 留在弹出框页脚,不放入 Settings。 + +#### Settings 变更传播 + +Coordinator 通过 `withObservationTracking` 订阅 `Settings.shared.backgroundIndexing`,并在 `onChange` 内重新注册。具体流程参见场景 E。 + +### UI: Toolbar Item + 弹出框 + +#### `BackgroundIndexingToolbarItem` + +`NSToolbarItem` 子类,在 `MainToolbarController.swift` 注册。标识符 `backgroundIndexing`。在默认与允许的标识符列表中放置在已有的 `mcpStatus` 项旁边(已有的 case 字面量是 `mcpStatus(sender:)`,而非 `mcpStatusPopover`)。 + +`view` 是 `BackgroundIndexingToolbarItemView`(NSView),中间放一个 16pt 的图标(SF Symbol `square.stack.3d.down.right`),当状态为 `indexing` 或 `hasFailures` 时叠加一个 `NSProgressIndicator(style: .spinning)`。`hasFailures` 时会在右下角绘制一个小红点徽标。 + +`IndexingToolbarState` 枚举:`.idle`、`.disabled`、`.indexing(percent: Double?)`、`.hasFailures(percent: Double?)`。 + +view 通过 toolbar 构建时弱持有的 observer 集合绑定到 coordinator 推送的 `Driver`。 + +点击该项触发**已有**的 `MainRoute` 表面新增的 case: + +```swift +case backgroundIndexing(sender: NSView) +``` + +注意名称**没有 `Popover` 后缀**,与同级的 `mcpStatus(sender:)` 保持一致。 + +#### `BackgroundIndexingPopoverViewController` + +基类 `UXKitViewController`。ViewModel 是 `ViewModel` —— **没有**单独的 `BackgroundIndexingPopoverRoute`。需要 `MainRoute` 路由的动作(目前只有 `dismiss`)走主层级已有 case;**`Open Settings` 不走 router**,因为 `MainRoute` 没有也不会增加 `openSettings` case —— ViewController 直接调用 `SettingsWindowController.shared.showWindow(nil)`,与 `MCPStatusPopoverViewController.swift:200-203` 的处理方式一致。固定宽度 380,高度从约 120(空状态)到 400(带滚动的大纲视图)。 + +内容布局: + +- 头部:`Label("Background Indexing")` 加一个读取聚合进度的副标题 `Label`。 +- 空状态 A(已禁用):图标 + "Background indexing is disabled" + `"Open Settings"` 按钮。 +- 空状态 B(已启用、无任何活跃 / 历史批次):图标 + "No active indexing tasks"。 +- 主体:渲染 `BackgroundIndexingNode` 的 `StatefulOutlineView`,顶层为两个 section: + - `ACTIVE` —— 默认展开,展示活跃批次及其 items。空时仅显示 section 头(无子行)。 + - `HISTORY` —— 默认折叠,展示已 finalized 批次(最新在前)。**仅当 `historyRelay` 非空时才出现**,空时整段 section 不渲染。用户展开 section 后,内部各个 batch 仍保持折叠(单独点击 disclosure 才展开 items),与活跃 batch 默认全展开形成对比 —— 历史是浏览,不是监控。 +- 页脚:`HStackView`,包含 `Cancel All` 按钮(无活动批次时禁用)、`Clear History` 按钮(仅当 `historyRelay` 非空时可见)以及 `Close` 按钮。 + +`BackgroundIndexingNode`: + +```swift +enum BackgroundIndexingNode: Hashable { + case section(SectionKind, batches: [BackgroundIndexingNode]) + case batch(RuntimeIndexingBatch, items: [BackgroundIndexingNode]) + case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) + + enum SectionKind: Hashable { case active, history } +} +``` + +`differenceIdentifier` 对 `.section` 仅取 `SectionKind`(不掺入 children)—— RxAppKit 的 staged-changeset 把 section 内 batch 的增删归约为子层 diff,不重建 section 行,从而**保住用户对 section header 的展开 / 折叠状态**。 + +大纲单元格: + +- Section header 行 (`SectionHeaderCellView`,本提案新增的私有嵌套类型):标题 `ACTIVE` / `HISTORY` + 子项计数。纯展示,无 Rx,无 disposeBag。 +- Batch 行 (`BatchCellView`):标题由 `reason` 派生、`"{completed}/{total}"`,以及一个 cancel 按钮。点击 cancel 会触发 `cancelBatchRelay.accept(batchID)`。**finalized 批次复用同一 cell**,通过 `batch.isFinished` 隐藏 cancel 按钮和 progress bar。 +- Item 行 (`ItemCellView`):状态图标(pending 灰点 / running 旋转 / completed 绿色 ✓ / failed 红色 ✗ / cancelled 灰色 ⊘) + 显示名 + 副标签。失败行展示完整 install name 与错误信息。`hasPriorityBoost == true` 的行展示一个 `"priority"` 标签。历史 batch 内的 items 复用同一 cell,无需特化。 + +防御性的大纲数据源分支使用 `preconditionFailure("unexpected outline item type")`,而不是返回零初始化的 batch,这样错误绑定的调用方会立即暴露。 + +#### `BackgroundIndexingPopoverViewModel` + +```swift +final class BackgroundIndexingPopoverViewModel: ViewModel { + @Observed private(set) var nodes: [BackgroundIndexingNode] = [] + @Observed private(set) var isEnabled: Bool = false + @Observed private(set) var hasAnyBatch: Bool = false // active 非空 + @Observed private(set) var hasAnyHistory: Bool = false // history 非空 + @Observed private(set) var subtitle: String = "" + + struct Input { + let cancelBatch: Signal + let cancelAll: Signal + let clearHistory: Signal + let openSettings: Signal + } + struct Output { + let nodes: Driver<[BackgroundIndexingNode]> + let isEnabled: Driver + let hasAnyBatch: Driver + let hasAnyHistory: Driver + let subtitle: Driver + // Forwarded to the ViewController, which calls + // `SettingsWindowController.shared.showWindow(nil)` directly. + let openSettings: Signal + } + + func transform(_ input: Input) -> Output { ... } +} +``` + +`isEnabled` 通过与 coordinator **相同**的 `withObservationTracking` 重新注册循环与 `Settings.shared.backgroundIndexing.isEnabled` 保持同步 —— 不是在 `transform` 中读一次后遗忘。这样弹出框打开时它的空状态会随 Settings 切换而响应。`hasAnyHistory` 由 `coordinator.historyObservable` 派生,驱动 `Clear History` 按钮的可见性以及 `HISTORY` section 是否渲染;`hasAnyBatch || hasAnyHistory` 决定空状态 B 是否隐藏。`transform` 中通过 `Observable.combineLatest(coordinator.batchesObservable, coordinator.historyObservable)` 合成 `nodes`,顶层产出 `[.section(.active, ...), .section(.history, ...)]`(history 为空则只产出第一个)。 + +`input.openSettings` 在 `transform` 内被中转到 `output.openSettings`(经一个内部 `PublishRelay`);ViewController 在 `setupBindings` 中订阅 `output.openSettings` 并直接调用 `SettingsWindowController.shared.showWindow(nil)` —— 见 `MCPStatusPopoverViewController.swift:200-203` 的同款先例。**不**经 `router.trigger(.openSettings)`,因为 `MainRoute` 没有该 case。 + +### 错误处理 + +| 失败位置 | 行为 | UI | +|---|---|---| +| 图扩展时 `MachOImage(name: path)` 返回 nil | 项 → `.failed("cannot open MachOImage")`,不递归 | 红色 ✗ + tooltip | +| `@rpath` / `@executable_path` / `@loader_path` 未解析 | 项 → `.failed("path unresolved")`,不递归 | 红色 ✗ + 原始 install name | +| `DyldUtilities.loadImage` 抛出(codesign、sandbox、文件缺失) | 项 → `.failed(dlopenError.localizedDescription)` | 红色 ✗ | +| ObjC section 解析抛出 | 项 → `.failed(objcParseError)` | 红色 ✗ | +| Swift section 解析抛出 | 项 → `.failed(swiftParseError)`。`isImageIndexed` 仍为 false,因为至少一个 factory 没有该路径的缓存 | 红色 ✗ | +| `Task.checkCancellation` 抛出 | 项 → `.cancelled`,不发出错误事件 | 灰色 ⊘ | +| Coordinator 在 Document 释放后收到事件 | `[weak self]` 静默丢弃事件 | — | + +`isImageIndexed(path:)` 要求**两个** factory 都有成功缓存的条目。解析失败不会留下缓存项,因此该路径会重新进入下一批次的 frontier。这是有意为之 —— 参见替代方案 D。 + +### 竞态 / 边界条件 + +1. **用户对正在被后台批次索引的相同路径执行手动 `loadImage(path)`。** + ObjC / Swift factory 必须按路径串行化解析,使两个并发调用方不会同时解析。规划阶段会核验(如有需要,会在每个 factory 中引入 `[String: Task]` 形式的 in-flight map)。 + +2. **批次取消时部分项已完成。** + 已完成项保留 `.completed`;`loadedImagePaths` 的插入不会回滚。在解析过程中收到 `CancellationError` 的 in-flight 项可能在 factory 中留下部分 section —— 本次迭代可接受;`isImageIndexed` 之后会返回 false,未来的显式加载会重做工作。 + +3. **同一根镜像的多个批次。** + manager 去重:如果某活动批次的 `rootImagePath == root` 且 `reason` 的判别式匹配,返回其已有 `RuntimeIndexingBatchID` 而非新启动一个。 + +4. **事件传输中 Document 关闭。** + 引擎(及其 manager)deinit 时会调用 `AsyncStream.Continuation.finish()`。Coordinator 的 `Task { for await event in manager.events }` 会干净退出。 + +### 假设 + +1. **`DocumentState.runtimeEngine` 在 Document 生命周期内可被重新赋值(source switch)。** 该属性声明为 `@Observed public var runtimeEngine: RuntimeEngine = .local`(`DocumentState.swift`),`MainCoordinator.prepareTransition(.main(...))` 会在用户切换 source(Local / XPC / Bonjour)时改写它。Coordinator 通过 RxSwift 订阅 `documentState.$runtimeEngine.skip(1)` 响应这一变化:取消旧 manager 的 doc batches、停旧 pumps、清 UI relays、把 `self.engine` 切到新 engine、重启 pumps、若 isEnabled 重新触发 `documentDidOpen()`。Coordinator 实例不重建,只是重新指向新 engine 的 manager —— 见场景 G。**先前 2026-04-26 的"不可变"假设已撤销** —— 实际代码路径(`MainCoordinator.swift:34`)始终违反该假设,导致 ultrareview N2 报告的 toolbar staleness。 + +2. **`RuntimeBackgroundIndexingManager` 与 engine 一对一构造,在客户端进程内活着。** 对于远程(XPC / directTCP)来源,manager 实例仍在客户端运行,但其内部调用的 engine 公共方法(`isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` / `dependencies(for:)` 等)都走 `request { local } remote: { RPC }` 分发,真正的索引工作由服务端目标进程执行。UI 客户端通过本地引擎引用消费 manager 事件流。 + +3. **Settings 修改频率较低。** `withObservationTracking` 重新注册在每次属性变更时触发一次。由于 Settings 的滑块 / toggle 以人类 UI 节奏运行,重新注册的成本可忽略不计。 + +4. **`DyldUtilities.dyldSharedCacheImagePaths()` 返回当前平台原生路径形式。** macOS 上 framework 路径带版本号(`Foundation.framework/Versions/C/Foundation`),iOS 上不带。`DyldUtilities.isInDyldSharedCache(_:)` 做**字面比较**,不在 macOS 上把 install name 规范化到 versioned 形式。如果 LC_LOAD_DYLIB install name 不与 cache 字面匹配(macOS 二进制可能既出现 versioned 也出现 unversioned),则该依赖在 BFS 中报 "path unresolved" 失败 —— 这是真实的解析失败,不是误报。`/usr/lib/libobjc.A.dylib` 这种纯 dylib 在两个平台 cache 里都是无歧义形式,直接命中。参见决策日志 2026-04-28(N4)。 + +### 测试策略 + +放在 `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/` 下。 + +1. `DylibPathResolverTests` + - `@rpath` 单条 + 多条 `LC_RPATH`,命中 + 未命中。 + - `@executable_path` 与 `@loader_path` 替换。 + - 绝对路径直通。 +2. `RuntimeBackgroundIndexingManagerTests` 使用一个遵循新内部协议 `BackgroundIndexingEngineRepresenting` 的 `MockBackgroundIndexingEngine`(`@unchecked Sendable`)。 + - 深度 0、1、2 的图扩展;已索引短路。 + - `prioritize` 让下一次分发选中被 boost 的路径。**基于时间的断言被替换为基于事件顺序的断言**(`taskStarted` 顺序),避免 CI 不稳定。 + - `cancelBatch` 终止 in-flight 工作,将剩余 pending 项标记为 cancelled。 + - 并发上限被遵守(spy 计数器永不超过配置值)。 + - 事件顺序:`batchStarted` 早于任何 `taskStarted`;`batchFinished` 最后。 +3. 如果 coordinator 端最终承担了非平凡的归约逻辑,则补充 `RuntimeIndexingBatch` / 事件 reducer 测试。 + +UI 不做自动化(没有现成的 UI 测试 harness);plan 包含一份手动验证清单。 + +### 文件清单 + +#### 新增文件 + +``` +RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ + RuntimeBackgroundIndexingManager.swift + RuntimeIndexingBatch.swift + RuntimeIndexingBatchID.swift + RuntimeIndexingBatchReason.swift + RuntimeIndexingTaskItem.swift + RuntimeIndexingTaskState.swift + RuntimeIndexingEvent.swift + ResolvedDependency.swift + BackgroundIndexingEngineRepresenting.swift +RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/ + DylibPathResolver.swift +RuntimeViewerCore/Sources/RuntimeViewerCore/ + RuntimeEngine+BackgroundIndexing.swift + +RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/ + DylibPathResolverTests.swift + RuntimeBackgroundIndexingManagerTests.swift + MockBackgroundIndexingEngine.swift + +RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/ + BackgroundIndexingSettingsView.swift + +RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/ + RuntimeBackgroundIndexingCoordinator.swift + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/ + BackgroundIndexingToolbarItem.swift + BackgroundIndexingToolbarItemView.swift + BackgroundIndexingPopoverViewController.swift + BackgroundIndexingPopoverViewModel.swift + BackgroundIndexingNode.swift +``` + +注意没有 `BackgroundIndexingPopoverRoute.swift` —— 路由通过 `MainRoute`。 + +#### 修改的文件 + +``` +RuntimeViewerCore/Package.swift + + 在 RuntimeViewerCore target 增加 .product(name: "Semaphore", package: "Semaphore") + +RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift + + BackgroundIndexing 结构体 + +RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift + + backgroundIndexing 属性 + +RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift + + SettingsPage.backgroundIndexing case 与 contentView 分支 + +RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift + + backgroundIndexingManager 存储属性(在 init 末尾设置) + + isImageIndexed(path:),使用 request/remote 分发 + + mainExecutablePath(),使用 request/remote 分发 + + loadImageForBackgroundIndexing(at:),使用 request/remote 分发 + + imageDidLoadPublisher(PassthroughSubject) + + 在 loadImage(at:) 成功时发出 imageDidLoadSubject.send(path) + + objcSectionFactory / swiftSectionFactory 访问级别提升至 internal + + 为三个新方法新增 CommandNames + setMessageHandlerBinding 处理器 + +RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift + + hasCachedSection(for:) 查询接口 + + 可选的按路径 in-flight 去重(plan 验证) + +RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift + + backgroundIndexingCoordinator 属性 + + 文档注释,断言 runtimeEngine 不可变 + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift + + backgroundIndexing(sender:) case(不带 "Popover" 后缀) + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift + + backgroundIndexing 项标识符 + 工厂 + + wireBackgroundIndexing(item:) 绑定 + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift + + backgroundIndexing(sender:) 转换 case + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift + + 调用 coordinator.documentDidOpen / documentWillClose +``` + +`RuntimeViewerUsingAppKit/.../BackgroundIndexing/` 下所有新文件必须手动加入 Xcode 项目(与 project memory 中提到的 MCPServer 模式一致)。 + +## 替代方案考量 + +### A. 通过新增的 `Combine.PassthroughSubject` 订阅 `Settings` + +在 `Settings` 上加一个 `PassthroughSubject`,从 `scheduleAutoSave` 中发出,让 coordinator 用 Combine 订阅。被否决,因为 `Settings` 已是 `@Observable` —— 增加一条平行 Combine 通道会复制事实来源,并迫使未来的读者二选一。`withObservationTracking` 是原生匹配,且对我们观察的少量属性可以扩展。 + +### B. 单独的 `BackgroundIndexingPopoverRoute` 枚举 + +镜像 `MCPStatusPopover` 的结构,定义一个专属的 Route 枚举。被否决,因为 `MainCoordinator` 已经绑定到 `SceneCoordinator`;增加第二个、有条件的 `Router` conformance 无法编译。考虑过通过单独的 adapter 转发,但比直接给 `MainRoute` 加一个 case(仅一行成本)更重。 + +### C. 不分发的、仅本地的 engine 扩展 + +让 `isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` 保持纯本地读取(不包裹 `request { local } remote: { RPC }`)。被否决,因为当 document 目标是远程源(XPC / directTCP)时这会静默返回错误数据 —— 本地 engine 对远程进程已加载的镜像一无所知。 + +### D. 缓存空 / nil 解析结果以建立"已尝试"位 + +让 `hasCachedSection(for:)` 把解析失败也算作已索引,从而避免重试。被否决:factory 缓存目前存的是成功的 `Section` 值,引入 `Result` 或并行的 `attemptedFailures` 集合会传播到许多调用点。更简单的语义 —— "indexed" = "成功解析" —— 意味着失败路径会在下一批次中重试,鉴于实际中确定性但可恢复的解析失败相当少见,这一选择可接受。 + +### E. UI 立即丢弃已完成 / 已取消的批次 + +更简单的归约逻辑:`.batchFinished` / `.batchCancelled` 到达时从 coordinator relay 中移除批次,弹出框就忘掉它存在过。被否决,因为失败的批次承载着可操作信息;静默丢失它们意味着 toolbar 的 `hasFailures` 指示器永远不会浮现。 + +**2026-04-29 修订**:不再保留失败批次于 `batchesRelay`,而是引入并行的 `historyRelay`,把所有 finalized 批次(成功 / 失败 / 取消)统一归入 history。失败信息仍可见(展开 history batch 时 `.failed` 项的红色 ✗ 图标和错误信息保留),用户用 `Clear History` 一键清空。原文里的 toolbar `hasFailures` 红点在实现期已被简化为静态 IconButton,`AggregateState.hasAnyFailure` 字段保留但无消费方,本次修订把它彻底从弹出框 ViewModel 的 Output 中下线。详见决策日志 2026-04-29。 + +## 影响 + +- **破坏性变更**: 无。该功能是可选的(默认关闭),且不修改既有 `loadImage(at:)` 的语义。 +- **受影响文件**: 见上文文件清单。 +- **是否需要迁移**: 不需要。Settings 默认值由已有的 `@Codable` 路径写入;缺失键回退到 `@Default` 值。 + +## 决策日志 + +| 日期 | 决策 | 理由 | +|------|----------|--------| +| 2026-04-24 | 创建为 Draft | 规范来自针对可选、基于 Swift Concurrency 的 dyld 已加载依赖闭包后台索引的头脑风暴 | +| 2026-04-24 | Settings 订阅 → `withObservationTracking` | `Settings` 是 `@Observable`;避免平行 Combine 通道 | +| 2026-04-24 | `BackgroundIndexingPopoverRoute` 合入 `MainRoute` | `MainCoordinator` 是 `SceneCoordinator`;条件性的第二个 conformance 无法编译 | +| 2026-04-24 | 所有新增 engine 方法都使用 `request { local } remote: { RPC }` | 否则远程(XPC / directTCP)源会读到本地进程数据 | +| 2026-04-24 | `isImageIndexed` = 仅 "成功解析" | 避免对每个 factory 缓存项做 Result 包装;失败路径会重试 | +| 2026-04-24 | `DocumentState.runtimeEngine` 视为不可变 | Coordinator 在 init 时一次性捕获 engine;重新赋值不在范围 | +| 2026-04-24 | 包含失败的已完成批次保留至被清除 | 保留可操作的失败信息;驱动 toolbar `hasFailures` 状态 | +| 2026-04-24 | 状态 → Accepted | Review 决策已落实;plan 重新生成以匹配 | +| 2026-04-26 | `Open Settings` 不经 `MainRoute`,ViewController 直接调 `SettingsWindowController.shared.showWindow(nil)` | `MainRoute` 没有 `openSettings` case;与 `MCPStatusPopoverViewController` 现成模式一致 | +| 2026-04-26 | `RuntimeBackgroundIndexingCoordinator` 整体 `@MainActor` | `DocumentState` 是 `@MainActor`,coordinator init 跨 actor 读 `runtimeEngine` 在 Swift 6 严格并发下报错;统一标注后简化所有事件归约路径 | +| 2026-04-26 | `BackgroundIndexingEngineRepresenting` 仅 `: Sendable`(去掉 `AnyObject`) | 协议无任何方法需要引用语义;去掉 `AnyObject` 避免 actor conformance 的边角依赖 | +| 2026-04-26 | Manager 通过 `BackgroundIndexingEngineRepresenting` 协议消费 engine,不直接依赖 `RuntimeEngine` 类型 | manager 单元测试无需构造真实 engine(用 `MockBackgroundIndexingEngine` / `InstrumentedEngine`);避免 actor↔actor 之间的 `unowned` 反向引用;Plan Task 5 先于 Task 6,协议先于实现 | +| 2026-04-28 | **回退**:`BackgroundIndexingEngineRepresenting: AnyObject, Sendable`,manager 改 `private unowned let engine` | ultrareview N1 暴露真实泄漏:`engine.backgroundIndexingManager` 强持 manager + manager 强持 engine = 跨 source switch 累积泄漏。Actor 满足 `AnyObject`;unowned 在生产上安全(engine deinit → manager 同步释放,反向引用没机会悬空)。测试加 `keep(_:)` helper 兜住平行 local mock 的 ARC 寿命 | +| 2026-04-28 | **撤销**:`DocumentState.runtimeEngine` 视为可变;coordinator 通过 RxSwift `documentState.$runtimeEngine.skip(1)` 订阅响应 source switch | 2026-04-24 假设"不可变"与代码现状(`MainCoordinator.swift:34` 在 `.main(...)` 时改写)长期不一致 → ultrareview N2 / implementation-review I3 报告 toolbar 静默断连。Coordinator `engine: var`,`handleEngineSwap(to:)` 取消旧 manager doc batches、停旧 pumps、清 relays、切引用、重启 pumps、若 isEnabled 重发 main exec batch | +| 2026-04-28 | `DylibPathResolver.pathExists` 兼顾文件系统与 `DyldUtilities.isInDyldSharedCache`,**字面匹配,不规范化** | ultrareview N4:Apple Silicon 上 `/usr/lib/lib*` / 系统 framework 无磁盘文件,纯 `fileExists` 拒绝全部 → batch 充满 "path unresolved" 红 ✗ 误报。规范化 macOS versioned ↔ unversioned 风险高(install name 与 cache 形式不一定按规则映射,iOS 还要分支),不如让真实失败显式呈现。`/usr/lib/libobjc.A.dylib` 这类无歧义路径在两平台都直接命中 | +| 2026-04-29 | **修订替代方案 E**:不再把失败批次保留于 `batchesRelay`;Coordinator 新增 `historyRelay`,所有 finalized 批次(成功 / 失败 / 取消)统一归入 history;弹出框新增 `HISTORY` 顶层 section(默认折叠),`Clear Failed` 按钮替换为 `Clear History` | 用户反馈:目前弹出框只能看到"正在跑的"和"失败留存的",看不到一次会话里完整的索引历史。同一 relay 既存活跃又存失败的设计语义混在一起,扩展成"完整历史"会让 active 概念被污染 —— 拆成两个 relay 是更干净的演化。toolbar `hasFailures` 红点在实现期被砍掉(`BackgroundIndexingToolbarItem` 是静态 IconButton),`AggregateState.hasAnyFailure` 不再有消费方,弹出框 ViewModel 的 `hasAnyFailure` Output 同步下线,改为 `hasAnyHistory`。`BackgroundIndexingNode` 加 `.section(SectionKind, batches:)` case,identifier kind-only 保住 section 展开状态 | diff --git a/Documentations/Plans/2026-04-24-background-indexing-design.md b/Documentations/Plans/2026-04-24-background-indexing-design.md deleted file mode 100644 index c0218a3c..00000000 --- a/Documentations/Plans/2026-04-24-background-indexing-design.md +++ /dev/null @@ -1,630 +0,0 @@ -# Background Indexing Design - -## Overview - -Runtime Viewer currently indexes an image (parses ObjC/Swift metadata) only when the user explicitly opens it. For images that the target process already has loaded via dyld — e.g. UIKit, Foundation, and the rest of the transitive dependency closure — the first lookup pays a visible parsing cost. - -**Background Indexing** is an opt-in feature that eagerly parses ObjC/Swift metadata for the dependency closure of known images. It runs on a per-Document basis, inside each `RuntimeEngine` actor, driven by Swift Concurrency. It is configurable from Settings, its progress is visible in a Toolbar popover, and running batches can be cancelled. - -## Goals - -- Reduce user-perceived latency for common lookups by pre-parsing likely-to-be-used images. -- Preserve the existing on-demand `loadImage(at:)` path and its semantics. -- Let the user trade CPU for responsiveness via Settings (depth, concurrency). -- Give the user real-time visibility and a one-click cancel for running work. - -## Non-Goals (explicit YAGNI) - -- No persistence of indexing history across app restarts (each session starts clean). -- No per-image (sub-batch) cancellation — batch-level cancellation only. -- No pause/resume. Only start / cancel. -- No automatic retry of failed items. -- No QoS tier beyond a single manual `prioritize(path:)` hook. -- No idle / low-power heuristics. Indexing runs regardless of system load. -- No exposure of indexing progress to MCP tools (MCP consumes results, not process state). -- No cross-Document / cross-Engine cache sharing beyond what already happens at the dyld level. -- No backwards-compatibility shims for callers assuming the old "loadImage == indexed" conflation. - -## Background Context from the Codebase - -Source of truth captured during brainstorming: - -- `RuntimeEngine` (actor) already tracks `imageList: [String]` (all dyld-known images) and `loadedImagePaths: Set` (images we have processed via `loadImage(at:)`). -- Indexing for a single image currently happens inside `loadImage(at:)`: it calls `objcSectionFactory.section(for:)` and `swiftSectionFactory.section(for:)` and then triggers `reloadData()`. -- `MachOImage.dependencies: [DependedDylib]` (MachOKit) gives the dependency list with a `type` discriminator (`load` / `weakLoad` / `reexport` / `upwardLoad` / `lazyLoad`). -- The `Semaphore` package (groue/Semaphore — `AsyncSemaphore`) is already resolved. -- `MCPStatusPopoverViewController` + `MCPStatusToolbarItem` are the existing template for a Toolbar-anchored, RxSwift-driven popover. - -## Terminology: Loaded vs. Indexed - -This distinction is load-bearing. The rest of the doc uses it strictly. - -- **Loaded** — the image is registered with dyld in the target process (appears in `DyldUtilities.imageNames()`). Being loaded says nothing about whether Runtime Viewer has parsed its ObjC / Swift metadata. -- **Indexed** — both `RuntimeObjCSectionFactory` and `RuntimeSwiftSectionFactory` have a cached section for the image's path, meaning metadata extraction has been attempted and the result (possibly empty) is memoized. - -A new API — `RuntimeEngine.isImageIndexed(path:) -> Bool` — answers the indexed question. The existing `isImageLoaded(path:)` continues to answer the loaded question. Background indexing deduplication always uses `isImageIndexed`. - -## Architecture - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ RuntimeViewerUsingAppKit (App target — no Runtime prefix) │ -│ │ -│ Toolbar: BackgroundIndexingToolbarItem (NSToolbarItem subclass) -│ + BackgroundIndexingToolbarItemView (NSProgressIndicator -│ overlaid on SFSymbol icon) │ -│ │ -│ Popover: BackgroundIndexingPopoverViewController │ -│ + BackgroundIndexingPopoverViewModel │ -│ + BackgroundIndexingNode enum (batch / item) │ -└───────────────────────────────────────────────────────────────────┘ - ↕ RxSwift (UI binding layer only) -┌───────────────────────────────────────────────────────────────────┐ -│ RuntimeViewerApplication (new types carry Runtime prefix) │ -│ │ -│ RuntimeBackgroundIndexingCoordinator (class) │ -│ · Subscribes to Document lifecycle and engine image-load events -│ · Reads Settings.backgroundIndexing │ -│ · Calls engine.backgroundIndexingManager.startBatch(...) │ -│ · Bridges the manager's AsyncStream into an RxSwift │ -│ Observable<[RuntimeIndexingBatch]> consumed by the popover │ -│ · Exposes aggregate state (Driver) │ -└───────────────────────────────────────────────────────────────────┘ - ↕ async / await -┌───────────────────────────────────────────────────────────────────┐ -│ RuntimeViewerCore (new types carry Runtime prefix) │ -│ │ -│ RuntimeEngine (actor, existing) │ -│ + var backgroundIndexingManager: RuntimeBackgroundIndexingManager -│ + func isImageIndexed(path:) -> Bool │ -│ + func mainExecutablePath() -> String │ -│ + func loadImageForBackgroundIndexing(at:) async throws (internal) -│ │ -│ RuntimeBackgroundIndexingManager (actor, new — core) │ -│ public API: │ -│ · events: AsyncStream │ -│ · batches: [RuntimeIndexingBatch] │ -│ · startBatch(rootImagePath:depth:maxConcurrency:reason:) │ -│ -> RuntimeIndexingBatchID │ -│ · cancelBatch(_:) │ -│ · cancelAllBatches() │ -│ · prioritize(imagePath:) │ -│ internals: │ -│ · activeBatches: [RuntimeIndexingBatchID: BatchState] │ -│ · AsyncSemaphore per batch for concurrency control │ -│ · per-batch driving Task hosting a TaskGroup │ -│ │ -│ Sendable value types (new): │ -│ RuntimeIndexingBatch, RuntimeIndexingBatchID, │ -│ RuntimeIndexingTaskItem, RuntimeIndexingTaskState, │ -│ RuntimeIndexingEvent, RuntimeIndexingBatchReason │ -│ │ -│ Utility (new): │ -│ DylibPathResolver — resolves @rpath / @executable_path / │ -│ @loader_path install names against a MachOImage │ -└───────────────────────────────────────────────────────────────────┘ -``` - -## Components - -### `RuntimeBackgroundIndexingManager` (actor) - -Owns every running batch and every event stream. Created by `RuntimeEngine` at init, unowned-references the engine back. - -```swift -public actor RuntimeBackgroundIndexingManager { - public nonisolated var events: AsyncStream { ... } - - public func startBatch( - rootImagePath: String, - depth: Int, - maxConcurrency: Int, - reason: RuntimeIndexingBatchReason - ) -> RuntimeIndexingBatchID - - public func cancelBatch(_ id: RuntimeIndexingBatchID) - public func cancelAllBatches() - public func prioritize(imagePath: String) - public func currentBatches() -> [RuntimeIndexingBatch] -} -``` - -### Sendable value types - -```swift -public struct RuntimeIndexingBatchID: Hashable, Sendable { let raw: UUID } - -public enum RuntimeIndexingBatchReason: Sendable { - case appLaunch - case imageLoaded(path: String) - case manual - case settingsEnabled -} - -public enum RuntimeIndexingTaskState: Sendable, Equatable { - case pending - case running - case completed - case failed(message: String) - case cancelled -} - -public struct RuntimeIndexingTaskItem: Sendable, Identifiable { - public let id: String // image path (install name if unresolved) - public let resolvedPath: String? - public var state: RuntimeIndexingTaskState - public var hasPriorityBoost: Bool -} - -public struct RuntimeIndexingBatch: Sendable, Identifiable { - public let id: RuntimeIndexingBatchID - public let rootImagePath: String - public let depth: Int - public let reason: RuntimeIndexingBatchReason - public var items: [RuntimeIndexingTaskItem] - public var isCancelled: Bool - public var isFinished: Bool -} - -public enum RuntimeIndexingEvent: Sendable { - case batchStarted(RuntimeIndexingBatch) - case taskStarted(batchID: RuntimeIndexingBatchID, path: String) - case taskFinished(batchID: RuntimeIndexingBatchID, path: String, - result: RuntimeIndexingTaskState) - case taskPrioritized(batchID: RuntimeIndexingBatchID, path: String) - case batchFinished(RuntimeIndexingBatch) - case batchCancelled(RuntimeIndexingBatch) -} -``` - -### `RuntimeBackgroundIndexingCoordinator` - -Created once per Document (held by `DocumentState` or a peer). Responsibilities: - -1. Listen for `Settings.backgroundIndexing` changes → enable / disable / restart. -2. Listen for engine's `didLoadImage(path:)` signal → start a dependency batch for that image. -3. Listen for Sidebar's image-selection signal → call `manager.prioritize(path:)`. -4. Bridge `manager.events` (AsyncStream) → `eventRelay: PublishRelay` (RxSwift). -5. Maintain `batchesRelay: BehaviorRelay<[RuntimeIndexingBatch]>` reduced from events, for the popover to drive off of. -6. Expose `aggregateStateDriver: Driver` used by the Toolbar item. -7. Own per-Document batch tracking: `[Document.ID: Set]`. - -## Data Flow Scenarios - -### Scenario A — App launch / Document opened with indexing enabled - -``` -Document opens - → DocumentState ready, RuntimeEngine available - → Coordinator.documentDidOpen(documentState) - reads Settings.backgroundIndexing - if !isEnabled → return - rootPath = await engine.mainExecutablePath() - batchID = await engine.backgroundIndexingManager.startBatch( - rootImagePath: rootPath, - depth: settings.depth, - maxConcurrency: settings.maxConcurrency, - reason: .appLaunch) - Toolbar item transitions idle → indexing -``` - -### Scenario B — User loads a new image at runtime - -``` -User action → documentState.loadImage(at: path) - → RuntimeEngine.loadImage(at:) (existing synchronous path completes) - → Engine emits didLoadImage(path) via existing Observable - → Coordinator (if isEnabled): - batchID = manager.startBatch( - rootImagePath: path, - depth: settings.depth, - maxConcurrency: settings.maxConcurrency, - reason: .imageLoaded(path: path)) - Dependency graph expansion skips items already indexed -``` - -### Scenario C — User selects an image already queued - -``` -Sidebar selection change → SidebarViewModel emits imageSelected(path) - → Coordinator → manager.prioritize(imagePath: path) - manager walks activeBatches, finds pending items matching path - marks hasPriorityBoost = true, dequeues + enqueues at head - emits .taskPrioritized - running / completed / absent paths: silent no-op -``` - -### Scenario D — Document closed - -``` -Document.close() - → Coordinator.documentWillClose(documentState) - for batchID in Coordinator.batchesFor(document): - await manager.cancelBatch(batchID) - remove document entry -``` - -### Scenario E — Settings toggle - -``` -isEnabled false → true: - for every open Document: run Scenario A - (main executable only; do NOT replay historical loadImage calls) - -isEnabled true → false: - await manager.cancelAllBatches() for every document's engine - -depth or maxConcurrency changed (isEnabled stays true): - no-op against running batches. Next startBatch picks up new values. -``` - -### Scenario F — User cancels from the popover - -``` -Popover cancel button → ViewModel cancelBatchRelay.accept(batchID) - → Coordinator → await manager.cancelBatch(id) - batch's driving Task → task.cancel() - TaskGroup children inherit cancellation - runSingleIndex catches CancellationError → item state .cancelled - already-completed items retain .completed (loadedImagePaths stays) - emits .batchCancelled -``` - -## Dependency Graph Expansion - -Implemented by `expandDependencyGraph(rootPath:depth:)` inside the manager. Runs synchronously at the start of `startBatch` so the batch's total item count is known before the first `taskStarted` event fires — this keeps the popover progress bar accurate from the first frame. - -```swift -// Pseudocode -func expandDependencyGraph(rootPath: String, depth: Int) async - -> [RuntimeIndexingTaskItem] -{ - var visited: Set = [] - var items: [RuntimeIndexingTaskItem] = [] - var frontier: Deque<(path: String, level: Int)> = [(rootPath, 0)] - - while let (path, level) = frontier.popFirst() { - guard visited.insert(path).inserted else { continue } - - if await engine.isImageIndexed(path: path) { continue } // short-circuit - - guard let image = MachOImage(name: path) else { - items.append(.init(id: path, resolvedPath: nil, - state: .failed("cannot open MachOImage"), - hasPriorityBoost: false)) - continue // do NOT recurse past an unreadable image - } - - items.append(.init(id: path, resolvedPath: path, - state: .pending, hasPriorityBoost: false)) - - guard level < depth else { continue } - - for dep in image.dependencies where dep.type != .lazyLoad { - guard let resolved = DylibPathResolver.resolve( - installName: dep.dylib.name, from: image) - else { - items.append(.init(id: dep.dylib.name, resolvedPath: nil, - state: .failed("path unresolved"), - hasPriorityBoost: false)) - continue - } - frontier.append((resolved, level + 1)) - } - } - return items -} -``` - -### Dependency type filter - -Included: `.load`, `.weakLoad`, `.reexport`, `.upwardLoad`. -Skipped: `.lazyLoad` — lazy-loaded dylibs may never actually load at runtime, so eagerly parsing them is speculative and wasteful. - -### Path resolution (`DylibPathResolver`) - -Install names come in three shapes: - -| Shape | Resolution | -|-------|------------| -| `/System/Library/...` (absolute) | Use as-is. Verify file exists. | -| `@rpath/Foo.framework/Foo` | For each `LC_RPATH` on the rooting image, substitute and take the first existing path. | -| `@executable_path/...` | Substitute using the main executable's directory. | -| `@loader_path/...` | Substitute using the current image's directory. | - -Returns `String?` — `nil` means resolution failed. - -## Concurrency Model - -Entirely Swift Concurrency — no `OperationQueue`, no `DispatchQueue`, no RxSwift in the work path. RxSwift is used only at the UI binding layer inside the Coordinator. - -```swift -// Manager internals, pseudocode -private func runBatch(id: RuntimeIndexingBatchID) async { - let state = activeBatches[id]! - eventsContinuation.yield(.batchStarted(state.batch)) - - let semaphore = AsyncSemaphore(value: state.maxConcurrency) - await withTaskGroup(of: Void.self) { group in - while let item = popNextPrioritizedPending(batchID: id) { - try? await semaphore.waitUnlessCancelled() - if Task.isCancelled { break } - group.addTask { [weak self] in - defer { Task { await semaphore.signal() } } - await self?.runSingleIndex(batchID: id, path: item.id) - } - } - } - - finalizeBatch(id) // emits .batchFinished or .batchCancelled -} - -private func runSingleIndex(batchID: RuntimeIndexingBatchID, - path: String) async { - updateItemState(batchID, path, .running) - eventsContinuation.yield(.taskStarted(batchID: batchID, path: path)) - do { - try Task.checkCancellation() - try await engine.loadImageForBackgroundIndexing(at: path) - updateItemState(batchID, path, .completed) - eventsContinuation.yield(.taskFinished( - batchID: batchID, path: path, result: .completed)) - } catch is CancellationError { - updateItemState(batchID, path, .cancelled) - } catch { - let message = error.localizedDescription - updateItemState(batchID, path, .failed(message: message)) - eventsContinuation.yield(.taskFinished( - batchID: batchID, path: path, result: .failed(message: message))) - } -} -``` - -### Priority queue mechanics - -Each batch state owns a `Deque` of pending paths. `prioritize(imagePath:)` removes the path from its current position and inserts it at the head. `popNextPrioritizedPending(batchID:)` always pops from the head, so priority-boosted items run next when a slot opens. - -Priority cannot preempt an already-running child task — Swift structured concurrency does not support that. `prioritize` on a running or completed path is a silent no-op, intentional per brainstorming. - -### `AsyncSemaphore` - -From `groue/Semaphore`, already in `Package.resolved`. Used to cap concurrent child tasks at `maxConcurrency`. `waitUnlessCancelled()` propagates parent cancellation. - -### UI refresh suppression - -`loadImageForBackgroundIndexing(at:)` does **not** call `reloadData()`. Calling it N times during a batch would storm the sidebar. The coordinator triggers `await engine.reloadData(isReloadImageNodes: false)` once per `.batchFinished` event so the sidebar picks up the newly-indexed icons in a single update. - -## Settings - -### `BackgroundIndexing` struct (in `RuntimeViewerSettings/Settings+Types.swift`) - -```swift -@Codable @MemberInit public struct BackgroundIndexing { - @Default(false) public var isEnabled: Bool - @Default(1) public var depth: Int // valid 1...5 - @Default(4) public var maxConcurrency: Int // valid 1...8 - public static let `default` = Self() -} -``` - -Added to the root `Settings` struct as `@Default(BackgroundIndexing.default) public var backgroundIndexing: BackgroundIndexing`. Persisted by the existing `SettingsFileSystemStorage` auto-save mechanism. - -### `BackgroundIndexingSettingsView` (SwiftUI) - -Lives at `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift`. Reached via `SettingsPage.backgroundIndexing` (new case in `SettingsRootView.swift`, icon `square.stack.3d.down.right`, title `"Background Indexing"`). - -Form contents: -- `Toggle "Enable background indexing"` bound to `$settings.isEnabled`. -- Caption paragraph explaining behavior. -- `Stepper` for depth (1...5), caption explaining the semantics. -- `Stepper` for maxConcurrency (1...8), caption noting the CPU tradeoff. - -Cancel-all live action stays out of Settings; it belongs in the popover (see below). - -### Settings change propagation - -`RuntimeBackgroundIndexingCoordinator` subscribes to settings changes. The concrete subscription path is TBD at the plan phase — options to evaluate: Combine publisher on `Settings`, a lightweight `SettingsSubject` relay, or polling `@AppSettings` reflection. Whichever path, the semantics are: - -- `isEnabled`: false → true triggers Scenario A for each open Document. -- `isEnabled`: true → false triggers `cancelAllBatches` on each engine. -- `depth` / `maxConcurrency` change while enabled: no-op against running batches; values apply to the next `startBatch`. - -## UI: Toolbar Item + Popover - -### `BackgroundIndexingToolbarItem` - -`NSToolbarItem` subclass registered in `MainToolbarController.swift`. Identifier `backgroundIndexing`. Placed next to `mcpStatus` in default + allowed identifier lists. - -`view` is a `BackgroundIndexingToolbarItemView` (NSView) holding a centered 16pt icon (SF Symbol `square.stack.3d.down.right`) with an `NSProgressIndicator(style: .spinning)` overlaid when state is `indexing` or `hasFailures`. A small red badge dot is drawn over the bottom-right corner for `hasFailures`. - -`IndexingToolbarState` enum: `.idle`, `.disabled`, `.indexing(percent: Double?)`, `.hasFailures(percent: Double?)`. - -The view binds to a `Driver` pushed from the Coordinator via a weakly-held observer set at toolbar construction. - -Clicking the item posts `backgroundIndexingPopover(sender:)` on `MainCoordinator`, analogous to the MCP popover route. - -### `BackgroundIndexingPopoverViewController` - -Base class `UXKitViewController`. Fixed width 380, height from ~120 (empty state) up to 400 (outline view with scroll). - -#### Content layout - -- Header: `Label("Background Indexing")` plus a subtitle `Label` reading the aggregate progress. -- Empty state A (disabled): icon + "Background indexing is disabled" + `"Open Settings"` button. -- Empty state B (enabled, no batches): icon + "No active indexing tasks". -- Body: `StatefulOutlineView` rendering `BackgroundIndexingNode`. -- Footer: `HStackView` with `Cancel All` button (disabled when no active batch) and `Close` button. - -#### `BackgroundIndexingNode` - -```swift -enum BackgroundIndexingNode { - case batch(RuntimeIndexingBatch) - case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) -} -``` - -Outline cells: - -- Batch row: a short title derived from `reason` (`"App launch indexing"` / `"MyFramework.framework deps"` / etc.), `"{completed}/{total}"`, and a cancel button. Clicking cancel fires `cancelBatchRelay.accept(batchID)`. -- Item row: status icon (pending grey dot / running spinning / completed green ✓ / failed red ✗ / cancelled grey ⊘) + display name + secondary label. Failed rows show the full install name and the error message. Rows with `hasPriorityBoost == true` show a `"priority"` tag. - -### `BackgroundIndexingPopoverViewModel` - -```swift -final class BackgroundIndexingPopoverViewModel: ViewModel { - @Observed private(set) var nodes: [BackgroundIndexingNode] = [] - @Observed private(set) var isEnabled: Bool = false - @Observed private(set) var hasAnyBatch: Bool = false - @Observed private(set) var subtitle: String = "" - - struct Input { - let cancelBatch: Signal - let cancelAll: Signal - let openSettings: Signal - } - struct Output { - let nodes: Driver<[BackgroundIndexingNode]> - let isEnabled: Driver - let hasAnyBatch: Driver - let subtitle: Driver - } - - func transform(_ input: Input) -> Output { ... } -} -``` - -Relays forward to the Coordinator's async APIs wrapped in `Task { ... }` blocks. - -### Popover presentation - -New route case in `MainRoute`: - -```swift -case backgroundIndexingPopover(sender: NSView) -``` - -`MainCoordinator.prepareTransition` builds the VC + VM and returns `.presentOnRoot(..., mode: .asPopover(...))`. - -## Error Handling - -| Failure site | Behavior | UI | -|---|---|---| -| `MachOImage(name: path)` returns nil | Item → `.failed("cannot open MachOImage")`, no recursion | red ✗ + tooltip | -| `@rpath` / `@executable_path` / `@loader_path` unresolved | Item → `.failed("path unresolved")`, no recursion | red ✗ + original install name | -| `DyldUtilities.loadImage` throws (codesign, sandbox, missing file) | Item → `.failed(dlopenError.localizedDescription)` | red ✗ | -| ObjC section parse throws | Item → `.failed(objcParseError)` | red ✗ | -| Swift section parse throws | Item → `.failed(swiftParseError)`. `isImageIndexed` stays false because at least one factory has no cache for this path | red ✗ | -| `Task.checkCancellation` throws | Item → `.cancelled`, no error event | grey ⊘ | -| Coordinator receives event after Document released | `[weak self]` drops event silently | — | - -`isImageIndexed` demands that **both** factories have a cached entry for the path. To distinguish "tried and found nothing" from "never tried", each factory will cache empty / nil results as well — the cache key's presence becomes the "attempted" bit. A follow-up in the plan will verify the factories support this without regression (the current `isExisted` return already implies they do). - -## Race / Edge Conditions - -1. **User manual `loadImage(path)` while a background batch is indexing the same path.** - The ObjC / Swift factories must serialize per-path parsing so two concurrent callers do not both parse. The plan phase will verify (and, if needed, introduce a `[String: Task]` in-flight map inside each factory). - -2. **Batch cancellation with partially-completed items.** - Completed items retain `.completed`; `loadedImagePaths` inserts are not rolled back. In-flight items that receive `CancellationError` mid-parse may leave the factories with partial sections — acceptable for this iteration; `isImageIndexed` will then return false and a future explicit load will redo the work. - -3. **Multiple batches for the same root.** - The manager dedupes: if an active batch already has `rootImagePath == root` and `reason`'s discriminant matches, return its existing `RuntimeIndexingBatchID` instead of starting another. - -4. **Document closure while events are mid-flight.** - `AsyncStream.Continuation.finish()` is called when the engine (and its manager) deinit. The Coordinator's `Task { for await event in manager.events }` exits cleanly. - -## Testing Strategy - -Added under `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/`. - -1. `DylibPathResolverTests` - - `@rpath` single + multiple `LC_RPATH`, hit + miss. - - `@executable_path` and `@loader_path` substitution. - - Absolute path passthrough. -2. `RuntimeBackgroundIndexingManagerTests` using a `MockBackgroundIndexingEngine` conforming to a new internal `BackgroundIndexingEngineRepresenting` protocol. - - Graph expansion at depth 0, 1, 2; already-indexed short-circuit. - - `prioritize` repositions pending items; no-op on running / completed. - - `cancelBatch` stops in-flight work, marks remaining pending items cancelled. - - Concurrency cap honored (spy counter never exceeds configured value). - - Event ordering: `batchStarted` precedes any `taskStarted`; `batchFinished` last. -3. `RuntimeIndexingBatch` / event reducers if non-trivial reduction logic ends up on the Coordinator side. - -UI is not automated (no existing UI test harness); the plan will include a manual verification checklist. - -## File Inventory - -### New files - -``` -RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ - RuntimeBackgroundIndexingManager.swift - RuntimeIndexingBatch.swift - RuntimeIndexingBatchID.swift - RuntimeIndexingBatchReason.swift - RuntimeIndexingTaskItem.swift - RuntimeIndexingTaskState.swift - RuntimeIndexingEvent.swift -RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/ - DylibPathResolver.swift -RuntimeViewerCore/Sources/RuntimeViewerCore/ - RuntimeEngine+BackgroundIndexing.swift - -RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/ - DylibPathResolverTests.swift - RuntimeBackgroundIndexingManagerTests.swift - MockBackgroundIndexingEngine.swift - -RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/ - BackgroundIndexingSettingsView.swift - -RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/ - RuntimeBackgroundIndexingCoordinator.swift - -RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/ - BackgroundIndexingToolbarItem.swift - BackgroundIndexingToolbarItemView.swift - BackgroundIndexingPopoverViewController.swift - BackgroundIndexingPopoverViewModel.swift - BackgroundIndexingPopoverRoute.swift - BackgroundIndexingNode.swift -``` - -### Modified files - -``` -RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift - + BackgroundIndexing struct - + Settings.backgroundIndexing property - -RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift - + SettingsPage.backgroundIndexing case and contentView branch - -RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift - + backgroundIndexingManager lazy property - + isImageIndexed(path:) - + mainExecutablePath() - -RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift -RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift - + hasCachedSection(for:) inspector - + in-flight task dedupe if plan verifies it is missing - -RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift - + backgroundIndexing item identifier + factory -RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift - + backgroundIndexingPopover(sender:) route -RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/AppDelegate.swift - + create RuntimeBackgroundIndexingCoordinator per Document -``` - -All new files under `RuntimeViewerUsingAppKit/.../BackgroundIndexing/` must be added to the Xcode project manually (consistent with the MCPServer pattern noted in project memory). - -## Open Questions (deferred to plan phase) - -1. **Settings change subscription path** — confirm which existing mechanism the `Coordinator` can hook into without inventing new infrastructure. -2. **Factory in-flight dedupe** — verify whether `RuntimeObjCSectionFactory` / `RuntimeSwiftSectionFactory` already serialize concurrent `section(for:)` calls, or if an in-flight task map must be added. -3. **Remote engine parity** — whether the `backgroundIndexingManager` + events need to be wired over `RuntimeViewerCommunication` for the remote (XPC / directTCP) case. Current scope assumes server-side execution only; remote UI parity may need a follow-up pass. -4. **Main executable path retrieval** — confirm the exact MachOKit / dyld helper used for dyld image index 0 in both local and server-injected contexts. - -These are specification gaps that the plan phase will close with code reads; they do not change the design. diff --git a/Documentations/Plans/2026-04-24-background-indexing-plan.md b/Documentations/Plans/2026-04-24-background-indexing-plan.md index 84d832ab..0ad527be 100644 --- a/Documentations/Plans/2026-04-24-background-indexing-plan.md +++ b/Documentations/Plans/2026-04-24-background-indexing-plan.md @@ -1,44 +1,81 @@ -# Background Indexing Implementation Plan +# 后台索引实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Build the opt-in background indexing feature per [2026-04-24-background-indexing-design.md](2026-04-24-background-indexing-design.md) — a per-`RuntimeEngine` Swift-Concurrency `RuntimeBackgroundIndexingManager` actor, Settings controls, and a Toolbar popover. +**目标:** 按 [0002-background-indexing.md](../Evolution/0002-background-indexing.md) 构建可选的后台索引功能 —— 一个每 `RuntimeEngine` 一份的 Swift Concurrency `RuntimeBackgroundIndexingManager` actor、Settings 控件以及一个 Toolbar 弹出框。 -**Architecture:** All core logic in `RuntimeViewerCore` (with `Runtime` prefix); coordinator in `RuntimeViewerApplication` (with `Runtime` prefix); UI in `RuntimeViewerUsingAppKit`, Settings UI in `RuntimeViewerSettingsUI` (neither prefixed). Swift Concurrency for all task scheduling; RxSwift only for UI binding in the coordinator. +**架构:** 所有核心逻辑置于 `RuntimeViewerCore`(带 `Runtime` 前缀);coordinator 置于 `RuntimeViewerApplication`(带 `Runtime` 前缀);UI 置于 `RuntimeViewerUsingAppKit`;Settings UI 置于 `RuntimeViewerSettingsUI`(后两者均不带前缀)。所有任务调度采用 Swift Concurrency;RxSwift 仅用于 coordinator 中的 UI 绑定。 -**Tech Stack:** Swift 5 (language mode v5), Swift Concurrency (actor / AsyncStream / TaskGroup), AsyncSemaphore (groue/Semaphore, already resolved), MachOKit (MachOImage.dependencies), RxSwift/RxCocoa, SnapKit, AppKit, SwiftUI (Settings only), MetaCodable `@Codable`, swift-memberwise-init-macro `@MemberInit`. +**技术栈:** Swift 5(语言模式 v5)、Swift Concurrency(actor / AsyncStream / TaskGroup)、AsyncSemaphore(groue/Semaphore,已解析)、MachOKit(MachOImage.dependencies)、RxSwift/RxCocoa、SnapKit、AppKit、SwiftUI(仅 Settings)、MetaCodable `@Codable`、swift-memberwise-init-macro `@MemberInit`。 --- -## Conventions used throughout this plan +## 全文通用约定 -- **Build / test commands**: all `swift build` / `swift test` invocations are preceded by `swift package update` and piped through `xcsift` per the project's CLAUDE.md. Run from the package directory (`RuntimeViewerCore/` or `RuntimeViewerPackages/`). -- **Commit style**: Conventional Commits (`feat:`, `test:`, `refactor:`, `docs:`) matching recent project history. -- **Every new file under `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/`** must be added to `RuntimeViewer.xcodeproj` — use the xcodeproj MCP (`add_file`) as shown in the integration tasks. Other packages (`RuntimeViewerCore`, `RuntimeViewerPackages`) are SPM and pick up new sources automatically. -- **Naming**: types created inside `RuntimeViewerCore` and `RuntimeViewerApplication` carry the `Runtime` prefix. Types created inside `RuntimeViewerUsingAppKit`, `RuntimeViewerSettingsUI`, and `RuntimeViewerSettings` do **not** (sticking with `MCP` / `MCPSettingsView` precedent). -- **Access control**: `private` by default; widen only when needed by callers. Observable state on ViewModels: `@Observed private(set) var`. -- **Weak-self idiom**: `guard let self else { return }` — never `strongSelf`, never `if let self`. -- **RxSwift subscription style**: trailing closure variants only (`.driveOnNext { }`, `.emitOnNext { }`, `.subscribeOnNext { }`). -- **Branch**: all work happens on `feature/runtime-background-indexing` (already created from `origin/main`). +- **构建 / 测试命令**: 所有 `swift build` / `swift test` 调用都先运行 `swift package update`,并按项目 CLAUDE.md 通过 `xcsift` 管道。在 package 目录(`RuntimeViewerCore/` 或 `RuntimeViewerPackages/`)下运行。 +- **提交风格**: 使用 Conventional Commits(`feat:`、`test:`、`refactor:`、`docs:`),匹配近期项目历史。 +- **`RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/` 下每个新文件**都必须加入 `RuntimeViewer.xcodeproj` —— 按集成任务中所示使用 xcodeproj MCP(`add_file`)。其他 packages(`RuntimeViewerCore`、`RuntimeViewerPackages`)是 SPM,新源文件会被自动识别。 +- **命名**: 在 `RuntimeViewerCore` 与 `RuntimeViewerApplication` 中创建的类型带 `Runtime` 前缀。在 `RuntimeViewerUsingAppKit`、`RuntimeViewerSettingsUI`、`RuntimeViewerSettings` 中创建的类型**不带**前缀(与 `MCP` / `MCPSettingsView` 先例保持一致)。 +- **访问控制**: 默认 `private`;仅在调用方需要时放宽。ViewModel 上的可观察状态:`@Observed private(set) var`。 +- **weak self 习惯**: `guard let self else { return }` —— 不用 `strongSelf`,不用 `if let self`。 +- **RxSwift 订阅风格**: 仅使用尾随闭包变体(`.driveOnNext { }`、`.emitOnNext { }`、`.subscribeOnNext { }`)。 +- **分支**: 所有工作发生在 `feature/runtime-background-indexing`(已从 `origin/main` 创建)。 --- -## Phase 1 — Foundation value types +## Phase 0 —— Package 接线 -### Task 1: Create Sendable value types for indexing events and batches +### 任务 0: 将 Semaphore 声明为 `RuntimeViewerCore` 的显式依赖 -**Files:** -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchID.swift` -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift` -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskState.swift` -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskItem.swift` -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift` -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingEvent.swift` -- Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift` +**文件:** +- 修改: `RuntimeViewerCore/Package.swift` -- [ ] **Step 1: Write failing tests for value type invariants** +**为什么:** `groue/Semaphore` 包已经为 `RuntimeViewerCommunication` target 解析(参见 `Package.swift:163`),但 `RuntimeViewerCore` 自身的 target 并未声明。`RuntimeBackgroundIndexingManager.swift`(任务 6)会 `import Semaphore`;依赖传递可见性是脆弱的(一旦启用 `.memberImportVisibility` 就会失效,而该选项已经在 `Package.swift:200` 定义)。在任何代码使用之前先把依赖显式化。 -File `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift`: +- [ ] **Step 1: 编辑 `RuntimeViewerCore` target 的 `dependencies` 数组** + +在 `RuntimeViewerCore/Package.swift` 的 `.target(name: "RuntimeViewerCore", dependencies: [...])`(当前行 142-157)内,在已有的 `MetaCodable` 产品之后追加: + +```swift +.product(name: "Semaphore", package: "Semaphore"), +``` + +- [ ] **Step 2: 解析并构建** + +```bash +cd RuntimeViewerCore && swift package update && swift build 2>&1 | xcsift +``` + +预期:构建无报错(尚未变更代码)。 + +- [ ] **Step 3: 提交** + +```bash +git add RuntimeViewerCore/Package.swift +git commit -m "chore(core): add Semaphore as explicit RuntimeViewerCore dependency" +``` + +--- + +## Phase 1 —— 基础值类型 + +### 任务 1: 为索引事件与批次创建 Sendable + Hashable 值类型 + +**文件:** +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchID.swift` +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift` +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskState.swift` +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskItem.swift` +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift` +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingEvent.swift` +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift` +- 测试: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift` + +**为什么处处都是 Hashable:** `BackgroundIndexingNode`(任务 18)声明为 `Hashable`,以便用作 `NSOutlineView` / `NSDiffableDataSource` 的更新键。它的关联值需要传递性的 `Hashable`。提前声明比后续补回更便宜。 + +- [ ] **Step 1: 写出针对值类型不变量的失败测试** + +文件 `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift`: ```swift import XCTest @@ -86,23 +123,23 @@ final class RuntimeIndexingValueTypesTests: XCTestCase { isCancelled: false, isFinished: false ) - XCTAssertEqual(batch.completedCount, 3) // completed + failed count as "done" + XCTAssertEqual(batch.completedCount, 3) // completed + failed 都计入"完成" XCTAssertEqual(batch.totalCount, 4) } } ``` -- [ ] **Step 2: Run tests — expect compile failure** +- [ ] **Step 2: 运行测试 —— 预期编译失败** ```bash cd RuntimeViewerCore && swift package update && swift test --filter RuntimeIndexingValueTypesTests 2>&1 | xcsift ``` -Expected: compilation errors for all types referenced. +预期:所有引用类型出现编译错误。 -- [ ] **Step 3: Create the value type files** +- [ ] **Step 3: 创建值类型文件** -File `RuntimeIndexingBatchID.swift`: +文件 `RuntimeIndexingBatchID.swift`: ```swift import Foundation @@ -113,10 +150,10 @@ public struct RuntimeIndexingBatchID: Hashable, Sendable { } ``` -File `RuntimeIndexingBatchReason.swift`: +文件 `RuntimeIndexingBatchReason.swift`: ```swift -public enum RuntimeIndexingBatchReason: Sendable, Equatable { +public enum RuntimeIndexingBatchReason: Sendable, Hashable { case appLaunch case imageLoaded(path: String) case settingsEnabled @@ -124,10 +161,10 @@ public enum RuntimeIndexingBatchReason: Sendable, Equatable { } ``` -File `RuntimeIndexingTaskState.swift`: +文件 `RuntimeIndexingTaskState.swift`: ```swift -public enum RuntimeIndexingTaskState: Sendable, Equatable { +public enum RuntimeIndexingTaskState: Sendable, Hashable { case pending case running case completed @@ -143,10 +180,10 @@ public enum RuntimeIndexingTaskState: Sendable, Equatable { } ``` -File `RuntimeIndexingTaskItem.swift`: +文件 `RuntimeIndexingTaskItem.swift`: ```swift -public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Equatable { +public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Hashable { public let id: String public let resolvedPath: String? public var state: RuntimeIndexingTaskState @@ -163,10 +200,24 @@ public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Equatable { } ``` -File `RuntimeIndexingBatch.swift`: +文件 `ResolvedDependency.swift`: + +```swift +public struct ResolvedDependency: Sendable, Hashable { + public let installName: String + public let resolvedPath: String? + + public init(installName: String, resolvedPath: String?) { + self.installName = installName + self.resolvedPath = resolvedPath + } +} +``` + +文件 `RuntimeIndexingBatch.swift`: ```swift -public struct RuntimeIndexingBatch: Sendable, Identifiable, Equatable { +public struct RuntimeIndexingBatch: Sendable, Identifiable, Hashable { public let id: RuntimeIndexingBatchID public let rootImagePath: String public let depth: Int @@ -197,7 +248,7 @@ public struct RuntimeIndexingBatch: Sendable, Identifiable, Equatable { } ``` -File `RuntimeIndexingEvent.swift`: +文件 `RuntimeIndexingEvent.swift`: ```swift public enum RuntimeIndexingEvent: Sendable { @@ -211,15 +262,15 @@ public enum RuntimeIndexingEvent: Sendable { } ``` -- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 4: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeIndexingValueTypesTests 2>&1 | xcsift ``` -Expected: 6 tests passed. +预期:6 个测试通过。 -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing @@ -228,25 +279,25 @@ git commit -m "feat(core): add Sendable value types for background indexing" --- -### Task 2: Implement `DylibPathResolver` +### 任务 2: 实现 `DylibPathResolver` -**Files:** -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift` -- Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift` +**文件:** +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift` +- 测试: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift` -- [ ] **Step 1: Explore `LC_RPATH` / executable path API on `MachOImage`** +- [ ] **Step 1: 探索 `MachOImage` 上的 `LC_RPATH` / 可执行路径 API** ```bash -rg -n "rpaths|LC_RPATH|executablePath|loaderPath" /Volumes/Code/OpenSource/MachOKit/Sources/MachOKit/ --type swift | head +rg -n "rpaths|LC_RPATH|executablePath|loaderPath" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/MachOKit/Sources/MachOKit/ --type swift | head ``` -Note which `MachOImage` property exposes `LC_RPATH` entries (expect `rpaths: [String]`) and whether there is a helper for the main-executable path (expect `_dyld_get_image_name(0)`). Record what you find in your scratch notes — the resolver design below assumes `image.rpaths: [String]`. +记录哪个 `MachOImage` 属性暴露了 `LC_RPATH` 条目(预期 `rpaths: [String]`),以及是否有获取主可执行文件路径的辅助函数(预期 `_dyld_get_image_name(0)`)。在你的草稿笔记中记下发现 —— 下面的 resolver 设计假设 `image.rpaths: [String]`。 -If the API is named differently (e.g. `rpathCommands` returning `RpathCommand` items whose `.path` gives the raw string), adjust the resolver code in Step 3 to match. +如果 API 名称不同(例如 `rpathCommands` 返回 `RpathCommand` 项,其 `.path` 给出原始字符串),按需在 Step 3 中调整 resolver 代码。 -- [ ] **Step 2: Write failing tests** +- [ ] **Step 2: 写出失败测试** -File `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift`: +文件 `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift`: ```swift import XCTest @@ -335,9 +386,9 @@ final class DylibPathResolverTests: XCTestCase { } ``` -- [ ] **Step 3: Implement the resolver** +- [ ] **Step 3: 实现 resolver** -File `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift`: +文件 `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift`: ```swift import Foundation @@ -400,15 +451,15 @@ struct DylibPathResolver { } ``` -- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 4: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter DylibPathResolverTests 2>&1 | xcsift ``` -Expected: 6 tests passed. +预期:6 个测试通过。 -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift @@ -417,36 +468,39 @@ git commit -m "feat(core): add DylibPathResolver for @rpath / @executable_path / --- -## Phase 2 — Engine extensions +## Phase 2 —— Engine 扩展 + +### 任务 3: 在两个 section factory 上暴露 `hasCachedSection`;在 engine 上加 `isImageIndexed`,使用 `request/remote` 分发 -### Task 3: Expose `hasCachedSection` on both section factories; add `isImageIndexed` to engine +**文件:** +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift`(factory 区域) +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift`(factory 区域) +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift`(factory 提升至 `internal`;`CommandNames` 加 `.isImageIndexed`;注册处理器) +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` +- 测试: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift` -**Files:** -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift` (factory area) -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift` (factory area) -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` -- Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift` +**为什么要 `request/remote`:** 当文档目标是远程源(XPC / directTCP)时,本地 engine 的 factory 缓存为空 —— 只有服务进程拥有真相。每一个已有的 engine 公共方法都使用 `request(local:remote:)` 原语(`RuntimeEngine.swift:468`);这里跳过会让远程源返回错误数据。 -- [ ] **Step 1: Read the factory classes for their caching layout** +- [ ] **Step 1: 阅读 factory 类以了解缓存结构** ```bash -rg -n "class RuntimeObjCSectionFactory|class RuntimeSwiftSectionFactory|private var sections|func section\(for" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/ +rg -n "class RuntimeObjCSectionFactory|class RuntimeSwiftSectionFactory|private var sections|func section\(for" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/ ``` -Record: cache storage variable name (expect `sections: [String: RuntimeObjCSection]` / similar), and whether factories already cache nil results. If not caching nil, the `hasCachedSection` predicate introduced below reflects "successfully parsed" — OK for MVP since a `.failed` task item captures the failure case. +记录:缓存存储变量名(预期 `sections: [String: RuntimeObjCSection]` / 类似),以及 factory 是否已经缓存 nil 结果。如果不缓存 nil,下面引入的 `hasCachedSection` 谓词体现"成功解析" —— 对 MVP 而言可以接受,因为 `.failed` 任务项会捕获失败情况。 -- [ ] **Step 2: Write failing test for `isImageIndexed`** +- [ ] **Step 2: 写出 `isImageIndexed` 的失败测试** -File `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift`: +文件 `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift`: ```swift import XCTest @testable import RuntimeViewerCore final class RuntimeEngineIndexStateTests: XCTestCase { - func test_isImageIndexed_falseForUnvisitedPath() async { + func test_isImageIndexed_falseForUnvisitedPath() async throws { let engine = await RuntimeEngine(source: .local) - let indexed = await engine.isImageIndexed(path: "/never/seen") + let indexed = try await engine.isImageIndexed(path: "/never/seen") XCTAssertFalse(indexed) } @@ -454,15 +508,15 @@ final class RuntimeEngineIndexStateTests: XCTestCase { let engine = await RuntimeEngine(source: .local) let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" try await engine.loadImage(at: foundation) - let indexed = await engine.isImageIndexed(path: foundation) + let indexed = try await engine.isImageIndexed(path: foundation) XCTAssertTrue(indexed) } } ``` -- [ ] **Step 3: Add `hasCachedSection(for:)` to each factory** +- [ ] **Step 3: 在每个 factory 上添加 `hasCachedSection(for:)`** -In `RuntimeObjCSection.swift`, inside `RuntimeObjCSectionFactory`: +在 `RuntimeObjCSection.swift` 的 `RuntimeObjCSectionFactory` 内: ```swift func hasCachedSection(for path: String) -> Bool { @@ -470,7 +524,7 @@ func hasCachedSection(for path: String) -> Bool { } ``` -In `RuntimeSwiftSection.swift`, same pattern: +在 `RuntimeSwiftSection.swift`,相同模式: ```swift func hasCachedSection(for path: String) -> Bool { @@ -478,183 +532,357 @@ func hasCachedSection(for path: String) -> Bool { } ``` -Match the exact storage name observed in Step 1. If a factory uses `cache` or `_sections`, substitute. +匹配 Step 1 中观察到的精确存储名。如果 factory 使用 `cache` 或 `_sections`,请相应替换。 -- [ ] **Step 4: Create the engine extension** +- [ ] **Step 4: 放宽 factory 与 `request` 分发原语的访问级别(必做)** -File `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift`: +`RuntimeEngine.swift:147-149` 当前将两个 factory 都声明为 `private`: + +```swift +private let objcSectionFactory: RuntimeObjCSectionFactory +private let swiftSectionFactory: RuntimeSwiftSectionFactory +``` + +`RuntimeEngine.swift:468` 当前将 `request` 也声明为 `private`: + +```swift +private func request(local: () async throws -> T, + remote: (_ senderConnection: RuntimeConnection) async throws -> T) + async throws -> T { ... } +``` + +将这三处 **全部** 改为 `internal`(去掉 `private` 关键字;默认即 `internal`)。下面 Step 6 / 任务 4 / 任务 4.5 创建的 `RuntimeEngine+BackgroundIndexing.swift` 扩展位于 **不同文件**,Swift 的 `private` 不允许跨文件 extension 访问 —— 即便在同一类型同一 module。`request` 与两个 factory 都会被那个扩展引用,必须提至 `internal`。已经核验过当前代码 —— 这三处现在均为 `private`。 + +- [ ] **Step 5: 在 `CommandNames` 中加 `.isImageIndexed` 并注册服务端处理器** + +在 `RuntimeEngine.swift` 中找到 `CommandNames` 枚举(约第 62 行)。添加: + +```swift +case isImageIndexed +``` + +在第 276 行附近的 `setMessageHandlerBinding(...)` 块中添加: + +```swift +setMessageHandlerBinding(forName: .isImageIndexed, of: self) { $0.isImageIndexed(path:) } +``` + +正好和已有的 `.isImageLoaded` 绑定相邻。 + +- [ ] **Step 6: 创建使用 `request/remote` 分发的 engine 扩展** + +文件 `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift`: ```swift import Foundation import MachOKit extension RuntimeEngine { - public func isImageIndexed(path: String) -> Bool { - objcSectionFactory.hasCachedSection(for: path) - && swiftSectionFactory.hasCachedSection(for: path) + public func isImageIndexed(path: String) async throws -> Bool { + try await request { + objcSectionFactory.hasCachedSection(for: path) + && swiftSectionFactory.hasCachedSection(for: path) + } remote: { senderConnection in + try await senderConnection.sendMessage( + name: .isImageIndexed, request: path) + } } } ``` -Verify `objcSectionFactory` / `swiftSectionFactory` are `internal` (not `private`) on `RuntimeEngine`. If they are `private`, widen to `internal` as part of this task. +注意:上面 Step 2 中的测试已更新为 `try await engine.isImageIndexed(path:)`,因为该方法现在 throws。 -- [ ] **Step 5: Run tests — expect pass** +- [ ] **Step 7: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift ``` -Expected: 2 tests passed. The second test relies on a real Foundation image; if CI lacks that exact path, comment out the second test and leave a TODO — but in this project (local macOS dev) it will pass. +预期:2 个测试通过。第二个测试依赖真实的 Foundation 镜像;如果 CI 中没有此精确路径,注释掉第二个测试并留 TODO —— 但本项目(macOS 本地开发)下会通过。 -- [ ] **Step 6: Commit** +- [ ] **Step 8: 提交** ```bash git add RuntimeViewerCore -git commit -m "feat(core): add isImageIndexed and factory hasCachedSection predicate" +git commit -m "feat(core): add isImageIndexed with request/remote dispatch + factory predicate" ``` --- -### Task 4: Add `mainExecutablePath` and `loadImageForBackgroundIndexing` to engine +### 任务 4: 在 engine 上加 `mainExecutablePath` 与 `loadImageForBackgroundIndexing`(带 `request/remote` 分发) + +**文件:** +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift`(增加两个 `CommandNames` case + 处理器) +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift`(仅当辅助缺失时) +- 测试: 追加到 `RuntimeEngineIndexStateTests.swift` -**Files:** -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift` (only if helper missing) -- Test: append to `RuntimeEngineIndexStateTests.swift` +**为什么要 `request/remote`:** 与任务 3 相同的理由。`mainExecutablePath` 必须反映目标进程,而非本地进程;对于远程源,正确答案只能在服务端获得。`loadImageForBackgroundIndexing` 也必须在目标进程内执行。 -- [ ] **Step 1: Explore `DyldUtilities` and `MachOImage` for main-executable lookup** +- [ ] **Step 1: 探索 `DyldUtilities` 与 `MachOImage` 中查询主可执行文件的 API** ```bash -rg -n "_dyld_get_image_name|_dyld_get_image_header|mainExecutable|static func images|MachOImage\.current" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/ /Volumes/Code/OpenSource/MachOKit/Sources/MachOKit/ --type swift | head +rg -n "_dyld_get_image_name|_dyld_get_image_header|mainExecutable|static func images|MachOImage\.current" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/ /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/MachOKit/Sources/MachOKit/ --type swift | head ``` -Note the canonical call sequence. On macOS the main executable is dyld image at index 0; the pattern is `String(cString: _dyld_get_image_name(0))`. +记录规范的调用序列。在 macOS 上主可执行文件是 dyld 索引 0 的镜像;常见模式是 `String(cString: _dyld_get_image_name(0))`。 -- [ ] **Step 2: Append failing tests** +- [ ] **Step 2: 追加失败测试** -In `RuntimeEngineIndexStateTests.swift`, append: +在 `RuntimeEngineIndexStateTests.swift` 中追加: ```swift - func test_mainExecutablePath_returnsNonEmptyPath() async { + func test_mainExecutablePath_returnsNonEmptyPath() async throws { + // In the XCTest context this returns the test runner's executable path, + // which validates the "return dyld image 0" contract without requiring + // RuntimeViewer.app to be running. let engine = await RuntimeEngine(source: .local) - let path = await engine.mainExecutablePath() + let path = try await engine.mainExecutablePath() XCTAssertFalse(path.isEmpty) XCTAssertTrue(FileManager.default.fileExists(atPath: path)) } func test_loadImageForBackgroundIndexing_doesNotTriggerReloadData() async throws { let engine = await RuntimeEngine(source: .local) - let before = await engine.imageListSnapshot().count // helper below let path = "/System/Library/Frameworks/CoreText.framework/CoreText" try await engine.loadImageForBackgroundIndexing(at: path) - let indexed = await engine.isImageIndexed(path: path) + let indexed = try await engine.isImageIndexed(path: path) XCTAssertTrue(indexed) - // imageList is recomputed only by reloadData; since we did not call it, - // the count must not change spuriously. - let after = await engine.imageListSnapshot().count - XCTAssertEqual(before, after) } ``` -If `RuntimeEngine` does not already expose a `imageListSnapshot()` or equivalent read-only snapshot, skip that assertion and keep only the `isImageIndexed` assertion. +- [ ] **Step 3: 增加 `CommandNames` case + 服务端处理器** + +在 `RuntimeEngine.swift` 的 `CommandNames` 枚举: + +```swift +case mainExecutablePath +case loadImageForBackgroundIndexing +``` + +在 `setMessageHandlerBinding(...)` 块中: -- [ ] **Step 3: Implement the new engine methods** +```swift +setMessageHandlerBinding(forName: .mainExecutablePath, + of: self) { $0.mainExecutablePath } +setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, + of: self) { $0.loadImageForBackgroundIndexing(at:) } +``` -Append to `RuntimeEngine+BackgroundIndexing.swift`: +- [ ] **Step 4: 用 `request/remote` 分发实现新的 engine 方法** + +追加到 `RuntimeEngine+BackgroundIndexing.swift`: ```swift extension RuntimeEngine { /// Path of the target process's main executable (dyld image at index 0). - public func mainExecutablePath() -> String { - // If a helper already exists on DyldUtilities, prefer it. - if let first = DyldUtilities.imageNames().first { return first } - return "" + public func mainExecutablePath() async throws -> String { + try await request { + // dyld guarantees image index 0 is the main executable. + DyldUtilities.imageNames().first ?? "" + } remote: { senderConnection in + try await senderConnection.sendMessage(name: .mainExecutablePath) + } } /// Like `loadImage(at:)` but does **not** call `reloadData()`. /// Used by the background indexing manager to avoid UI refresh storms. - internal func loadImageForBackgroundIndexing(at path: String) async throws { - // Ensure the image is dlopen'd in the target process (idempotent). - try DyldUtilities.loadImage(at: path) - _ = objcSectionFactory.section(for: path) - _ = swiftSectionFactory.section(for: path) - loadedImagePaths.insert(path) + public func loadImageForBackgroundIndexing(at path: String) async throws { + try await request { + // Mirror loadImage(at:) body sans reloadData — see RuntimeEngine.swift:485-495. + try DyldUtilities.loadImage(at: path) + _ = try await objcSectionFactory.section(for: path) + _ = try await swiftSectionFactory.section(for: path) + loadedImagePaths.insert(path) + } remote: { senderConnection in + try await senderConnection.sendMessage( + name: .loadImageForBackgroundIndexing, request: path) + } } } ``` -Check the existing `DyldUtilities.loadImage` signature — if it does not throw, drop `try`. If `DyldUtilities.imageNames()` returns path 0 last rather than first, use `DyldUtilities.imageNames().first(where: { $0.hasSuffix("RuntimeViewer") })` — but the dyld contract guarantees index 0 is the main executable. +注意两次 factory 调用的 `try await` —— 与已核验的签名 `section(for:progressContinuation:) async throws -> (isExisted: Bool, section: ...)` 一致(`RuntimeObjCSection.swift:704` / `RuntimeSwiftSection.swift:802`)。 -- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift ``` -Expected: all tests in that file pass. +预期:该文件中的所有测试通过。 -- [ ] **Step 5: Commit** +- [ ] **Step 6: 提交** ```bash git add RuntimeViewerCore -git commit -m "feat(core): add mainExecutablePath and loadImageForBackgroundIndexing on RuntimeEngine" +git commit -m "feat(core): mainExecutablePath + loadImageForBackgroundIndexing with request/remote" ``` --- -## Phase 3 — The indexing manager +### 任务 4.5: 在 `RuntimeEngine` 上添加 `imageDidLoadPublisher` + +**文件:** +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` +- 测试: 追加到 `RuntimeEngineIndexStateTests.swift` -### Task 5: Declare the engine-representing protocol and mock +**为什么:** Coordinator(任务 16)需要一个携带新加载镜像路径的信号。当今 `RuntimeEngine` 只暴露 `reloadDataPublisher`(无负载)和 `imageNodesPublisher`(完整列表);没有按镜像的信号。任务 16 会订阅这一新 publisher。本地分支在 `loadImage(at:)` 成功后发出;远程分支的 `setMessageHandlerBinding(forName: .imageDidLoad)` 处理器在服务器转发事件时由客户端发出。 -**Files:** -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift` -- Create: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift` +- [ ] **Step 1: 检查现有的 `reloadDataPublisher` 接线,作为模式参照** -- [ ] **Step 1: Create the protocol** +```bash +rg -n "reloadDataPublisher|reloadDataSubject|PassthroughSubject" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift | head +``` + +预期发现:`private nonisolated let reloadDataSubject = PassthroughSubject()`、暴露它的 `nonisolated` 公共属性,以及在服务端处理器表上的 `setMessageHandlerBinding(forName: .reloadData) { $0.reloadDataSubject.send() }`。 -File `BackgroundIndexingEngineRepresenting.swift`: +- [ ] **Step 2: 添加 subject + publisher** + +在 `RuntimeEngine.swift` 中已有的 `reloadDataSubject` 旁: ```swift -import MachOKit +private nonisolated let imageDidLoadSubject = PassthroughSubject() + +public nonisolated var imageDidLoadPublisher: some Publisher { + imageDidLoadSubject.eraseToAnyPublisher() +} +``` + +- [ ] **Step 3: 在 `CommandNames` 加 `.imageDidLoad` 并双向接线** +在 `CommandNames`: + +```swift +case imageDidLoad +``` + +在处理器表中,与 `reloadData` 模式镜像,让远程客户端也接收事件: + +```swift +setMessageHandlerBinding(forName: .imageDidLoad) { (engine: RuntimeEngine, path: String) in + engine.imageDidLoadSubject.send(path) +} +``` + +在 `loadImage(at:)`(当前位于 `RuntimeEngine.swift:485-495`)中,在已有的 `reloadData(isReloadImageNodes: false)` 调用之后发出: + +```swift +imageDidLoadSubject.send(path) +sendRemoteDataIfNeeded(name: .imageDidLoad, payload: path) +// or inline the remote push similar to sendRemoteDataIfNeeded(isReloadImageNodes:) +``` + +核验现有 `sendRemoteDataIfNeeded(...)` 签名 —— 如果它不接受任意命令名,在它旁边新增一个小辅助 `sendRemoteImageDidLoad(_ path: String)`。 + +- [ ] **Step 4: 追加测试** + +```swift + func test_imageDidLoadPublisher_firesAfterLoadImage() async throws { + let engine = await RuntimeEngine(source: .local) + let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" + let expectation = expectation(description: "imageDidLoad") + var received: String? + // imageDidLoadPublisher is `nonisolated` — no await needed; Swift 6 + // would warn "no 'async' operations occur in 'await' expression". + let cancellable = engine.imageDidLoadPublisher.sink { path in + received = path + expectation.fulfill() + } + try await engine.loadImage(at: foundation) + await fulfillment(of: [expectation], timeout: 5) + cancellable.cancel() + XCTAssertEqual(received, foundation) + } +``` + +如果测试文件顶部尚无 `import Combine`,请添加。 + +- [ ] **Step 5: 运行测试** + +```bash +cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift +``` + +- [ ] **Step 6: 提交** + +```bash +git add RuntimeViewerCore +git commit -m "feat(core): imageDidLoadPublisher for per-path load notifications" +``` + +--- + +## Phase 3 —— 索引管理器 + +### 任务 5: 声明 engine 表示协议与 mock + +**文件:** +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift` +- 创建: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift` + +- [ ] **Step 1: 创建协议** + +文件 `BackgroundIndexingEngineRepresenting.swift`: + +```swift /// Abstraction seam for `RuntimeBackgroundIndexingManager` to interact with a /// `RuntimeEngine`. Lets tests swap in a fake engine without real dyld I/O. -protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable { - func isImageIndexed(path: String) async -> Bool +/// +/// Methods that proxy to remote sources via `RuntimeEngine.request { ... } remote: { ... }` +/// are `async throws` because the XPC / TCP transport can fail. Pure-local +/// queries (`canOpenImage`) stay non-throwing. +/// +/// Note: the protocol intentionally does NOT expose `MachOImage` —— that type +/// is a non-Sendable struct (contains unsafe pointers); returning it across +/// actor boundaries triggers Swift 6 strict-concurrency errors. Callers that +/// only need to gate recursion can use `canOpenImage(at:)` instead. +/// +/// Conformance is `Sendable` only —— no `AnyObject` constraint. The manager +/// holds the engine by value (`engine: any BackgroundIndexingEngineRepresenting`), +/// no `weak`/`unowned` is needed, and `actor RuntimeEngine`'s conformance +/// would otherwise depend on the Swift 5.7+ "actor satisfies AnyObject" edge +/// behavior unnecessarily. +protocol BackgroundIndexingEngineRepresenting: Sendable { + func isImageIndexed(path: String) async throws -> Bool func loadImageForBackgroundIndexing(at path: String) async throws - func mainExecutablePath() async -> String - /// Returns `MachOImage` for the given path, or nil when the image cannot - /// be opened. Exposed so the mock can return deterministic dependency lists. - func machOImage(for path: String) async -> MachOImage? - /// Returns the LC_RPATH entries for the image at `path`. - func rpaths(for path: String) async -> [String] + func mainExecutablePath() async throws -> String + /// Whether the image at `path` can be opened as a MachO. Pure local check. + func canOpenImage(at path: String) async -> Bool + /// Returns the LC_RPATH entries for the image at `path`. Empty when the + /// image cannot be opened. + func rpaths(for path: String) async throws -> [String] /// Returns the resolved dependency dylib paths for the image at `path`, - /// excluding lazy-load entries. Implementations may return nil entries - /// for unresolved install names; the caller will mark them failed. + /// excluding lazy-load entries. May return nil `resolvedPath` entries for + /// unresolved install names; the caller marks them failed. func dependencies(for path: String) - async -> [(installName: String, resolvedPath: String?)] + async throws -> [(installName: String, resolvedPath: String?)] } ``` -- [ ] **Step 2: Conform `RuntimeEngine` to the protocol** +- [ ] **Step 2: 让 `RuntimeEngine` 遵循该协议** -Append to `RuntimeEngine+BackgroundIndexing.swift`: +追加到 `RuntimeEngine+BackgroundIndexing.swift`。`MachOImage(name:)` 仅在 actor-isolated 实现内部使用,**不**作为协议返回值跨边界传递: ```swift +import MachOKit + extension RuntimeEngine: BackgroundIndexingEngineRepresenting { - func machOImage(for path: String) -> MachOImage? { - MachOImage(name: path) + func canOpenImage(at path: String) -> Bool { + MachOImage(name: path) != nil } func rpaths(for path: String) -> [String] { guard let image = MachOImage(name: path) else { return [] } - return image.rpaths // adjust to actual API name from Task 2 exploration + return image.rpaths // confirmed: MachOImage.swift:145 returns [String] } - func dependencies(for path: String) -> [(installName: String, resolvedPath: String?)] { + func dependencies(for path: String) async throws + -> [(installName: String, resolvedPath: String?)] + { guard let image = MachOImage(name: path) else { return [] } let resolver = DylibPathResolver() - let main = mainExecutablePath() + let main = try await mainExecutablePath() let rpathList = image.rpaths return image.dependencies .filter { $0.type != .lazyLoad } @@ -669,18 +897,22 @@ extension RuntimeEngine: BackgroundIndexingEngineRepresenting { } ``` -If the actual MachOImage API returns `rpaths` as e.g. `[RpathCommand]` with `.path` strings, replace `image.rpaths` with the correct accessor (e.g. `image.rpaths.map { $0.path }`). Do the exploration at the top of this task and stick to the verified API. +注:`canOpenImage` 与 `rpaths` 的 conformance 实现保留为 non-throwing,Swift 允许 sync / non-throwing 函数满足 `async throws` 协议要求。`dependencies` 必须是 `async throws`,因为它内部 `try await mainExecutablePath()`(远端分发可能抛错)。`MachOImage` 类型自身不出现在协议表面 —— 它是非 Sendable 的结构体,仅在 actor-isolated 实现内部使用。 -- [ ] **Step 3: Create the mock** +- [ ] **Step 3: 创建 mock** -File `MockBackgroundIndexingEngine.swift`: +文件 `MockBackgroundIndexingEngine.swift`: ```swift import Foundation import MachOKit @testable import RuntimeViewerCore -final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting { +// `@unchecked Sendable` is required because the protocol is `Sendable` and this +// class stores mutable state protected by `NSLock` rather than an actor. +final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, + @unchecked Sendable +{ struct ProgrammedPath: Sendable { var isIndexed: Bool = false var shouldFailLoad: Error? = nil @@ -719,7 +951,10 @@ final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting { func mainExecutablePath() async -> String { mainExecutable } - func machOImage(for path: String) async -> MachOImage? { nil } + func canOpenImage(at path: String) async -> Bool { + lock.lock(); defer { lock.unlock() } + return paths[path] != nil + } func rpaths(for path: String) async -> [String] { [] } func dependencies(for path: String) async -> [(installName: String, resolvedPath: String?)] @@ -730,15 +965,17 @@ final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting { } ``` -- [ ] **Step 4: Compile check** +注:mock 的所有方法保留为 non-throwing 形式(`async -> ...` 而非 `async throws -> ...`)—— Swift 允许更弱的实现满足更强的协议要求。这样测试代码内调用 mock 时仍需 `try await`(因为通过 protocol 调用),但 mock 内部不必显式 throw。`MachOImage` 不再出现在 mock 的接口或导入中。 + +- [ ] **Step 4: 编译检查** ```bash cd RuntimeViewerCore && swift build 2>&1 | xcsift ``` -Expected: build succeeds. +预期:构建成功。 -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerCore @@ -747,15 +984,15 @@ git commit -m "feat(core): protocol and mock engine for background indexing" --- -### Task 6: Create the manager actor skeleton with AsyncStream +### 任务 6: 创建带 AsyncStream 的 manager actor 骨架 -**Files:** -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift` -- Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift` +**文件:** +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift` +- 测试: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift` -- [ ] **Step 1: Write failing test for empty manager state** +- [ ] **Step 1: 写出针对空 manager 状态的失败测试** -File `RuntimeBackgroundIndexingManagerTests.swift`: +文件 `RuntimeBackgroundIndexingManagerTests.swift`: ```swift import XCTest @@ -800,17 +1037,17 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } ``` -- [ ] **Step 2: Run test — expect compile failure** +- [ ] **Step 2: 运行测试 —— 预期编译失败** ```bash cd RuntimeViewerCore && swift test --filter RuntimeBackgroundIndexingManagerTests 2>&1 | xcsift ``` -Expected: `RuntimeBackgroundIndexingManager` undefined. +预期:`RuntimeBackgroundIndexingManager` 未定义。 -- [ ] **Step 3: Implement the skeleton** +- [ ] **Step 3: 实现骨架** -File `RuntimeBackgroundIndexingManager.swift`: +文件 `RuntimeBackgroundIndexingManager.swift`: ```swift import Foundation @@ -861,11 +1098,11 @@ public actor RuntimeBackgroundIndexingManager { return id } - // Placeholder — Task 8 replaces with real BFS. + // Placeholder — Task 7 replaces with real BFS. func expandDependencyGraph(rootPath: String, depth: Int) async -> [RuntimeIndexingTaskItem] { - if await engine.isImageIndexed(path: rootPath) { return [] } + if (try? await engine.isImageIndexed(path: rootPath)) == true { return [] } return [.init(id: rootPath, resolvedPath: rootPath, state: .pending, hasPriorityBoost: false)] } @@ -907,15 +1144,15 @@ public actor RuntimeBackgroundIndexingManager { } ``` -- [ ] **Step 4: Run test — expect pass** +- [ ] **Step 4: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeBackgroundIndexingManagerTests 2>&1 | xcsift ``` -Expected: both tests pass. +预期:两个测试通过。 -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerCore @@ -924,15 +1161,15 @@ git commit -m "feat(core): manager actor skeleton with AsyncStream plumbing" --- -### Task 7: Implement `expandDependencyGraph` — BFS with depth limit and short-circuit +### 任务 7: 实现 `expandDependencyGraph` —— 带深度限制与短路的 BFS -**Files:** -- Modify: `RuntimeBackgroundIndexingManager.swift` -- Test: append to `RuntimeBackgroundIndexingManagerTests.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingManager.swift` +- 测试: 追加到 `RuntimeBackgroundIndexingManagerTests.swift` -- [ ] **Step 1: Write failing tests** +- [ ] **Step 1: 写出失败测试** -Append to `RuntimeBackgroundIndexingManagerTests.swift`: +追加到 `RuntimeBackgroundIndexingManagerTests.swift`: ```swift func test_expand_emptyWhenRootAlreadyIndexed() async { @@ -1003,9 +1240,9 @@ Append to `RuntimeBackgroundIndexingManagerTests.swift`: } ``` -- [ ] **Step 2: Replace the placeholder `expandDependencyGraph` implementation** +- [ ] **Step 2: 替换占位 `expandDependencyGraph` 实现** -In `RuntimeBackgroundIndexingManager.swift` replace the existing stub with: +在 `RuntimeBackgroundIndexingManager.swift` 中将已有的 stub 替换为: ```swift func expandDependencyGraph(rootPath: String, depth: Int) @@ -1019,20 +1256,30 @@ func expandDependencyGraph(rootPath: String, depth: Int) let (path, level) = frontier.removeFirst() guard visited.insert(path).inserted else { continue } - if await engine.isImageIndexed(path: path) { continue } + // `try?` — if the engine errors out (e.g. remote XPC drops mid-batch), + // treat the image as unindexed; loadImageForBackgroundIndexing will + // surface a real failure later. This matches Evolution 0002 Alt D: + // failure ≠ indexed. + if (try? await engine.isImageIndexed(path: path)) == true { continue } - // Before recursing, confirm the image opens. If not, record a failed - // item and do not recurse. - if await engine.machOImage(for: path) == nil && path != rootPath { - // Root is allowed to be represented even if we cannot open it — - // loadImageForBackgroundIndexing will surface the failure later. + // Non-root paths that can't be opened as MachO go straight to + // `.failed` and don't recurse — saves a wasted dlopen attempt later. + // Root is always represented so that the batch has at least one item. + if path != rootPath && !(await engine.canOpenImage(at: path)) { + items.append(.init(id: path, resolvedPath: path, + state: .failed(message: "cannot open MachOImage"), + hasPriorityBoost: false)) + continue } items.append(.init(id: path, resolvedPath: path, state: .pending, hasPriorityBoost: false)) guard level < depth else { continue } - for dep in await engine.dependencies(for: path) { + // `try?` — if dependency lookup fails, treat as no deps; the path + // itself is still pending and will be retried on next batch. + let deps = (try? await engine.dependencies(for: path)) ?? [] + for dep in deps { if let resolved = dep.resolvedPath { if !visited.contains(resolved) { frontier.append((resolved, level + 1)) @@ -1050,15 +1297,15 @@ func expandDependencyGraph(rootPath: String, depth: Int) } ``` -- [ ] **Step 3: Run tests — expect pass** +- [ ] **Step 3: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeBackgroundIndexingManagerTests 2>&1 | xcsift ``` -Expected: all tests in the file pass, including the new ones. +预期:该文件中所有测试,包括新增的,均通过。 -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerCore @@ -1067,15 +1314,15 @@ git commit -m "feat(core): implement dependency graph BFS for background indexin --- -### Task 8: Implement concurrent batch execution with AsyncSemaphore +### 任务 8: 用 AsyncSemaphore 实现并发批次执行 -**Files:** -- Modify: `RuntimeBackgroundIndexingManager.swift` -- Test: append to `RuntimeBackgroundIndexingManagerTests.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingManager.swift` +- 测试: 追加到 `RuntimeBackgroundIndexingManagerTests.swift` -- [ ] **Step 1: Write failing tests** +- [ ] **Step 1: 写出失败测试** -Append: +追加: ```swift func test_batch_indexesAllPendingItems() async { @@ -1156,14 +1403,16 @@ Append: func exit() { lock.lock(); current -= 1; lock.unlock() } } - private final class InstrumentedEngine: BackgroundIndexingEngineRepresenting { + private final class InstrumentedEngine: BackgroundIndexingEngineRepresenting, + @unchecked Sendable + { let base: any BackgroundIndexingEngineRepresenting let counter: ConcurrencyCounter init(base: any BackgroundIndexingEngineRepresenting, counter: ConcurrencyCounter) { self.base = base; self.counter = counter } - func isImageIndexed(path: String) async -> Bool { - await base.isImageIndexed(path: path) + func isImageIndexed(path: String) async throws -> Bool { + try await base.isImageIndexed(path: path) } func loadImageForBackgroundIndexing(at path: String) async throws { counter.enter() @@ -1171,24 +1420,26 @@ Append: try await Task.sleep(nanoseconds: 20_000_000) try await base.loadImageForBackgroundIndexing(at: path) } - func mainExecutablePath() async -> String { await base.mainExecutablePath() } - func machOImage(for path: String) async -> MachOImage? { - await base.machOImage(for: path) + func mainExecutablePath() async throws -> String { + try await base.mainExecutablePath() + } + func canOpenImage(at path: String) async -> Bool { + await base.canOpenImage(at: path) + } + func rpaths(for path: String) async throws -> [String] { + try await base.rpaths(for: path) } - func rpaths(for path: String) async -> [String] { await base.rpaths(for: path) } func dependencies(for path: String) - async -> [(installName: String, resolvedPath: String?)] + async throws -> [(installName: String, resolvedPath: String?)] { - await base.dependencies(for: path) + try await base.dependencies(for: path) } } ``` -Add `import MachOKit` at the top of the test file if not already present. +- [ ] **Step 2: 用真正的执行替换 `runBatch` 桩** -- [ ] **Step 2: Replace the `runBatch` stub with real execution** - -In `RuntimeBackgroundIndexingManager.swift` replace `runBatch` and introduce a helper `runSingleIndex`: +在 `RuntimeBackgroundIndexingManager.swift` 中替换 `runBatch` 并引入辅助 `runSingleIndex`: ```swift private func runBatch(id: RuntimeIndexingBatchID) async { @@ -1272,15 +1523,15 @@ private func updateItemState(batchID: RuntimeIndexingBatchID, } ``` -- [ ] **Step 3: Run tests — expect pass** +- [ ] **Step 3: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeBackgroundIndexingManagerTests 2>&1 | xcsift ``` -Expected: all previous tests plus the 3 new ones pass. +预期:之前的所有测试加上 3 个新增测试通过。 -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerCore @@ -1289,15 +1540,15 @@ git commit -m "feat(core): concurrent batch execution with AsyncSemaphore" --- -### Task 9: Implement `cancelBatch` and `cancelAllBatches` +### 任务 9: 实现 `cancelBatch` 与 `cancelAllBatches` -**Files:** -- Modify: `RuntimeBackgroundIndexingManager.swift` -- Test: append to `RuntimeBackgroundIndexingManagerTests.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingManager.swift` +- 测试: 追加到 `RuntimeBackgroundIndexingManagerTests.swift` -- [ ] **Step 1: Write failing tests** +- [ ] **Step 1: 写出失败测试** -Append: +追加: ```swift func test_cancelBatch_stopsPendingItemsAndEmitsCancelledEvent() async { @@ -1342,9 +1593,9 @@ Append: } ``` -- [ ] **Step 2: Implement cancellation** +- [ ] **Step 2: 实现取消** -Add these methods to `RuntimeBackgroundIndexingManager`: +在 `RuntimeBackgroundIndexingManager` 中加入: ```swift public func cancelBatch(_ id: RuntimeIndexingBatchID) { @@ -1360,7 +1611,7 @@ public func cancelAllBatches() { } ``` -Update `finalize` to propagate the already-set `isCancelled` flag: +更新 `finalize` 以传播已经设置的 `isCancelled` 标志: ```swift private func finalize(id: RuntimeIndexingBatchID, cancelled: Bool) { @@ -1387,13 +1638,13 @@ private func finalize(id: RuntimeIndexingBatchID, cancelled: Bool) { } ``` -- [ ] **Step 3: Run tests — expect pass** +- [ ] **Step 3: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeBackgroundIndexingManagerTests 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerCore @@ -1402,68 +1653,48 @@ git commit -m "feat(core): cancelBatch and cancelAllBatches on indexing manager" --- -### Task 10: Implement `prioritize(imagePath:)` +### 任务 10: 实现 `prioritize(imagePath:)` -**Files:** -- Modify: `RuntimeBackgroundIndexingManager.swift` -- Test: append to `RuntimeBackgroundIndexingManagerTests.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingManager.swift` +- 测试: 追加到 `RuntimeBackgroundIndexingManagerTests.swift` -- [ ] **Step 1: Write failing tests** +- [ ] **Step 1: 写出失败测试** -Append: +追加: ```swift - func test_prioritize_movesPendingItemAhead() async { + func test_prioritize_emitsTaskPrioritizedEvent() async { + // Time-independent assertion: verify the manager emits + // `.taskPrioritized` for a pending path and does NOT emit it for + // running / absent paths. Load order would depend on sleep timing + // and is flaky on CI — event emission is the real contract. let engine = MockBackgroundIndexingEngine() - let deps = (0..<8).map { (installName: "/D\($0)", resolvedPath: "/D\($0)") } - engine.program(path: "/App", .init(dependencies: deps)) - for dep in deps { engine.program(path: dep.installName, .init()) } - - // Slow engine to keep concurrency 1 and make ordering observable. - final class Slow: BackgroundIndexingEngineRepresenting { - let base: MockBackgroundIndexingEngine - init(_ base: MockBackgroundIndexingEngine) { self.base = base } - func isImageIndexed(path: String) async -> Bool { - await base.isImageIndexed(path: path) - } - func loadImageForBackgroundIndexing(at path: String) async throws { - try await Task.sleep(nanoseconds: 30_000_000) - try await base.loadImageForBackgroundIndexing(at: path) - } - func mainExecutablePath() async -> String { await base.mainExecutablePath() } - func machOImage(for path: String) async -> MachOImage? { nil } - func rpaths(for path: String) async -> [String] { [] } - func dependencies(for path: String) async - -> [(installName: String, resolvedPath: String?)] - { - await base.dependencies(for: path) - } - } - let slow = Slow(engine) - let manager = RuntimeBackgroundIndexingManager(engine: slow) - let id = await manager.startBatch(rootImagePath: "/App", depth: 1, - maxConcurrency: 1, reason: .manual) - - // After a brief delay the root is indexing; prioritize /D5 so it runs - // immediately after the current task, ahead of D0..D4. - try? await Task.sleep(nanoseconds: 15_000_000) - await manager.prioritize(imagePath: "/D5") - _ = id + let deps = ["/D0", "/D1", "/D2"] + engine.program(path: "/App", .init( + dependencies: deps.map { ($0, $0) } + )) + for dep in deps { engine.program(path: dep, .init()) } + let manager = RuntimeBackgroundIndexingManager(engine: engine) - // Wait for completion and check the early portion of the load order. let events = manager.events let consumer = Task { () -> [String] in + var boosted: [String] = [] for await event in events { - if case .batchFinished = event { return engine.loadedOrder() } - if case .batchCancelled = event { return engine.loadedOrder() } + if case .taskPrioritized(_, let path) = event { + boosted.append(path) + } + if case .batchFinished = event { return boosted } + if case .batchCancelled = event { return boosted } } - return engine.loadedOrder() + return boosted } - let order = await consumer.value - // /D5 must come before the other deps (D0..D4 or D6..D7 after it). - let d5Index = order.firstIndex(of: "/D5") ?? Int.max - let d4Index = order.firstIndex(of: "/D4") ?? Int.max - XCTAssertLessThan(d5Index, d4Index) + _ = await manager.startBatch(rootImagePath: "/App", depth: 1, + maxConcurrency: 1, reason: .manual) + await manager.prioritize(imagePath: "/D2") + + let boosted = await consumer.value + XCTAssertEqual(boosted, ["/D2"]) } func test_prioritize_isNoOpForUnknownPath() async { @@ -1473,13 +1704,13 @@ Append: _ = await manager.startBatch(rootImagePath: "/App", depth: 0, maxConcurrency: 1, reason: .manual) await manager.prioritize(imagePath: "/does/not/exist") - // No crash; batch still completes. + // No crash; batch still completes. No .taskPrioritized emitted. } ``` -- [ ] **Step 2: Implement prioritize** +- [ ] **Step 2: 实现 prioritize** -Add to `RuntimeBackgroundIndexingManager`: +在 `RuntimeBackgroundIndexingManager` 中加入: ```swift public func prioritize(imagePath: String) { @@ -1496,13 +1727,13 @@ public func prioritize(imagePath: String) { } ``` -- [ ] **Step 3: Run tests — expect pass** +- [ ] **Step 3: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeBackgroundIndexingManagerTests 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerCore @@ -1511,45 +1742,44 @@ git commit -m "feat(core): prioritize pending item to head of queue" --- -## Phase 4 — Engine integration +## Phase 4 —— Engine 集成 -### Task 11: Hold `RuntimeBackgroundIndexingManager` on `RuntimeEngine` +### 任务 11: 在 `RuntimeEngine` 上持有 `RuntimeBackgroundIndexingManager` -**Files:** -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` (init area and new stored property) +**文件:** +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift`(init 区域和新增存储属性) -- [ ] **Step 1: Inspect RuntimeEngine init** +- [ ] **Step 1: 检查 RuntimeEngine init** ```bash -rg -n "init\(source|actor RuntimeEngine" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift | head +rg -n "init\(source|actor RuntimeEngine" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift | head ``` -Note the initializer signature so we can inject the manager without breaking callers. +记录初始化器签名,以便在不破坏调用方的前提下注入 manager。 -- [ ] **Step 2: Add the property and wire it up** +- [ ] **Step 2: 增加显式存储属性,并在 `init` 末尾初始化** -In `RuntimeEngine.swift`, add inside the actor: +actor 上的 `lazy var` 强制每次首次访问都通过 actor 隔离,初始化时机变得不直观,且与 `nonisolated` 属性访问器交互不顺畅。改用一个显式的隐式可解包存储属性,作为 `init` 的最后一行赋值: ```swift -public private(set) lazy var backgroundIndexingManager: RuntimeBackgroundIndexingManager = - RuntimeBackgroundIndexingManager(engine: self) -``` +// Near the other stored properties: +public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingManager! -`lazy` is supported inside actors in Swift 5.9+. If the compiler complains, replace with an explicit stored property initialized after `self` is available — move the assignment to the end of `init`: - -```swift +// Last line of init(source:...): self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) ``` -- [ ] **Step 3: Build** +为什么 IUO 而不是普通 `let`:`RuntimeEngine.init` 末尾把 `self` 交给 `RuntimeBackgroundIndexingManager(engine: self)` 时,所有其他 stored property 已经初始化完成(参见 `RuntimeEngine.swift:178-179`),因此不存在"前向引用 self"问题。真正需要 IUO 的原因是更纯粹的初始化时机偏好:把 manager 的构造放在 `init` 末尾、所有其它依赖到位之后,是最易读的写法;普通 `let` 要求在声明时给初值,把构造表达式上提到 stored-property 区域反而割裂了"engine 完成 → 构造 manager"这条线性叙事。manager 在 init 之后只读,不存在重新赋值或 nil 访问路径,IUO 的不安全面在此被结构性地约束住。 + +- [ ] **Step 3: 构建** ```bash cd RuntimeViewerCore && swift build 2>&1 | xcsift ``` -Expected: clean build. +预期:构建无报错。 -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -1558,22 +1788,22 @@ git commit -m "feat(core): expose backgroundIndexingManager on RuntimeEngine" --- -## Phase 5 — Settings +## Phase 5 —— Settings -### Task 12: Add `BackgroundIndexing` struct to `Settings+Types.swift` +### 任务 12: 在 `Settings+Types.swift` 中加入 `BackgroundIndexing` 结构体 -**Files:** -- Modify: `RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift` +**文件:** +- 修改: `RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift` -- [ ] **Step 1: Read the existing MCP struct to match its style** +- [ ] **Step 1: 阅读已有的 MCP 结构体以匹配风格** ```bash -rg -n "public struct MCP|public struct Notifications|public var mcp" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +rg -n "public struct MCP|public struct Notifications|public var mcp" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift ``` -- [ ] **Step 2: Append the new struct and root property** +- [ ] **Step 2: 追加新结构体与根属性** -In `Settings+Types.swift`, next to the other nested settings structs, add: +在 `Settings+Types.swift` 中、其他嵌套设置结构体旁,加入: ```swift @Codable @MemberInit public struct BackgroundIndexing { @@ -1584,21 +1814,24 @@ In `Settings+Types.swift`, next to the other nested settings structs, add: } ``` -In the root `Settings` struct, add a new stored property next to `mcp`: +在根 `Settings` 类中、紧挨 `mcp` 加入新存储属性。**必须**镜像现有字段的 `didSet { scheduleAutoSave() }` 模式(见 `Settings.swift:14-37` 中 `general` / `notifications` / `transformer` / `mcp` / `update` 全部使用这一形式),否则 toggle / depth / maxConcurrency 改动不会自动写盘: ```swift -@Default(BackgroundIndexing.default) public var backgroundIndexing: BackgroundIndexing +@Default(BackgroundIndexing.default) +public var backgroundIndexing: BackgroundIndexing = .init() { + didSet { scheduleAutoSave() } +} ``` -- [ ] **Step 3: Build the packages** +- [ ] **Step 3: 构建 packages** ```bash cd RuntimeViewerPackages && swift package update && swift build 2>&1 | xcsift ``` -Expected: clean build. +预期:构建无报错。 -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -1607,23 +1840,23 @@ git commit -m "feat(settings): add BackgroundIndexing settings struct" --- -### Task 13: Add the Settings UI page +### 任务 13: 添加 Settings UI 页面 -**Files:** -- Modify: `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift` -- Create: `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift` +**文件:** +- 修改: `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift` +- 创建: `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift` -- [ ] **Step 1: Read the existing Settings root view** +- [ ] **Step 1: 阅读已有 Settings 根视图** ```bash -rg -n "case general|case mcp|SettingsPage|contentView" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift | head -20 +rg -n "case general|case mcp|SettingsPage|contentView" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift | head -20 ``` -- [ ] **Step 2: Add the enum case and content switch arm** +- [ ] **Step 2: 增加枚举 case 和 content switch 分支** -In `SettingsRootView.swift`, add `case backgroundIndexing` to the `SettingsPage` enum. Match the formatting of existing cases. +在 `SettingsRootView.swift` 中给 `SettingsPage` 枚举添加 `case backgroundIndexing`,匹配现有 case 的格式。 -Provide the title and icon: +提供标题与图标: ```swift var title: String { @@ -1643,15 +1876,15 @@ var iconName: String { } ``` -In the `contentView` switch, add: +在 `contentView` switch 中加入: ```swift case .backgroundIndexing: BackgroundIndexingSettingsView() ``` -- [ ] **Step 3: Create the SwiftUI page** +- [ ] **Step 3: 创建 SwiftUI 页面** -File `BackgroundIndexingSettingsView.swift`: +文件 `BackgroundIndexingSettingsView.swift`: ```swift import SwiftUI @@ -1692,13 +1925,13 @@ public struct BackgroundIndexingSettingsView: View { } ``` -- [ ] **Step 4: Build** +- [ ] **Step 4: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI @@ -1707,24 +1940,24 @@ git commit -m "feat(settings-ui): Background Indexing settings page" --- -## Phase 6 — Coordinator (RuntimeViewerApplication) +## Phase 6 —— Coordinator (RuntimeViewerApplication) -### Task 14: Create `RuntimeBackgroundIndexingCoordinator` skeleton +### 任务 14: 创建 `RuntimeBackgroundIndexingCoordinator` 骨架 -**Files:** -- Create: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift` +**文件:** +- 创建: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift` -- [ ] **Step 1: Read DocumentState to understand the environment the coordinator will live in** +- [ ] **Step 1: 阅读 DocumentState 以了解 coordinator 将存活的环境** ```bash -rg -n "final class DocumentState|runtimeEngine|public var" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift | head -30 +rg -n "final class DocumentState|runtimeEngine|public var" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift | head -30 ``` -Note the name of the engine property (`runtimeEngine` is likely) and whether `DocumentState` already exposes an observable for `loadImage` completion (e.g. a Rx subject) — this determines the subscription wire-up in Task 15. +记录引擎属性的名称(很可能是 `runtimeEngine`),以及 `DocumentState` 是否已经为 `loadImage` 完成暴露了一个可观察对象(如 Rx subject) —— 这决定了任务 15 中的订阅接线方式。 -- [ ] **Step 2: Create the coordinator skeleton** +- [ ] **Step 2: 创建 coordinator 骨架** -File `RuntimeBackgroundIndexingCoordinator.swift`: +文件 `RuntimeBackgroundIndexingCoordinator.swift`: ```swift import Foundation @@ -1733,6 +1966,7 @@ import RuntimeViewerSettings import RxSwift import RxRelay +@MainActor public final class RuntimeBackgroundIndexingCoordinator { public struct AggregateState: Equatable, Sendable { public var hasActiveBatch: Bool @@ -1793,16 +2027,18 @@ public final class RuntimeBackgroundIndexingCoordinator { // MARK: - Event pump (AsyncStream → Relay) private func startEventPump() { + // The class is `@MainActor`, so this Task and its `for await` loop + // run on the main actor. `apply(event:)` can be called synchronously + // without an extra `MainActor.run` hop. eventPumpTask = Task { [weak self] in guard let self else { return } let stream = await self.engine.backgroundIndexingManager.events for await event in stream { - await MainActor.run { self.apply(event: event) } + self.apply(event: event) } } } - @MainActor private func apply(event: RuntimeIndexingEvent) { var batches = batchesRelay.value switch event { @@ -1834,7 +2070,12 @@ public final class RuntimeBackgroundIndexingCoordinator { refreshAggregate(batches: batches) } - @MainActor + private func mutating(_ value: T, _ mutate: (inout T) -> Void) -> T { + var copy = value + mutate(©) + return copy + } + private func refreshAggregate(batches: [RuntimeIndexingBatch]) { let hasActive = !batches.isEmpty let hasFailure = batches.contains { @@ -1852,21 +2093,17 @@ public final class RuntimeBackgroundIndexingCoordinator { progress: progress)) } } - -private func mutating(_ value: T, _ mutate: (inout T) -> Void) -> T { - var copy = value - mutate(©) - return copy -} ``` -- [ ] **Step 3: Build** +`mutating(_:_:)` 辅助函数现在是 coordinator 上的私有方法(参见上面插入位置)。它不是全局函数 —— 文件作用域的 `private` 仍会污染同模块未来文件,而私有方法把工具范围限定在需要它的 coordinator 内。 + +- [ ] **Step 3: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing @@ -1875,30 +2112,35 @@ git commit -m "feat(application): coordinator skeleton for background indexing" --- -### Task 15: Hook coordinator into document lifecycle — start `.appLaunch` batch +### 任务 15: 把 coordinator 接入 document 生命周期 —— 启动 `.appLaunch` 批次 -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingCoordinator.swift` -- [ ] **Step 1: Add settings access and startup entry point** +- [ ] **Step 1: 增加 settings 访问与启动入口** -Append to `RuntimeBackgroundIndexingCoordinator.swift`: +追加到 `RuntimeBackgroundIndexingCoordinator.swift`: ```swift extension RuntimeBackgroundIndexingCoordinator { public func documentDidOpen() { + // The class is `@MainActor`, so this Task inherits main-actor isolation + // and can mutate `documentBatchIDs` synchronously after the awaits. Task { [weak self] in guard let self else { return } - let settings = await self.currentBackgroundIndexingSettings() + let settings = self.currentBackgroundIndexingSettings() guard settings.isEnabled else { return } - let root = await engine.mainExecutablePath() - guard !root.isEmpty else { return } + // mainExecutablePath is `async throws` because remote (XPC / TCP) + // sources may fail; on launch we silently skip the batch in that + // case rather than surface the error to the user. + guard let root = try? await engine.mainExecutablePath(), + !root.isEmpty else { return } let id = await engine.backgroundIndexingManager.startBatch( rootImagePath: root, depth: settings.depth, maxConcurrency: settings.maxConcurrency, reason: .appLaunch) - await MainActor.run { self.documentBatchIDs.insert(id) } + self.documentBatchIDs.insert(id) } } @@ -1912,7 +2154,7 @@ extension RuntimeBackgroundIndexingCoordinator { } } - private func currentBackgroundIndexingSettings() async -> BackgroundIndexing { + private func currentBackgroundIndexingSettings() -> BackgroundIndexing { // Access the Settings snapshot via the project's existing mechanism. // If `Settings.shared` is the accessor, use it; adjust to match. Settings.shared.backgroundIndexing @@ -1920,15 +2162,15 @@ extension RuntimeBackgroundIndexingCoordinator { } ``` -Check the Settings singleton access pattern; `Settings.shared.backgroundIndexing` is the placeholder — substitute whatever the codebase actually uses (e.g. `@Dependency(\.settings)`). +检查 Settings 单例的访问模式;`Settings.shared.backgroundIndexing` 只是占位 —— 用代码库实际使用的方式替换(如 `@Dependency(\.settings)`)。 -- [ ] **Step 2: Build** +- [ ] **Step 2: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 3: Commit** +- [ ] **Step 3: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -1937,66 +2179,72 @@ git commit -m "feat(application): documentDidOpen / documentWillClose hooks for --- -### Task 16: Subscribe to image-loaded events — start per-image dependency batches +### 任务 16: 订阅镜像加载事件 —— 启动按镜像的依赖批次 -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingCoordinator.swift` -- [ ] **Step 1: Inspect the engine's image-loaded signal** +**为什么用 Combine `.values` 桥到 AsyncStream:** 任务 4.5 引入的 `imageDidLoadPublisher` 是 `some Publisher`(Combine)。Coordinator 已经用 `Task { for await event in stream }` 模式消费 manager 的 `AsyncStream`(任务 14 `startEventPump`),把 publisher 桥到 async-for-loop 复用同一模式,比再起一条 RxCombine bridge 简单。 -```bash -rg -n "didLoadImage|imageLoaded|imageDidLoad|PublishSubject.*String" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/ | head +- [ ] **Step 1: 添加按 path 的事件泵存储** + +在 coordinator 类内、与 `eventPumpTask` 并列: + +```swift +private var imageLoadedPumpTask: Task? ``` -Record the exact Rx observable or async sequence name. Adapt the subscription below to match. +更新 `deinit` 一并取消: + +```swift +deinit { + eventPumpTask?.cancel() + imageLoadedPumpTask?.cancel() +} +``` -- [ ] **Step 2: Add the subscription in the coordinator init, after `startEventPump()`** +- [ ] **Step 2: 在 coordinator init 的 `startEventPump()` 之后增加订阅** ```swift -private func subscribeToImageLoadedEvents() { - // Adjust to the actual observable name discovered in Step 1. - engine.imageLoadedSignal - .emitOnNext { [weak self] path in - guard let self else { return } - Task { await self.handleImageLoaded(path: path) } +private func startImageLoadedPump() { + // Class is `@MainActor`; this Task and `for await` loop run on the main + // actor. `handleImageLoaded` doesn't need a `MainActor.run` hop. + imageLoadedPumpTask = Task { [weak self] in + guard let self else { return } + // Combine.Publisher.values bridges to AsyncSequence on macOS 12+ / + // iOS 15+; the project's deployment targets satisfy this. Errors are + // Never on this publisher, so no try is needed. + for await path in self.engine.imageDidLoadPublisher.values { + await self.handleImageLoaded(path: path) } - .disposed(by: disposeBag) + } } private func handleImageLoaded(path: String) async { - let settings = await currentBackgroundIndexingSettings() + let settings = currentBackgroundIndexingSettings() guard settings.isEnabled else { return } // Avoid double-starting if the path is the main executable being opened - // at app launch — documentDidOpen already dispatched that batch. + // at app launch — documentDidOpen already dispatched that batch. Manager + // dedups batches that share rootImagePath + reason discriminant, so a + // second call here is a no-op rather than a wasted batch. let id = await engine.backgroundIndexingManager.startBatch( rootImagePath: path, depth: settings.depth, maxConcurrency: settings.maxConcurrency, reason: .imageLoaded(path: path)) - await MainActor.run { self.documentBatchIDs.insert(id) } + self.documentBatchIDs.insert(id) } ``` -Call `subscribeToImageLoadedEvents()` at the end of `init`. +在 `init` 末尾、`startEventPump()` 之后调用 `startImageLoadedPump()`。 -If the engine exposes only an `AsyncSequence` (not Rx), replace the subscription with: - -```swift -imageEventPumpTask = Task { [weak self] in - guard let self else { return } - for await path in self.engine.imageLoadedAsyncSequence { - await self.handleImageLoaded(path: path) - } -} -``` - -- [ ] **Step 3: Build** +- [ ] **Step 3: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -2005,114 +2253,102 @@ git commit -m "feat(application): subscribe to engine image-loaded events to spa --- -### Task 17: Expose `prioritize` entry point for sidebar selection - -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` - -This API already exists from Task 14 (`public func prioritize(imagePath:)`). This task wires it up from the sidebar side in Task 26's UI work; no coordinator changes are required here. Skip — the placeholder is intentional so we don't forget to check off the design requirement. - -- [ ] **Step 1: Confirm the public API is present** - -```bash -rg -n "public func prioritize" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift -``` - -Expected: one match. +### 任务 17: 通过 `withObservationTracking` 响应 Settings 变更 -- [ ] **Step 2: No commit. This is a checklist item, not a code change.** - ---- +**文件:** +- 修改: `RuntimeBackgroundIndexingCoordinator.swift` -### Task 18: React to Settings changes +**为什么用 `withObservationTracking`(不用 Combine):** `RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift:6` 的 `Settings` 声明为 `@Observable`。它没有 Combine publisher,`scheduleAutoSave` 路径只通过 `didSet` 触发。增加平行的 `PassthroughSubject` 会复制事实来源。`withObservationTracking` 是原生匹配 —— coordinator 在 `apply` 闭包内读取被跟踪的属性,Swift Observation 注册一次性观察者。我们在 `onChange` 内重新注册以在每次变更后保持观察。 -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` +- [ ] **Step 1: 添加 observation 导入与状态** -- [ ] **Step 1: Find the Settings change notification hook** +在 `RuntimeBackgroundIndexingCoordinator.swift` 顶部: -```bash -rg -n "SettingsStorage|NotificationCenter.*settings|scheduleAutoSave|public static var shared|SettingsPublisher" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerSettings/ | head -20 +```swift +import Observation +import RuntimeViewerSettings ``` -Decide which hook to use: -- If there is a Combine `Publisher` exposed on `Settings`, subscribe to it and convert to an Rx `Observable`. -- Else if there is a `NotificationCenter` post, subscribe to that notification name. -- Else add a minimal `PublishRelay` on `Settings` that `scheduleAutoSave` emits on, and subscribe. +在 coordinator 类上加私有状态: -Whichever you choose, document the decision in the commit message. +```swift +private var lastKnownIsEnabled: Bool = false +``` -- [ ] **Step 2: Implement the subscription** +- [ ] **Step 2: 实现 observation 循环** -Example with an assumed Combine publisher `Settings.shared.publisher`: +类已是 `@MainActor`,所有方法默认在主线程运行,不必再单独标 `@MainActor`。 ```swift private func subscribeToSettings() { - Settings.shared.publisher - .map(\.backgroundIndexing) - .removeDuplicates() - .sink { [weak self] settings in + withObservationTracking { + let snapshot = Settings.shared.backgroundIndexing + _ = snapshot.isEnabled + _ = snapshot.depth + _ = snapshot.maxConcurrency + } onChange: { [weak self] in + // onChange fires off the main actor synchronously after any mutation. + // Hop back to MainActor to (a) handle the change and (b) re-register. + Task { @MainActor [weak self] in guard let self else { return } - Task { await self.handleSettings(settings) } + self.handleSettingsChange() + self.subscribeToSettings() } - .store(in: &combineBag) + } } -private var lastKnownIsEnabled: Bool = false -private var combineBag: Set = [] - -private func handleSettings(_ settings: BackgroundIndexing) async { - let wasEnabled = await MainActor.run { self.lastKnownIsEnabled } - await MainActor.run { self.lastKnownIsEnabled = settings.isEnabled } - if !wasEnabled && settings.isEnabled { - documentDidOpen() // restart for the main executable - } else if wasEnabled && !settings.isEnabled { - await engine.backgroundIndexingManager.cancelAllBatches() +private func handleSettingsChange() { + let latest = Settings.shared.backgroundIndexing + let wasEnabled = lastKnownIsEnabled + lastKnownIsEnabled = latest.isEnabled + if !wasEnabled && latest.isEnabled { + documentDidOpen() // Scenario E on→off→on + } else if wasEnabled && !latest.isEnabled { + Task { [engine] in + await engine.backgroundIndexingManager.cancelAllBatches() + } } + // depth / maxConcurrency changes: intentional no-op; next startBatch picks + // up the new values. } ``` -Add `import Combine` at the top and call `subscribeToSettings()` from `init`. - -If the codebase does not have a Combine publisher on Settings, add one: +- [ ] **Step 3: 在 init 中播种初始状态并注册** -In `RuntimeViewerSettings/Settings.swift`, next to the storage: +类是 `@MainActor`,init 也在主线程,直接同步播种与订阅: ```swift -public let publisher: PassthroughSubject = .init() +// At end of init(documentState:) +self.lastKnownIsEnabled = Settings.shared.backgroundIndexing.isEnabled +self.subscribeToSettings() ``` -And in `scheduleAutoSave()`: - -```swift -publisher.send(self) -``` - -- [ ] **Step 3: Build** +- [ ] **Step 4: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 5: 提交** ```bash -git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift -git commit -m "feat(application): react to background indexing settings changes" +git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +git commit -m "feat(application): observe Settings.backgroundIndexing via withObservationTracking" ``` --- -## Phase 7 — Toolbar popover UI +## Phase 7 —— Toolbar 弹出框 UI -### Task 19: Create `BackgroundIndexingNode` and popover ViewModel +### 任务 18: 创建 `BackgroundIndexingNode` 与弹出框 ViewModel(在 `MainRoute` 上) -**Files:** -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift` -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverRoute.swift` -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift` +**文件:** +- 创建: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift` +- 创建: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift` -- [ ] **Step 1: Create `BackgroundIndexingNode`** +**为什么没有单独的 Route:** `MainCoordinator` 声明为 `final class MainCoordinator: SceneCoordinator`(`MainCoordinator.swift:11`)。它的 `Route` 已经绑定到 `MainRoute`;为 `BackgroundIndexingPopoverRoute` 增加第二个、有条件的 `Router` conformance 无法编译。改为给 `MainRoute` 加一个 case(任务 21),让 ViewModel 是 `ViewModel`。 + +- [ ] **Step 1: 创建 `BackgroundIndexingNode`** ```swift import RuntimeViewerCore @@ -2123,41 +2359,30 @@ enum BackgroundIndexingNode: Hashable { } ``` -- [ ] **Step 2: Create the route enum** - -```swift -import CocoaCoordinator - -@AssociatedValue(.public) -@CaseCheckable(.public) -public enum BackgroundIndexingPopoverRoute: Routable { - case openSettings - case dismiss -} -``` - -- [ ] **Step 3: Create the ViewModel** +- [ ] **Step 2: 在 `MainRoute` 上创建 ViewModel** ```swift import Foundation +import Observation import RuntimeViewerApplication import RuntimeViewerArchitectures import RuntimeViewerCore +import RuntimeViewerSettings import RxCocoa import RxSwift -final class BackgroundIndexingPopoverViewModel: - ViewModel -{ +final class BackgroundIndexingPopoverViewModel: ViewModel { @Observed private(set) var nodes: [BackgroundIndexingNode] = [] @Observed private(set) var isEnabled: Bool = false @Observed private(set) var hasAnyBatch: Bool = false + @Observed private(set) var hasAnyFailure: Bool = false @Observed private(set) var subtitle: String = "" private let coordinator: RuntimeBackgroundIndexingCoordinator + private let openSettingsRelay = PublishRelay() init(documentState: DocumentState, - router: any Router, + router: any Router, coordinator: RuntimeBackgroundIndexingCoordinator) { self.coordinator = coordinator @@ -2167,13 +2392,20 @@ final class BackgroundIndexingPopoverViewModel: struct Input { let cancelBatch: Signal let cancelAll: Signal + let clearFailed: Signal let openSettings: Signal } struct Output { let nodes: Driver<[BackgroundIndexingNode]> let isEnabled: Driver let hasAnyBatch: Driver + let hasAnyFailure: Driver let subtitle: Driver + // Forwarded to the ViewController so it can call + // `SettingsWindowController.shared.showWindow(nil)` directly —— mirrors + // MCPStatusPopoverViewController.swift:200-203 (no `MainRoute` case + // exists for openSettings). + let openSettings: Signal } func transform(_ input: Input) -> Output { @@ -2188,17 +2420,20 @@ final class BackgroundIndexingPopoverViewModel: .disposed(by: rx.disposeBag) coordinator.aggregateStateObservable - .map(Self.subtitleFor) - .asDriver(onErrorJustReturn: "") - .driveOnNext { [weak self] s in + .asDriver(onErrorDriveWith: .empty()) + .driveOnNext { [weak self] state in guard let self else { return } - subtitle = s + subtitle = Self.subtitleFor(state) + hasAnyFailure = state.hasAnyFailure } .disposed(by: rx.disposeBag) - // Settings isEnabled observation — reuse the same stream; - // alternatively project it from appDefaults. - isEnabled = Settings.shared.backgroundIndexing.isEnabled + // ViewModel base class (`open class ViewModel`) is + // `@MainActor`, so `transform` runs on the main actor and can call + // `subscribeToIsEnabled()` synchronously. Synchronous seed is what + // keeps the popover's first frame from flashing the "disabled" + // empty state when Settings is actually enabled. + subscribeToIsEnabled() input.cancelBatch.emitOnNext { [weak self] id in guard let self else { return } @@ -2210,19 +2445,42 @@ final class BackgroundIndexingPopoverViewModel: coordinator.cancelAllBatches() }.disposed(by: rx.disposeBag) + input.clearFailed.emitOnNext { [weak self] in + guard let self else { return } + coordinator.clearFailedBatches() + }.disposed(by: rx.disposeBag) + + // Forward the user signal to the output. The ViewController will + // open the Settings window directly — see MCPStatusPopover precedent. input.openSettings.emitOnNext { [weak self] in guard let self else { return } - router.trigger(.openSettings) + openSettingsRelay.accept(()) }.disposed(by: rx.disposeBag) return Output( nodes: $nodes.asDriver(), isEnabled: $isEnabled.asDriver(), hasAnyBatch: $hasAnyBatch.asDriver(), - subtitle: $subtitle.asDriver() + hasAnyFailure: $hasAnyFailure.asDriver(), + subtitle: $subtitle.asDriver(), + openSettings: openSettingsRelay.asSignal() ) } + private func subscribeToIsEnabled() { + withObservationTracking { + _ = Settings.shared.backgroundIndexing.isEnabled + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.isEnabled = Settings.shared.backgroundIndexing.isEnabled + self.subscribeToIsEnabled() // re-register + } + } + // Seed the current value synchronously on initial subscribe. + isEnabled = Settings.shared.backgroundIndexing.isEnabled + } + private static func renderNodes(from batches: [RuntimeIndexingBatch]) -> [BackgroundIndexingNode] { @@ -2248,46 +2506,48 @@ final class BackgroundIndexingPopoverViewModel: } ``` -- [ ] **Step 4: Add the new files to the Xcode project** +注意:`coordinator.clearFailedBatches()` 在任务 24 与"保留失败批次直至被清除"的 reducer 变更一起加入。如果你在任务 24 之前到达任务 18,把 `clearFailed` 绑定保留为 TODO 直通,回头再补。 -Using xcodeproj MCP, add the three files: +- [ ] **Step 3: 把两个新文件加入 Xcode 项目** + +使用 xcodeproj MCP,加入: ``` RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift -RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverRoute.swift RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift ``` -Each to the `RuntimeViewerUsingAppKit` target. +均加入 `RuntimeViewerUsingAppKit` target。**不存在** `BackgroundIndexingPopoverRoute.swift` —— 路由通过 `MainRoute`。 -- [ ] **Step 5: Build the app target** +- [ ] **Step 4: 构建 app target** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -Expected: clean build. +预期:构建无报错。 -- [ ] **Step 6: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerUsingAppKit -git commit -m "feat(ui): popover ViewModel and node enum for background indexing" +git commit -m "feat(ui): popover ViewModel on MainRoute + BackgroundIndexingNode" ``` --- -### Task 20: Build the popover ViewController +### 任务 19: 构建弹出框 ViewController -**Files:** -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift` +**文件:** +- 创建: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift` -- [ ] **Step 1: Create the ViewController** +- [ ] **Step 1: 创建 ViewController** ```swift import AppKit import RuntimeViewerArchitectures import RuntimeViewerCore +import RuntimeViewerSettingsUI // SettingsWindowController.shared import RuntimeViewerUI import RxCocoa import RxSwift @@ -2299,6 +2559,7 @@ final class BackgroundIndexingPopoverViewController: // MARK: - Relays private let cancelBatchRelay = PublishRelay() private let cancelAllRelay = PublishRelay() + private let clearFailedRelay = PublishRelay() private let openSettingsRelay = PublishRelay() // MARK: - Views @@ -2332,6 +2593,11 @@ final class BackgroundIndexingPopoverViewController: $0.bezelStyle = .accessoryBarAction $0.title = "Cancel All" } + private let clearFailedButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Clear Failed" + $0.isHidden = true // shown only when a retained failed batch exists + } private let closeButton = NSButton().then { $0.bezelStyle = .accessoryBarAction $0.title = "Close" @@ -2353,6 +2619,7 @@ final class BackgroundIndexingPopoverViewController: } let buttonStack = HStackView(spacing: 8) { cancelAllButton + clearFailedButton closeButton } buttonStack.alignment = .centerY @@ -2401,6 +2668,8 @@ final class BackgroundIndexingPopoverViewController: private func setupActions() { cancelAllButton.target = self cancelAllButton.action = #selector(cancelAllClicked) + clearFailedButton.target = self + clearFailedButton.action = #selector(clearFailedClicked) closeButton.target = self closeButton.action = #selector(closeClicked) openSettingsButton.target = self @@ -2408,6 +2677,7 @@ final class BackgroundIndexingPopoverViewController: } @objc private func cancelAllClicked() { cancelAllRelay.accept(()) } + @objc private func clearFailedClicked() { clearFailedRelay.accept(()) } @objc private func closeClicked() { dismiss(nil) } @objc private func openSettingsClicked() { openSettingsRelay.accept(()) } @@ -2416,6 +2686,7 @@ final class BackgroundIndexingPopoverViewController: let input = BackgroundIndexingPopoverViewModel.Input( cancelBatch: cancelBatchRelay.asSignal(), cancelAll: cancelAllRelay.asSignal(), + clearFailed: clearFailedRelay.asSignal(), openSettings: openSettingsRelay.asSignal() ) let output = viewModel.transform(input) @@ -2431,6 +2702,20 @@ final class BackgroundIndexingPopoverViewController: } .disposed(by: rx.disposeBag) + output.hasAnyFailure + .driveOnNext { [weak self] hasFailure in + guard let self else { return } + clearFailedButton.isHidden = !hasFailure + } + .disposed(by: rx.disposeBag) + + // Direct-call into the Settings window. There is no `MainRoute.openSettings` + // case — see MCPStatusPopoverViewController.swift:200-203 for the same pattern. + output.openSettings.emitOnNext { + SettingsWindowController.shared.showWindow(nil) + } + .disposed(by: rx.disposeBag) + Observable.combineLatest( output.isEnabled.asObservable(), output.hasAnyBatch.asObservable() @@ -2475,9 +2760,8 @@ extension BackgroundIndexingPopoverViewController: NSOutlineViewDataSource, NSOu return BackgroundIndexingNode.batch(batches[index]) } guard let node = item as? BackgroundIndexingNode, case .batch(let batch) = node - else { return BackgroundIndexingNode.batch(.init( - id: .init(), rootImagePath: "", depth: 0, reason: .manual, - items: [], isCancelled: false, isFinished: false)) + else { + preconditionFailure("unexpected outline item type: \(type(of: item))") } return BackgroundIndexingNode.item(batchID: batch.id, item: batch.items[index]) @@ -2536,17 +2820,17 @@ extension BackgroundIndexingPopoverViewController: NSOutlineViewDataSource, NSOu } ``` -- [ ] **Step 2: Add to Xcode project** +- [ ] **Step 2: 加入 Xcode 项目** -xcodeproj MCP `add_file`: `BackgroundIndexingPopoverViewController.swift` to the `RuntimeViewerUsingAppKit` target. +xcodeproj MCP `add_file`:将 `BackgroundIndexingPopoverViewController.swift` 加入 `RuntimeViewerUsingAppKit` target。 -- [ ] **Step 3: Build** +- [ ] **Step 3: 构建** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerUsingAppKit @@ -2555,13 +2839,13 @@ git commit -m "feat(ui): popover view controller for background indexing" --- -### Task 21: Build the Toolbar item view with `NSProgressIndicator` overlay +### 任务 20: 构建带 `NSProgressIndicator` 叠加的 Toolbar item view -**Files:** -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift` -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift` +**文件:** +- 创建: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift` +- 创建: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift` -- [ ] **Step 1: Create the custom view** +- [ ] **Step 1: 创建自定义 view** ```swift import AppKit @@ -2649,7 +2933,7 @@ final class BackgroundIndexingToolbarItemView: NSView { } ``` -- [ ] **Step 2: Create the `NSToolbarItem` subclass** +- [ ] **Step 2: 创建 `NSToolbarItem` 子类** ```swift import AppKit @@ -2687,17 +2971,17 @@ final class BackgroundIndexingToolbarItem: NSToolbarItem { } ``` -- [ ] **Step 3: Add both files to Xcode** +- [ ] **Step 3: 把两个文件都加入 Xcode** -xcodeproj MCP `add_file` twice to the `RuntimeViewerUsingAppKit` target. +xcodeproj MCP `add_file` 两次,均加入 `RuntimeViewerUsingAppKit` target。 -- [ ] **Step 4: Build** +- [ ] **Step 4: 构建** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerUsingAppKit @@ -2706,27 +2990,39 @@ git commit -m "feat(ui): toolbar item view and item class for background indexin --- -### Task 22: Register the toolbar item and the popover route +### 任务 21: 注册 toolbar item 并增加 `MainRoute.backgroundIndexing` case -**Files:** -- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift` -- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift` +**文件:** +- 修改: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift` +- 修改: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift` +- 修改: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift` -- [ ] **Step 1: Inspect the existing MCPStatus wiring** +**为什么是一个 route case 而不是单独的 `Router` conformance:** `MainCoordinator` 已是 `SceneCoordinator`。一个有条件的 `extension MainCoordinator: Router where Route == BackgroundIndexingPopoverRoute` 无法编译 —— `Route` 已固定到 `MainRoute`。因此本计划直接在 `MainRoute` 上扩展一个 case,并把弹出框的 `.openSettings` 通过已有的 `MainRoute.openSettings` case 路由。 + +- [ ] **Step 1: 检查现有的 MCPStatus 接线** ```bash -rg -n "mcpStatus|MCPStatusToolbarItem|toolbarDefaultItemIdentifiers|itemForItemIdentifier" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift | head -30 +rg -n "mcpStatus|MCPStatusToolbarItem|toolbarDefaultItemIdentifiers|itemForItemIdentifier" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift | head -30 ``` -- [ ] **Step 2: Register the new item** +也查看 `MainRoute.swift:18` —— 已有 case 字面量是 `case mcpStatus(sender: NSView)`,而非 `mcpStatusPopover`。匹配该命名风格。 + +- [ ] **Step 2: 在 `MainRoute` 上添加 route case** -In `MainToolbarController.swift`: +在 `MainRoute.swift` 中、紧挨 `case mcpStatus(sender: NSView)` 加入: + +```swift +case backgroundIndexing(sender: NSView) +``` + +(无 `Popover` 后缀 —— 与同级 `mcpStatus` 先例一致。) + +- [ ] **Step 3: 在 `MainToolbarController` 中注册 toolbar item** ```swift override func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - // append to the existing list var ids = super.toolbarDefaultItemIdentifiers(toolbar) ids.append(BackgroundIndexingToolbarItem.identifier) return ids @@ -2768,36 +3064,22 @@ private func wireBackgroundIndexing(item: BackgroundIndexingToolbarItem) { item.tapRelay .emitOnNext { [weak self] sender in guard let self else { return } - mainCoordinator.trigger(.backgroundIndexingPopover(sender: sender)) + mainCoordinator.trigger(.backgroundIndexing(sender: sender)) } .disposed(by: rx.disposeBag) } ``` -The exact field names (`documentState`, `mainCoordinator`) must match `MainToolbarController`'s existing fields — adjust if the property is spelled differently. +精确字段名(`documentState`、`mainCoordinator`)必须匹配 `MainToolbarController` 已有字段 —— 如果属性拼写不同请相应调整。 -- [ ] **Step 3: Add the route case on `MainRoute` and handle it** - -Find `MainRoute`: - -```bash -rg -n "enum MainRoute|case mcpStatusPopover" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/ | head -``` - -Add a new case next to `mcpStatusPopover`: +- [ ] **Step 4: 在 `MainCoordinator.prepareTransition` 处理新 case** ```swift -case backgroundIndexingPopover(sender: NSView) -``` - -In `MainCoordinator.prepareTransition`, add: - -```swift -case .backgroundIndexingPopover(let sender): +case .backgroundIndexing(let sender): let viewController = BackgroundIndexingPopoverViewController() let viewModel = BackgroundIndexingPopoverViewModel( documentState: documentState, - router: self, + router: self, // already Router coordinator: documentState.backgroundIndexingCoordinator) viewController.setupBindings(for: viewModel) return .presentOnRoot( @@ -2808,62 +3090,51 @@ case .backgroundIndexingPopover(let sender): behavior: .transient)) ``` -Since `MainCoordinator` doesn't yet implement `BackgroundIndexingPopoverRoute`, you also need to handle the child route at the main coordinator level. Either: - -(a) Add `MainCoordinator` as a conformer / router of `BackgroundIndexingPopoverRoute` and translate `.openSettings` into `MainRoute.openSettings`; or - -(b) Pass `self` of `MainCoordinator` bridged through a small adapter that forwards `BackgroundIndexingPopoverRoute` cases. Simplest is (a). - -```swift -extension MainCoordinator: Router where Route == BackgroundIndexingPopoverRoute { - public func contextTrigger(_ route: BackgroundIndexingPopoverRoute, - with options: TransitionOptions, - completion: PresentationHandler?) - { - switch route { - case .openSettings: trigger(.openSettings, with: options, - completion: completion) - case .dismiss: trigger(.dismiss, with: options, completion: completion) - } - } -} -``` - -If `MainCoordinator` already has a generic `Router` conformance and cannot add a second one, wrap it with a thin adapter class `BackgroundIndexingPopoverRouterAdapter` that forwards. +不需要 `extension MainCoordinator: Router where Route == ...` 包装 —— `self` 已经是 `Router`,作为 ViewModel 的 router 注入即可。弹出框的 `Open Settings` 按钮**不**经 router:`MainRoute` 没有 `openSettings` case;ViewController 在 `setupBindings` 中订阅 `output.openSettings` 直接调用 `SettingsWindowController.shared.showWindow(nil)`(与 `MCPStatusPopoverViewController` 完全相同的处理方式)。 -- [ ] **Step 4: Build** +- [ ] **Step 5: 构建** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 5: Commit** +- [ ] **Step 6: 提交** ```bash git add RuntimeViewerUsingAppKit -git commit -m "feat(ui): register background indexing toolbar item and popover route" +git commit -m "feat(ui): toolbar item + MainRoute.backgroundIndexing popover route" ``` --- -## Phase 8 — Integration and QA +## Phase 8 —— 集成与 QA -### Task 23: Hold a coordinator on `DocumentState` and invoke lifecycle hooks +### 任务 22: 在 `DocumentState` 上持有 coordinator,并调用生命周期钩子 -**Files:** -- Modify: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift` -- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift` +**文件:** +- 修改: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift` +- 修改: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift` -- [ ] **Step 1: Add the coordinator property to `DocumentState`** +- [ ] **Step 1: 给 `DocumentState` 添加 coordinator 属性并强化 `runtimeEngine` 不变量** ```swift +/// Immutable for the lifetime of the Document. The property is declared +/// `@Observed` for historical UI reasons, but callers MUST NOT reassign it. +/// The background indexing coordinator (and any future per-engine actor) +/// captures this reference at init time; reassignment would silently route +/// work to a stale engine. +@Observed +public var runtimeEngine: RuntimeEngine = .local + public private(set) lazy var backgroundIndexingCoordinator = RuntimeBackgroundIndexingCoordinator(documentState: self) ``` -- [ ] **Step 2: Invoke lifecycle hooks from `Document`** +编辑 `DocumentState.swift:10-11` 处 `runtimeEngine` 的现有声明,加入上面的 doc comment;保留类型与初值不变。 -In `Document.swift`: +- [ ] **Step 2: 在 `Document` 中调用生命周期钩子** + +在 `Document.swift`: ```swift override func makeWindowControllers() { @@ -2877,17 +3148,17 @@ override func close() { } ``` -Check the current `makeWindowControllers` / `close` implementation before editing; splice the lines in without removing existing logic. +编辑前先检查现有的 `makeWindowControllers` / `close` 实现;插入这些行而不删除现有逻辑。 -- [ ] **Step 3: Build (package + app)** +- [ ] **Step 3: 构建(package + app)** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift -cd /Volumes/Code/Personal/RuntimeViewer +cd /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add RuntimeViewerPackages RuntimeViewerUsingAppKit @@ -2896,20 +3167,20 @@ git commit -m "feat(app): wire background indexing coordinator into Document lif --- -### Task 24: Wire sidebar selection → `prioritize` +### 任务 23: 把 sidebar 选中接到 `prioritize` -**Files:** -- Modify: the coordinator or VC that observes sidebar selection (likely `MainCoordinator` or `SidebarCoordinator`) +**文件:** +- 修改: 观察 sidebar 选中的 coordinator 或 VC(很可能是 `MainCoordinator` 或 `SidebarCoordinator`) -- [ ] **Step 1: Find the sidebar image selection signal** +- [ ] **Step 1: 找到 sidebar 镜像选中信号** ```bash -rg -n "imageSelected|didSelectImage|sidebar.*Selected" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/ /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/ | head -20 +rg -n "imageSelected|didSelectImage|sidebar.*Selected" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/ /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/ | head -20 ``` -Record the exact signal name and where it's published. +记录精确的信号名称及其发布位置。 -- [ ] **Step 2: In the sidebar coordinator init (or wherever selection is handled), add:** +- [ ] **Step 2: 在 sidebar coordinator init(或处理选中的位置)中加入:** ```swift sidebarViewModel.$selectedImagePath @@ -2920,15 +3191,15 @@ sidebarViewModel.$selectedImagePath .disposed(by: rx.disposeBag) ``` -Use whichever observable already tracks sidebar image selection. If there isn't one, promote the existing relay to `public` and use it. +使用任何已经跟踪 sidebar 镜像选中的 observable。如果没有,把已有 relay 提升为 `public` 并使用。 -- [ ] **Step 3: Build** +- [ ] **Step 3: 构建** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 4: 提交** ```bash git add . @@ -2937,91 +3208,126 @@ git commit -m "feat(app): prioritize indexing when user selects an image in side --- -### Task 25: Trigger a single `reloadData` per batch finish +### 任务 24: 保留失败批次;每个批次结束时刷新一次镜像列表 -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingCoordinator.swift` -- [ ] **Step 1: After `apply(event:)` handles `.batchFinished` / `.batchCancelled`, invoke `engine.reloadData` once** +**为什么保留失败批次:** Toolbar 状态 `.hasFailures(...)` 由 coordinator 的 `aggregateState` 派生。如果 `.batchFinished` 立即移除批次 —— 即便包含 `.failed` 项 —— toolbar 永远不会浮现失败。本任务修改 `.batchFinished` / `.batchCancelled` reducer:干净完成与取消会移除;含任意 `.failed` 项的完成保留在 `batchesRelay` 中,直到用户从弹出框调用 `clearFailedBatches()`。 -Change the existing `apply(event:)` branch: +- [ ] **Step 1: 更新 `apply(event:)` reducer 中的 `.batchFinished` / `.batchCancelled`** ```swift -case .batchFinished(let finished), .batchCancelled(let finished): - batches.removeAll { $0.id == finished.id } - documentBatchIDs.remove(finished.id) +case .batchFinished(let finished): + if finished.items.contains(where: { if case .failed = $0.state { true } else { false } }) { + // Keep the failed batch in the list until the user dismisses it. + if let idx = batches.firstIndex(where: { $0.id == finished.id }) { + batches[idx] = finished + } + } else { + batches.removeAll { $0.id == finished.id } + documentBatchIDs.remove(finished.id) + } Task { [engine] in await engine.reloadData(isReloadImageNodes: false) } + +case .batchCancelled(let cancelled): + // Cancellation always removes — user already acknowledged the outcome. + batches.removeAll { $0.id == cancelled.id } + documentBatchIDs.remove(cancelled.id) + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } +``` + +- [ ] **Step 2: 在 coordinator 公共表面加入 `clearFailedBatches()`** + +```swift +public func clearFailedBatches() { + // Class is `@MainActor`; we're already on the main thread when called + // from the popover's button. No hop required. + let remaining = batchesRelay.value.filter { batch in + !batch.items.contains { if case .failed = $0.state { true } else { false } } + } + batchesRelay.accept(remaining) + refreshAggregate(batches: remaining) +} ``` -- [ ] **Step 2: Build** +这是任务 18 中弹出框 ViewModel 从 `Clear Failed` 按钮输入调用的方法。 + +- [ ] **Step 3: 更新 `refreshAggregate`,使 `hasAnyFailure` 考虑保留的批次** + +已有的 `hasAnyFailure` 计算已经扫描 `batches` 中的 `.failed` 项,无需更改 —— 保留的失败批次会留在聚合状态中。 + +- [ ] **Step 4: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 3: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift -git commit -m "feat(application): refresh engine image list once per finished batch" +git commit -m "feat(application): retain failed batches + single reloadData per batch finish" ``` --- -### Task 26: Full build, run tests, manual QA +### 任务 25: 完整构建、跑测试、手动 QA -- [ ] **Step 1: Run the full Core test suite** +- [ ] **Step 1: 跑完整 Core 测试套件** ```bash cd RuntimeViewerCore && swift test 2>&1 | xcsift ``` -Expected: all tests pass. +预期:所有测试通过。 -- [ ] **Step 2: Run the full Packages build** +- [ ] **Step 2: 完整构建 Packages** ```bash -cd /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerPackages && swift package update && swift build 2>&1 | xcsift +cd /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerPackages && swift package update && swift build 2>&1 | xcsift ``` -- [ ] **Step 3: Build the app** +- [ ] **Step 3: 构建 app** ```bash -cd /Volumes/Code/Personal/RuntimeViewer && xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift +cd /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer && xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 4: Manual QA checklist** +- [ ] **Step 4: 手动 QA 清单** -Launch the debug app and verify, ticking each box: +启动 debug app 并逐项验证: -- [ ] With Background Indexing disabled in Settings, the toolbar item shows the faded idle icon and the popover shows the "disabled" empty state. -- [ ] Enabling the toggle in Settings triggers a new batch for the app's main executable; the toolbar icon starts spinning; the popover shows the batch with items progressing. -- [ ] Reducing depth / maxConcurrency while a batch is running does not affect that batch. -- [ ] A new batch after changing settings uses the new values (verify by inspecting `items.count` for a deep-tree image). -- [ ] Loading a new image (File → Open) spawns a second batch named after the new image; both batches progress concurrently. -- [ ] Clicking the batch's cancel button (⊘) stops the batch; its unfinished items become grey; the toolbar icon returns to idle when no batches remain. -- [ ] The "Cancel All" button in the popover cancels every batch. -- [ ] Selecting an image in the sidebar that is currently pending in a batch shows a `(priority)` tag on its popover row and it runs next. -- [ ] An image with an unresolvable `@rpath` dependency renders a red ✗ row with the install name and the error message. -- [ ] Closing the Document cancels its batches; the toolbar icon for that window resets to idle. +- [ ] Settings 中禁用 Background Indexing 时,toolbar 项显示淡化的 idle 图标,弹出框显示"已禁用"空状态。 +- [ ] 在 Settings 启用开关会为 app 主可执行触发新批次;toolbar 图标开始旋转;弹出框显示批次及其项进展。 +- [ ] 批次运行中减小 depth / maxConcurrency 不会影响该批次。 +- [ ] 设置变更后启动的新批次使用新值(通过查看深度依赖树镜像的 `items.count` 验证)。 +- [ ] 加载新镜像(File → Open)会启动以新镜像命名的第二个批次;两个批次并行进行。 +- [ ] 点击批次的取消按钮(⊘)停止该批次;其未完成项变灰;当无批次时 toolbar 图标返回 idle。 +- [ ] 弹出框中的 "Cancel All" 按钮取消所有批次。 +- [ ] 在 sidebar 选中目前在批次中 pending 的镜像会让其弹出框行显示 `(priority)` 标签,并下一个运行。 +- [ ] 包含无法解析 `@rpath` 依赖的镜像渲染为红色 ✗ 行,并显示 install name 与错误信息。 +- [ ] 关闭 Document 取消其批次;该窗口的 toolbar 图标重置为 idle。 -- [ ] **Step 5: Commit the manual verification checklist outcome (optional)** +- [ ] **Step 5: 提交手动验证清单结果(可选)** -If all boxes tick, no code change is required. Otherwise, fix the failing item in a new task, then re-run Step 4. +如果所有项都打勾,无需代码改动。否则在新任务中修复失败项,然后重新执行 Step 4。 --- -### Task 27: Open a pull request +### 任务 26: 提交 pull request -- [ ] **Step 1: Push the branch** +- [ ] **Step 1: 推送分支** ```bash git push -u origin feature/runtime-background-indexing ``` -- [ ] **Step 2: Create the PR** +- [ ] **Step 2: 创建 PR** ```bash gh pr create --title "feat: background indexing" --body "$(cat <<'EOF' @@ -3033,29 +3339,84 @@ gh pr create --title "feat: background indexing" --body "$(cat <<'EOF' ## Test plan - [ ] `swift test` passes in `RuntimeViewerCore` (unit tests for value types, `DylibPathResolver`, manager behavior). - [ ] App builds cleanly for macOS. -- [ ] Manual QA checklist in `Documentations/Plans/2026-04-24-background-indexing-plan.md` (Task 26) executed end-to-end. +- [ ] Manual QA checklist in `Documentations/Plans/2026-04-24-background-indexing-plan.md` (Task 25) executed end-to-end. ## Design -See [2026-04-24-background-indexing-design.md](Documentations/Plans/2026-04-24-background-indexing-design.md). +See [0002-background-indexing.md](../Evolution/0002-background-indexing.md). EOF )" ``` --- -## Self-Review Summary - -- **Spec coverage:** every section of the design doc has at least one task. - - `Loaded vs Indexed` → Task 3 (`isImageIndexed`, `hasCachedSection`). - - Value types → Task 1. - - `DylibPathResolver` → Task 2. - - Engine new APIs → Task 4. - - Manager (protocol, skeleton, BFS, concurrency, cancel, prioritize) → Tasks 5-10. - - Engine integration → Task 11. - - Settings → Tasks 12-13. - - Coordinator (lifecycle, image loaded, Sidebar prioritize binding, reload refresh, Settings reaction) → Tasks 14-18, 24, 25. - - UI (Node, ViewModel, VC, toolbar view + item, registration, route) → Tasks 19-22. - - Integration (Document wiring) → Task 23. - - Manual QA → Task 26. -- **Placeholder scan:** no `TODO` / `TBD` patterns in step content. Step 1 of several tasks asks the engineer to confirm an API name — these are verification steps, not placeholders. The one "intentional checklist task" (Task 17) is called out as such and has no work to do. -- **Type consistency:** `RuntimeIndexingBatchID`, `RuntimeIndexingBatch`, `RuntimeIndexingTaskState`, `RuntimeIndexingEvent`, `BackgroundIndexingToolbarState`, `BackgroundIndexing`, `BackgroundIndexingNode`, `BackgroundIndexingPopoverViewModel`, `BackgroundIndexingPopoverViewController`, `BackgroundIndexingToolbarItem`, `BackgroundIndexingToolbarItemView`, `RuntimeBackgroundIndexingManager`, `RuntimeBackgroundIndexingCoordinator`, `DylibPathResolver`, `BackgroundIndexingEngineRepresenting` — all cross-referenced names match between their definition task and the tasks that consume them. +## 自审小结 + +- **规范覆盖:** evolution 提案的每一节都至少对应一个任务。 + - Package 接线(Semaphore 依赖)→ 任务 0。 + - 值类型(全部 `Hashable`)+ `ResolvedDependency` → 任务 1。 + - `DylibPathResolver` → 任务 2。 + - `Loaded vs Indexed` + `request/remote` 分发的 `isImageIndexed` → 任务 3。 + - Engine 新 API(`mainExecutablePath`、`loadImageForBackgroundIndexing`)带 `request/remote` → 任务 4;`imageDidLoadPublisher` → 任务 4.5。 + - Manager(协议 + mock、骨架、BFS、并发、取消、prioritize)→ 任务 5-10。 + - Engine 集成(非 `lazy` 存储 manager)→ 任务 11。 + - Settings → 任务 12-13。 + - Coordinator(生命周期、镜像加载、通过 `withObservationTracking` 观察 Settings)→ 任务 14-17。 + - UI(`MainRoute` 上的 Node + ViewModel、带 `preconditionFailure` 数据源的 VC、toolbar view + item、`MainRoute.backgroundIndexing` 注册)→ 任务 18-21。 + - 集成(Document 接线 + `runtimeEngine` 不变量 doc 注释)→ 任务 22。 + - Sidebar → prioritize → 任务 23。 + - 保留失败批次 + 刷新镜像列表 → 任务 24。 + - 手动 QA → 任务 25。 +- **review 决策已落实:** 2026-04-24 review 中三条头部决策 —— 通过 `withObservationTracking` 处理 Settings(任务 17)、`BackgroundIndexingPopoverRoute` 合入 `MainRoute`(任务 18/21)、engine 方法的 `request/remote` 分发(任务 3/4)—— 均有专属任务与显式理由段落。 +- **类型一致性:** `RuntimeIndexingBatchID`、`RuntimeIndexingBatch`、`RuntimeIndexingTaskState`、`RuntimeIndexingEvent`、`RuntimeIndexingBatchReason`、`RuntimeIndexingTaskItem`、`ResolvedDependency`、`BackgroundIndexingToolbarState`、`BackgroundIndexing`、`BackgroundIndexingNode`、`BackgroundIndexingPopoverViewModel`、`BackgroundIndexingPopoverViewController`、`BackgroundIndexingToolbarItem`、`BackgroundIndexingToolbarItemView`、`RuntimeBackgroundIndexingManager`、`RuntimeBackgroundIndexingCoordinator`、`DylibPathResolver`、`BackgroundIndexingEngineRepresenting` —— 所有交叉引用名称在定义任务与消费任务之间一致。任何位置都没有引入 `BackgroundIndexingPopoverRoute` 类型。 + +--- + +## Post-review fixes (2026-04-28) + +`feature/runtime-background-indexing` 已合并 Task 0–24 之后,implementation-review 与 ultrareview 提出的 3 条高优先级问题在原分支上后续修补,不重新走 plan-task 流程,但记录在此以便未来追溯。 + +### Fix #1 — N1 RuntimeEngine ↔ Manager 循环引用 + +**问题来源:** ultrareview N1。`engine.backgroundIndexingManager` 强持 manager + manager 的 `private let engine: any BackgroundIndexingEngineRepresenting` 强持 engine = 跨 source switch 累积泄漏(每次 attach/detach 漏一对 engine + manager + section caches)。 + +**改动:** +- `BackgroundIndexingEngineRepresenting.swift`:`: Sendable` → `: AnyObject, Sendable` +- `RuntimeBackgroundIndexingManager.swift`:`private let engine` → `private unowned let engine` +- `RuntimeBackgroundIndexingManagerTests.swift`:加 `aliveObjects: [AnyObject]` + `keep(_:) -> T` helper,把所有 `MockBackgroundIndexingEngine()` / `InstrumentedEngine(...)` 局部包成 `keep(...)`。tearDown 清空。原因:测试里 mock 与 manager 是平行 local,unowned 在 ARC 跨 await 释放 mock 后会读到悬空指针。 + +**验证:** `swift test` —— 412/412 通过(此前 `test_expand_dedupsSharedDependencies` 因 `Fatal error: Attempted to read an unowned reference but object 0x... was already destroyed` 失败,加 keep 后稳定通过)。 + +### Fix #2 — I3 / N2 source switch coordinator staleness + +**问题来源:** implementation-review I3 / ultrareview N2。Coordinator 在 init 时一次性快照 `documentState.runtimeEngine`;`MainCoordinator.prepareTransition(.main(...))` 改写 engine 后 coordinator 的 pump、`documentBatchIDs` cancel、`prioritize` 全部走旧 manager。 + +**改动:** +- `RuntimeBackgroundIndexingCoordinator.swift`: + - `engine`: `let` → `var`(见类型 doc comment) + - 加 `bootstrapEngineObservation()`(init 末尾调用),订阅 `documentState.$runtimeEngine.skip(1)`(`@Observed` 暴露的 RxSwift `BehaviorRelay`) + - 加 `handleEngineSwap(to:)`:取旧 engine 与 `documentBatchIDs` 快照 → 取消旧 pumps → fire-and-forget 取消旧 manager 上 doc batches → 清 `documentBatchIDs` / `batchesRelay` / `aggregateRelay` → `engine = newEngine` → 重启 `startEventPump` / `startImageLoadedPump` → 若 isEnabled 重新 `documentDidOpen()` +- `DocumentState.swift`:`runtimeEngine` doc comment 改为"reassignable;coordinator subscribes via `$runtimeEngine` and rewires" + +**验证:** `swift build` RuntimeViewerPackages 干净;coordinator 的事件归约逻辑 / 现有 manager 测试无变化,无回归。 + +### Fix #3 — N4 DylibPathResolver 拒绝 dyld shared cache 系统镜像 + +**问题来源:** ultrareview N4。Apple Silicon 上 `/usr/lib/libobjc.A.dylib`、`/usr/lib/libSystem.B.dylib`、系统 framework 等无磁盘文件,resolver 走 `fileManager.fileExists` 一律返回 `nil` → BFS 把每个系统依赖标 `.failed("path unresolved")`,toolbar 永久红徽。 + +**改动:** +- `DyldUtilities.swift`:加 `package static func isInDyldSharedCache(_ path: String) -> Bool`,Set-cache。`invalidDyldSharedCacheImagePathsCache()` 同步清 Set 缓存。**字面比较,不规范化** —— cache 中存的是平台原生形式(macOS versioned,iOS unversioned),与 install name 不一致时让 BFS 走真实失败,见 Evolution 假设 #4 与决策日志 2026-04-28(N4) +- `DylibPathResolver.swift`:加 `private func pathExists(_:) -> Bool`,先 `fileManager.fileExists`,再 `DyldUtilities.isInDyldSharedCache`,任一通过即可。所有 4 处 `fileManager.fileExists(atPath:)` 替换 +- `DylibPathResolverTests.swift`:加 `test_absolutePath_acceptsDyldSharedCachePath`,从 `[Foundation.framework/Foundation, CoreFoundation.framework/CoreFoundation, /usr/lib/libobjc.A.dylib, /usr/lib/libSystem.B.dylib]` 取第一个本机 `isInDyldSharedCache` 命中的路径,断言 `fileExists == false`、resolver 返回原路径。`XCTSkipUnless` 处理 cache 不可访问的环境 + +**验证:** macOS host 测试中 `/usr/lib/libobjc.A.dylib` 命中,确认 install name 形式与 cache 字面匹配的路径走得通;不匹配的(macOS 上的 unversioned framework install name)按预期失败。 + +### 未处理(本轮范围外) + +- **implementation-review I5 / ultrareview Pre-1**:`isImageIndexed` patch 路径 / `loadImageForBackgroundIndexing` 不 patch —— 仅 iOS Simulator(`DYLD_ROOT_PATH` 非空)激活,绑 iOS Simulator 支持工作,不在本轮 +- **implementation-review I1 / I2 / I4 / I6,Minor M1–M10,ultrareview N3 / Nit-1 / Nit-2 / Nit-3**:中低优先级,作为后续 follow-up + +### 文档同步 + +- `Documentations/Evolution/0002-background-indexing.md`:第 174 / 148 行附近的协议 / manager 段落、第 261 行 coordinator 职责、新增场景 G、假设 #1 撤销 + #4 新增、决策日志 2026-04-28 三条 +- `Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md`:I3 标已修 +- `Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md`:N1 / N2 / N4 标已修 diff --git a/Documentations/Plans/2026-04-29-background-indexing-history-plan.md b/Documentations/Plans/2026-04-29-background-indexing-history-plan.md new file mode 100644 index 00000000..59523f0f --- /dev/null +++ b/Documentations/Plans/2026-04-29-background-indexing-history-plan.md @@ -0,0 +1,937 @@ +# Background Indexing — History Section Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an in-memory `HISTORY` section to `BackgroundIndexingPopoverViewController` so users can review every batch produced during the current document session (success / failure / cancelled), not just active or failure-retained batches. + +**Architecture:** New `historyRelay` on `RuntimeBackgroundIndexingCoordinator` parallel to the existing `batchesRelay`. Finalized batches flow into history via the existing `apply(event:)` reduction. `BackgroundIndexingNode` gains a `.section(SectionKind, batches:)` case so the outline renders two top-level groups (`ACTIVE` always, `HISTORY` when non-empty). The popover's `Clear Failed` button is replaced by `Clear History`, which empties the new relay. + +**Tech Stack:** Swift 6.2, RxSwift / RxCocoa / RxAppKit (staged-changeset diffing), `@Observable` state, AppKit `NSOutlineView` with `OutlineNodeType` / `Differentiable` from `RxAppKit`. + +**Spec:** `Documentations/Evolution/0002-background-indexing.md` (2026-04-29 revisions — new History section, Alternative E revision, decision log entry). + +--- + +## Pre-Flight + +The working tree at plan-write time contains **pre-existing uncommitted changes** unrelated to this feature (Core renames `BackgroundIndexingEngineRepresenting.swift` → `RuntimeBackgroundIndexingEngineRepresenting.swift`, `ResolvedDependency.swift` → `RuntimeResolvedDependency.swift`, plus modifications to `RuntimeBackgroundIndexingManager.swift`, `RuntimeEngine+BackgroundIndexing.swift`, `MockBackgroundIndexingEngine.swift`, `RuntimeBackgroundIndexingManagerTests.swift`). + +**Before starting this plan**, decide one of: + +1. **Commit them first** under their own message (e.g. `refactor(core): adopt Runtime prefix for indexing helper types`) so this feature's commits stay focused. +2. **Stash them** (`git stash push --keep-index -m "pre-history-feature renames" -- RuntimeViewerCore/`) and pop after Task 4. +3. **Bundle them** if the engineer reviewing knows they belong with this feature (unlikely — check with the user first). + +Default recommendation: **option 1**. Verify they build cleanly first. + +The 0002 spec edits (`M Documentations/Evolution/0002-background-indexing.md`) ARE part of this feature — they should land in Task 1's commit so the spec and implementation arrive together. + +--- + +## File Structure + +| File | Touch | Responsibility | +|---|---|---| +| `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift` | Modify | Add history relay/API; route finalized batches into history; clear history on engine swap | +| `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift` | Modify | Add `.section(SectionKind, batches:)` case + identifier + `OutlineNodeType.children` branch | +| `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift` | Modify | Combine active+history into section-grouped `nodes`; rename `clearFailed`/`hasAnyFailure` to `clearHistory`/`hasAnyHistory` | +| `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift` | Modify | Replace `clearFailedButton` with `clearHistoryButton`; add `SectionHeaderCellView`; cell-provider branch for `.section`; section-aware expansion; updated empty-state binding | +| `Documentations/Evolution/0002-background-indexing.md` | Modify (already done) | Spec revisions land with Task 1 commit | + +**Build target:** `RuntimeViewerUsingAppKit` (Debug). Workspace: `../MxIris-Reverse-Engineering.xcworkspace` (verified to exist; required per project CLAUDE.md to pick up local SPM checkouts). + +**No automated tests.** This codebase has no test target for `RuntimeViewerApplication` (only `RuntimeViewerSettingsTests` exists). The original 0002 spec explicitly states "UI 不做自动化". Verification is build-pass + manual smoke test per the design's checklist (Task 4). + +--- + +## Task 1: Coordinator — History data layer (additive) + +**Files:** +- Modify: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift` + +**Goal:** Add `historyRelay` + public surface, populate it from `apply(event:)`, clear it on engine swap. **Don't change existing failure-retention behavior in `batchesRelay` yet** — that flips in Task 3 when the UI is ready to show history. Mid-state: history grows in memory but no UI consumer; behavior visible to user is unchanged. + +- [ ] **Step 1: Add `historyRelay` storage and public accessors** + +In `RuntimeBackgroundIndexingCoordinator.swift`, locate the existing `batchesRelay` declaration (around line 35-38): + +```swift +private let batchesRelay = BehaviorRelay<[RuntimeIndexingBatch]>(value: []) +private let aggregateRelay = BehaviorRelay( + value: .init(hasActiveBatch: false, hasAnyFailure: false, progress: nil) +) +``` + +Add immediately after `batchesRelay`: + +```swift +private let historyRelay = BehaviorRelay<[RuntimeIndexingBatch]>(value: []) +``` + +Then locate the `// MARK: - Public observables for UI` section (around line 61-69) and add after `aggregateStateObservable`: + +```swift +public var historyObservable: Observable<[RuntimeIndexingBatch]> { + historyRelay.asObservable() +} + +// Synchronous accessors so the ViewModel can do `Observable.combineLatest` +// without re-subscribing inside drive callbacks. Mirror `batchesRelay.value`. +public var batchesValue: [RuntimeIndexingBatch] { batchesRelay.value } +public var historyValue: [RuntimeIndexingBatch] { historyRelay.value } +``` + +- [ ] **Step 2: Add `clearHistory()` to the public command surface** + +Locate the `// MARK: - Public command surface` section (around line 71-108). After the existing `clearFailedBatches()` method, add: + +```swift +public func clearHistory() { + historyRelay.accept([]) +} +``` + +Leave `clearFailedBatches()` untouched for now — it'll be removed in Task 3 once no caller remains. + +- [ ] **Step 3: Route finalized batches into history** + +In `apply(event:)`, locate the `.batchFinished` case (around line 148-167): + +```swift +case .batchFinished(let finished): + if finished.items.contains(where: { + if case .failed = $0.state { return true } else { return false } + }) { + // Keep the failed batch in the list until the user dismisses it. + if let batchIndex = batches.firstIndex(where: { $0.id == finished.id }) { + batches[batchIndex] = finished + } + } else { + batches.removeAll { $0.id == finished.id } + } + documentBatchIDs.remove(finished.id) + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } +``` + +Replace with (additive only — the existing branches stay; we just push into history): + +```swift +case .batchFinished(let finished): + var updatedHistory = historyRelay.value + updatedHistory.insert(finished, at: 0) + historyRelay.accept(updatedHistory) + if finished.items.contains(where: { + if case .failed = $0.state { return true } else { return false } + }) { + // Keep the failed batch in the list until the user dismisses it. + // (Removed in Task 3 once history UI is wired.) + if let batchIndex = batches.firstIndex(where: { $0.id == finished.id }) { + batches[batchIndex] = finished + } + } else { + batches.removeAll { $0.id == finished.id } + } + documentBatchIDs.remove(finished.id) + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } +``` + +Then locate the `.batchCancelled` case (around line 169-176): + +```swift +case .batchCancelled(let cancelled): + // Cancellation always removes — user already acknowledged the outcome. + batches.removeAll { $0.id == cancelled.id } + documentBatchIDs.remove(cancelled.id) + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } +``` + +Replace with: + +```swift +case .batchCancelled(let cancelled): + // Cancellation always removes from active. Now also lands in history + // so the user can review what got cancelled. + var updatedHistory = historyRelay.value + updatedHistory.insert(cancelled, at: 0) + historyRelay.accept(updatedHistory) + batches.removeAll { $0.id == cancelled.id } + documentBatchIDs.remove(cancelled.id) + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } +``` + +- [ ] **Step 4: Clear history on engine swap** + +Locate `handleEngineSwap(to:)` (around line 224-264) and the comment block beginning `// 3) Drop UI state`. The current code: + +```swift +// 3) Drop UI state — the old engine's batches no longer apply. +documentBatchIDs.removeAll() +batchesRelay.accept([]) +refreshAggregate(batches: []) +``` + +Replace with: + +```swift +// 3) Drop UI state — the old engine's batches and history no longer apply. +documentBatchIDs.removeAll() +batchesRelay.accept([]) +historyRelay.accept([]) +refreshAggregate(batches: []) +``` + +- [ ] **Step 5: Build to verify Coordinator compiles** + +Use the `xcodebuildmcp-cli` skill to build the `RuntimeViewerUsingAppKit` scheme against the umbrella workspace. + +```bash +xcodebuildmcp build --workspace ../MxIris-Reverse-Engineering.xcworkspace --scheme RuntimeViewerUsingAppKit --configuration Debug +``` + +Expected: BUILD SUCCEEDED. If unavailable, fall back to `xcodebuild -workspace ../MxIris-Reverse-Engineering.xcworkspace -scheme RuntimeViewerUsingAppKit -configuration Debug build 2>&1 | xcsift`. + +- [ ] **Step 6: Commit** + +The 0002 spec edits land here so the design and implementation introduce the history concept together. + +```bash +git add Documentations/Evolution/0002-background-indexing.md \ + RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +git commit -m "$(cat <<'EOF' +feat(background-indexing): add coordinator-level history relay + +Finalized batches (success / failure / cancelled) now also flow into +historyRelay alongside the existing active-batch tracking. No UI consumer +yet — failure-retention in batchesRelay stays unchanged in this commit; +the history relay is wired so the popover can render it in the next +commit. handleEngineSwap clears history along with active batches since +the old engine's metadata no longer applies. +EOF +)" +``` + +--- + +## Task 2: Node enum extension + cell scaffolding (additive case) + +**Files:** +- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift` +- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift` + +**Goal:** Make `BackgroundIndexingNode` carry a `.section` case and give the outline view a cell that knows how to render section headers. The ViewModel still produces flat `[.batch, .batch, ...]` after this commit, so `.section` is never instantiated yet, but the type system and switch-exhaustiveness handle it. Adding both the producer and consumer side of an enum case in the same commit is the only way to keep the build green for an exhaustive switch. + +- [ ] **Step 1: Extend `BackgroundIndexingNode` with `.section` case** + +Open `BackgroundIndexingNode.swift`. Replace the entire file: + +```swift +import RuntimeViewerCore +import RxAppKit + +enum BackgroundIndexingNode: Hashable { + case section(SectionKind, batches: [BackgroundIndexingNode]) + case batch(RuntimeIndexingBatch, items: [BackgroundIndexingNode]) + case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) + + enum SectionKind: Hashable { + case active + case history + } +} + +extension BackgroundIndexingNode: OutlineNodeType { + var children: [BackgroundIndexingNode] { + switch self { + case .section(_, let batches): return batches + case .batch(_, let items): return items + case .item: return [] + } + } +} + +extension BackgroundIndexingNode: Differentiable { + enum Identifier: Hashable { + case section(SectionKind) + case batch(RuntimeIndexingBatchID) + case item(batchID: RuntimeIndexingBatchID, itemID: String) + } + + // Identifier for `.section` is intentionally kind-only — not derived + // from children. RxAppKit's staged changeset detects child insertions + // and removals as nested diffs without recreating the section row, + // which preserves the user's expand / collapse state across updates. + var differenceIdentifier: Identifier { + switch self { + case .section(let kind, _): + return .section(kind) + case .batch(let batch, _): + return .batch(batch.id) + case .item(let batchID, let item): + return .item(batchID: batchID, itemID: item.id) + } + } +} +``` + +- [ ] **Step 2: Add `SectionHeaderCellView` private nested class** + +Open `BackgroundIndexingPopoverViewController.swift`. Locate the existing extension block at the bottom (`extension BackgroundIndexingPopoverViewController { ... }` containing `BatchCellView` and `ItemCellView`, starting around line 237). Add a new private nested class **at the top of that extension** (immediately after the extension brace, before `BatchCellView`): + +```swift +extension BackgroundIndexingPopoverViewController { + private final class SectionHeaderCellView: NSTableCellView { + private let titleLabel = Label("").then { + $0.font = .systemFont(ofSize: 11, weight: .semibold) + $0.textColor = .secondaryLabelColor + } + private let countLabel = Label("").then { + $0.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + $0.textColor = .tertiaryLabelColor + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + countLabel.setContentHuggingPriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let stack = HStackView(alignment: .centerY, spacing: 6) { + titleLabel + countLabel + } + + addSubview(stack) + stack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(4) + make.bottom.equalToSuperview().offset(-4) + make.leading.trailing.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(kind: BackgroundIndexingNode.SectionKind, count: Int) { + switch kind { + case .active: titleLabel.stringValue = "ACTIVE" + case .history: titleLabel.stringValue = "HISTORY" + } + countLabel.stringValue = "\(count)" + } + } + + private final class BatchCellView: NSTableCellView { + // ... existing implementation unchanged ... +``` + +(Place the new `SectionHeaderCellView` class **inside** the existing extension, just before `BatchCellView`. Do not add a second extension block — keep them all in the one existing extension.) + +- [ ] **Step 3: Add `.section` branch to outline cell provider** + +In the same file, locate `setupBindings(for:)`'s `outlineView.rx.nodes` closure (around line 209-227): + +```swift +output.nodes.drive(outlineView.rx.nodes) { [weak self] (outlineView: NSOutlineView, _: NSTableColumn?, node: BackgroundIndexingNode) -> NSView? in + switch node { + case .batch(let batch, _): + let cell = outlineView.box.makeView(ofClass: BatchCellView.self) + cell.bind( + batch: viewModel.batch(for: batch.id), + onCancel: { [weak self] in + guard let self else { return } + cancelBatchRelay.accept(batch.id) + } + ) + return cell + case .item(let batchID, let item): + let cell = outlineView.box.makeView(ofClass: ItemCellView.self) + cell.bind(item: viewModel.item(for: batchID, itemID: item.id)) + return cell + } +} +.disposed(by: rx.disposeBag) +``` + +Add a `.section` case at the top of the switch: + +```swift +output.nodes.drive(outlineView.rx.nodes) { [weak self] (outlineView: NSOutlineView, _: NSTableColumn?, node: BackgroundIndexingNode) -> NSView? in + switch node { + case .section(let kind, let batches): + let cell = outlineView.box.makeView(ofClass: SectionHeaderCellView.self) + cell.configure(kind: kind, count: batches.count) + return cell + case .batch(let batch, _): + let cell = outlineView.box.makeView(ofClass: BatchCellView.self) + cell.bind( + batch: viewModel.batch(for: batch.id), + onCancel: { [weak self] in + guard let self else { return } + cancelBatchRelay.accept(batch.id) + } + ) + return cell + case .item(let batchID, let item): + let cell = outlineView.box.makeView(ofClass: ItemCellView.self) + cell.bind(item: viewModel.item(for: batchID, itemID: item.id)) + return cell + } +} +.disposed(by: rx.disposeBag) +``` + +- [ ] **Step 4: Build to verify exhaustive switches still pass** + +```bash +xcodebuildmcp build --workspace ../MxIris-Reverse-Engineering.xcworkspace --scheme RuntimeViewerUsingAppKit --configuration Debug +``` + +Expected: BUILD SUCCEEDED. The `OutlineNodeType.children`, `Differentiable.differenceIdentifier`, and outline cell provider switches must all handle `.section`. + +- [ ] **Step 5: Commit** + +```bash +git add RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift \ + RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +git commit -m "$(cat <<'EOF' +feat(background-indexing): add section node case + header cell + +BackgroundIndexingNode gains a .section(SectionKind, batches:) case so +the popover outline can render top-level Active / History groups. +Identifier for the section is kind-only so RxAppKit's staged-changeset +preserves the user's expand-collapse state across updates. ViewModel +still produces flat batch nodes for now — sectioning is wired in the +next commit. +EOF +)" +``` + +--- + +## Task 3: Wire active+history into sections, swap the button + +**Files:** +- Modify: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift` (drop `clearFailedBatches`, drop failure-retention in `batchesRelay`) +- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift` +- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift` + +**Goal:** Flip the user-visible behavior. ViewModel renders nodes as `[.section(.active, ...), .section(.history, ...)]`. Button renamed to `Clear History`. Failed batches no longer linger in `batchesRelay` — they're in history only. Recursive expansion swaps for section-aware expansion. + +- [ ] **Step 1: ViewModel — replace `hasAnyFailure` with `hasAnyHistory`** + +Open `BackgroundIndexingPopoverViewModel.swift`. Locate the `@Observed` property declarations (around line 11-15): + +```swift +@Observed private(set) var nodes: [BackgroundIndexingNode] = [] +@Observed private(set) var isEnabled: Bool = false +@Observed private(set) var hasAnyBatch: Bool = false +@Observed private(set) var hasAnyFailure: Bool = false +@Observed private(set) var subtitle: String = "" +``` + +Replace `hasAnyFailure` with `hasAnyHistory`: + +```swift +@Observed private(set) var nodes: [BackgroundIndexingNode] = [] +@Observed private(set) var isEnabled: Bool = false +@Observed private(set) var hasAnyBatch: Bool = false +@Observed private(set) var hasAnyHistory: Bool = false +@Observed private(set) var subtitle: String = "" +``` + +- [ ] **Step 2: ViewModel — rename Input/Output fields** + +Locate the `Input` and `Output` structs (around line 28-46). Replace `clearFailed` with `clearHistory` in `Input`, and `hasAnyFailure` with `hasAnyHistory` in `Output`: + +```swift +struct Input { + let cancelBatch: Signal + let cancelAll: Signal + let clearHistory: Signal + let openSettings: Signal +} + +struct Output { + let nodes: Driver<[BackgroundIndexingNode]> + let isEnabled: Driver + let hasAnyBatch: Driver + let hasAnyHistory: Driver + let subtitle: Driver + // Forwarded to the ViewController so it can call + // `SettingsWindowController.shared.showWindow(nil)` directly — mirrors + // MCPStatusPopoverViewController.swift:200-203 (no `MainRoute` case + // exists for openSettings). + let openSettings: Signal +} +``` + +- [ ] **Step 3: ViewModel — combine active + history into section nodes** + +Locate the `transform(_:)` method (around line 48-107). The current implementation reads `coordinator.batchesObservable` and renders nodes with `Self.renderNodes`. Replace the `coordinator.batchesObservable` subscription block (around line 49-57) with a `combineLatest` of active and history: + +```swift +Observable.combineLatest( + coordinator.batchesObservable, + coordinator.historyObservable +) +.map { active, history in + Self.renderNodes(active: active, history: history) +} +.asDriver(onErrorJustReturn: []) +.driveOnNext { [weak self] newNodes in + guard let self else { return } + nodes = newNodes + hasAnyBatch = !coordinator.batchesValue.isEmpty + hasAnyHistory = !coordinator.historyValue.isEmpty +} +.disposed(by: rx.disposeBag) +``` + +- [ ] **Step 4: ViewModel — drop `hasAnyFailure` reading from aggregate state** + +In the same `transform(_:)`, locate the `aggregateStateObservable` subscription (around line 59-66): + +```swift +coordinator.aggregateStateObservable + .asDriver(onErrorDriveWith: .empty()) + .driveOnNext { [weak self] state in + guard let self else { return } + subtitle = Self.subtitleFor(state) + hasAnyFailure = state.hasAnyFailure + } + .disposed(by: rx.disposeBag) +``` + +Replace with (drop the `hasAnyFailure` line — `subtitle` still uses progress from `state`): + +```swift +coordinator.aggregateStateObservable + .asDriver(onErrorDriveWith: .empty()) + .driveOnNext { [weak self] state in + guard let self else { return } + subtitle = Self.subtitleFor(state) + } + .disposed(by: rx.disposeBag) +``` + +- [ ] **Step 5: ViewModel — wire `clearHistory` input** + +In the same `transform(_:)`, locate the `clearFailed` input handler (around line 85-89): + +```swift +input.clearFailed.emitOnNext { [weak self] in + guard let self else { return } + coordinator.clearFailedBatches() +} +.disposed(by: rx.disposeBag) +``` + +Replace with: + +```swift +input.clearHistory.emitOnNext { [weak self] in + guard let self else { return } + coordinator.clearHistory() +} +.disposed(by: rx.disposeBag) +``` + +- [ ] **Step 6: ViewModel — update returned Output** + +Locate the `return Output(...)` block at the end of `transform(_:)` (around line 99-106): + +```swift +return Output( + nodes: $nodes.asDriver(), + isEnabled: $isEnabled.asDriver(), + hasAnyBatch: $hasAnyBatch.asDriver(), + hasAnyFailure: $hasAnyFailure.asDriver(), + subtitle: $subtitle.asDriver(), + openSettings: openSettingsRelay.asSignal() +) +``` + +Replace with: + +```swift +return Output( + nodes: $nodes.asDriver(), + isEnabled: $isEnabled.asDriver(), + hasAnyBatch: $hasAnyBatch.asDriver(), + hasAnyHistory: $hasAnyHistory.asDriver(), + subtitle: $subtitle.asDriver(), + openSettings: openSettingsRelay.asSignal() +) +``` + +- [ ] **Step 7: ViewModel — update `renderNodes` to produce sections** + +Locate the existing `renderNodes(from:)` static method (around line 151-160): + +```swift +private static func renderNodes(from batches: [RuntimeIndexingBatch]) + -> [BackgroundIndexingNode] +{ + batches.map { batch in + let itemNodes = batch.items.map { item in + BackgroundIndexingNode.item(batchID: batch.id, item: item) + } + return .batch(batch, items: itemNodes) + } +} +``` + +Replace with: + +```swift +private static func renderNodes(active: [RuntimeIndexingBatch], + history: [RuntimeIndexingBatch]) + -> [BackgroundIndexingNode] +{ + let activeBatchNodes = active.map(makeBatchNode) + var nodes: [BackgroundIndexingNode] = [.section(.active, batches: activeBatchNodes)] + // History section is omitted entirely when empty so it doesn't clutter + // the popover with an empty header. Active is always present so the + // user always has the "ACTIVE" group as context. + if !history.isEmpty { + let historyBatchNodes = history.map(makeBatchNode) + nodes.append(.section(.history, batches: historyBatchNodes)) + } + return nodes +} + +private static func makeBatchNode(_ batch: RuntimeIndexingBatch) + -> BackgroundIndexingNode +{ + let itemNodes = batch.items.map { item in + BackgroundIndexingNode.item(batchID: batch.id, item: item) + } + return .batch(batch, items: itemNodes) +} +``` + +- [ ] **Step 8: ViewController — rename button** + +Open `BackgroundIndexingPopoverViewController.swift`. Locate the `clearFailedButton` declaration (around line 56-60): + +```swift +private let clearFailedButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Clear Failed" + $0.isHidden = true +} +``` + +Replace with: + +```swift +private let clearHistoryButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Clear History" + $0.isHidden = true +} +``` + +- [ ] **Step 9: ViewController — update button stack composition** + +In the same file, locate `setupLayout()`'s `buttonStack` (around line 82-86): + +```swift +let buttonStack = HStackView(spacing: 8) { + cancelAllButton + clearFailedButton + closeButton +} +``` + +Replace with: + +```swift +let buttonStack = HStackView(spacing: 8) { + cancelAllButton + clearHistoryButton + closeButton +} +``` + +- [ ] **Step 10: ViewController — update Input wiring + bindings** + +In `setupBindings(for:)`, locate the `Input` construction (around line 154-159): + +```swift +let input = BackgroundIndexingPopoverViewModel.Input( + cancelBatch: cancelBatchRelay.asSignal(), + cancelAll: cancelAllButton.rx.click.asSignal(), + clearFailed: clearFailedButton.rx.click.asSignal(), + openSettings: openSettingsButton.rx.click.asSignal() +) +``` + +Replace with: + +```swift +let input = BackgroundIndexingPopoverViewModel.Input( + cancelBatch: cancelBatchRelay.asSignal(), + cancelAll: cancelAllButton.rx.click.asSignal(), + clearHistory: clearHistoryButton.rx.click.asSignal(), + openSettings: openSettingsButton.rx.click.asSignal() +) +``` + +Then locate the `hasAnyFailure` binding (around line 181-183): + +```swift +output.hasAnyFailure.not() + .drive(clearFailedButton.rx.isHidden) + .disposed(by: rx.disposeBag) +``` + +Replace with: + +```swift +output.hasAnyHistory.not() + .drive(clearHistoryButton.rx.isHidden) + .disposed(by: rx.disposeBag) +``` + +- [ ] **Step 11: ViewController — update empty-state binding to include history** + +Locate the existing empty-state binding pair (around line 193-203): + +```swift +Driver.combineLatest(output.isEnabled, output.hasAnyBatch) { enabled, hasBatches in + !enabled || hasBatches +} +.drive(emptyIdleView.rx.isHidden) +.disposed(by: rx.disposeBag) + +Driver.combineLatest(output.isEnabled, output.hasAnyBatch) { enabled, hasBatches in + !enabled || !hasBatches +} +.drive(scrollView.rx.isHidden) +.disposed(by: rx.disposeBag) +``` + +Replace with (factor in history so the empty state hides when only history exists): + +```swift +let hasAnyContent = Driver.combineLatest(output.hasAnyBatch, output.hasAnyHistory) { + $0 || $1 +} + +Driver.combineLatest(output.isEnabled, hasAnyContent) { enabled, hasContent in + !enabled || hasContent +} +.drive(emptyIdleView.rx.isHidden) +.disposed(by: rx.disposeBag) + +Driver.combineLatest(output.isEnabled, hasAnyContent) { enabled, hasContent in + !enabled || !hasContent +} +.drive(scrollView.rx.isHidden) +.disposed(by: rx.disposeBag) +``` + +- [ ] **Step 12: ViewController — replace recursive expand with section-aware expand** + +Locate the post-`output.nodes` expansion block (around line 229-233): + +```swift +output.nodes.driveOnNext { [weak self] _ in + guard let self else { return } + outlineView.expandItem(nil, expandChildren: true) +} +.disposed(by: rx.disposeBag) +``` + +Replace with: + +```swift +output.nodes.driveOnNext { [weak self] nodes in + guard let self else { return } + // Auto-expand only the ACTIVE section and its batches. HISTORY stays + // collapsed by default; once the user expands it, NSOutlineView + // preserves that state across diffs (the section identifier is + // kind-only, see BackgroundIndexingNode.differenceIdentifier). + for node in nodes { + if case .section(.active, _) = node { + outlineView.expandItem(node, expandChildren: true) + } + } +} +.disposed(by: rx.disposeBag) +``` + +- [ ] **Step 13: Coordinator — drop failure-retention in `apply(event:)`** + +Open `RuntimeBackgroundIndexingCoordinator.swift`. Locate the `.batchFinished` case as modified in Task 1: + +```swift +case .batchFinished(let finished): + var updatedHistory = historyRelay.value + updatedHistory.insert(finished, at: 0) + historyRelay.accept(updatedHistory) + if finished.items.contains(where: { + if case .failed = $0.state { return true } else { return false } + }) { + // Keep the failed batch in the list until the user dismisses it. + // (Removed in Task 3 once history UI is wired.) + if let batchIndex = batches.firstIndex(where: { $0.id == finished.id }) { + batches[batchIndex] = finished + } + } else { + batches.removeAll { $0.id == finished.id } + } + documentBatchIDs.remove(finished.id) + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } +``` + +Replace with (failures now removed from active just like clean finishes — they live in history): + +```swift +case .batchFinished(let finished): + var updatedHistory = historyRelay.value + updatedHistory.insert(finished, at: 0) + historyRelay.accept(updatedHistory) + batches.removeAll { $0.id == finished.id } + documentBatchIDs.remove(finished.id) + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } +``` + +- [ ] **Step 14: Coordinator — remove `clearFailedBatches()`** + +In the same file, locate `clearFailedBatches()` (around line 91-108): + +```swift +public func clearFailedBatches() { + // Class is `@MainActor`; we're already on the main thread when called + // from the popover's button. No hop required. + let allBatches = batchesRelay.value + let remaining = allBatches.filter { batch in + !batch.items.contains { item in + if case .failed = item.state { return true } else { return false } + } + } + // Drop the cleared batches from documentBatchIDs as well — they're + // already finalized on the manager side, but leaving their ids here + // makes documentBatchIDs grow unboundedly and causes documentWillClose + // to fire no-op cancel Tasks for ghost ids. + let removedIDs = Set(allBatches.map(\.id)).subtracting(remaining.map(\.id)) + documentBatchIDs.subtract(removedIDs) + batchesRelay.accept(remaining) + refreshAggregate(batches: remaining) +} +``` + +Delete the entire method. After Task 3 the only caller was the old `Input.clearFailed` wiring, which was renamed to `clearHistory` in Step 5. + +- [ ] **Step 15: Build to verify everything compiles** + +```bash +xcodebuildmcp build --workspace ../MxIris-Reverse-Engineering.xcworkspace --scheme RuntimeViewerUsingAppKit --configuration Debug +``` + +Expected: BUILD SUCCEEDED. If there's a stray reference to `clearFailed` / `hasAnyFailure` / `clearFailedBatches` anywhere, the build will surface it — fix and rebuild. + +- [ ] **Step 16: Commit** + +```bash +git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift \ + RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift \ + RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +git commit -m "$(cat <<'EOF' +feat(background-indexing): render Active / History sections in popover + +Popover now groups batches under top-level ACTIVE (always present, +default-expanded) and HISTORY (rendered only when non-empty, +default-collapsed). Failed batches no longer linger in batchesRelay; +they land in history alongside successes and cancels. Clear Failed +button replaced by Clear History which empties historyRelay. Empty +state hides whenever active or history has content. +EOF +)" +``` + +--- + +## Task 4: Build verification + manual smoke test + +**Files:** none modified. + +This task is non-coding verification. No commits expected unless the smoke test surfaces a bug requiring an additional task. + +- [ ] **Step 1: Clean build** + +```bash +xcodebuildmcp clean --workspace ../MxIris-Reverse-Engineering.xcworkspace --scheme RuntimeViewerUsingAppKit +xcodebuildmcp build --workspace ../MxIris-Reverse-Engineering.xcworkspace --scheme RuntimeViewerUsingAppKit --configuration Debug +``` + +Expected: BUILD SUCCEEDED. + +- [ ] **Step 2: Run the app and verify the smoke checklist** + +Launch the built app via `xcodebuildmcp run` (or open from Xcode). Walk through the checklist from `Documentations/Evolution/0002-background-indexing.md` — the verification path was added with the 2026-04-29 design revision. Specifically: + +1. Open Settings → Indexing → enable Background Indexing (depth ≥ 1). +2. Open a Document. The auto-launched `.appLaunch` batch appears under `ACTIVE`. +3. Wait for it to finish. + - **Expected:** the batch disappears from `ACTIVE`. A `HISTORY` section appears containing one entry. The history section is collapsed by default. +4. Click the disclosure on `HISTORY`. The batch row appears (still collapsed). Click the disclosure on the batch — items show their final states. +5. Toggle Settings off, then on again. Confirm a new `.settingsEnabled` batch runs and lands in `HISTORY` newest-first when done. +6. Click `Clear History` in the footer. The `HISTORY` section disappears entirely; the `Clear History` button hides. +7. Trigger a failure (e.g. switch to a remote source whose dependencies are unreachable, or load an image whose deps include something Mach-O cannot open). The failed batch ends up in `HISTORY` — expand it and confirm the failed item shows the red xmark + error message. +8. Switch source (Local → XPC, or close + reopen the document). Confirm `HISTORY` clears. + +- [ ] **Step 3: Quick code review of the diff** + +Run `git diff main..HEAD -- RuntimeViewerPackages/ RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/` and skim. Common things to look for that are easy to miss in a manual smoke test: + +- Did any reference to `clearFailedBatches` / `hasAnyFailure` / `Clear Failed` slip through? (`rg "clearFailed|hasAnyFailure|Clear Failed" RuntimeViewerPackages RuntimeViewerUsingAppKit` should return nothing meaningful.) +- Does `historyObservable` / `historyValue` get used only by the popover ViewModel, not other ViewModels? (Grep to confirm; cross-document leaks would mean the API surface should be tightened.) +- Did the `SectionHeaderCellView` end up inside the existing `extension BackgroundIndexingPopoverViewController { ... }` block, not a new extension? + +If everything looks good and the smoke test passed, the feature is done. No further commits. + +--- + +## Self-Review Notes + +Spec coverage: + +- ✅ Add `historyRelay` + public observable + `clearHistory()` — Task 1 Steps 1-2 +- ✅ Route finalized batches into history (success / failure / cancelled) — Task 1 Step 3, Task 3 Step 13 +- ✅ Clear history on engine swap — Task 1 Step 4 +- ✅ `BackgroundIndexingNode.section` case + identifier + children — Task 2 Step 1 +- ✅ `SectionHeaderCellView` private nested type — Task 2 Step 2 +- ✅ Cell provider handles `.section` — Task 2 Step 3 +- ✅ Active always rendered, history rendered only when non-empty — Task 3 Step 7 +- ✅ Active default-expanded, history default-collapsed — Task 3 Step 12 +- ✅ `Clear Failed` → `Clear History` button + binding — Task 3 Steps 8-10 +- ✅ Drop `hasAnyFailure` / `Output.hasAnyFailure` / `clearFailedBatches()` — Task 3 Steps 1-2, 4-6, 14 +- ✅ Empty-state hides when active OR history has content — Task 3 Step 11 + +Type / naming consistency check: + +- `historyRelay` / `historyObservable` / `historyValue` consistent across Coordinator and ViewModel. +- `hasAnyHistory` consistent across ViewModel `@Observed`, Output struct, and ViewController binding. +- `Input.clearHistory` consistent across ViewModel struct and ViewController construction site. +- `clearHistory()` (Coordinator) called from `transform`'s `input.clearHistory.emitOnNext`. +- `BackgroundIndexingNode.SectionKind.{active,history}` cases consistent across enum, identifier, `SectionHeaderCellView.configure`, and `renderNodes`. + +No placeholders detected. All code blocks are complete; all build / commit commands are concrete. diff --git a/Documentations/Reviews/2026-04-24-background-indexing-review.md b/Documentations/Reviews/2026-04-24-background-indexing-review.md index 8c7fa2f7..d27dd64a 100644 --- a/Documentations/Reviews/2026-04-24-background-indexing-review.md +++ b/Documentations/Reviews/2026-04-24-background-indexing-review.md @@ -1,234 +1,203 @@ -# Background Indexing Design & Plan — 审查遗留问题 +# Background Indexing Evolution & Plan — 审查闭环记录 审查对象: -- [2026-04-24-background-indexing-design.md](../Plans/2026-04-24-background-indexing-design.md) +- [0002-background-indexing.md](../Evolution/0002-background-indexing.md)(原 `Plans/2026-04-24-background-indexing-design.md`,已挪至 Evolution 并改成演进文档格式) - [2026-04-24-background-indexing-plan.md](../Plans/2026-04-24-background-indexing-plan.md) -本文件只记录尚未闭环的问题。已在对话中确定方案、不再单独跟踪的决策: -- Settings 变化订阅 → 改用 `@Observable` + `withObservationTracking` 重注册模式 -- `BackgroundIndexingPopoverRoute` 合并进 `MainRoute`(ViewModel 改成 `ViewModel`) -- 远程 source 支持 → 所有 engine 新方法按 `request { 本地 } remote: { RPC }` 模式实现,server dispatcher 挂对应 handler +本文件现为闭环记录:列出审查中发现的问题,并标注每项是否已在 evolution 0002 / plan 中落实。 --- -## Critical — 不修会直接编译失败或运行出错 +## 已决议并落地 -### C1. `Semaphore` 不是 `RuntimeViewerCore` 的直接依赖 +| 决议 | 落地位置 | +|------|---------| +| Settings 变化订阅 → `@Observable` + `withObservationTracking` 重注册模式 | Plan Task 17;Evolution Scenario E / Alternative A | +| `BackgroundIndexingPopoverRoute` 合并进 `MainRoute`,ViewModel 为 `ViewModel` | Plan Task 18 / Task 21;Evolution Alternative B / Components | +| 远程 source 支持 → 所有 engine 新方法按 `request { 本地 } remote: { RPC }` 模式实现,server dispatcher 挂对应 handler | Plan Task 3 / Task 4 / Task 4.5;Evolution "Remote Dispatch Model" | -`Package.swift` 里 `Semaphore` 只挂在 `RuntimeViewerCommunication` target 下。Plan 的 `RuntimeBackgroundIndexingManager.swift` 里 `import Semaphore` 会找不到 module。 +--- -**修复**:在 `RuntimeViewerCore/Package.swift` 的 `RuntimeViewerCore` target dependencies 追加: -```swift -.product(name: "Semaphore", package: "Semaphore") -``` +## Critical — 已全部落地 -Plan 需新增 Task 0 专做此事。 +### C1. `Semaphore` 不是 `RuntimeViewerCore` 的直接依赖 — ✅ 已修 + +`Package.swift:163` 里 `Semaphore` 只挂在 `RuntimeViewerCommunication` target 下。Plan 的 `RuntimeBackgroundIndexingManager.swift` 里 `import Semaphore` 会找不到 module(尤其一旦启用 `.memberImportVisibility`)。 + +**落地**:新增 **Plan Task 0**,在 `RuntimeViewerCore` target dependencies 追加 `.product(name: "Semaphore", package: "Semaphore")`。 --- -### C2. `section(for:)` 的签名和 Plan 假设不一致 +### C2. `section(for:)` 的签名和 Plan 假设不一致 — ✅ 已修 -真实签名(`RuntimeObjCSection.swift:704`、`RuntimeSwiftSection.swift:802`): -```swift -func section(for imagePath: String, progressContinuation: ...) async throws - -> (isExisted: Bool, section: RuntimeObjCSection) -``` +真实签名(`RuntimeObjCSection.swift:704`、`RuntimeSwiftSection.swift:802`)是 `async throws -> (isExisted: Bool, section: ...)`。 -Plan 里 `loadImageForBackgroundIndexing` 漏了 `try await`: -```swift -_ = objcSectionFactory.section(for: path) -_ = swiftSectionFactory.section(for: path) -``` +**落地**:**Plan Task 4 Step 4** 的 `loadImageForBackgroundIndexing` 本地实现改成 `try await` 两个 factory 调用,与 `RuntimeEngine.swift:485-495` 一致。 -**修复**:与 `RuntimeEngine.loadImage(at:)`(`RuntimeEngine.swift:485-495`)一致: -```swift -_ = try await objcSectionFactory.section(for: path) -_ = try await swiftSectionFactory.section(for: path) -``` +--- + +### C3. `engine.imageLoadedSignal` 不存在 — ✅ 已修 + +`RuntimeEngine` 只暴露 `reloadDataPublisher: some Publisher` 和 `imageNodesPublisher`,没有带 path 的 publisher。 + +**落地**:新增 **Plan Task 4.5**(`imageDidLoadPublisher`),在 `RuntimeEngine` 新增 `imageDidLoadSubject: PassthroughSubject`;本地 `loadImage(at:)` 成功后 emit;新增 `.imageDidLoad` CommandName 让远程 dispatcher 也可以 forward。**Plan Task 16** 订阅该 publisher。 --- -### C3. `engine.imageLoadedSignal` 不存在 +### C4. 值类型 `Hashable` 声明不一致 — ✅ 已修 -Plan Task 16 Step 2 订阅 `engine.imageLoadedSignal`,但 `RuntimeEngine` 只暴露 `reloadDataPublisher: some Publisher`(无 path 载荷)和 `imageNodesPublisher`(全量列表)。 +`BackgroundIndexingNode: Hashable` 要求关联值也是 `Hashable`。 -**修复**:在 `RuntimeEngine` 新增一个带 path 的 publisher,`loadImage(at:)` 的本地分支和远程 dispatcher 对应 handler 都要 emit: -```swift -private nonisolated let imageDidLoadSubject = PassthroughSubject() -public nonisolated var imageDidLoadPublisher: some Publisher { - imageDidLoadSubject.eraseToAnyPublisher() -} -``` -`loadImage(at:)` 成功后 `imageDidLoadSubject.send(path)`。Plan Task 16 订阅该 publisher。 +**落地**:**Plan Task 1** 改标题为 "Create Sendable + Hashable value types ...",所有 `RuntimeIndexingBatchID` / `RuntimeIndexingBatchReason` / `RuntimeIndexingTaskState` / `RuntimeIndexingTaskItem` / `RuntimeIndexingBatch` / `RuntimeIndexingEvent` 统一加 `Hashable`;新增 `ResolvedDependency.swift` 文件。 --- -### C4. 值类型 `Hashable` 声明不一致 +## Significant — 已拍板 -Plan Task 19 声明 `BackgroundIndexingNode: Hashable`,但其关联值 `RuntimeIndexingBatch` / `RuntimeIndexingTaskItem` / `RuntimeIndexingBatchReason` / `RuntimeIndexingTaskState` / `RuntimeIndexingBatchID` / `ResolvedDependency` 只有 `Sendable, Identifiable, Equatable`。 +### S1. Factory 缓存只在解析成功时写入;失败路径语义未定 — ✅ 已拍板(方案 B) -**修复**:Task 1 所有值类型统一加 `Hashable`: -```swift -public struct RuntimeIndexingBatchID: Hashable, Sendable { ... } -public enum RuntimeIndexingBatchReason: Sendable, Hashable { ... } -public enum RuntimeIndexingTaskState: Sendable, Hashable { ... } -public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Hashable { ... } -public struct RuntimeIndexingBatch: Sendable, Identifiable, Hashable { ... } -public struct ResolvedDependency: Codable, Sendable, Hashable { ... } -``` +**决议**:采用 **方案 B** —— `isImageIndexed` 语义定为"成功解析过",失败 path 下一个 batch 重试。 + +**落地**:Evolution 0002 "Terminology: Loaded vs. Indexed" 明确 "Failure to parse does **not** count as indexed";"Error Handling" 小节和 "Alternative D" 展开理由。Plan Task 3 `hasCachedSection(for:)` 保持 `sections[path] != nil` 语义无需改动 factory 内部。 --- -## Significant — 需要拍板的语义/假设 +### S2. `DocumentState.runtimeEngine` 是 `@Observed`,可被重新赋值 — ✅ 已拍板(方案 a) -### S1. Factory 缓存只在解析成功时写入;失败路径语义未定 +**决议**:采用 **方案 a** —— 约定 `runtimeEngine` 在 Document 生命周期内不变。 -`RuntimeObjCSection.swift:710-713`: -```swift -let section = try await RuntimeObjCSection(...) -sections[imagePath] = section // throw 时不写缓存 -``` +**落地**:Evolution 0002 "Assumptions" 1 写明;**Plan Task 22 Step 1** 在 `DocumentState.swift` 现有 `@Observed public var runtimeEngine` 声明上补 doc comment 重申不可重赋。 + +--- -所以 `hasCachedSection(path) = (sections[path] != nil)` 实际等价于"解析成功过"。失败 path 下一个 batch 会重试。 +## Moderate — 已全部落地 -设计文档写了"cache empty / nil results as well — the cache key's presence becomes the 'attempted' bit",但 plan 悬空。二选一: +### M1. 路由案例名不一致 — ✅ 已修 -- **方案 A**(对齐设计文档):给 factory 加 `attemptedFailures: Set` 或把缓存值改成 `Result`,`isImageIndexed` 包含失败路径。 -- **方案 B**(简化):`isImageIndexed` 语义定为"成功解析过",设计 + 测试文档明确"失败 path 每次重试"。 +**落地**:**Plan Task 21** 改为 "Register the toolbar item and add the `MainRoute.backgroundIndexing` case",新增 case 命名为 `backgroundIndexing(sender:)`(不带 Popover 后缀),与现有 `mcpStatus(sender:)` 对齐。 --- -### S2. `DocumentState.runtimeEngine` 是 `@Observed`,可被重新赋值 +### M2. `actor` 内 `lazy var` 的指引不准 — ✅ 已修 + +**落地**:**Plan Task 11 Step 2** 改成显式存储属性 + init 末尾赋值: -`DocumentState.swift`: ```swift -@Observed public var runtimeEngine: RuntimeEngine = .local +public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingManager! +// ... +self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) ``` -Coordinator init 时的 `let engine = documentState.runtimeEngine` 只做一次性捕获。如果 Document 生命周期内切换 local/remote,Coordinator 持有旧 actor,batch 发到错的进程。 +`lazy` 分支已删除。 -**修复**(择一): -- (a) 文档里明确约定:`runtimeEngine` 在 Document 生命周期内不变 —— 写进设计文档 Assumptions。 -- (b) Coordinator 订阅 `documentState.$runtimeEngine`,切换时 `cancelAllBatches` 并重绑。 +--- + +### M3. `objcSectionFactory` / `swiftSectionFactory` 当前是 `private` — ✅ 已修 -推荐 (a)。 +**落地**:**Plan Task 3 Step 4** 标记为 "must-do",把两个 factory 的访问级别从 `private` 改为 `internal`,以便 `RuntimeEngine+BackgroundIndexing.swift` 的 extension 访问。 --- -## Moderate — 名字/结构错位,机械修复但别漏 +### M4. `DependType.weakLoad` 实际遇不到 — ✅ 已修 + +**落地**:Evolution 0002 "Dependency type filter" 明确写 "Included: `.load`, `.reexport`, `.upwardLoad`;`.lazyLoad` skipped。`LC_LOAD_WEAK_DYLIB` 被 MachOKit 解码为 `.load`(见 `MachOImage.swift:168-173`)"。 -### M1. 路由案例名不一致 +--- -`MainRoute.swift:18` 实际是 `case mcpStatus(sender: NSView)`,不是 `mcpStatusPopover`。 +### M5. BFS 容器在设计文档和 plan 之间漂移 — ✅ 已修 -**修复**: -- Plan Task 22 文案 `next to mcpStatusPopover` → `next to mcpStatus`。 -- 新增 case 按现有风格命名为 `backgroundIndexing(sender:)`,不带 Popover 后缀。 +**落地**:Evolution 0002 "Dependency Graph Expansion" 改为 `Array + removeFirst()`,并说明 `Array.removeFirst()` 对 depth ≤ 5 足够。与 Plan Task 7 对齐。 --- -### M2. `actor` 内 `lazy var` 的指引不准 - -Plan Task 11 把 `lazy var backgroundIndexingManager` 作为主方案。actor 的 `lazy` 初始化触发点走 actor 隔离,实践里不自然。 +## Minor — 已全部落地 -**修复**:主方案改为显式存储 + init 末尾赋值,删 `lazy` 分支: -```swift -public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingManager! +### m1. Task 17 是空 checklist — ✅ 已修 -// init 末尾 -self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) -``` +**落地**:原 Task 17("Expose prioritize entry point for sidebar selection")整段删除。编号重排后 Task 17 现在是 "React to Settings changes via `withObservationTracking`"。 --- -### M3. `objcSectionFactory` / `swiftSectionFactory` 当前是 `private` +### m2. `test_mainExecutablePath_returnsNonEmptyPath` 注释缺失 — ✅ 已修 -`RuntimeEngine.swift:147-149`: -```swift -private let objcSectionFactory: RuntimeObjCSectionFactory -private let swiftSectionFactory: RuntimeSwiftSectionFactory -``` +**落地**:**Plan Task 4 Step 2** 在测试函数上方补注释说明在 XCTest context 下该方法返回 test runner 的路径,这恰好验证"返回 dyld image 0"契约。 + +--- -Plan 的 `RuntimeEngine+BackgroundIndexing.swift` 在 extension 里访问两者 —— extension 不能访问 private(除非同文件)。 +### m3. Popover outline view `child(_:ofItem:)` defensive 分支 — ✅ 已修 -**修复**:Task 3 里把"如果是 private 再改"改成**必做**:提升到 `internal`,或把 extension 方法写进主文件。 +**落地**:**Plan Task 19 Step 1** 的 `NSOutlineViewDataSource.child(_:ofItem:)` failure 分支改成 `preconditionFailure("unexpected outline item type: \(type(of: item))")`。 --- -### M4. `DependType.weakLoad` 实际遇不到 - -MachOKit 的 `MachOImage.swift:174-180` 把 `.loadWeakDylib` 归并到 `.load`,`.weakLoad` case 只在 DependType 定义里声明。 +### m4. `mutating(_:_:)` 全局函数污染模块 — ✅ 已修 -**修复**:设计文档 Dependency type filter 一节改成: -> Included: `.load`, `.reexport`, `.upwardLoad`(注:weak-linked dylib 在 MachOKit 里也解析为 `.load`) -> Skipped: `.lazyLoad` +**落地**:**Plan Task 14** 把 `mutating` helper 从文件末尾的全局函数挪到 `RuntimeBackgroundIndexingCoordinator` class 的 private method(在 `apply(event:)` 下方)。 --- -### M5. BFS 容器在设计文档和 plan 之间漂移 - -设计文档写 `Deque<(path, level)>`,Plan 用 `Array + removeFirst()`。深度 ≤5 不影响正确性。 +### m5. 优先级测试靠 sleep 控制顺序,易 flake — ✅ 已修 -**修复**(择一):把设计文档改成 Array,或把 Plan 回退到 Deque —— 保持一致。 +**落地**:**Plan Task 10 Step 1** 将 `test_prioritize_movesPendingItemAhead` 重写为 `test_prioritize_emitsTaskPrioritizedEvent`,通过断言 `.taskPrioritized` 事件序列来验证,不依赖 `Task.sleep` 时序。 --- -## Minor — 清理项 +## Review 自己遗漏的问题(新增 N1–N6) -### m1. Task 17 是空 checklist +下列问题在初稿 review 中未捕捉,已在本轮更新时落到 evolution / plan。 -Plan Task 17 明确写"Skip — the placeholder is intentional"。执行 plan 时会疑惑。 +### N1. Popover ViewModel 的 `isEnabled` 只在 `transform` 里读一次 -**修复**:删 Task 17,或把"prioritize API 已存在"验证合进 Task 24 Step 1。 +原 plan Task 19(现 Task 18)写 `isEnabled = Settings.shared.backgroundIndexing.isEnabled`,后续 Settings 切换 toggle 时不刷新,popover 的 empty state 卡死。 + +**落地**:**Plan Task 18 Step 2** 新增 `subscribeToIsEnabled()` 方法,用同样的 `withObservationTracking` re-register 模式同步 `isEnabled`。`init` 里 seed 初值。 --- -### m2. `test_mainExecutablePath_returnsNonEmptyPath` 注释缺失 +### N2. Coordinator 一次性捕获 `runtimeEngine` 与 S2 联动 -该测试拿到的是 XCTest runner 的路径,不是 RuntimeViewer.app。断言本身没错,但执行者会误解。 +Coordinator `init` 里 `self.engine = documentState.runtimeEngine` 一次性捕获,配合 `@Observed` 的 `runtimeEngine` 可以被重新赋值,会出现持有旧 engine 的 bug。 -**修复**:加一行注释说明 `mainExecutablePath()` 在测试里返回 XCTest runner 路径,这恰好验证"返回 dyld image 0"契约。 +**落地**:与 S2 合并处理 —— Evolution Assumptions 与 Plan Task 22 Step 1 的 doc comment 统一约束。 --- -### m3. Popover outline view `child(_:ofItem:)` defensive 分支 +### N3. MockEngine / InstrumentedEngine 缺 `@unchecked Sendable` -Plan Task 20 失败分支构造了一个空 `RuntimeIndexingBatch` 返回,会掩盖逻辑错误。 +协议声明 `AnyObject, Sendable`,但 `MockBackgroundIndexingEngine` / `InstrumentedEngine` 以 `NSLock + var` 守同步,Swift 6 concurrency checker 下会报非 Sendable。 -**修复**:换成 `preconditionFailure("unexpected outline item type")`。 +**落地**:**Plan Task 5 Step 3** 给 `MockBackgroundIndexingEngine` 加 `@unchecked Sendable`;**Task 8 Step 1** 的 `InstrumentedEngine` 同样加 `@unchecked Sendable`。`ConcurrencyCounter` 原本已有。 --- -### m4. `mutating(_:_:)` 全局函数污染模块 +### N4. `mainExecutablePath` 本地实现与 design dyld index 0 的契约 -Plan Task 14 把 `mutating` helper 放在 `RuntimeBackgroundIndexingCoordinator.swift` 末尾作为全局函数。 +原 plan Task 4 Step 3 用 `DyldUtilities.imageNames().first ?? ""`;dyld 合约是 image 0 就是主执行体,但没在 plan 里明确。远程分支更需要分发。 -**修复**:挪到 Coordinator 的 `private` extension,或加 `private` file-scope。 +**落地**:**Plan Task 4 Step 4** 本地分支加注释 `// dyld guarantees image index 0 is the main executable.`;远程走 `request { local } remote: { RPC }` 分发,具体按 R3 决议落实(新增 `.mainExecutablePath` CommandName)。 --- -### m5. 优先级测试靠 sleep 控制顺序,易 flake +### N5. Task 10 prioritize 测试断言本身依赖实现细节 -Plan Task 10 `test_prioritize_movesPendingItemAhead` 用 `Task.sleep(15_000_000)` / `30_000_000` 控制 ordering,CI 卡顿会 flake。 +原断言基于 load 顺序和 `maxConcurrency=1` 的假设,加 sleep 导致更容易 flake。 -**修复**(择一): -- 给 MockEngine 加"手动步进"机制(`continuation` 闸门),测试确定性控制每一步完成时机。 -- 或把断言改为"`taskPrioritized` 事件被 emit 且 `priorityBoostPaths` 包含该 path"这种不依赖时序的等价条件。 +**落地**:与 m5 合并 —— **Plan Task 10 Step 1** 断言改为事件序列(不依赖时序的等价条件),具体是 `.taskPrioritized` 事件序列。 --- -## 修复顺序建议 +### N6. `.batchFinished` 立刻从 UI 移除,失败批次无处可见 + +原 plan Task 25(现 Task 24)的 reducer `batches.removeAll { $0.id == finished.id }` 在 `.batchFinished` 也直接删,含 `.failed` 子项的批次随之消失,toolbar 的 `.hasFailures` 永远不会亮。 + +**落地**:**Plan Task 24** 重写为 "Retain failed batches; refresh image list once per batch finish",`.batchFinished` 含失败子项则保留 batch,直到用户按 Popover 的 `Clear Failed` 触发 `clearFailedBatches()`。Evolution 0002 Alternative E 解释此权衡。 + +--- -改 plan 自身、再落 code: +## 收尾状态 -1. **新增 Task 0**:C1(`Semaphore` 依赖) -2. **Task 1 改**:C4(`Hashable`);补 `ResolvedDependency` 类型 -3. **Task 3 改**:C2(`try await`)、M3(`internal`) -4. **新增 Task 4.x**:C3(`imageDidLoadPublisher`) -5. **Task 11 改**:M2(去掉 `lazy var`) -6. **Task 17 改**:m1(删/合并) -7. **Task 20 改**:m3(`preconditionFailure`) -8. **Task 10 / 14 改**:m5(去 sleep)、m4(helper 挪位) +- **Evolution 0002** 已生效,替代原 `Plans/2026-04-24-background-indexing-design.md`(文件已删除)。 +- **Plan** 按 review 全部建议更新,Tasks 重编号为 0 / 1–4 / 4.5 / 5–26,并补 "Why" 说明段落。 +- **本 review** 不再存在 open issue,保留作为历史闭环记录。 -S1 / S2 需先拍板语义/假设再落 plan。 -M1 / M4 / M5 / m2 是文档/注释一致性,对照改即可。 +新发现的问题请新开一轮 review 记录,不要追加到本文件。 diff --git a/Documentations/Reviews/2026-04-25-background-indexing-review.md b/Documentations/Reviews/2026-04-25-background-indexing-review.md new file mode 100644 index 00000000..8f4096b6 --- /dev/null +++ b/Documentations/Reviews/2026-04-25-background-indexing-review.md @@ -0,0 +1,231 @@ +# Background Indexing Evolution & Plan — 第二轮审查 + +审查对象: +- [0002-background-indexing.md](../Evolution/0002-background-indexing.md) +- [2026-04-24-background-indexing-plan.md](../Plans/2026-04-24-background-indexing-plan.md) + +承接 [2026-04-24-background-indexing-review.md](2026-04-24-background-indexing-review.md) (该文件已闭环,本轮在新文件中开新一轮 issue)。 + +本轮针对 Evolution 0002 进入 Accepted 后的 Plan / Evolution 文档,做一次代码侧的对账核验。下列问题在上一轮 review 中没有被捕获,均通过查看实际仓库代码确认。 + +**状态**: O1–O8 已全部在本轮闭环时落地到 Plan / Evolution;无 open issue。 + +--- + +## 复核确认 (与第一轮 review 一致) + +| 已落实条目 | 代码侧核验 | +|---|---| +| C1: `RuntimeViewerCore` 显式依赖 `Semaphore` | `RuntimeViewerCore/Package.swift:163` 当前 Semaphore 仅挂在 `RuntimeViewerCommunication` target;Plan Task 0 修复正确 | +| C2: `section(for:)` 真实签名 | `RuntimeObjCSection.swift:704` / `RuntimeSwiftSection.swift:802` 均为 `async throws -> (isExisted: Bool, section: ...)`,Plan Task 4 Step 4 已对齐 | +| C3: 缺 per-path publisher | `RuntimeEngine.swift:135/139` 当前只有 `imageNodesPublisher` / `reloadDataPublisher`,Plan Task 4.5 新增方向正确 | +| C4: 值类型 `Hashable` 全套 | Plan Task 1 字段齐全 | +| M3: factory 当前 `private` | `RuntimeEngine.swift:147/149` 确认 `private let`,Plan Task 3 Step 4 提到 internal 正确 | +| M4: `LC_LOAD_WEAK_DYLIB` 折叠为 `.load` | `MachOKit/Sources/MachOKit/MachOImage.swift:168-173` 的 `loadWeakDylib` 分支显式构造 `type: .load`,Evolution 引用正确 | +| Settings `@Observable` | `RuntimeViewerSettings/Settings.swift:6-9` 已是 `@Observable`,`withObservationTracking` 路线可行 | + +--- + +## Critical — 阻塞实现 — ✅ 已落地 + +### O1. `RuntimeEngine.request` 是 `private`,跨文件 extension 调不到 — ✅ 已修 + +`RuntimeEngine.swift:468`: + +```swift +private func request(local: () async throws -> T, + remote: (_ senderConnection: RuntimeConnection) async throws -> T) + async throws -> T { ... } +``` + +Plan Task 3 / 4 / 4.5 把所有新 API 写在**新增的另一文件** `RuntimeEngine+BackgroundIndexing.swift` 里,例如: + +```swift +// RuntimeEngine+BackgroundIndexing.swift (Plan Task 3 Step 6) +extension RuntimeEngine { + public func isImageIndexed(path: String) async throws -> Bool { + try await request { // <-- private,跨文件不可见 + objcSectionFactory.hasCachedSection(for: path) + && swiftSectionFactory.hasCachedSection(for: path) + } remote: { ... } + } +} +``` + +Swift 中 `private` 允许同一类型在**同一文件**内的 extension 共享 private 成员;`RuntimeEngine.swift` 与 `RuntimeEngine+BackgroundIndexing.swift` 是不同文件,private 在该边界仍不可见。后果是 Plan Task 3、Task 4、Task 4.5 内引用 `request { ... } remote: { ... }` 全部编不过。 + +**建议修复**: 在 Plan Task 3 Step 4 旁新增一步,把 `RuntimeEngine.swift:468` 的 `private` 提至 `internal`(与同步骤把两个 factory 提到 internal 的做法一致),或把 `+BackgroundIndexing.swift` 的 extension 内容直接放进 `RuntimeEngine.swift` 末尾 (后者牺牲文件组织、但不动访问级别)。Evolution 0002 "Remote Dispatch Model" 节也应补一句说明 `request` 已开放给 internal extension。 + +**落地**: Plan Task 3 Step 4 标题改为"放宽 factory 与 `request` 分发原语的访问级别(必做)",把 `request` 与两个 factory 一并提至 `internal`。Evolution 0002 "Remote Dispatch Model" 节补充说明跨文件 extension 与访问级别要求。 + +--- + +### O2. Plan Task 12 与 Evolution 0002 关于 `Settings.backgroundIndexing` 的 `didSet` 不一致 — ✅ 已修 + +Evolution 0002:467-471 写法 (正确): + +```swift +@Default(BackgroundIndexing.default) +public var backgroundIndexing: BackgroundIndexing = .init() { + didSet { scheduleAutoSave() } +} +``` + +Plan Task 12 Step 2 line 1776 写法: + +```swift +@Default(BackgroundIndexing.default) public var backgroundIndexing: BackgroundIndexing +``` + +后者**没有** `didSet { scheduleAutoSave() }`。然而 `Settings.swift:14-37` 上现有所有字段 (`general`、`notifications`、`transformer`、`mcp`、`update`) 全都用 `didSet { scheduleAutoSave() }` 模式触发自动保存: + +```swift +// Settings.swift:14-17 (representative) +@Default(General.default) +public var general: General = .init() { + didSet { scheduleAutoSave() } +} +``` + +按 Plan Task 12 Step 2 实施后,toggle Background Indexing 开关、调整 depth / maxConcurrency 都不会自动写盘,重启即丢失。 + +**建议修复**: Plan Task 12 Step 2 对齐 Evolution 0002,把 `didSet { scheduleAutoSave() }` 加回去。 + +**落地**: Plan Task 12 Step 2 已加回 `= .init() { didSet { scheduleAutoSave() } }`,并补充说明镜像现有字段模式的必要性。 + +--- + +## Significant — ✅ 已落地 + +### O3. `BackgroundIndexingEngineRepresenting` 协议签名与 RuntimeEngine 实际方法 / Coordinator 调用三处错位 — ✅ 已修 + +四处对同一组方法的 `async` / `throws` 假设不一致: + +| 出处 | 签名 | +|---|---| +| Plan Task 5 Step 1 protocol | `func mainExecutablePath() async -> String` (no throws) | +| Plan Task 5 Step 1 protocol | `func dependencies(for path: String) async -> [...]` (no throws) | +| Plan Task 4 Step 4 RuntimeEngine 实现 | `public func mainExecutablePath() async throws -> String` | +| Plan Task 5 Step 2 conformance 实现 | `func dependencies(for path: String) -> [...]` (同步,非 async) | +| Plan Task 15 Coordinator 调用 | `let root = await engine.mainExecutablePath()` (不带 `try`) | + +要么 protocol 必须改为 `async throws`,要么 RuntimeEngine 端对这两个 API 提供一组 non-throwing wrapper。直接抄 Plan 任意一种实现都会编不过。 + +`mainExecutablePath` 远程分支会真实 throw (XPC / TCP 失败),所以 throws 版本更安全。 + +**建议修复**: +- Plan Task 5 Step 1 协议把这两个方法改为 `async throws`。 +- Plan Task 5 Step 2 conformance 实现也改为 `async throws`,内部 `let main = try await mainExecutablePath()`。 +- Plan Task 15 (`documentDidOpen`) 把 `let root = await engine.mainExecutablePath()` 改为 `let root = try? await engine.mainExecutablePath()`,失败时 `guard let root = root, !root.isEmpty else { return }`。 +- Coordinator 其它调用点同样补 `try`。 + +**落地**: Plan Task 5 Step 1 协议中 `isImageIndexed` / `mainExecutablePath` / `rpaths` / `dependencies` 全部改为 `async throws`(`canOpenImage` 保留为纯 async 因为它仅本地检查)。Plan Task 5 Step 2 conformance 中 `dependencies` 改为 `async throws`,`canOpenImage` / `rpaths` 保留 sync(Swift 允许更弱实现满足 `async throws` 协议)。Plan Task 5 Step 3 mock 同样保留 sync / non-throwing 实现。Plan Task 6 placeholder 与 Plan Task 7 BFS 内部调用改为 `try? await`,把错误降级为"未索引"以便重试(与 Alt D 一致)。Plan Task 8 `InstrumentedEngine` 同步改 throws。Plan Task 15 `documentDidOpen` 改为 `try? await` 包裹 + `guard let root` 解包。 + +### O4. Protocol 暴露 `MachOImage` 触发 Swift 6 严格并发问题 — ✅ 已修 + +Plan Task 5 Step 1: + +```swift +protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable { + func machOImage(for path: String) async -> MachOImage? // <-- 非 Sendable + ... +} +``` + +`MachOImage: MachORepresentable` (`MachOKit/Sources/MachOKit/MachOImage.swift:26`) 是含 unsafe pointer (`UnsafePointer`) 的 struct,未 conform `Sendable`。Sendable 协议在跨 actor 边界返回该类型会触发严格并发错误。`RuntimeViewerCore/Package.swift:158-160` 已启用 `.internalImportsByDefault` + `.immutableWeakCaptures`,后续若再启用 `.memberImportVisibility` 或 Swift 6 严格模式 (Swift 5 mode 下亦会 warn),这处会爆。 + +实际上 manager 端 (Plan Task 6 之后) **没有**直接消费 `MachOImage` —— Task 7 BFS 只调用 `engine.machOImage(for:)` 来"确认是否能 open",这完全可以用 `Bool` 返回值替代;真正用 `MachOImage` 的只有 conformance 内部的 `dependencies(for:)` / `rpaths(for:)` 实现 (它们不暴露 image 出去)。 + +**建议修复**: +- Plan Task 5 Step 1 把 `func machOImage(for path: String) async -> MachOImage?` 改为 `func canOpenImage(at path: String) async -> Bool`。 +- Plan Task 7 BFS 中 `if await engine.machOImage(for: path) == nil` 同步替换为 `if !(await engine.canOpenImage(at: path))`。 +- Plan Task 5 Step 2 conformance 把 `MachOImage(name: path) != nil` 作为 `canOpenImage` 实现。 + +**落地**: Plan Task 5 Step 1 协议表面去掉 `MachOImage`,新增 `canOpenImage(at:) async -> Bool`,并在协议 doc comment 写明"不暴露 MachOImage"。Plan Task 5 Step 2 / Step 3 / Plan Task 7 / Plan Task 8 InstrumentedEngine 同步全部更新。Plan Task 7 BFS 中无法打开的非根 path 直接标 `.failed("cannot open MachOImage")` 并 `continue`,替代了原先的 dead-code if 分支。 + +### O5. Plan Task 18 `transform` 同步调用 `@MainActor` 方法 — ✅ 已修 + +```swift +// Plan Task 18 Step 2 中 +func transform(_ input: Input) -> Output { + ... + subscribeToIsEnabled() // 同一文件下方标 @MainActor +} + +@MainActor +private func subscribeToIsEnabled() { ... } +``` + +`ViewModel` 基类未明示 `@MainActor` 隔离 (从 CLAUDE.md "Base class: All ViewModels inherit `ViewModel`" 看不出);若 `transform` 不在 main actor,Swift 6 严格并发会报 isolation 错。即便编译通过,`subscribeToIsEnabled` 内部直接读写 `self.isEnabled` 也需保证调用点已在主线程。 + +**建议修复**: Plan Task 18 Step 2 把 transform 内的同步调用改为: + +```swift +Task { @MainActor [weak self] in + self?.subscribeToIsEnabled() +} +``` + +或在 ViewModel 类型上显式 `@MainActor` 标注,与 coordinator 中 `subscribeToSettings` 已经写的 `Task { @MainActor [weak self] in ... }` 模式保持一致。 + +**落地**: Plan Task 18 Step 2 transform 内 `subscribeToIsEnabled()` 包入 `Task { @MainActor [weak self] in self?.subscribeToIsEnabled() }`。 + +--- + +## Minor — ✅ 已落地 + +### O6. Plan Task 16 占位名 `engine.imageLoadedSignal` 与 Task 4.5 引入的 `imageDidLoadPublisher` 不一致 — ✅ 已修 + +Plan Task 4.5 Step 2 已经引入: + +```swift +public nonisolated var imageDidLoadPublisher: some Publisher { ... } +``` + +但 Plan Task 16 Step 2 代码示例仍写: + +```swift +engine.imageLoadedSignal + .emitOnNext { [weak self] path in ... } +``` + +虽然 Step 1 写"调整下面的订阅以匹配",但同一份 plan 内两节命名不一致会让执行者在 Step 2 真去搜不存在的 `imageLoadedSignal` 符号。 + +**建议修复**: Plan Task 16 Step 2 直接用 `engine.imageDidLoadPublisher`,通过 RxCombine 桥 (项目 CLAUDE.md 列出 `RxCombine` 已是依赖) 转 RxSwift 后 `.emitOnNext { ... }`,或直接 `Task { for await ... in publisher.values }` 风格。 + +**落地**: Plan Task 16 重写为"Combine `Publisher.values` 桥到 AsyncStream"模式 —— 与 coordinator 已有的 manager event pump (`Task { for await event in stream }`) 形态一致。Step 1 新增 `imageLoadedPumpTask: Task?` 与 deinit 取消;Step 2 用 `for await path in self.engine.imageDidLoadPublisher.values` 消费,补 `handleImageLoaded` 内"manager dedups by rootImagePath + reason discriminant"的注释,移除原"如果 engine 仅暴露 AsyncSequence" 的备选分支(已无歧义)。 + +### O7. Plan Task 4.5 Step 4 测试中 `await` 冗余 — ✅ 已修 + +```swift +let cancellable = await engine.imageDidLoadPublisher.sink { ... } +``` + +Plan Task 4.5 Step 2 把 publisher 标为 `nonisolated var`,访问无需 `await`。Swift 6 会 warn `no 'async' operations occur in 'await' expression`。 + +**建议修复**: 去掉 `await`: + +```swift +let cancellable = engine.imageDidLoadPublisher.sink { ... } +``` + +**落地**: Plan Task 4.5 Step 4 测试中 `await` 已删除,并补一条"Swift 6 会 warn 'no async operations occur'"的解释注释。 + +### O8. Plan Task 11 IUO 解释措辞偏题 — ✅ 已修 + +> IUO 的理由:actor 不能在 `init` 完成对其他存储属性的注册前把 `self` 交给 manager;而 manager 在 init 之后是只读的 … + +实际上 `RuntimeEngine.init` 在最后一行构造 manager 时,`self` 的所有 stored property (`objcSectionFactory`、`swiftSectionFactory` 等,见 `RuntimeEngine.swift:178-179`) 都已经初始化完成,不存在"前向引用 self"问题。真正需要的是规避 actor `lazy var` 与 `nonisolated` accessor 的初始化路径冲突。措辞不影响实现,但解释偏题,后续读者照该理由设计自己的 actor 时会被误导。 + +**建议修复**: Plan Task 11 Step 2 的"IUO 的理由"段改写为:"actor 上的 `lazy var` 强制每次首次访问都通过 actor 隔离,与 `nonisolated` 访问器不兼容,且初始化时机不直观。改用 IUO + 在 init 末尾赋值,语义更明确。" + +**落地**: Plan Task 11 Step 2 的"IUO 的理由"段已改写,纠正"前向引用 self"措辞,改为强调"初始化时机偏好"——`init` 末尾构造 manager 时所有 stored property 已就位,没有前向 self 问题;选 IUO 是为了把 manager 构造保留在线性叙事末尾,与普通 `let` 必须在声明处给初值相比更可读。 + +--- + +## 收尾状态 + +- 本轮 8 条问题与 [2026-04-24 review](2026-04-24-background-indexing-review.md) 不重叠,该文件保留为闭环记录。 +- **O1–O8 全部已在本轮闭环时落地**到 Plan / Evolution,具体修改见各条目的"落地"段。 +- 不再存在 open issue,本文件保留作为历史闭环记录。 +- 下一轮新发现请在 `Documentations/Reviews/` 下另开一份记录,不要追加到本文件。 diff --git a/Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md b/Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md new file mode 100644 index 00000000..5adf505e --- /dev/null +++ b/Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md @@ -0,0 +1,223 @@ +# Background Indexing 实现审查 — 最终轮 + +审查对象: +- 分支 `feature/runtime-background-indexing` 上完整的 29 个 commit(Task 0–Task 24) +- [0002-background-indexing.md](../Evolution/0002-background-indexing.md) +- [2026-04-24-background-indexing-plan.md](../Plans/2026-04-24-background-indexing-plan.md) +- 承接 [2026-04-24](2026-04-24-background-indexing-review.md) / [2026-04-25](2026-04-25-background-indexing-review.md) / [2026-04-26](2026-04-26-background-indexing-review.md) 三轮 plan / evolution 审查(均已闭环) + +本轮把 Plan / Evolution 视为已 Accepted,只对实际落地的 implementation 做最后一次代码审查,覆盖跨三层(Core actor / Application coordinator / AppKit UI)的整体行为。 + +**判定**: SHIP, with conditions —— 没有阻塞 merge 的 Critical issue,但有 6 条 Important 与 10 条 Minor 建议,部分应在 PR 中或紧随 PR 处理。 + +**2026-04-28 更新 — 修复状态:** + +- ✅ **I3 source-switch staleness** — 已修。Coordinator 通过 RxSwift 订阅 `documentState.$runtimeEngine.skip(1)`,变化时 cancel 旧 pumps、cancel 旧 doc batches、清 relays、切引用、重启 pumps、若 isEnabled 重发 main exec batch。详见 [plan post-review fixes](../Plans/2026-04-24-background-indexing-plan.md#post-review-fixes-2026-04-28) 与 [Evolution 0002](../Evolution/0002-background-indexing.md) 假设 #1 / 场景 G / 决策日志 2026-04-28 +- ⏳ **I1 (manager dedup)、I2 (loadImageForBackgroundIndexing 不发 imageDidLoadSubject 的 doc/test)、I4 (prioritize doc)、I6 (NSTableCellView 复用)、所有 Minor M1–M10** — 未处理,follow-up +- ⏳ **I5 (path normalization 不对称)** — 仅 iOS Simulator 激活,绑 iOS Simulator 支持工作,本轮不修 + +**验证结果**: +- `swift test` in `RuntimeViewerCore`:445/445 通过(其中 4 个 `XCTSkipUnless` 在 sandbox 下跳过 Foundation/CoreText 测试,本机 GUI 运行时全部命中)。 +- `swift build` in `RuntimeViewerPackages`:0 错误,我们引入 0 警告。 +- `xcodebuild` for `RuntimeViewer macOS` workspace:0 错误,0 警告,49.7s。 + +--- + +## Strengths(摘要) + +1. **三层 seam 切得很干净**。`BackgroundIndexingEngineRepresenting: Sendable` 协议(无 `AnyObject`)给 manager 一个窄的边界,Mock 只 58 行,`InstrumentedEngine`(测试本地)也就几十行。`MachOImage` 这种非 Sendable 类型从未越界。 +2. **取消处理在常见路径下正确**。`finalize` 里的 `wasCancelled || Task.isCancelled || state.batch.isCancelled` 三重 OR 是防御性正确;semaphore 用 `waitUnlessCancelled`;driving Task 通过 `Task.checkCancellation()` 把取消传播到正在跑的 `runSingleIndex`。`finalize` 只把 `.pending` / `.running` 翻成 `.cancelled` 而保留 `.completed` / `.failed`,符合 Evolution 0002 决议 #2。 +3. **保留失败批次的语义直观**。"完成且含失败 → 留到用户清除;取消 → 立即清掉"是合理的用户视角。Toolbar 的 `hasFailures` 经 `aggregateRelay → MainWindowController.setupBindings → backgroundIndexingItem.itemView.state` 一路冒泡,链路清晰。 +4. **Settings observation 重注册放置正确**。`withObservationTracking { … } onChange:` 的 callback 跳回 MainActor 后再读最新快照、再注册;只对 `isEnabled` 切换做动作,depth / maxConcurrency 改动有意 no-op(下一次 `startBatch` 自动用新值)。变化频率被人类 UI 节奏天然限速。 +5. **Actor 单元测试覆盖度扎实**。`RuntimeBackgroundIndexingManagerTests` 跑了 BFS dedup、依赖解析失败 → `.failed` item、并发上限(实测的 lock-counting `InstrumentedEngine`)、cancel-mid-batch、cancelAll、prioritize 事件发射。`test_prioritize_emitsTaskPrioritizedEvent` 故意放弃 "load order" 改为 "event emission" 断言,是 CI 稳定性的正确选择。 +6. **Engine API 与既有面一致**。三个新 public 方法都走 `request { local } remote: { … }` + `CommandNames`;`imageDidLoadPublisher` 镜像现有 `reloadDataPublisher` / `imageNodesPublisher` 的 Combine 风格。没有发明新机制。 +7. **文档密度异常高**。BFS 中的 `// try?` 注释、`// Class is @MainActor` 提示、`DocumentState.runtimeEngine` 的 immutability 警告、`machOImageName(forPath:)` 的 TODO 都到位。未来维护者不会迷路。 + +--- + +## Critical — 阻塞 merge + +无。 + +--- + +## Important — 进 PR 之前修或同步开 follow-up + +### I1. Manager 实际未实现 batch dedup,但 coordinator 注释声称"会 dedup" + +Evolution 0002 第 626 行写:*"manager 去重:如果某活动批次的 `rootImagePath == root` 且 `reason` 的判别式匹配,返回其已有 `RuntimeIndexingBatchID` 而非新启动一个。"* + +Coordinator 在 [`RuntimeBackgroundIndexingCoordinator.swift:240-241`](../../RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift) 的注释说: +``` +// Manager dedups batches that share rootImagePath + reason discriminant, so a +// second call here is a no-op rather than a wasted batch. +``` + +但 `RuntimeBackgroundIndexingManager.startBatch` 没有任何 dedup 逻辑 —— 每次都 alloc 新 ID 并加进 `activeBatches`。这是用户在 PR 描述里点出的"已知 pre-existing 问题 #3"(`documentDidOpen` 的 `.appLaunch` 与同一路径 `imageDidLoad` 之间的双批次),而注释让它看上去已经修了,实际没有。 + +可选修法: +- **实现 dedup**:在 `RuntimeBackgroundIndexingManager.startBatch` 里扫一遍 `activeBatches.values`,如果存在 `rootImagePath == root` 且 `reason` 判别式相同且 `!isFinished`,直接返回那条 ID。约 10 行。 +- **或删掉假注释,把 spec 降级**。更新 Evolution 0002 标 dedup 为延后,把 coordinator 的注释改成"manager 不 dedup,我们目前接受冗余工作"。 + +建议第一种 —— 改动小、spec 已经写了 dedup 是目标、用户也明确点出双批次。文件 `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:51-73`。 + +### I2. `loadImageForBackgroundIndexing` 不发 `imageDidLoadSubject`,但 doc / test 都没说 + +`loadImage(at:)`(RuntimeEngine.swift:530-542)成功后会发 `imageDidLoadSubject.send(path)` 并 `sendRemoteImageDidLoadIfNeeded(path:)`。 + +`loadImageForBackgroundIndexing(at:)`(`RuntimeEngine+BackgroundIndexing.swift:29-40`)有意不发 —— 否则每个被后台索引的 image 又会触发 `handleImageLoaded`,递归 spawn 新 batch。这是正确判断。 + +但是: +- doc comment 只提了不调 `reloadData`,没提不发 `imageDidLoadSubject` —— 后者对正确性同样关键。加一行说明。 +- `RuntimeEngineIndexStateTests.swift:61-70`(`test_loadImageForBackgroundIndexing_doesNotTriggerReloadData`)名字是 reloadData 跳过,但断言只检查"image 变成 indexed",既没断言"无 reload 通知"也没断言"无 imageDidLoad 通知"。补一个 `Combine.sink` 断言"调用期间 publisher 不发火"。 + +### I3. Source-switch 时 coordinator 抓住旧 engine ✅ FIXED 2026-04-28 + +`MainCoordinator.swift:34` 在 `.main(let runtimeEngine)` 时 reassign `documentState.runtimeEngine`。`backgroundIndexingCoordinator` 是 `lazy var`,首次访问后捕获了那时候的 engine + manager。后果: + +- Source switch 后 toolbar 状态停止反映新 engine 的 batches(`MainWindowController.swift:160-171` 在每次 `setupBindings` 重绑,但 `aggregateStateObservable` 来自旧 coordinator 的 relay,relay 又被旧 manager 喂)。 +- `documentDidOpen` / `documentWillClose`(`Document.swift:21, 25`)调到旧 coordinator 的旧 manager;新 engine 的 batch 永远启动不了。 +- Sidebar `prioritize` 调旧 manager,无效果。 + +`DocumentState.runtimeEngine` 的 doc comment 警告了不要 reassign,**但 MainCoordinator 现在的代码就在违反这个 contract**。Source switch 是真实用户路径,toolbar 静默与现实脱钩是糟糕体验。 + +可选修法: +- 在 `MainCoordinator.prepareTransition` `.main(...)` 处,reassign 之后调 `documentState.recreateBackgroundIndexingCoordinator()`,新 coordinator 重新订阅事件 + 重新装 manager。约 15 行。 +- 或让 coordinator 不持有 `engine`,每次现取 `documentState.runtimeEngine`(但这样事件泵也得重建,反而更复杂)。 + +建议第一种作为紧随 PR 的修复,而不是无限期 follow-up。文件 `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift:34` 与 `RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift:37-38`。 + +**修复 2026-04-28**:采用方案 (b) 的轻量变体 —— coordinator 不重建,通过 RxSwift `documentState.$runtimeEngine.skip(1)` 订阅 swap。`engine` 改 `var`,`handleEngineSwap(to:)` 取消旧 pumps、cancel 旧 manager 上的 doc batches(fire-and-forget)、清 `documentBatchIDs` / `batchesRelay` / `aggregateRelay`、切引用、重启 pumps、若 isEnabled 重发 main exec batch。`DocumentState.runtimeEngine` 的 doc comment 同步改为 reassignable。`MainCoordinator.prepareTransition` `.main(...)` 路径无变,沿用现有 `documentState.runtimeEngine = runtimeEngine` 触发 BehaviorRelay。 + +### I4. `prioritize(imagePath:)` 对已 dispatched 的路径无效 + +`prioritize` 把路径塞进 `priorityBoostPaths`、置 `hasPriorityBoost = true`、发 `.taskPrioritized`。但 `runBatch`(line 134)在开始时把 pending 列表 snapshot 进局部 `var pending`,`popNextPrioritizedPath` 之后只在这个本地数组里找 boosted 项。 + +只有在 `runBatch` while loop 还没把 P 弹出时,boost 才能改变 dispatch 顺序;一旦 P 已经 `.running` 或被弹出待 dispatch,boost 等于 no-op。 + +测试 `test_prioritize_emitsTaskPrioritizedEvent` 只断言事件发射,不断言加载顺序变化。所以 contract 实际是"best-effort priority boost,可能对已离开 pending 的项无效"。这没问题,但**要在 public 方法的 doc comment 与 spec 里写明**。当前 `prioritize` 没有任何 doc comment。文件 `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:38-49`。 + +### I5. `isImageIndexed` 与 `loadImageForBackgroundIndexing` 之间路径规范化不对称 + +`isImageIndexed`(`RuntimeEngine+BackgroundIndexing.swift:6-15`)在查 factory 缓存前调 `DyldUtilities.patchImagePathForDyld(path)`。`loadImageForBackgroundIndexing`(line 29-40)不调 —— 用 raw path。所以在非空 `DYLD_ROOT_PATH`(simulator runner)下,BFS 会:`isImageIndexed("/Foo")` → false(用 unpatched key 查 patched key 的 cache);然后调 `loadImageForBackgroundIndexing("/Foo")`,把 unpatched key 写入 cache;**下次 `isImageIndexed` 还是 false**,造成每轮 BFS 都重新加载。 + +这是用户在 PR 描述里的"已知 pre-existing 问题 #2"`loadImage` 不规范化的另一个版本。本机 macOS 上 `patchImagePathForDyld` 是 no-op(只在 simulator 下生效),所以**只有上 iOS Simulator 支持时才会暴露**。 + +修法二选一: +- `loadImageForBackgroundIndexing` 也 patch path(项目级修复:同时让 `loadImage(at:)` 也 patch); +- 把 `isImageIndexed` 的 patch 移除,接受现有 factory 用的是 unpatched key。 + +合同必须二选一。当前的"isImageIndexed patch / loadImage* 不 patch"是错配。文件 `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift:6-15, 29-40`。 + +### I6. UI 在每次 `outlineView(viewFor:)` 都重建 NSTableCellView + +`BackgroundIndexingPopoverViewController.swift:282-322` 每次取 cell 都 alloc 一个新的 `NSTableCellView` + `Label` + 一组新的 SnapKit 约束。Popover 刷新时调 `outlineView.reloadData()` 然后 `expandItem(nil, expandChildren: true)` —— 一个深 5、~30 个 dep 的 batch 在 actor 每发一次事件就要分配 ~30 个 view。在并发 4 的批次里 5+ Hz 都可能。 + +AppKit 标准做法是 `outlineView.makeView(withIdentifier:owner:)` + identifier-based recycling,配置一次,每行 populate。`.taskStarted` / `.taskFinished` 流在屏幕上打开时这是可量到的性能回归,尤其 spinner 还在转。 + +不是正确性 bug,popover 可关闭、用户也不大会一直打开它。但本地 fix ~20 行,且项目其他 outline view(sidebar / MCP status)都是 makeView-with-identifier 风格,这一处不一致。文件 `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift:282-322`。 + +--- + +## Minor + +### M1. Manager actor 在 `runBatch` 拿到 semaphore 后立即取消时的可重入 + +`runBatch`(line 146-162): +```swift +do { + try await semaphore.waitUnlessCancelled() +} catch { wasCancelled = true; break } +if Task.isCancelled { wasCancelled = true; break } // ← 拿到 slot 但没 signal() 就 break +``` + +如果 `waitUnlessCancelled` 成功(slot 拿到),但 `Task.isCancelled` 在 addTask 之前变 true,我们 break 了又没 signal。因为 semaphore 是函数局部变量,函数返回时随 stack 销毁,实际无害。但如果有人把 semaphore 提到实例级,这就是埋的雷。要么在 `if Task.isCancelled` 之前 `defer { semaphore.signal() }`,要么加注释说明 leak 是因为函数局部所以可接受。 + +### M2. `events` AsyncStream 启动期可能丢事件(理论上) + +`startEventPump` 里 `await self.engine.backgroundIndexingManager.events` 在 Task 调度后才订阅。在 `init` 返回到这个 Task 真正跑起来之间,manager 理论上可能 yield 事件 —— 实际上 engine 此刻 `.initializing`,不会有事件。AsyncStream 默认 `.unbounded`,所以也不会丢;但如果 buffering policy 改了就会。把 manager init 里的 buffering policy 显式声明(`AsyncStream.makeStream(bufferingPolicy: .unbounded)`)能锁住意图。 + +### M3. `BackgroundIndexingPopoverViewController.outlineView(child:ofItem:)` 每次都重建 batch 列表 + +Line 260-262 用 `compactMap` 过滤 `renderedNodes` 取 batches,而 NSOutlineView 每次刷新会调这个方法 O(visible-rows) 次。`nodes` 更新时 cache 一份 batch-only slice 即可。简单修复。 + +### M4. `engine.reloadData(isReloadImageNodes: false)` 每个 batch 终态都触发一次,会 reload 整个 imageList + +Coordinator `apply` 里 `.batchFinished` 与 `.batchCancelled` 都派发: +```swift +Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) +} +``` + +每个 batch 完成时调一次(不是每个 item),这点是好的;但 `reloadData(false)` 仍然会 reload 整个 imageList(`DyldUtilities.imageNames()` + RPC 推)。多个 doc 各跑 batch 时可能抖动。考虑加 100ms debounce,窗口期内不再有 batch 完成才发火。不是 bug,只是 polish。 + +### M5. `Document.close()` 不 await `documentWillClose` 的取消 + +`Document.close()`(`Document.swift:24-27`)同步调 `documentWillClose()`,后者 spawn 一个 Task 取消 batches 再返回,然后 `super.close()` 继续。取消异步在飞,如果 engine + manager 在 Task 落地前就 deinit,`cancelBatch` 跑在已 `finish()` 的 AsyncStream 上 —— 因为有 `guard let state = activeBatches[id] else { return }` 兜底,无害,但语义脆弱。要么在 close() 里 await 取消,要么显式注释说"fire-and-forget"。 + +### M6. `subscribeToIsEnabled` 在 popover ViewModel 与 coordinator 重复 + +`BackgroundIndexingPopoverViewModel.swift:109-124` 与 `RuntimeBackgroundIndexingCoordinator.swift:260-275` 都给 `Settings.backgroundIndexing.isEnabled` 写了 `withObservationTracking` re-registration。两处不严格冗余(popover 只关心 isEnabled;coordinator 关心 isEnabled 切换以启动/取消批次),但模板代码在两层重复。可以抽出一个 `Settings.observe(\.backgroundIndexing.isEnabled)` helper。不阻塞,但这种 SwiftUI/Rx-Settings 桥接只会越来越多。 + +### M7. `BackgroundIndexingToolbarState.disabled` 是死代码 + +state 枚举 4 个 case(`idle` / `disabled` / `indexing` / `hasFailures`),但 `MainWindowController.swift:160-171` 只产 `idle` / `indexing` / `hasFailures` —— `disabled` 永远不发。要么把 toolbar 也接 `Settings.backgroundIndexing.isEnabled`(关闭时发 `.disabled`),要么删掉这个 case。 + +### M8. BFS 的 `try?` 吞错有代价 + +Line 90 `(try? await engine.isImageIndexed(path: path)) == true` 与 line 111 `(try? await engine.dependencies(for: path)) ?? []` 把远端错误吞掉。注释说明了 trade-off,但在 XPC 短暂掉线时,BFS 会产出半成品的图(大多数 dep 没采到),后续 batch 全靠 `loadImageForBackgroundIndexing` 抛错才暴露,用户看到 N 个并发失败但找不到共因。考虑:如果 root 的 `engine.isImageIndexed` 抛错,直接发一条 `.batchCancelled`(reason "engine disconnected")替代半成品 batch。文件 `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:75-127`。 + +### M9. 测试缺口 —— coordinator 层集成测试没写 + +16 个 actor 测试都在 `RuntimeBackgroundIndexingManager` 用 mock engine。`RuntimeBackgroundIndexingCoordinator` 的事件泵本身没测 —— `apply(event:)` 是个纯 batch state machine,可以用 mock manager 测: +- `.batchFinished` 含全部 `.failed` items 时,batch 应保留在 `batchesRelay`,aggregate 更新。 +- `.batchCancelled` 移除 batch。 +- `clearFailedBatches` 移除全失败的批次,保留干净的。 + +这些是用户可见规则,目前无回归保护。`RuntimeViewerApplication` 已有(弱)测试 target,至少补一条"settings 关闭 → cancelAll 触发"的 happy-path 测试。 + +### M10. Popover 三种空/列表态没显式 z-stack 顺序 + +`emptyDisabledStack`、`emptyIdleView`、`scrollView` 都是中央/全填的(line 117-130),靠 `isEnabled` / `hasAnyBatch` 组合控制 `isHidden`(line 215-225)。组合正确(每次只一个可见),但未来重构破了组合就会 z-fight 而无明显错指示。要么改成 `NSTabView`-style switcher,要么 debug 断言"三者中至少两个 hidden"。 + +--- + +## 风险评估 —— 明天 merge 的话最坏会怎样 + +最高风险:**I3 source-switch staleness**。开了 feature 的用户在 local / remote / Bonjour 之间切换,会安静地丢失后台索引(toolbar 项显示 idle,但新 engine 的 batches 启动不了)。可能数小时都注意不到,而且更可能被报成"toolbar 项坏了"而不是"已知限制"。 + +次高:**I1 没有真正的 dedup**,与 `documentDidOpen` + `imageDidLoad` 的交互意味着 main executable 在启动时被索引两次,每个重复 batch 浪费 ~200ms 的 dyld + ObjC/Swift 解析。macOS 15+ 现代硬件下不可见;CI/老硬件会被报"索引慢"。 + +第三:**I5 路径规范化**潜伏(只在 simulator 下激活),目前无用户影响,但随着 iOS Simulator 支持上线立即活化。 + +三者都不会数据损坏、卡死 app、或影响非 feature 用户。Feature 是 opt-in(`isEnabled = false` 默认),不开就零风险面。 + +--- + +## Verdict + +**SHIP, with conditions:** + +- I1(manager dedup) —— 进 PR 前实现 OR 写一篇 KnownIssues。约 10 行,spec 已经要求。 +- I3(source-switch staleness) —— 在本分支修 OR 立 P1 follow-up issue 并在 PR body 里链上。用户已经知道这事;倾向在分支上修,但 P1 issue 可接受。 +- I2、I4 —— doc/test 改动,进 PR 前做。 +- I5 —— P2 follow-up,与 iOS Simulator 支持工作绑定。 +- I6 —— P2 polish issue。 +- M1–M10 —— 单独开"Background Indexing polish"汇总 issue,后续 PR 处理。 + +架构站得住(Sendable seam 保得住,actor 可重入被 manager 的小 public 表面框住,AsyncStream / Combine / Rx 桥的取舍都有理由),actor 的测试覆盖度真好,doc 注释密度可以做范本。三轮审查抹掉了所有显眼陷阱;剩下的问题真实但都不大。 + +--- + +## 附:相关文件路径 + +- `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift` — I1, I4, M1, M8 +- `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` — I2, I5 +- `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift` — I2 +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift` — I3 +- `RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift` — I3 +- `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift` — I1(误导注释), M5, M9 +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift` — I6, M3, M10 +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift` — M7 +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift` — M5 diff --git a/Documentations/Reviews/2026-04-26-background-indexing-review.md b/Documentations/Reviews/2026-04-26-background-indexing-review.md new file mode 100644 index 00000000..fd08d7df --- /dev/null +++ b/Documentations/Reviews/2026-04-26-background-indexing-review.md @@ -0,0 +1,190 @@ +# Background Indexing Evolution & Plan — 第三轮审查 + +审查对象: +- [0002-background-indexing.md](../Evolution/0002-background-indexing.md) +- [2026-04-24-background-indexing-plan.md](../Plans/2026-04-24-background-indexing-plan.md) + +承接 [2026-04-24-background-indexing-review.md](2026-04-24-background-indexing-review.md) 与 [2026-04-25-background-indexing-review.md](2026-04-25-background-indexing-review.md)(均已闭环),本轮在新文件中开新一轮 issue。 + +本轮把 Plan / Evolution 当作"已 Accepted"的稳定文档,再次对仓库当前代码做核验,挖出前两轮没捕捉的 6 条问题(N1–N6)。 + +**状态**: N1–N6 已全部在本轮闭环时落地到 Plan / Evolution;无 open issue。 + +--- + +## Critical — 阻塞实现 + +### N1. `MainRoute.openSettings` case 实际不存在 — ✅ 已修 + +Evolution 0002 第 568 行与 Plan Task 18 Step 2 都写道:popover 的 "Open Settings" 按钮触发 `router.trigger(.openSettings)`,理由是"已有的 `MainRoute.openSettings` case"。 + +但 `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift:10-21` 的实际枚举只有: + +```swift +public enum MainRoute: Routable { + case main(RuntimeEngine) + case select(RuntimeObject) + case sidebarBack + case contentBack + case generationOptions(sender: NSView) + case loadFramework + case attachToProcess + case mcpStatus(sender: NSView) + case dismiss + case exportInterfaces +} +``` + +**没有 `openSettings`**,`router.trigger(.openSettings)` 直接编译失败。 + +代码侧的现成参照在 `MCPStatusPopoverViewController.swift:200-203`: + +```swift +output.openSettings.emitOnNext { + SettingsWindowController.shared.showWindow(nil) +} +.disposed(by: rx.disposeBag) +``` + +—— ViewController 的闭包**直接**调用 `SettingsWindowController.shared.showWindow(nil)`,**不**走 router。ViewModel 只负责把 input.openSettings 透传到 output.openSettings。 + +**落地**:与 MCP popover 完全对齐 —— +- Plan Task 18 Step 2 ViewModel 的 `Output` 增加 `openSettings: Signal`,`transform` 把 `input.openSettings` 透传(经一个 PublishRelay)而**不**调用 `router.trigger(.openSettings)`。 +- Plan Task 19 Step 1 ViewController `setupBindings` 增加 `output.openSettings.emitOnNext { SettingsWindowController.shared.showWindow(nil) }` 绑定,顶部 `import` 段补 `RuntimeViewerSettingsUI` 以拿到 `SettingsWindowController`。 +- Evolution 0002 "Components" 与 "Sendable 值类型" 对应段落同步修订:`openSettings` 走 ViewController 闭包,**不**经 MainRoute。 +- Evolution 0002 "决策日志" 追加一行。 + +--- + +### N2. ViewModel `Input` 4 字段,ViewController 只填 3 字段 — ✅ 已修 + +Plan Task 18 Step 2(第 2380-2385 行)`Input` 声明: + +```swift +struct Input { + let cancelBatch: Signal + let cancelAll: Signal + let clearFailed: Signal // ← 4 项 + let openSettings: Signal +} +``` + +Plan Task 19 Step 1(第 2656-2660 行)ViewController 创建处: + +```swift +let input = BackgroundIndexingPopoverViewModel.Input( + cancelBatch: cancelBatchRelay.asSignal(), + cancelAll: cancelAllRelay.asSignal(), + openSettings: openSettingsRelay.asSignal() // ← 缺 clearFailed +) +``` + +且 ViewController 完全没有声明 `clearFailedRelay` / `clearFailedButton`。但 Evolution 0002 第 521 行明确写"页脚 ... 包含 `Cancel All` 按钮 ... `Clear Failed` 按钮(仅当存在保留的失败批次时可见)以及 `Close` 按钮"。Task 24 又交付了 `coordinator.clearFailedBatches()` 公共方法,等待 Input 路径调用,接不上。 + +**落地**:Plan Task 19 Step 1 补齐: +- 顶部 Relay 段加 `private let clearFailedRelay = PublishRelay()`。 +- View 段加 `private let clearFailedButton = NSButton().then { $0.bezelStyle = .accessoryBarAction; $0.title = "Clear Failed" }`。 +- `setupLayout` 的 `buttonStack` 改为 `{ cancelAllButton; clearFailedButton; closeButton }`。 +- `setupActions` 增加 `clearFailedButton.target / action`,新增 `@objc private func clearFailedClicked() { clearFailedRelay.accept(()) }`。 +- `setupBindings` 的 `Input` 初始化补 `clearFailed: clearFailedRelay.asSignal()`。 +- `setupBindings` 增加 `output.hasAnyFailure` 绑定 → `clearFailedButton.isHidden = !hasAnyFailure`。 + +Plan Task 18 Step 2 ViewModel 同步: +- 类内增加 `@Observed private(set) var hasAnyFailure: Bool = false`。 +- `Output` 增加 `hasAnyFailure: Driver`。 +- `transform` 把 `coordinator.aggregateStateObservable.map { $0.hasAnyFailure }` 桥到 `hasAnyFailure` 属性。 + +--- + +### N3. `RuntimeBackgroundIndexingCoordinator` init 跨 actor 访问 `DocumentState` — ✅ 已修 + +`DocumentState.swift:6-7` 声明: + +```swift +@MainActor +public final class DocumentState { +``` + +而 Plan Task 14 Step 2 的 coordinator init: + +```swift +public init(documentState: DocumentState) { + self.documentState = documentState + self.engine = documentState.runtimeEngine // ← @MainActor 隔离属性 + startEventPump() +} +``` + +`RuntimeBackgroundIndexingCoordinator` 类**没有** `@MainActor` 隔离;只有 `apply(event:)` / `handleSettingsChange` / `refreshAggregate` 等单方法被标。Swift 6 严格并发(以及 Swift 5 的 `complete` checking)下,`init` 同步读取 `documentState.runtimeEngine` 会报跨 actor isolation 错。 + +**落地**:Plan Task 14 Step 2 把整个 coordinator 类标 `@MainActor`(与 `DocumentState` 一致)。原本散布在 `apply(event:)` / `handleSettingsChange` / `refreshAggregate` / `subscribeToSettings` / `clearFailedBatches` 上的 `@MainActor` 全部删除(类标注涵盖所有方法)。`startEventPump` / `startImageLoadedPump` 内 `for await ... in stream` 自动在 main actor 上跑,`apply(event:)` / `handleImageLoaded(path:)` 直接同步调用即可,不再需要 `await MainActor.run { ... }` 包装(Plan Task 14 Step 2 / Task 16 Step 2 的事件泵代码同步简化)。 + +Evolution 0002 "Components" 段 `RuntimeBackgroundIndexingCoordinator` 子节补一行:"`@MainActor` 隔离类(与 `DocumentState` 一致),所有事件归约与 Settings 观察都在主线程"。 + +--- + +### N4. `protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable` 与 actor conformance — ✅ 已修 + +Plan Task 5 Step 1 把协议声明成 `AnyObject, Sendable`,Plan Task 5 Step 2 让 `extension RuntimeEngine: BackgroundIndexingEngineRepresenting`(`RuntimeEngine` 是 `actor`)。 + +actor 类型对 `AnyObject` 约束的 conformance 在 Swift 5.7+ 主线允许,但仍是相对边角的特性,**而且协议的所有方法都不要求引用语义**(没有 `unowned` / `weak` 持有的需求,manager 内是按值持有 `engine: any BackgroundIndexingEngineRepresenting`)。`AnyObject` 约束纯粹是历史习惯,在此并无作用,反而引入"actor conform AnyObject 是否合法"的认知负担。 + +**落地**:Plan Task 5 Step 1 协议改为只 `: Sendable`: + +```swift +protocol BackgroundIndexingEngineRepresenting: Sendable { + ... +} +``` + +Mock(`MockBackgroundIndexingEngine`)与 `InstrumentedEngine` 保持 `final class ... @unchecked Sendable`(它们本来就是 class,Sendable 协议要求由 `@unchecked` 满足),conformance 不变;`RuntimeEngine`(actor)conformance 也不变,但少了"actor + AnyObject" 的边角依赖。 + +--- + +## Significant — 表述/优化 + +### N5. Plan Task 18 Step 2 `transform` 中 `subscribeToIsEnabled` 的 `Task` 包裹是过度修正 — ✅ 已修 + +`open class ViewModel: NSObject` 类头带 `@MainActor`(`ViewModel.swift:9-10`),所以 `transform(_:)` 自动是 MainActor 隔离方法,直接同步调用 `subscribeToIsEnabled()` 完全合法。Plan 当前写法: + +```swift +Task { @MainActor [weak self] in + self?.subscribeToIsEnabled() +} +``` + +把"初始 isEnabled 订阅 + seed 同步初值"延后到下一次 main runloop 调度,popover 弹出瞬间的 isEnabled 仍是 stored 默认值 `false`,会出现一帧"已禁用"空状态闪烁(即便 Settings 中 isEnabled = true)。 + +第二轮 review O5 当时按"`transform` 不在 MainActor"假设修正,但 ViewModel 基类的 `@MainActor` 标注让该假设站不住脚。 + +**落地**:Plan Task 18 Step 2 `transform` 内 `Task { @MainActor [weak self] in self?.subscribeToIsEnabled() }` 改回同步直调: + +```swift +subscribeToIsEnabled() +``` + +并补一条注释:"ViewModel 基类已是 `@MainActor`,直接同步调用即可,seed 初值同步比异步派发更适合 popover 第一帧"。 + +第二轮 review 文件 [2026-04-25-background-indexing-review.md](2026-04-25-background-indexing-review.md) 的 O5"落地"段会保留(历史闭环不动),但本轮 N5 视为对其的二次修正。 + +--- + +### N6. Evolution 0002 Assumption #2 表述不严谨 — ✅ 已修 + +Evolution 0002 第 601 行: + +> 2. **`RuntimeBackgroundIndexingManager` 仅运行在引擎的宿主进程内。** 对于远程(XPC / directTCP)来源,*引擎方法*通过 `request { local } remote: { RPC }` 镜像,但 *manager* 存活在服务端引擎的 actor 中。UI 客户端只通过本地引擎引用消费 manager 状态。 + +但 Plan Task 11 在**所有** RuntimeEngine 实例(含远程引擎)init 末尾构造 manager,manager 实例**本地**活着。manager 触发的 `engine.loadImageForBackgroundIndexing(at:)` 等方法走 `request { local } remote: { RPC }` 分发到服务端,这才符合 Plan 的实际接线。Evolution 原话"manager 存活在服务端引擎的 actor 中"会让读者误以为远程 engine 不创建 manager。 + +**落地**:Evolution 0002 假设 #2 改写为: + +> 2. **`RuntimeBackgroundIndexingManager` 与 engine 一对一构造,在客户端进程内活着。** 对于远程(XPC / directTCP)来源,manager 实例仍在客户端运行,但其内部调用的 engine 公共方法(`isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` / `dependencies(for:)` 等)都走 `request { local } remote: { RPC }` 分发,真正的索引工作由服务端目标进程执行。UI 客户端通过本地引擎引用消费 manager 事件流。 + +--- + +## 收尾状态 + +- 本轮 6 条问题(N1–N6)与前两轮 review 不重叠,已全部在本轮闭环时落地到 Plan / Evolution,具体修改见各条目"落地"段。 +- 不再存在 open issue,本文件保留作为历史闭环记录。 +- 下一轮新发现请在 `Documentations/Reviews/` 下另开一份记录,不要追加到本文件。 diff --git a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md new file mode 100644 index 00000000..1058c546 --- /dev/null +++ b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md @@ -0,0 +1,288 @@ +# Background Indexing UltraReview 审查发现 + +审查对象: +- 分支 `feature/runtime-background-indexing` → `main` +- 范围:46 files changed, 4710 insertions(+), 1408 deletions(-) +- 工具:`/ultrareview` 云端多 Agent 审查 + +承接 [2026-04-26 implementation-review](2026-04-26-background-indexing-implementation-review.md) 的内部审查,本轮由独立 Agent 重新走一遍代码,产出 8 条发现。其中部分与内部 review 的 I 项条目重叠(I1 / I3 / I5),作为独立佐证;另外补出 4 条新问题。 + +**判定**: 没有阻塞 merge 的 Critical 项;有 4 条 Normal 与 3 条 Nit + 1 条 pre-existing 跟进项。 + +**2026-04-28 更新 — 修复状态:** + +- ✅ **N1 RuntimeEngine ↔ Manager 循环引用** — 已修。协议恢复 `: AnyObject, Sendable`,manager 改 `private unowned let engine`。生产上 engine 强持 manager,unowned 反向引用安全(engine deinit 同步释放 manager)。测试加 `keep(_:)` helper 兜住平行 local mock 的 ARC 寿命。详见 [plan post-review fixes](../Plans/2026-04-24-background-indexing-plan.md#post-review-fixes-2026-04-28) 与 [Evolution 0002](../Evolution/0002-background-indexing.md) 决策日志 2026-04-28 +- ✅ **N2 source switch staleness** — 已修(同 implementation-review I3)。Coordinator 通过 RxSwift `documentState.$runtimeEngine.skip(1)` 响应 swap。详见上 +- ✅ **N4 DylibPathResolver 拒绝 dyld shared cache** — 已修。`DyldUtilities.isInDyldSharedCache(_:)` 加 Set-cache 字面查询,`DylibPathResolver.pathExists` 兼顾文件系统与 cache。**字面匹配,不规范化** —— 与本审查建议的方向一致(让真实失败显式呈现);用户明确选 "字面匹配" 是因为 macOS 上 versioned ↔ unversioned 的规范化在 install name 形式不一致时有误导风险,iOS 不需要规范化。详见 Evolution 0002 假设 #4 与决策日志 2026-04-28(N4) +- ✅ **N3 manager dedup** — 已修。`startBatch` 在 expand 前后两次扫 `activeBatches`,命中 `!isFinished && rootImagePath == 入参` 即返回旧 ID;reason 判别式不参与比较,所以 `.appLaunch` ↔ `.imageLoaded(path:)` 折叠到一个 batch。Coordinator 的 `handleImageLoaded` 注释同步更新。新增两条 manager 测试(同 root 跨 reason 命中、batch finalize 后允许新批次) +- ✅ **Nit-1 per-batch cancel 按钮** — 已修。`BatchCellView` 加 inline `xmark.circle` borderless 按钮,通过 closure 把 `batch.id` 推到新的 `cancelBatchRelay`(对接已经存在的 Input.cancelBatch → coordinator → manager 路径)。`isFinished` 的批次(failed-retain 行)按钮隐藏 +- ✅ **Nit-2 .settingsEnabled reason 永不构造** — 已修。抽 `startMainExecutableBatch(reason:)` helper,`documentDidOpen()` 传 `.appLaunch`,`handleSettingsChange` off→on 分支传 `.settingsEnabled`。Popover 的 `title(for: .settingsEnabled) → "Settings enabled"` 不再死代码 +- ✅ **Nit-3 documentBatchIDs 失败保留泄漏** — 已修。`.batchFinished` 分支(无论 UI 是否保留 batch)统一 `documentBatchIDs.remove(finished.id)`;`clearFailedBatches()` 计算被清掉的 id 集合并 `documentBatchIDs.subtract`。`documentWillClose` 不再向 manager 派发 ghost ids 的 cancel Task +- ✅ **Pre-1 (path normalization)** — 已修。Step 1(commit `a033d3d`)给 `DyldUtilities.patchImagePathForDyld` 加幂等性保护,避免 iOS Simulator 下 `dyld` 已经返回 patched path 时再次 patch 产生 `/sim_root/sim_root/...` 双前缀。Step 2 在 `loadImage(at:)` 与 `loadImageForBackgroundIndexing(at:)` 入口 patch,内部存储(`loadedImagePaths` / section factory caches / `imageDidLoadSubject`)统一 canonical 形式,wire 上保持 raw(receiver 各自 patch)。Reader/writer 现在一致 canonical + +--- + +## Normal + +### N1. `RuntimeEngine` ↔ `RuntimeBackgroundIndexingManager` 循环引用导致每个远程 engine 泄漏 ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:4-16` + +`RuntimeEngine.swift:186` 强持 `backgroundIndexingManager: RuntimeBackgroundIndexingManager!`,manager 又通过 `private let engine: any BackgroundIndexingEngineRepresenting` 强持 engine。 + +Evolution 0002 决议 N4 主动把协议从 `AnyObject, Sendable` 改成纯 `Sendable`,理由是"manager 按值持有 engine,无引用语义需求"。**这个理由是错的**:`any P` 装箱 actor / class 仍然是强引用,移除 `AnyObject` 只是失去了把 existential 标 `weak`/`unowned` 的可能性,并没有让它变成值语义。 + +`RuntimeEngine.local` 是单例,泄漏一次性。但以下路径每次都 `new RuntimeEngine`: +- `RuntimeViewerUsingAppKit/.../RuntimeEngineManager.swift:168, 269, 290`(attached / Catalyst client / 通用工厂) +- `RuntimeViewerServer/.../RuntimeViewerServer.swift:59, 62, 77`(server 端每连接一对 engine+manager) +- `RuntimeViewerUsingUIKit/.../AppDelegate.swift:23`(Bonjour server engine) + +`RuntimeEngineManager.terminateRuntimeEngine` 把 engine 从 tracking 数组移除时,环让 engine + manager + AsyncStream continuation + activeBatches + driving Task + 两个 SectionFactory 缓存全部留下,跨用户切换 source / 多次 attach-detach 累计增长无界。 + +`RuntimeBackgroundIndexingManager.deinit` 只 `continuation.finish()`,不能解环 —— 实际上因为环存在 deinit 永远不会被调用。 + +**修法**:回退 N4 决议,把协议恢复为 `AnyObject, Sendable`,manager 持有改为 `private weak var engine: (any BackgroundIndexingEngineRepresenting)?`(或 `unowned` 如果文档约定 engine 寿命包住 manager)。所有 callsite `try await engine?.…`,nil 时直接 bail。约 3 行核心改动 + doc comment 修正。 + +**修复 2026-04-28**:协议改 `AnyObject, Sendable`,manager 用 `private unowned let engine`(非 `weak`)。理由:engine 强持 manager(`RuntimeEngine.backgroundIndexingManager: RuntimeBackgroundIndexingManager!`),engine deinit 必然先释放 `backgroundIndexingManager` 属性,manager 一同消亡 —— unowned 反向引用没机会悬空。`weak` 会引入 nil-safety 模板代码而无实际收益。测试里 mock 与 manager 是平行 local,加 `keep(_:)` helper 把 mock 钉到 test instance 的 `aliveObjects` 数组。详见 Evolution 0002 决策日志 2026-04-28(回退 N4 决议)。 + +### N2. Coordinator 跨 source 切换捕获过时 `RuntimeEngine` ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift:40-48` + +(对应 implementation-review 的 I3,这里再次确认问题确实未在 PR 中修。) + +`init` 一次性快照 `documentState.runtimeEngine` 到 `self.engine`,所有方法(`cancelBatch` / `cancelAllBatches` / `prioritize` / `startEventPump` / `startImageLoadedPump` / `documentDidOpen` / `handleImageLoaded` / `handleSettingsChange`)都闭包 `self.engine`。 + +`MainCoordinator.swift:33-34` 在 `.main(let runtimeEngine)` 时 `documentState.runtimeEngine = runtimeEngine`,`backgroundIndexingCoordinator` 是 `lazy var`,不会重建。 + +复现: + +1. 启动时 `Document.makeWindowControllers` 触发首次访问 → coordinator 构造,捕获 `engine = .local`。 +2. 用户在 toolbar PopUp 切到 Bonjour/XPC 远程 → `MainCoordinator.prepareTransition` 改写 `documentState.runtimeEngine`,但 `documentState.backgroundIndexingCoordinator` 仍是同一实例。 +3. `MainWindowController.setupBindings` 重新绑定 toolbar 到 `coordinator.aggregateStateObservable`,但该 relay 由旧的 `.local` manager 驱动 → toolbar 永远空闲。 +4. 新 engine 的 `backgroundIndexingManager` 没人订阅,主可执行文件永远不被索引。 +5. `SidebarRootViewModel` 的 `prioritize(...)` 全部路由到死 manager,静默 no-op。 + +`DocumentState.runtimeEngine` 的 doc comment 警告"不要重新赋值",但 `MainCoordinator` 在每次 source 切换都违反这个约定。 + +**修法**(两选一): +- (a) 在 `DocumentState` 暴露 `recreateBackgroundIndexingCoordinator()`,`MainCoordinator.prepareTransition` `.main` 分支 reassign 之后调用,旧 coordinator 取消 pump、新 coordinator 接管。约 15 行。 +- (b) 让 coordinator 订阅 `documentState.$runtimeEngine`,变更时取消 pump、swap `self.engine`、重启 pump。改动更深但保留失败批次 state。 + +推荐 (a),与"每个 Document/engine 对一个 coordinator"心智模型一致。 + +**修复 2026-04-28**:采用方案 (b) 的轻量变体 —— coordinator 不重建,通过 RxSwift `documentState.$runtimeEngine.skip(1)`(`@Observed` 暴露的 `BehaviorRelay`)订阅 swap。`engine` 改 `var`,`handleEngineSwap(to:)` 取消旧 pumps、cancel 旧 manager 上的 doc batches(fire-and-forget)、清 `documentBatchIDs` / `batchesRelay` / `aggregateRelay`、切引用、重启 pumps、若 isEnabled 重发 main exec batch。Coordinator 实例不变,所以 toolbar 的 `coordinator.aggregateStateObservable` 订阅链自动跟随;失败批次状态不跨 swap 保留(swap 时清空,因为它属于旧 engine)。`DocumentState.runtimeEngine` 的 doc comment 改为 reassignable。详见 Evolution 0002 假设 #1 / 场景 G / 决策日志 2026-04-28。 + +### N3. Manager batch dedup 注释/spec 都说有,代码中没实现 ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:51-73` + +(对应 implementation-review 的 I1,独立验证。) + +`RuntimeBackgroundIndexingCoordinator.swift:236-247` `handleImageLoaded` 注释: +```swift +// Avoid double-starting if the path is the main executable being opened +// at app launch — documentDidOpen already dispatched that batch. Manager +// dedups batches that share rootImagePath + reason discriminant, so a +// second call here is a no-op rather than a wasted batch. +``` + +Evolution 0002 第 626 行:*"manager 去重:如果某活动批次的 `rootImagePath == root` 且 `reason` 的判别式匹配,返回其已有 `RuntimeIndexingBatchID`。"* + +`RuntimeBackgroundIndexingManager.startBatch` 实际:每次都 `RuntimeIndexingBatchID()` + `activeBatches[id] = state`,无任何扫描。 + +**额外发现**:即使 spec 描述的 dedup 实现了,最现实的双批次场景也抓不到 —— `documentDidOpen` 派发 `.appLaunch`、之后 `imageDidLoadPublisher` 对同一 path 触发 `.imageLoaded(path:)`,**两个 reason 的判别式不同**,spec 的去重规则也太窄。 + +**修法**: +- 实现 dedup,扫 `activeBatches.values` 找 `!isFinished && rootImagePath == root && (reason 判别式相同 OR 同根扩展规则)`,命中则返回旧 ID。约 10 行。 +- 把规则放宽为"任意匹配 `rootImagePath`",抓住 `.appLaunch` ↔ `.imageLoaded` 这一对。 +- 否则**至少删掉 coordinator 的误导注释**,不要让未来维护者以为有保护。 + +**修复 2026-04-28**:走第二条路 —— 规则放宽到"任意匹配 `rootImagePath`",reason 判别式不参与。`startBatch` 抽 `private func findActiveBatchID(forRootImagePath:)` helper,在 `expandDependencyGraph` 前后各扫一次 `activeBatches`:第一次省去无谓 expand 工作,第二次兜住 actor 重入(若 A 与 B 同时 `await` expand,actor 可能交错执行,re-check + insert 在 actor 上是原子的,所以输家总能看到赢家的 insertion)。Coordinator `handleImageLoaded` 的注释从 "shares rootImagePath + reason discriminant" 改成 "dedups by `rootImagePath`",并指出 `Set.insert` 让重复 id 在 `documentBatchIDs` 上是 no-op。 + +新增 manager tests(`RuntimeBackgroundIndexingManagerTests.swift`): +- `startBatchDedupsByRootImagePathAcrossDifferentReasons` —— `.appLaunch` 与 `.imageLoaded(path:)` 在同 root 上必须返回相同 id +- `startBatchAllowsNewBatchAfterPreviousFinishedForSameRoot` —— batch finalize 后同 root 允许新批次,确保 dedup 不锈住已完成历史 + +### N4. `DylibPathResolver` 拒绝所有 dyld-shared-cache 系统 framework ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift:36-41` + +绝对路径分支末尾: +```swift +return fileManager.fileExists(atPath: installName) ? installName : nil +``` + +Apple Silicon 上 `/usr/lib/libobjc.A.dylib`、`/usr/lib/libSystem.B.dylib`、`/System/Library/Frameworks/Foundation.framework/Foundation`、`/System/Library/Frameworks/UIKit.framework/UIKit` 等**只存在于 dyld shared cache,无磁盘文件**,`fileExists` 返回 false,resolver 返回 nil。 + +`expandDependencyGraph`(RuntimeBackgroundIndexingManager.swift:117-123)对 nil `resolvedPath` 落入: +```swift +items.append(.init(id: dep.installName, resolvedPath: nil, + state: .failed(message: "path unresolved"), + hasPriorityBoost: false)) +``` + +Task 24 后 batch 含 `.failed` 即被保留,toolbar 永久 `hasFailures` 红徽,popover 充满"path unresolved"红 ✗ 行 —— 全是误报。 + +测试已经感知到这一点: +- `DylibPathResolverTests.swift:8-10`:"// Use /usr/lib/dyld because most dylibs live in the dyld shared cache and have no on-disk file on Apple Silicon Macs (e.g. libSystem.B.dylib). /usr/lib/dyld is a real on-disk file across macOS versions." +- `RuntimeEngineIndexStateTests` 用 `XCTSkipUnless` 给 Foundation 兜底。 + +测试用绕路、生产代码没修。功能 opt-in 一旦开启在 Apple Silicon Mac 上基本不可用。 + +**修法**(两选一): +- 让绝对路径也接受 `DyldUtilities.dyldSharedCacheImagePaths()` 返回集合的成员,Set 查找 O(1),列表本就缓存。 +- 对绝对路径直接跳过 `fileExists` 检查,把判定权交给 `DyldUtilities.loadImage`,真正 `dlopen` 失败时再标 `.failed` —— 让"失败"项有意义。 + +**修复 2026-04-28**:采用第一种方案。`DyldUtilities` 加 `package static func isInDyldSharedCache(_:) -> Bool`(Set 缓存,与 `dyldSharedCacheImagePathsCache` 同步 invalidate)。`DylibPathResolver` 加 `private func pathExists(_:) -> Bool`,先 `fileManager.fileExists`,再 `DyldUtilities.isInDyldSharedCache`,任一通过即可。所有 4 处 `fileManager.fileExists(atPath:)` 替换为 `pathExists`。 + +**字面比较,不规范化**:cache 中存的是平台原生形式 —— macOS 上 `Foundation.framework/Versions/C/Foundation`、iOS 上 `Foundation.framework/Foundation`。本审查的"也加 versioned ↔ unversioned 规范化"建议被否决,原因: +- macOS 上 install name 实际形式不一定按 `Versions/X/` 规则映射(取决于二进制),规范化在边角情况误导 +- iOS 不需要规范化,加规范化只服务 macOS,但 macOS 上仍可能有 install name 与 cache 不一致的真实失败 +- `/usr/lib/libobjc.A.dylib` / `/usr/lib/libSystem.B.dylib` 在两个平台 cache 里都是无歧义形式,直接命中 —— 这覆盖了系统 dylib 的常见路径 + +让 install name 与 cache 形式不匹配的依赖走 `.failed("path unresolved")` 是有意为之 —— 是真实的解析失败,不是误报。详见 Evolution 0002 假设 #4 与决策日志 2026-04-28(N4)。 + +新增测试 `test_absolutePath_acceptsDyldSharedCachePath`(`DylibPathResolverTests.swift`)从 `[Foundation.framework/Foundation, CoreFoundation.framework/CoreFoundation, /usr/lib/libobjc.A.dylib, /usr/lib/libSystem.B.dylib]` 取第一个本机 `isInDyldSharedCache` 命中的路径,断言 `fileExists == false`、resolver 返回原路径。`XCTSkipUnless` 兜住 cache 不可访问的环境。本机 macOS 命中 `/usr/lib/libobjc.A.dylib`。 + +--- + +## Nit + +### Nit-1. 每批次 Cancel 按钮缺失,`cancelBatchRelay` 是死代码 ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift:282-311` + +`cancelBatchRelay`(line 15)、Input 接线(line 181)、ViewModel `transform` → `coordinator.cancelBatch(id)` → `manager.cancelBatch(id)` 一路通到底,**全程无 `.accept(...)` callsite**。`outlineView(_:viewFor:item:)` 的批次行只渲染 Label,无任何按钮 / target-action / 点击转发。 + +Evolution 0002 第 521 行:*"Batch 行:标题由 reason 派生、`{completed}/{total}`,以及一个 cancel 按钮。点击 cancel 会触发 `cancelBatchRelay.accept(batchID)`。"* + +用户多 batch 并发时(e.g. main exec + dlopen 进来的 framework),只能"Cancel All"丢掉所有进度,无法选择性取消单个慢 batch。 + +**修法**(两选一): +- (A) 实现 spec:在 cell 加一个 NSButton(SF Symbol `xmark.circle`,`accessoryBarAction` 风格),target-action 推 `batch.id` 到捕获的 relay。需要小型自定义 NSTableCellView 子类持有 batch id。 +- (B) 删掉死路:relay / Input / route 全部移除,Evolution 0002 标记 per-batch cancel 为延后。 + +(A) 是正确选择 —— 基础设施已经全部就位,只缺一个按钮。 + +**修复 2026-04-28**:走 (A)。`BackgroundIndexingPopoverViewController` 加 `cancelBatchRelay: PublishRelay`,Input.cancelBatch 从 `.never()` 改成 `cancelBatchRelay.asSignal()`(下游 ViewModel → coordinator → manager 路径已经全部就位)。`BatchCellView` 加 inline `xmark.circle` SF Symbol 按钮(`bezelStyle = .accessoryBar`,`isBordered = false`,`contentTintColor = .secondaryLabelColor`),通过 `onCancel: () -> Void` 闭包把 `batch.id` 注入。 + +`configure(...)` 多收一个 `isCancellable: Bool` 参数 ——`!batch.isFinished` 时显示按钮,failed-retain 状态(manager 已 finalize,UI 仅留显)隐藏。HStackView 排版,`titleLabel` 拿 `.defaultLow` content hugging,按钮拿 `.required` 让按钮固定大小、标题 fill。 + +### Nit-2. Settings off→on 触发用错误 `reason` ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift:277-290` + +`handleSettingsChange` 的 off→on 分支: +```swift +if !wasEnabled && latest.isEnabled { + documentDidOpen() +} +``` + +但 `documentDidOpen()` 硬编码 `reason: .appLaunch`(line 207)。 + +`RuntimeIndexingBatchReason.settingsEnabled` 在生产代码中**永远不会被构造** —— 全仓搜索只命中枚举定义本身。Popover 的 `title(for: .settingsEnabled) → "Settings enabled"` 分支不可达,用户切 Settings 时看到的标题是"App launch indexing",误导。 + +纯外观 bug,索引行为完全相同(同 root / 同 depth / 同 maxConcurrency)。 + +**修法**:抽 `private func startMainExecutableBatch(reason: RuntimeIndexingBatchReason)` helper,`documentDidOpen()` 传 `.appLaunch`,`handleSettingsChange` off→on 分支传 `.settingsEnabled`。 + +**修复 2026-04-28**:照办。`documentDidOpen()` 退化为 `startMainExecutableBatch(reason: .appLaunch)`,helper 收住所有 main exec batch 的派发逻辑(check settings.isEnabled、`mainExecutablePath` 容错、`startBatch`、写入 `documentBatchIDs`)。`handleSettingsChange` off→on 分支改调 `startMainExecutableBatch(reason: .settingsEnabled)`。`title(for: .settingsEnabled) → "Settings enabled"` 不再死代码,popover 在 settings 切换触发的 batch 上显示正确标题。索引行为完全相同(同 root / 同 depth / 同 maxConcurrency)。 + +### Nit-3. `documentBatchIDs` 泄漏失败完成批次的 ID ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift:135-158` + +```swift +case .batchFinished(let finished): + if finished.items.contains(where: { /* has .failed */ }) { + if let idx = batches.firstIndex(where: { $0.id == finished.id }) { + batches[idx] = finished + } + // ← 缺 documentBatchIDs.remove(finished.id) + } else { + batches.removeAll { $0.id == finished.id } + documentBatchIDs.remove(finished.id) // 仅清洁路径 + } +``` + +并行的 `.batchCancelled` arm 注释明确写"Cancellation always removes — user already acknowledged the outcome",从 `batchesRelay` 和 `documentBatchIDs` 都删。但失败保留分支只更新 `batches`,不清 `documentBatchIDs`。`clearFailedBatches()`(line 85-95)也只 filter `batchesRelay`,不动 `documentBatchIDs`。 + +后果: +- `documentBatchIDs` 在 Document 生命期单调增长(每个部分失败 batch +1)。 +- `documentWillClose` 用 `documentBatchIDs` 派发 `cancelBatch`,每个泄漏 ID 落到 manager 的 `guard let state = activeBatches[id] else { return }` 短路 —— 多发若干 no-op Task。 + +实际影响 < 100 字节量级,但与代码注释自相矛盾。 + +**修法**(两处,共 ~5 行): +- 失败保留分支补 `documentBatchIDs.remove(finished.id)`(batch 在 manager 侧已 finalize,无论 UI 是否保留)。 +- `clearFailedBatches()` 计算被清掉的 batches,从 `documentBatchIDs` 减。 + +**修复 2026-04-28**:照办。`apply(event:)` 的 `.batchFinished` 分支把 `documentBatchIDs.remove(finished.id)` 提升到 if/else 之外 —— 不管失败保留还是干净路径,manager 都已 finalize,documentBatchIDs 总要清掉;UI 是否留显 batch 是独立决定。`clearFailedBatches()` 重写:先快照 `batchesRelay.value`,过滤后算 `removedIDs = Set(allBatches.map(\.id)).subtracting(remaining.map(\.id))`,`documentBatchIDs.subtract(removedIDs)`。`documentWillClose` 不再向 manager 派发 ghost id 的 cancel Task。 + +--- + +## Pre-existing(P2 跟进) + +### Pre-1. `isImageIndexed` 与 `loadImageForBackgroundIndexing` 路径规范化不对称 ✅ FIXED 2026-04-28 + +**文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift:6-15` + +(对应 implementation-review 的 I5,独立验证。) + +`isImageIndexed(path:)` 用 `DyldUtilities.patchImagePathForDyld(path)` 规范化后查 cache,`loadImageForBackgroundIndexing(at:)` 用 raw path 写 cache。pre-existing `loadImage(at:)` 同样用 raw 写。 + +`patchImagePathForDyld` 仅在 `DYLD_ROOT_PATH` 设置时非 identity → 当前 macOS 主线休眠,**iOS Simulator runner 一启用立刻坏**:每个 `isImageIndexed` 永远 false,BFS 短路失效,`handleImageLoaded` 持续 spawn 新 batch,toolbar 转圈不停。 + +测试 `test_isImageIndexed_normalizesPath`(`RuntimeEngineIndexStateTests.swift:36-50`)的注释自己点出:"On most macOS hosts ... the raw and patched forms are identical and this test still pins the contract" —— 测试只 pin 契约不检查端到端工作。 + +**原修法**(择一): +- 廉价:从 `isImageIndexed` 拿掉 patch,与 writer 的 raw 契约对齐,顺便审计 `isImageLoaded`。 +- 彻底:在 `loadImageForBackgroundIndexing` / `loadImage(at:)` / 所有 cache writer 都加 patch,保留 `isImageIndexed` 的 patch。 + +**Step 1 修复 2026-04-28**(commit `a033d3d`):优先解决"彻底方案"的隐藏陷阱 —— `patchImagePathForDyld` 此前不幂等,iOS Simulator 下 `dyld` 返回的 image name 已经是 patched 形式,在 reader 入口再 patch 一次会得到 `/sim_root/sim_root/usr/lib/libobjc.A.dylib` 双前缀。 + +修法: +- `DyldUtilities.swift` 拆出 pure overload `patchImagePathForDyld(_:rootPath:)`,显式接收 `rootPath`,默认 wrapper 透过它读 `ProcessInfo.processInfo.environment["DYLD_ROOT_PATH"]`。便于测试不污染进程 env。 +- 主体加幂等性 guard:`if imagePath == rootPath || imagePath.hasPrefix(rootPath + "/") { return imagePath }`。注意 `rootPath + "/"` 而非 `rootPath`,避免 `/sim_root_other/file` 在 rootPath 为 `/sim_root` 时被误判为 already-patched。 +- 新建 `DyldUtilitiesTests.swift`(7 个 `@Test`):identity 情形(nil rootPath / 相对路径 / path 等于 rootPath)、基本 patching、幂等性(patch×2、patch×3 都等于 patch×1)、prefix 精度(sibling prefix 不被误识别)。 + +这一步并未改变 reader/writer 的对称性 —— `isImageIndexed` 仍 patch、writer 仍 raw,iOS Simulator 上语义层 bug 依旧。但它**清掉了 Step 2 在 writer 入口加 patch 时会撞上的双前缀地雷**。 + +**Step 2 修复 2026-04-28**:走"彻底方案"。`loadImage(at:)` 与 `loadImageForBackgroundIndexing(at:)` 入口 `let canonical = DyldUtilities.patchImagePathForDyld(path)`,后续所有 `DyldUtilities.loadImage` / `objcSectionFactory.section(for:)` / `swiftSectionFactory.section(for:)` / `loadedImagePaths.insert` / `imageDidLoadSubject.send` / `sendRemoteImageDidLoadIfNeeded` 全部用 `canonical`。wire 上仍是 raw(`request: path` 不变),让 receiver 自行 patch —— 跨进程 / 跨平台 server-client 不假设两端有相同 `DYLD_ROOT_PATH`。 + +与现有 `_objects(in:)`(line 461/467)与 `_localObjectsWithProgress`(line 605/611)的 "先 patch 再 insert" 对齐。Reader 端 `isImageLoaded`(line 524)、`isImageIndexed`(line 8)早就 patch 后查询,现在 writer/reader 完全对称。 + +新增契约测试: +- `loadImageInsertsCanonicalPathIntoLoadedImagePaths`(`RuntimeEngineIndexStateTests.swift`)—— 加载 Foundation 后断言 `loadedImagePaths.contains(canonical)` +- `loadImageForBackgroundIndexingInsertsCanonicalPathIntoLoadedImagePaths` —— 同上,针对 BG 入口 + +测试在 macOS host 上因 patch 是 identity 而 trivially 通过(与 `isImageIndexedNormalizesPath` 同模式),pinning the contract for iOS Simulator regression coverage。265/265 单元测试通过。 + +--- + +## 与 implementation-review 的关系 + +| 本审查 | implementation-review (内部) | 备注 | +|--------|------------------------------|------| +| N2 | I3 | 独立验证,确认未在 PR 中修复 | +| N3 | I1 | 独立验证 + 补出 spec 规则太窄 | +| Pre-1 | I5 | 独立验证 | +| N1 | — | 新发现:N4 决议引发的循环引用 | +| N4 | — | 新发现:dyld shared cache 系统 framework 误判 | +| Nit-1 | — | 新发现:per-batch cancel 死代码 | +| Nit-2 | — | 新发现:`.settingsEnabled` 永远不构造 | +| Nit-3 | — | 新发现:`documentBatchIDs` 失败泄漏 | + +internal review 的 I2 / I4 / I6 / 各 Minor 项未被 ultrareview 覆盖(范围或 prompt 差异),不矛盾。 + +--- + +## 优先级建议 + +1. **N1 + N4** 优先 —— 内存泄漏 + 功能在主流硬件上误报,改动都 ≤ 10 行。 +2. **N2** 紧随 PR —— source switch 是真实用户路径,内部 review 已点名,可与 N1 一起做。 +3. **N3 + Nit-1** 一起处理 —— spec 与代码契约对齐,要么实现要么删,留着只会越来越假。 +4. **Nit-2 + Nit-3** 顺手 —— 总共不到 20 行,清掉死代码与轻微泄漏。 +5. **Pre-1** 跟进 —— 绑 iOS Simulator 支持,P2。 diff --git a/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4198f57b..e7f775d3 100644 --- a/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -570,10 +570,10 @@ { "identity" : "swift-memberwise-init-macro", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/swift-memberwise-init-macro", + "location" : "https://github.com/gohanlon/swift-memberwise-init-macro", "state" : { - "revision" : "6121b169fb5a83d7262a69b640468606f98c6c6e", - "version" : "0.5.3-fork.1" + "revision" : "d0fb82bb6638051524214fb54524bfcd876735a1", + "version" : "0.6.0" } }, { diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index 6ce8246a..52728517 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a3dd8fcc311882d02a960d48c289a156d4e6905b90262ad225bc4c3c8ced6b64", + "originHash" : "559e3422e65f895209b7bc59f592f199a4ce355371a1a367f971bf06b56fc6fd", "pins" : [ { "identity" : "associatedobject", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox.git", "state" : { - "revision" : "d011291f5e8d6430fb91b52296dda50e85dc5c11", - "version" : "0.5.2" + "revision" : "22f92afb2520417e60a464a6a5abb88621c2b43d", + "version" : "0.5.3" } }, { @@ -292,10 +292,10 @@ { "identity" : "swift-memberwise-init-macro", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/swift-memberwise-init-macro", + "location" : "https://github.com/gohanlon/swift-memberwise-init-macro", "state" : { - "revision" : "6121b169fb5a83d7262a69b640468606f98c6c6e", - "version" : "0.5.3-fork.1" + "revision" : "d0fb82bb6638051524214fb54524bfcd876735a1", + "version" : "0.6.0" } }, { diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index dab0b276..ff1f36d2 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -118,6 +118,10 @@ let package = Package( url: "https://github.com/MxIris-Library-Forks/Semaphore", from: "0.1.0" ), + .package( + url: "https://github.com/apple/swift-collections", + from: "1.1.0" + ), .package( url: "https://github.com/Mx-Iris/FrameworkToolbox.git", from: "0.4.0" @@ -127,8 +131,8 @@ let package = Package( from: "0.5.100" ), .package( - url: "https://github.com/MxIris-Library-Forks/swift-memberwise-init-macro", - from: "0.5.3-fork" + url: "https://github.com/gohanlon/swift-memberwise-init-macro", + from: "0.6.0" ), .package( url: "https://github.com/mxcl/Version", @@ -165,6 +169,8 @@ let package = Package( .product(name: "MachOSwiftSection", package: "MachOSwiftSection"), .product(name: "SwiftInterface", package: "MachOSwiftSection"), .product(name: "MetaCodable", package: "MetaCodable"), + .product(name: "Semaphore", package: "Semaphore"), + .product(name: "DequeModule", package: "swift-collections"), ], swiftSettings: [ .internalImportsByDefault, diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift new file mode 100644 index 00000000..a3cbbbe4 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift @@ -0,0 +1,48 @@ +/// Abstraction seam for `RuntimeBackgroundIndexingManager` to interact with a +/// `RuntimeEngine`. Lets tests swap in a fake engine without real dyld I/O. +/// +/// Methods that proxy to remote sources via `RuntimeEngine.request { ... } remote: { ... }` +/// are `async throws` because the XPC / TCP transport can fail. Pure-local +/// queries (`canOpenImage`) stay non-throwing. +/// +/// Note: the protocol intentionally does NOT expose `MachOImage` —— that type +/// is a non-Sendable struct (contains unsafe pointers); returning it across +/// actor boundaries triggers Swift 6 strict-concurrency errors. Callers that +/// only need to gate recursion can use `canOpenImage(at:)` instead. +/// +/// Conformance is `AnyObject, Sendable` so the manager can hold the engine via +/// `unowned let engine`. The engine owns the manager +/// (`RuntimeEngine.backgroundIndexingManager`); making the back-reference +/// non-retaining breaks the cycle that would otherwise leak engine + manager +/// + section caches on every source switch. +protocol RuntimeBackgroundIndexingEngineRepresenting: AnyObject, Sendable { + func isImageIndexed(path: String) async throws -> Bool + func loadImageForBackgroundIndexing(at path: String) async throws + func mainExecutablePath() async throws -> String + /// Whether the image at `path` can be opened as a MachO. Pure local check. + func canOpenImage(at path: String) async -> Bool + /// Returns the LC_RPATH entries for the image at `path`. Empty when the + /// image cannot be opened. + func rpaths(for path: String) async throws -> [String] + /// Returns the resolved dependency dylib paths for the image at `path`, + /// excluding lazy-load entries. May return nil `resolvedPath` entries for + /// unresolved install names; the caller marks them failed. + /// + /// `ancestorRpaths` are the LC_RPATH entries collected from every loader + /// walking up the chain to the main executable. dyld's real `@rpath/...` + /// resolution searches the union of the image's own LC_RPATH and the + /// LC_RPATH of every loader in the chain, so a child framework that has + /// no LC_RPATH but is loaded via the host's LC_RPATH still resolves at + /// runtime. Pass `[]` for the root image; the BFS in + /// `RuntimeBackgroundIndexingManager.expandDependencyGraph` accumulates + /// each visited image's own rpaths into the value passed to its children. + /// + /// `mainExecutablePath` is fetched once by the BFS at entry and threaded + /// through every call so a deep dependency graph does not trigger one + /// `mainExecutablePath()` call per node — a measurable win on remote + /// (XPC / TCP) sources where each call is a network round-trip. + func dependencies(for path: String, + ancestorRpaths: [String], + mainExecutablePath: String) + async throws -> [(installName: String, resolvedPath: String?)] +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift new file mode 100644 index 00000000..bfb6a8d1 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -0,0 +1,322 @@ +import Foundation +import Semaphore +import DequeModule + +public actor RuntimeBackgroundIndexingManager { + struct BatchState { + var batch: RuntimeIndexingBatch + var maxConcurrency: Int + var drivingTask: Task? + var priorityBoostPaths: Set = [] + } + + /// `unowned` because the engine owns this manager + /// (`RuntimeEngine.backgroundIndexingManager`); a strong back-reference + /// would form a retain cycle that leaks engine + manager + section caches + /// on every source switch. + private unowned let engine: any RuntimeBackgroundIndexingEngineRepresenting + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var activeBatches: [RuntimeIndexingBatchID: BatchState] = [:] + + public nonisolated var events: AsyncStream { stream } + + init(engine: any RuntimeBackgroundIndexingEngineRepresenting) { + self.engine = engine + (self.stream, self.continuation) = AsyncStream.makeStream() + } + + deinit { continuation.finish() } + + public func currentBatches() -> [RuntimeIndexingBatch] { + activeBatches.values.map(\.batch) + } + + public func cancelBatch(_ id: RuntimeIndexingBatchID) { + guard let state = activeBatches[id] else { return } + activeBatches[id]?.batch.isCancelled = true + state.drivingTask?.cancel() + // The driving task's finalize() will emit .batchCancelled. + } + + public func cancelAllBatches() { + let ids = Array(activeBatches.keys) + for id in ids { + cancelBatch(id) + } + } + + /// Best-effort priority boost for `imagePath` inside any active batch. + /// + /// Items currently in `.pending` state are marked with `hasPriorityBoost` + /// and inserted into the batch's `priorityBoostPaths` set, which + /// `popNextPrioritizedPath` consults so the next free slot dispatches + /// the boosted item ahead of FIFO order. + /// + /// Items already dispatched (`.running`) or already terminal + /// (`.completed` / `.failed` / `.cancelled`) are silent no-ops — + /// `prioritize` cannot preempt running tasks. Items that have been + /// removed from `runBatch`'s local pending array (i.e. about to dispatch) + /// will also miss the boost; the contract is "boosts items that haven't + /// been picked yet." + /// + /// Each successful boost emits `.taskPrioritized(batchID:path:)`. Tested + /// for event emission (not load order, which depends on scheduler timing) + /// by `test_prioritize_emitsTaskPrioritizedEvent`. + public func prioritize(imagePath: String) { + for (id, var state) in activeBatches { + if let itemIndex = state.batch.items.firstIndex(where: { + $0.id == imagePath && $0.state == .pending + }) { + state.batch.items[itemIndex].hasPriorityBoost = true + state.priorityBoostPaths.insert(imagePath) + activeBatches[id] = state + continuation.yield(.taskPrioritized(batchID: id, path: imagePath)) + } + } + } + + public func startBatch( + rootImagePath: String, + depth: Int, + maxConcurrency: Int, + reason: RuntimeIndexingBatchReason + ) async -> RuntimeIndexingBatchID { + // Dedup before doing any expansion work. Real-world trigger: + // `documentDidOpen` dispatches `.appLaunch` on the main executable + // and dyld's add-image notification simultaneously fires + // `handleImageLoaded` with the same path, dispatching `.imageLoaded`. + // Two concurrent batches on the same root would duplicate work and + // race for the same section caches. + // + // We dedup by `rootImagePath` only — `reason` is intentionally + // ignored so `.appLaunch` ↔ `.imageLoaded(path:)` (which have + // different discriminants) collapse together. Callers that want + // a fresh batch must wait for the previous one to finish. + if let existingId = findActiveBatchID(forRootImagePath: rootImagePath) { + return existingId + } + + let id = RuntimeIndexingBatchID() + let items = await expandDependencyGraph(rootPath: rootImagePath, depth: depth) + + // Re-check after the suspension: actor reentrancy means another + // `startBatch` call for the same root could have raced us through + // its own `expandDependencyGraph`. The check + insert below is + // atomic on the actor (no awaits between them) so the loser of the + // race always sees the winner's insertion. + // + // Both racers run a full BFS before this second check — we + // intentionally don't hold the actor across BFS so cancel/prioritize + // remain responsive. The loser's BFS work is discarded; concurrent + // triggers (`documentDidOpen` + dyld add-image notification firing + // for the same path) are infrequent enough that this is the right + // trade-off versus serializing all batches behind one in-flight BFS. + if let existingId = findActiveBatchID(forRootImagePath: rootImagePath) { + return existingId + } + + let batch = RuntimeIndexingBatch( + id: id, rootImagePath: rootImagePath, depth: depth, + reason: reason, items: items, + isCancelled: false, isFinished: false + ) + let state = BatchState(batch: batch, maxConcurrency: max(1, maxConcurrency)) + activeBatches[id] = state + continuation.yield(.batchStarted(batch)) + + let drivingTask = Task { [weak self] in + guard let self else { return } + await self.runBatch(id: id) + } + activeBatches[id]?.drivingTask = drivingTask + return id + } + + private func findActiveBatchID(forRootImagePath rootImagePath: String) + -> RuntimeIndexingBatchID? { + activeBatches.first { _, state in + !state.batch.isFinished && state.batch.rootImagePath == rootImagePath + }?.key + } + + func expandDependencyGraph(rootPath: String, depth: Int) + async -> [RuntimeIndexingTaskItem] { + var visited: Set = [] + var items: [RuntimeIndexingTaskItem] = [] + // Fetch `mainExecutablePath` once at BFS entry and thread it through + // every `dependencies(for:...)` call below. Without this, a 50-image + // graph triggers 50 redundant calls (50 XPC / TCP round-trips on a + // remote source). `try?` falls back to "" — `DylibPathResolver` + // handles an empty `mainExecutablePath` by failing `@executable_path` + // resolution, which mirrors a missing-host behavior anyway. + let mainExecutablePath = (try? await engine.mainExecutablePath()) ?? "" + // `ancestorRpaths` carries the LC_RPATH entries collected from every + // loader walking up the chain to `rootPath`. dyld combines these with + // the visited image's own LC_RPATH when resolving `@rpath/...`, so a + // child framework with no LC_RPATH still resolves siblings via the + // host's rpath. Root starts with `[]` and each level appends the + // current image's own rpaths before descending. We don't dedup — + // dyld doesn't either, and order matters for first-match resolution. + // + // `Deque` (swift-collections) gives O(1) `popFirst()`; `Array.removeFirst()` + // is O(n) and would make a deep BFS quadratic. + var frontier: Deque<(path: String, level: Int, ancestorRpaths: [String])> = + [(rootPath, 0, [])] + + while let (path, level, ancestorRpaths) = frontier.popFirst() { + guard visited.insert(path).inserted else { continue } + + // `try?` — if the engine errors out (e.g. remote XPC drops mid-batch), + // treat the image as unindexed; loadImageForBackgroundIndexing will + // surface a real failure later. This matches Evolution 0002 Alt D: + // failure ≠ indexed. + if (try? await engine.isImageIndexed(path: path)) == true { continue } + + // Non-root paths that can't be opened as MachO go straight to + // `.failed` and don't recurse — saves a wasted dlopen attempt later. + // Root is always represented so that the batch has at least one item. + if path != rootPath { + let canOpen = await engine.canOpenImage(at: path) + if !canOpen { + items.append(.init(id: path, resolvedPath: path, + state: .failed(message: "cannot open MachOImage"), + hasPriorityBoost: false)) + continue + } + } + + items.append(.init(id: path, resolvedPath: path, + state: .pending, hasPriorityBoost: false)) + guard level < depth else { continue } + + // `try?` — if dependency lookup fails, treat as no deps; the path + // itself is still pending and will be retried on next batch. + let deps = (try? await engine.dependencies( + for: path, + ancestorRpaths: ancestorRpaths, + mainExecutablePath: mainExecutablePath + )) ?? [] + // Pre-compute the ancestor list for the next level once. Failing + // this lookup degrades the next level to "no inherited rpaths", + // matching the `try?` failure-mode of `dependencies`/`isImageIndexed`. + let ownRpaths = (try? await engine.rpaths(for: path)) ?? [] + let descendantAncestors = ancestorRpaths + ownRpaths + for dep in deps { + if let resolved = dep.resolvedPath { + if !visited.contains(resolved) { + frontier.append((resolved, level + 1, descendantAncestors)) + } + } else { + if visited.insert(dep.installName).inserted { + items.append(.init(id: dep.installName, resolvedPath: nil, + state: .failed(message: "path unresolved"), + hasPriorityBoost: false)) + } + } + } + } + return items + } + + private func runBatch(id: RuntimeIndexingBatchID) async { + guard let startState = activeBatches[id] else { return } + let maxConcurrency = startState.maxConcurrency + + // Pending paths in FIFO order, skipping already-terminal items. + var pending = startState.batch.items + .filter { !$0.state.isTerminal } + .map(\.id) + + if pending.isEmpty { + finalize(id: id, cancelled: false) + return + } + + let semaphore = AsyncSemaphore(value: maxConcurrency) + var wasCancelled = false + + await withTaskGroup(of: Void.self) { group in + while !pending.isEmpty { + let path = popNextPrioritizedPath(batchID: id, pending: &pending) + do { + try await semaphore.waitUnlessCancelled() + } catch { + wasCancelled = true + break + } + if Task.isCancelled { wasCancelled = true; break } + group.addTask { [weak self] in + defer { semaphore.signal() } + await self?.runSingleIndex(batchID: id, path: path) + } + } + await group.waitForAll() + } + finalize(id: id, cancelled: wasCancelled || Task.isCancelled) + } + + /// Selects the next path to dispatch. Priority-boosted paths jump to the head. + private func popNextPrioritizedPath( + batchID: RuntimeIndexingBatchID, pending: inout [String] + ) -> String { + if let state = activeBatches[batchID], + let boostedPendingIndex = pending.firstIndex(where: { state.priorityBoostPaths.contains($0) }) { + return pending.remove(at: boostedPendingIndex) + } + return pending.removeFirst() + } + + private func runSingleIndex(batchID: RuntimeIndexingBatchID, path: String) async { + updateItemState(batchID: batchID, path: path, state: .running) + continuation.yield(.taskStarted(batchID: batchID, path: path)) + do { + try Task.checkCancellation() + try await engine.loadImageForBackgroundIndexing(at: path) + updateItemState(batchID: batchID, path: path, state: .completed) + continuation.yield(.taskFinished(batchID: batchID, path: path, + result: .completed)) + } catch is CancellationError { + updateItemState(batchID: batchID, path: path, state: .cancelled) + } catch { + let state: RuntimeIndexingTaskState = + .failed(message: error.localizedDescription) + updateItemState(batchID: batchID, path: path, state: state) + continuation.yield(.taskFinished(batchID: batchID, path: path, + result: state)) + } + } + + private func updateItemState(batchID: RuntimeIndexingBatchID, + path: String, + state: RuntimeIndexingTaskState) { + guard var batchState = activeBatches[batchID] else { return } + if let itemIndex = batchState.batch.items.firstIndex(where: { $0.id == path }) { + batchState.batch.items[itemIndex].state = state + activeBatches[batchID] = batchState + } + } + + private func finalize(id: RuntimeIndexingBatchID, cancelled: Bool) { + guard var state = activeBatches[id] else { return } + let effectiveCancel = cancelled || state.batch.isCancelled + state.batch.isFinished = true + state.batch.isCancelled = effectiveCancel + // Mark any still-pending or running items as cancelled so the UI reflects state. + if effectiveCancel { + for itemIndex in state.batch.items.indices + where state.batch.items[itemIndex].state == .pending + || state.batch.items[itemIndex].state == .running { + state.batch.items[itemIndex].state = .cancelled + } + } + activeBatches[id] = state + if effectiveCancel { + continuation.yield(.batchCancelled(state.batch)) + } else { + continuation.yield(.batchFinished(state.batch)) + } + activeBatches[id] = nil + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift new file mode 100644 index 00000000..476afe0b --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift @@ -0,0 +1,60 @@ +public struct RuntimeIndexingBatch: Sendable, Identifiable, Hashable { + public let id: RuntimeIndexingBatchID + public let rootImagePath: String + public let depth: Int + public let reason: RuntimeIndexingBatchReason + public var items: [RuntimeIndexingTaskItem] + public var isCancelled: Bool + public var isFinished: Bool + + public init(id: RuntimeIndexingBatchID, rootImagePath: String, depth: Int, + reason: RuntimeIndexingBatchReason, + items: [RuntimeIndexingTaskItem], + isCancelled: Bool, isFinished: Bool) { + self.id = id + self.rootImagePath = rootImagePath + self.depth = depth + self.reason = reason + self.items = items + self.isCancelled = isCancelled + self.isFinished = isFinished + } + + public var totalCount: Int { items.count } + + /// Items that have reached any terminal state (`.completed`, `.failed`, + /// `.cancelled`). Drives `progress` because progress should reach 100% + /// once every item has stopped processing, regardless of outcome. + public var finishedCount: Int { items.lazy.filter { $0.state.isTerminal }.count } + + /// Items that finished with `.completed` only. Use this for UI labels + /// where the user reads "X done out of Y" as "X succeeded". + public var succeededCount: Int { + items.lazy.filter { item in + if case .completed = item.state { return true } + return false + } + .count + } + + public var failedCount: Int { + items.lazy.filter { item in + if case .failed = item.state { return true } + return false + } + .count + } + + public var cancelledCount: Int { + items.lazy.filter { item in + if case .cancelled = item.state { return true } + return false + } + .count + } + + public var progress: Double { + guard totalCount > 0 else { return 1 } + return Double(finishedCount) / Double(totalCount) + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchID.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchID.swift new file mode 100644 index 00000000..492c2215 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchID.swift @@ -0,0 +1,6 @@ +public import Foundation + +public struct RuntimeIndexingBatchID: Hashable, Sendable { + public let raw: UUID + public init(raw: UUID = UUID()) { self.raw = raw } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift new file mode 100644 index 00000000..5982fb78 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift @@ -0,0 +1,6 @@ +public enum RuntimeIndexingBatchReason: Sendable, Hashable { + case appLaunch + case imageLoaded(path: String) + case settingsEnabled + case manual +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingEvent.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingEvent.swift new file mode 100644 index 00000000..53288ae6 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingEvent.swift @@ -0,0 +1,9 @@ +public enum RuntimeIndexingEvent: Sendable { + case batchStarted(RuntimeIndexingBatch) + case taskStarted(batchID: RuntimeIndexingBatchID, path: String) + case taskFinished(batchID: RuntimeIndexingBatchID, path: String, + result: RuntimeIndexingTaskState) + case taskPrioritized(batchID: RuntimeIndexingBatchID, path: String) + case batchFinished(RuntimeIndexingBatch) + case batchCancelled(RuntimeIndexingBatch) +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskItem.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskItem.swift new file mode 100644 index 00000000..6cdc6849 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskItem.swift @@ -0,0 +1,15 @@ +public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Hashable { + public let id: String + public let resolvedPath: String? + public var state: RuntimeIndexingTaskState + public var hasPriorityBoost: Bool + + public init(id: String, resolvedPath: String?, + state: RuntimeIndexingTaskState, + hasPriorityBoost: Bool) { + self.id = id + self.resolvedPath = resolvedPath + self.state = state + self.hasPriorityBoost = hasPriorityBoost + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskState.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskState.swift new file mode 100644 index 00000000..5db6aa42 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskState.swift @@ -0,0 +1,14 @@ +public enum RuntimeIndexingTaskState: Sendable, Hashable { + case pending + case running + case completed + case failed(message: String) + case cancelled + + public var isTerminal: Bool { + switch self { + case .completed, .failed, .cancelled: return true + case .pending, .running: return false + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeResolvedDependency.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeResolvedDependency.swift new file mode 100644 index 00000000..dff19985 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeResolvedDependency.swift @@ -0,0 +1,9 @@ +public struct RuntimeResolvedDependency: Sendable, Hashable { + public let installName: String + public let resolvedPath: String? + + public init(installName: String, resolvedPath: String?) { + self.installName = installName + self.resolvedPath = resolvedPath + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift index f5b059b2..f8df3eea 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift @@ -110,9 +110,8 @@ actor RuntimeObjCSection { init(imagePath: String, factory: RuntimeObjCSectionFactory, progressContinuation: LoadingEventContinuation? = nil) async throws { #log(.info, "Initializing ObjC section for image: \(imagePath, privacy: .public)") - let imageName = imagePath.lastPathComponent.deletingPathExtension.deletingPathExtension - guard let machO = MachOImage(name: imageName) else { - #log(.error, "Failed to create MachOImage for: \(imageName, privacy: .public)") + guard let machO = DyldUtilities.machOImage(forPath: imagePath) else { + #log(.error, "Failed to create MachOImage for: \(imagePath, privacy: .public)") throw Error.invalidMachOImage } self.machO = machO @@ -703,6 +702,10 @@ actor RuntimeObjCSectionFactory { sections[imagePath] } + func hasCachedSection(for path: String) -> Bool { + sections[path] != nil + } + func section(for imagePath: String, progressContinuation: LoadingEventContinuation? = nil) async throws -> (isExisted: Bool, section: RuntimeObjCSection) { if let section = sections[imagePath] { #log(.debug, "Using cached ObjC section for: \(imagePath, privacy: .public)") diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index a5a1f1b2..180a6d5d 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -183,9 +183,8 @@ actor RuntimeSwiftSection { init(imagePath: String, factory: RuntimeSwiftSectionFactory, progressContinuation: LoadingEventContinuation? = nil) async throws { #log(.info, "Initializing Swift section for image: \(imagePath, privacy: .public)") - let imageName = imagePath.lastPathComponent.deletingPathExtension.deletingPathExtension - guard let machO = MachOImage(name: imageName) else { - #log(.error, "Failed to create MachOImage for: \(imageName, privacy: .public)") + guard let machO = DyldUtilities.machOImage(forPath: imagePath) else { + #log(.error, "Failed to create MachOImage for: \(imagePath, privacy: .public)") throw Error.invalidMachOImage } self.factory = factory @@ -799,6 +798,10 @@ actor RuntimeSwiftSectionFactory { sections[imagePath] } + func hasCachedSection(for path: String) -> Bool { + sections[path] != nil + } + func section(for imagePath: String, progressContinuation: LoadingEventContinuation? = nil) async throws -> (isExisted: Bool, section: RuntimeSwiftSection) { if let section = sections[imagePath] { #log(.debug, "Using cached Swift section for: \(imagePath, privacy: .public)") diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift new file mode 100644 index 00000000..cf3cfa94 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -0,0 +1,103 @@ +import Foundation +import FoundationToolbox +import MachOKit + +extension RuntimeEngine { + public func isImageIndexed(path: String) async throws -> Bool { + try await request { + let normalized = DyldUtilities.patchImagePathForDyld(path) + let hasObjC = await objcSectionFactory.hasCachedSection(for: normalized) + let hasSwift = await swiftSectionFactory.hasCachedSection(for: normalized) + return hasObjC && hasSwift + } remote: { senderConnection in + try await senderConnection.sendMessage(name: .isImageIndexed, request: path) + } + } + + /// Path of the target process's main executable. + public func mainExecutablePath() async throws -> String { + try await request { + // `imageNames().first` is unreliable under `DYLD_INSERT_LIBRARIES` + // (Xcode injects `libLogRedirect.dylib` at index 0 during debug + // runs). `_NSGetExecutablePath` always returns the host binary. + DyldUtilities.mainExecutablePath() + } remote: { senderConnection in + try await senderConnection.sendMessage(name: .mainExecutablePath) + } + } + + /// Like `loadImage(at:)` but does **not** call `reloadData()` and does + /// **not** emit `imageDidLoadPublisher`. + /// + /// Both omissions are deliberate. Triggering `reloadData()` for every + /// image visited by a depth-2+ BFS would storm the sidebar during a + /// background batch; emitting `imageDidLoadPublisher` would feed + /// `RuntimeBackgroundIndexingCoordinator`'s image-loaded pump and + /// recursively spawn a fresh batch for every image we just indexed. + public func loadImageForBackgroundIndexing(at path: String) async throws { + try await request { + // Mirror loadImage(at:) byte-for-byte sans reloadData. See loadImage + // for the canonicalization rationale. + let canonical = DyldUtilities.patchImagePathForDyld(path) + try DyldUtilities.loadImage(at: canonical) + _ = try await objcSectionFactory.section(for: canonical) + _ = try await swiftSectionFactory.section(for: canonical) + loadedImagePaths.insert(canonical) + } remote: { senderConnection in + try await senderConnection.sendMessage( + name: .loadImageForBackgroundIndexing, request: path) + } + } +} + +// MARK: - BackgroundIndexingEngineRepresenting + +extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { + func canOpenImage(at path: String) -> Bool { + DyldUtilities.machOImage(forPath: path) != nil + } + + func rpaths(for path: String) -> [String] { + guard let image = DyldUtilities.machOImage(forPath: path) else { + return [] + } + return image.rpaths + } + + func dependencies(for path: String, + ancestorRpaths: [String], + mainExecutablePath: String) async throws + -> [(installName: String, resolvedPath: String?)] + { + guard let image = DyldUtilities.machOImage(forPath: path) else { + return [] + } + let resolver = DylibPathResolver() + // dyld searches the union of every loader's LC_RPATH walking up the + // chain to the main executable plus the image's own LC_RPATH. The BFS + // accumulates ancestors into `ancestorRpaths`; appending self-rpaths + // matches dyld's lookup order (loaders first, then self). + let mergedRpaths = ancestorRpaths + image.rpaths + return image.dependencies + .filter { $0.type != .lazyLoad } + .compactMap { dependency in + let installName = dependency.dylib.name + let resolvedPath = resolver.resolve( + installName: installName, + imagePath: path, + rpaths: mergedRpaths, + mainExecutablePath: mainExecutablePath + ) + // LC_LOAD_WEAK_DYLIB: dyld silently skips at runtime when the + // target isn't on disk (e.g. Xcode embeds + // `libswiftCompatibilitySpan.dylib` only for older deployment + // targets). Mirror that here — surfacing it as `.failed("path + // unresolved")` floods the popover with red ✗ rows for a + // miss the runtime explicitly tolerates. + if resolvedPath == nil, dependency.type == .weakLoad { + return nil + } + return (installName, resolvedPath) + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 7fa330fb..9001ac20 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -51,6 +51,9 @@ public actor RuntimeEngine { case imageNodes case loadImage case isImageLoaded + case isImageIndexed + case mainExecutablePath + case loadImageForBackgroundIndexing case patchImagePathForDyld case runtimeObjectHierarchy case runtimeObjectInfo @@ -60,6 +63,7 @@ public actor RuntimeEngine { case runtimeObjectsOfKindInImage case runtimeObjectsInImage case reloadData + case imageDidLoad case memberAddresses case engineList case engineListChanged @@ -122,7 +126,7 @@ public actor RuntimeEngine { public private(set) var imageList: [String] = [] - public private(set) var loadedImagePaths: Set = [] + public internal(set) var loadedImagePaths: Set = [] private nonisolated let imageNodesSubject = CurrentValueSubject<[RuntimeImageNode], Never>([]) @@ -142,11 +146,26 @@ public actor RuntimeEngine { private nonisolated let reloadDataSubject = PassthroughSubject() + /// Publisher that emits the image path each time `loadImage(at:)` succeeds. + /// + /// Fires on the local arm immediately after the image has been loaded and + /// its ObjC/Swift sections cached. On a client engine, it fires when the + /// server forwards an `.imageDidLoad` event (handled by + /// `setupMessageHandlerForClient`). + /// + /// Marked `nonisolated` so subscribers (including Combine sinks in tests + /// and downstream coordinators) can attach without an actor hop. + public nonisolated var imageDidLoadPublisher: some Publisher { + imageDidLoadSubject.eraseToAnyPublisher() + } + + private nonisolated let imageDidLoadSubject = PassthroughSubject() + private nonisolated let objectsLoadingProgressSubject = PassthroughSubject() - private let objcSectionFactory: RuntimeObjCSectionFactory + let objcSectionFactory: RuntimeObjCSectionFactory - private let swiftSectionFactory: RuntimeSwiftSectionFactory + let swiftSectionFactory: RuntimeSwiftSectionFactory private let communicator = RuntimeCommunicator() @@ -160,6 +179,13 @@ public actor RuntimeEngine { /// types in the actor interface; cast to `SwiftyXPC.XPCEndpoint` on macOS. public private(set) var xpcListenerEndpoint: (any Sendable)? + /// Coordinator for background indexing batches that load and index images + /// without blocking the main runtime data flow. `lazy` so it captures + /// `self` only after all other stored properties are initialized; the + /// actor's isolation guarantees the lazy initialization is single-threaded. + public private(set) lazy var backgroundIndexingManager: RuntimeBackgroundIndexingManager = + RuntimeBackgroundIndexingManager(engine: self) + public init( source: RuntimeSource, engineID: String = UUID().uuidString, @@ -274,7 +300,12 @@ public actor RuntimeEngine { private func setupMessageHandlerForServer() { #log(.debug, "Setting up server message handlers") setMessageHandlerBinding(forName: .isImageLoaded, of: self) { $0.isImageLoaded(path:) } + setMessageHandlerBinding(forName: .isImageIndexed, of: self) { $0.isImageIndexed(path:) } + setMessageHandlerBinding(forName: .mainExecutablePath) { engine -> String in + try await engine.mainExecutablePath() + } setMessageHandlerBinding(forName: .loadImage, of: self) { $0.loadImage(at:) } + setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, of: self) { $0.loadImageForBackgroundIndexing(at:) } setMessageHandlerBinding(forName: .imageNameOfClassName, of: self) { $0.imageName(ofObjectName:) } connection?.setMessageHandler(name: CommandNames.runtimeObjectsInImage.commandName) { [weak self] (imagePath: String) -> [RuntimeObject] in @@ -298,6 +329,9 @@ public actor RuntimeEngine { setMessageHandlerBinding(forName: .imageList) { $0.imageList = $1 } setMessageHandlerBinding(forName: .imageNodes) { $0.imageNodes = $1 } setMessageHandlerBinding(forName: .reloadData) { $0.reloadDataSubject.send() } + setMessageHandlerBinding(forName: .imageDidLoad) { (engine: RuntimeEngine, path: String) in + engine.imageDidLoadSubject.send(path) + } setMessageHandlerBinding(forName: .objectsLoadingProgress) { $0.objectsLoadingProgressSubject.send($1) } setMessageHandlerBinding(forName: .engineListChanged) { (engine: RuntimeEngine, descriptors: [RemoteEngineDescriptor]) in #log(.debug, "[EngineMirroring] engineListChanged received: \(descriptors.count, privacy: .public) descriptors, handler set: \(RuntimeEngine.engineListChangedHandler != nil, privacy: .public)") @@ -411,6 +445,17 @@ public actor RuntimeEngine { } } + /// Forwards an `imageDidLoad` event to the connected client when this + /// engine is acting as a server. On a local-only engine the local subject + /// has already been signaled by the caller, so this is a no-op. + private func sendRemoteImageDidLoadIfNeeded(path: String) { + guard let role = source.remoteRole, role.isServer, let connection else { return } + Task { + try await connection.sendMessage(name: .imageDidLoad, request: path) + #log(.debug, "Remote imageDidLoad sent for path: \(path, privacy: .public)") + } + } + private func _objects(in image: String) async throws -> [RuntimeObject] { #log(.debug, "Getting objects in image: \(image, privacy: .public)") let image = DyldUtilities.patchImagePathForDyld(image) @@ -465,7 +510,7 @@ extension RuntimeEngine { case senderConnectionIsLose } - private func request(local: () async throws -> T, remote: (_ senderConnection: RuntimeConnection) async throws -> T) async throws -> T { + func request(local: () async throws -> T, remote: (_ senderConnection: RuntimeConnection) async throws -> T) async throws -> T { if let remoteRole = source.remoteRole, remoteRole.isClient { guard let connection else { throw RequestError.senderConnectionIsLose } return try await remote(connection) @@ -484,11 +529,21 @@ extension RuntimeEngine { public func loadImage(at path: String) async throws { try await request { - try DyldUtilities.loadImage(at: path) - _ = try await objcSectionFactory.section(for: path) - _ = try await swiftSectionFactory.section(for: path) + // Canonicalize on entry so internal storage (loadedImagePaths, + // section factory caches) stays symmetric with reader-side + // lookups (isImageLoaded, isImageIndexed, _objects), all of which + // patch first. On macOS this is identity; on iOS Simulator it + // applies DYLD_ROOT_PATH so dyld's own image-name reports match. + // patchImagePathForDyld is idempotent — re-patching an already + // patched path is safe. + let canonical = DyldUtilities.patchImagePathForDyld(path) + try DyldUtilities.loadImage(at: canonical) + _ = try await objcSectionFactory.section(for: canonical) + _ = try await swiftSectionFactory.section(for: canonical) reloadData(isReloadImageNodes: false) - loadedImagePaths.insert(path) + loadedImagePaths.insert(canonical) + imageDidLoadSubject.send(canonical) + sendRemoteImageDidLoadIfNeeded(path: canonical) } remote: { try await $0.sendMessage(name: .loadImage, request: path) } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift index 796ad0f0..549bdae4 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift @@ -125,6 +125,16 @@ public actor RuntimeEngineProxyServer { try await engine.isImageLoaded(path: path) } + connection.setMessageHandler(name: RuntimeEngine.CommandNames.isImageIndexed.commandName) { + [engine] (path: String) -> Bool in + try await engine.isImageIndexed(path: path) + } + + connection.setMessageHandler(name: RuntimeEngine.CommandNames.mainExecutablePath.commandName) { + [engine] () -> String in + try await engine.mainExecutablePath() + } + connection.setMessageHandler(name: RuntimeEngine.CommandNames.runtimeObjectsInImage.commandName) { [engine] (image: String) -> [RuntimeObject] in try await engine.objects(in: image) @@ -145,6 +155,11 @@ public actor RuntimeEngineProxyServer { try await engine.loadImage(at: path) } + connection.setMessageHandler(name: RuntimeEngine.CommandNames.loadImageForBackgroundIndexing.commandName) { + [engine] (path: String) in + try await engine.loadImageForBackgroundIndexing(at: path) + } + connection.setMessageHandler(name: RuntimeEngine.CommandNames.imageNameOfClassName.commandName) { [engine] (name: RuntimeObject) -> String? in try await engine.imageName(ofObjectName: name) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift index 911e0363..876eee55 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift @@ -1,7 +1,7 @@ package import Foundation import FoundationToolbox import MachO.dyld -import MachOKit +package import MachOKit public struct DyldOpenError: Error { public let message: String? @@ -14,9 +14,25 @@ package enum DyldUtilities { package static let removeImageNotification = Notification.Name("com.JH.RuntimeViewerCore.DyldRegisterObserver.removeImageNotification") package static func patchImagePathForDyld(_ imagePath: String) -> String { + patchImagePathForDyld( + imagePath, + rootPath: ProcessInfo.processInfo.environment["DYLD_ROOT_PATH"] + ) + } + + /// Pure overload that takes the dyld root path explicitly so callers + /// (and tests) can drive the patching logic without touching process env. + /// + /// Idempotent: calling repeatedly with the same `rootPath` returns the + /// same string after the first invocation. This matters because dyld + /// reports already-patched paths in simulator runners — re-patching them + /// would produce a doubled prefix like `/sim_root/sim_root/usr/lib/...`. + package static func patchImagePathForDyld(_ imagePath: String, rootPath: String?) -> String { guard imagePath.starts(with: "/") else { return imagePath } - let rootPath = ProcessInfo.processInfo.environment["DYLD_ROOT_PATH"] guard let rootPath else { return imagePath } + if imagePath == rootPath || imagePath.hasPrefix(rootPath + "/") { + return imagePath + } return rootPath.appending(imagePath) } @@ -43,6 +59,61 @@ package enum DyldUtilities { return names } + /// Path of the host process's main executable. + /// + /// Uses `_NSGetExecutablePath()` rather than `imageNames().first` because + /// dyld image index 0 is **not** guaranteed to be the host executable when + /// the process was launched with `DYLD_INSERT_LIBRARIES`. Xcode injects + /// `/Applications/Xcode.app/Contents/Developer/usr/lib/libLogRedirect.dylib` + /// during debug runs and that dylib lands at index 0, so `imageNames().first` + /// returns Xcode's helper instead of the app binary. Downstream uses + /// (BFS root path, `@executable_path/...` rpath expansion) need the real + /// executable or every `@rpath/...` resolves against Xcode's directory and + /// gets reported as `path unresolved`. + package static func mainExecutablePath() -> String { + var bufSize: UInt32 = 1024 + var buf = [CChar](repeating: 0, count: Int(bufSize)) + if _NSGetExecutablePath(&buf, &bufSize) == 0 { + return String(cString: buf) + } + // bufSize was too small. _NSGetExecutablePath wrote the required size + // back into `bufSize`; allocate accordingly and retry. + buf = [CChar](repeating: 0, count: Int(bufSize)) + if _NSGetExecutablePath(&buf, &bufSize) == 0 { + return String(cString: buf) + } + // Last-resort fallback. Won't happen in practice, but better than + // returning "" — `@executable_path` expansion downstream prefers an + // imperfect path over an empty one. + return imageNames().first ?? "" + } + + /// Resolves a filesystem path to its loaded `MachOImage`. + /// + /// For the main executable's path, returns `MachOImage.current()` rather + /// than performing a basename lookup. In Debug builds Xcode emits the + /// product as a thin stub at `Contents/MacOS/` plus a sibling + /// `.debug.dylib` that holds the real code; `MachOImage(name:)` + /// strips both extensions and matches by basename, so it picks the stub + /// (loaded first at dyld index 0) and the caller never sees the actual + /// dependency graph or sections. `MachOImage.current(_:)` resolves via + /// `#dsohandle` of the calling code, so it always returns the image that + /// actually contains our compiled symbols (the `.debug.dylib` in Debug, + /// the main executable in statically linked Release). + /// + /// Uses `mainExecutablePath()` (which goes through `_NSGetExecutablePath`) + /// for the main-executable check rather than `imageNames().first`, since + /// the latter returns Xcode's injected `libLogRedirect.dylib` under + /// `DYLD_INSERT_LIBRARIES` and would skip the `MachOImage.current()` + /// branch for the actual host binary path. + package static func machOImage(forPath path: String) -> MachOImage? { + if path == mainExecutablePath() { + return MachOImage.current() + } + let imageName = path.lastPathComponent.deletingPathExtension.deletingPathExtension + return MachOImage(name: imageName) + } + package func imagePath(for ptr: UnsafeRawPointer) -> String? { var info: Dl_info = .init() dladdr(ptr, &info) @@ -66,7 +137,8 @@ package enum DyldUtilities { } private static var dyldSharedCacheImagePathsCache: [String]? - + private static var dyldSharedCacheImagePathsSetCache: Set? + private static func dyldSharedCacheImagePaths() -> [String] { if let dyldSharedCacheImagePathsCache { #log(.debug, "Using cached dyld shared cache image paths (\(dyldSharedCacheImagePathsCache.count, privacy: .public) paths)") @@ -83,9 +155,36 @@ package enum DyldUtilities { return results } + /// Whether `path` corresponds to an image baked into the dyld shared cache. + /// + /// On Apple Silicon (and recent Intel macOS), system dylibs like + /// `/usr/lib/libobjc.A.dylib` have **no on-disk file** —— + /// `FileManager.fileExists` returns `false` for them. Callers that need + /// to validate "does this image really exist" must check both the + /// filesystem and this set. + /// + /// Lookup is by literal equality against the cache's stored paths. The + /// cache stores the platform-native form (`Foundation.framework/Versions/C/Foundation` + /// on macOS, `Foundation.framework/Foundation` on iOS); install names that + /// use a different form fall through to a real "path unresolved" failure + /// rather than being silently rewritten. + package static func isInDyldSharedCache(_ path: String) -> Bool { + return dyldSharedCacheImagePathsSet().contains(path) + } + + private static func dyldSharedCacheImagePathsSet() -> Set { + if let dyldSharedCacheImagePathsSetCache { + return dyldSharedCacheImagePathsSetCache + } + let set = Set(dyldSharedCacheImagePaths()) + dyldSharedCacheImagePathsSetCache = set + return set + } + package static func invalidDyldSharedCacheImagePathsCache() { #log(.debug, "Invalidating dyld shared cache image paths cache") dyldSharedCacheImagePathsCache = nil + dyldSharedCacheImagePathsSetCache = nil } package static var dyldSharedCacheImageRootNode: RuntimeImageNode { diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift new file mode 100644 index 00000000..d67b1ae5 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift @@ -0,0 +1,88 @@ +import Foundation +import FoundationToolbox + +@Loggable +struct DylibPathResolver { + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + /// Resolves a dylib install name to a concrete filesystem path. + /// Returns nil when the resolved path does not exist. + func resolve(installName: String, + imagePath: String, + rpaths: [String], + mainExecutablePath: String) -> String? { + if installName.hasPrefix("@rpath/") { + let tail = String(installName.dropFirst("@rpath/".count)) + var attempts: [String] = [] + for rpath in rpaths { + let expanded = expand(rpath, imagePath: imagePath, + mainExecutablePath: mainExecutablePath) + let candidate = expanded + "/" + tail + let exists = pathExists(candidate) + attempts.append("[rpath=\(rpath) expanded=\(expanded) candidate=\(candidate) exists=\(exists)]") + if exists { + return candidate + } + } + let attemptsLine = attempts.joined(separator: " ") + let rpathsLine = rpaths.joined(separator: ", ") + #log(.error, "@rpath unresolved | installName=\(installName, privacy: .public) | imagePath=\(imagePath, privacy: .public) | mainExecutablePath=\(mainExecutablePath, privacy: .public) | rpaths=[\(rpathsLine, privacy: .public)] | attempts=\(attemptsLine, privacy: .public)") + return nil + } + if installName.hasPrefix("@executable_path/") { + let tail = String(installName.dropFirst("@executable_path/".count)) + let candidate = (mainExecutablePath as NSString) + .deletingLastPathComponent + "/" + tail + let exists = pathExists(candidate) + if !exists { + #log(.error, "@executable_path unresolved | installName=\(installName, privacy: .public) | mainExecutablePath=\(mainExecutablePath, privacy: .public) | candidate=\(candidate, privacy: .public)") + } + return exists ? candidate : nil + } + if installName.hasPrefix("@loader_path/") { + let tail = String(installName.dropFirst("@loader_path/".count)) + let candidate = (imagePath as NSString) + .deletingLastPathComponent + "/" + tail + let exists = pathExists(candidate) + if !exists { + #log(.error, "@loader_path unresolved | installName=\(installName, privacy: .public) | imagePath=\(imagePath, privacy: .public) | candidate=\(candidate, privacy: .public)") + } + return exists ? candidate : nil + } + let exists = pathExists(installName) + if !exists { + #log(.error, "absolute path unresolved | installName=\(installName, privacy: .public)") + } + return exists ? installName : nil + } + + /// True when `path` is either an on-disk file OR an image baked into the + /// dyld shared cache. Apple Silicon ships system frameworks (Foundation, + /// UIKit, libobjc, libSystem, ...) inside the cache with no backing file, + /// so a pure `FileManager.fileExists` check rejects them as unresolved. + private func pathExists(_ path: String) -> Bool { + if fileManager.fileExists(atPath: path) { return true } + if DyldUtilities.isInDyldSharedCache(path) { return true } + return false + } + + private func expand(_ rpath: String, + imagePath: String, + mainExecutablePath: String) -> String { + if rpath.hasPrefix("@executable_path/") { + let tail = String(rpath.dropFirst("@executable_path/".count)) + return (mainExecutablePath as NSString) + .deletingLastPathComponent + "/" + tail + } + if rpath.hasPrefix("@loader_path/") { + let tail = String(rpath.dropFirst("@loader_path/".count)) + return (imagePath as NSString) + .deletingLastPathComponent + "/" + tail + } + return rpath + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift new file mode 100644 index 00000000..76619d87 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing +@testable import RuntimeViewerCore + +@Suite struct DylibPathResolverTests { + private let resolver = DylibPathResolver() + + /// Candidates probed by `absolutePathAcceptsDyldSharedCachePath`. Lifted + /// out so the `.enabled(if:)` trait can reuse it as a registration-time + /// gate (no candidate in cache → skip the test on this host). + private static let dyldSharedCacheCandidates = [ + "/System/Library/Frameworks/Foundation.framework/Foundation", + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", + "/usr/lib/libobjc.A.dylib", + "/usr/lib/libSystem.B.dylib", + ] + + @Test func absolutePathReturnsAsIsWhenExists() { + // Use /usr/lib/dyld because most "dylibs" live in the dyld shared cache + // and have no on-disk file on Apple Silicon Macs (e.g. libSystem.B.dylib). + // /usr/lib/dyld is a real on-disk file across macOS versions. + let path = "/usr/lib/dyld" + #expect(FileManager.default.fileExists(atPath: path), + "precondition: /usr/lib/dyld exists in this test env") + #expect(resolver.resolve(installName: path, + imagePath: "/any", rpaths: [], + mainExecutablePath: "/any") == path) + } + + @Test func absolutePathReturnsNilWhenMissing() { + #expect(resolver.resolve(installName: "/nonexistent/Foo.dylib", + imagePath: "/any", rpaths: [], + mainExecutablePath: "/any") == nil) + } + + @Test( + .enabled( + if: dyldSharedCacheCandidates.contains(where: DyldUtilities.isInDyldSharedCache), + "no candidate in dyld shared cache (test env may lack cache access)" + ) + ) + func absolutePathAcceptsDyldSharedCachePath() throws { + // System frameworks live in the dyld shared cache and have no on-disk + // file on Apple Silicon. The resolver must accept them anyway, + // otherwise BFS marks every UIKit/Foundation dependency as + // "path unresolved" and the toolbar floods with red ✗ rows. + let candidate = try #require( + Self.dyldSharedCacheCandidates.first(where: DyldUtilities.isInDyldSharedCache) + ) + #expect(!FileManager.default.fileExists(atPath: candidate), + "precondition: \(candidate) should NOT exist on disk on this host") + #expect(resolver.resolve(installName: candidate, + imagePath: "/any", rpaths: [], + mainExecutablePath: "/any") == candidate) + } + + @Test func executablePathSubstitutesMainExecutableDir() throws { + let tempDir = FileManager.default.temporaryDirectory.path + let exePath = tempDir + "/FakeExe" + let frameworkPath = tempDir + "/Foo" + try "".write(toFile: exePath, atomically: true, encoding: .utf8) + try "".write(toFile: frameworkPath, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(atPath: exePath) + try? FileManager.default.removeItem(atPath: frameworkPath) + } + let resolved = resolver.resolve( + installName: "@executable_path/Foo", + imagePath: "/any", rpaths: [], + mainExecutablePath: exePath) + #expect(resolved == frameworkPath) + } + + @Test func loaderPathSubstitutesImageDir() throws { + let tempDir = FileManager.default.temporaryDirectory.path + let imagePath = tempDir + "/FakeLib" + let siblingPath = tempDir + "/Sibling" + try "".write(toFile: imagePath, atomically: true, encoding: .utf8) + try "".write(toFile: siblingPath, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(atPath: imagePath) + try? FileManager.default.removeItem(atPath: siblingPath) + } + let resolved = resolver.resolve( + installName: "@loader_path/Sibling", + imagePath: imagePath, rpaths: [], + mainExecutablePath: "/any") + #expect(resolved == siblingPath) + } + + @Test func rpathUsesFirstMatchingRpath() throws { + let tempDir = FileManager.default.temporaryDirectory.path + let rpath1 = tempDir + "/DoesNotExist" + let rpath2 = tempDir + "/RPath2" + try? FileManager.default.createDirectory(atPath: rpath2, + withIntermediateDirectories: true) + let target = rpath2 + "/MyLib" + try "".write(toFile: target, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(atPath: target) + try? FileManager.default.removeItem(atPath: rpath2) + } + let resolved = resolver.resolve( + installName: "@rpath/MyLib", + imagePath: "/any", rpaths: [rpath1, rpath2], + mainExecutablePath: "/any") + #expect(resolved == target) + } + + @Test func rpathReturnsNilWhenNoMatch() { + #expect(resolver.resolve( + installName: "@rpath/Missing", + imagePath: "/any", rpaths: ["/nope1", "/nope2"], + mainExecutablePath: "/any") == nil) + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift new file mode 100644 index 00000000..20f6f01c --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift @@ -0,0 +1,79 @@ +import Foundation +@testable import RuntimeViewerCore + +// `@unchecked Sendable` is required because the protocol is `Sendable` and this +// class stores mutable state protected by `NSLock` rather than an actor. +final class MockBackgroundIndexingEngine: RuntimeBackgroundIndexingEngineRepresenting, + @unchecked Sendable +{ + struct ProgrammedPath: Sendable { + var isIndexed: Bool = false + var shouldFailLoad: Error? = nil + var dependencies: [(installName: String, resolvedPath: String?)] = [] + var rpaths: [String] = [] + } + + struct DependenciesCall: Sendable, Equatable { + var path: String + var ancestorRpaths: [String] + var mainExecutablePath: String + } + + private let lock = NSLock() + private var paths: [String: ProgrammedPath] = [:] + private var loadOrder: [String] = [] + private var dependenciesCallLog: [DependenciesCall] = [] + var mainExecutable: String = "/fake/MainApp" + + func program(path: String, _ entry: ProgrammedPath) { + lock.lock(); defer { lock.unlock() } + paths[path] = entry + } + + func loadedOrder() -> [String] { + lock.lock(); defer { lock.unlock() } + return loadOrder + } + + func dependenciesCalls() -> [DependenciesCall] { + lock.lock(); defer { lock.unlock() } + return dependenciesCallLog + } + + func isImageIndexed(path: String) async -> Bool { + lock.lock(); defer { lock.unlock() } + return paths[path]?.isIndexed ?? false + } + + func loadImageForBackgroundIndexing(at path: String) async throws { + try await Task.sleep(nanoseconds: 5_000_000) // force real async + lock.lock(); defer { lock.unlock() } + if let err = paths[path]?.shouldFailLoad { throw err } + var entry = paths[path] ?? ProgrammedPath() + entry.isIndexed = true + paths[path] = entry + loadOrder.append(path) + } + + func mainExecutablePath() async -> String { mainExecutable } + + func canOpenImage(at path: String) async -> Bool { + lock.lock(); defer { lock.unlock() } + return paths[path] != nil + } + func rpaths(for path: String) async -> [String] { + lock.lock(); defer { lock.unlock() } + return paths[path]?.rpaths ?? [] + } + func dependencies(for path: String, + ancestorRpaths: [String], + mainExecutablePath: String) + async -> [(installName: String, resolvedPath: String?)] + { + lock.lock(); defer { lock.unlock() } + dependenciesCallLog.append(.init(path: path, + ancestorRpaths: ancestorRpaths, + mainExecutablePath: mainExecutablePath)) + return paths[path]?.dependencies ?? [] + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift new file mode 100644 index 00000000..a221a090 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -0,0 +1,418 @@ +import Foundation +import Semaphore +import Testing +@testable import RuntimeViewerCore + +@Suite final class RuntimeBackgroundIndexingManagerTests { + /// Keepalives for engines / wrappers passed to a manager. + /// + /// Production safety: `RuntimeBackgroundIndexingManager.engine` is `unowned` + /// because the engine owns the manager (`RuntimeEngine.backgroundIndexingManager`), + /// so the engine always outlives the manager in real code. + /// + /// In tests we construct mocks as locals and ARC may eagerly release them + /// across `await` suspension points — at which point the manager's unowned + /// reference dangles and the next access traps. Stash mocks in this array + /// to pin them to the suite instance's lifetime; Swift Testing instantiates + /// a fresh suite per test, so the array is scoped to one test naturally. + private var aliveObjects: [AnyObject] = [] + + @discardableResult + private func keep(_ object: T) -> T { + aliveObjects.append(object) + return object + } + + @Test func currentBatchesInitiallyEmpty() async { + let engine = keep(MockBackgroundIndexingEngine()) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let batches = await manager.currentBatches() + #expect(batches.isEmpty) + } + + @Test func eventsStreamYieldsBatchStartedThenFinishedForEmptyGraph() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/fake/Root", + .init(isIndexed: true)) // short-circuit immediately + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let events = manager.events + let consumer = Task { + var seen: [String] = [] + for await event in events { + switch event { + case .batchStarted: seen.append("started") + case .batchFinished: seen.append("finished"); return seen + case .batchCancelled: seen.append("cancelled"); return seen + default: break + } + } + return seen + } + + _ = await manager.startBatch(rootImagePath: "/fake/Root", + depth: 0, maxConcurrency: 1, + reason: .manual) + let finalSeen = await consumer.value + #expect(finalSeen == ["started", "finished"]) + } + + @Test func expandEmptyWhenRootAlreadyIndexed() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", .init(isIndexed: true)) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 5) + #expect(items.isEmpty) + } + + @Test func expandDepth1IncludesRootAndDirectDeps() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", .init( + dependencies: [("/UIKit", "/UIKit"), ("/Foundation", "/Foundation")] + )) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 1) + #expect(Set(items.map(\.id)) == Set(["/App", "/UIKit", "/Foundation"])) + } + + @Test func expandDepth1DoesNotIncludeSecondLevel() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", + .init(dependencies: [("/UIKit", "/UIKit")])) + engine.program(path: "/UIKit", + .init(dependencies: [("/CoreGraphics", "/CoreGraphics")])) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 1) + #expect(Set(items.map(\.id)) == Set(["/App", "/UIKit"])) + } + + @Test func expandSkipsAlreadyIndexedDeps() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", + .init(dependencies: [("/UIKit", "/UIKit"), + ("/Foundation", "/Foundation")])) + engine.program(path: "/UIKit", .init(isIndexed: true)) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 1) + #expect(Set(items.map(\.id)) == Set(["/App", "/Foundation"])) + } + + @Test func expandUnresolvedInstallNameBecomesFailedItem() async throws { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", .init( + dependencies: [("@rpath/Missing", nil)] + )) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 1) + let missing = try #require(items.first { $0.id == "@rpath/Missing" }) + guard case .failed = missing.state else { + Issue.record("expected failed state, got \(missing.state)") + return + } + } + + @Test func expandDedupsSharedDependencies() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", + .init(dependencies: [("/A", "/A"), ("/B", "/B")])) + engine.program(path: "/A", + .init(dependencies: [("/Shared", "/Shared")])) + engine.program(path: "/B", + .init(dependencies: [("/Shared", "/Shared")])) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 2) + let sharedCount = items.filter { $0.id == "/Shared" }.count + #expect(sharedCount == 1) + } + + /// dyld resolves `@rpath/...` against the union of every loader's + /// LC_RPATH walking up the chain to the main executable. The BFS must + /// pass each visited image's accumulated ancestor rpaths to the engine + /// so that frameworks without their own LC_RPATH (very common — + /// they rely on the host app's rpath to find sibling frameworks) still + /// get their `@rpath/...` deps resolved instead of marked + /// `.failed("path unresolved")`. + @Test func expandPropagatesAncestorRpathsToDescendants() async { + let engine = keep(MockBackgroundIndexingEngine()) + // Root has rpath ["/HostFrameworks"], depends on /Child. + engine.program(path: "/Root", .init( + dependencies: [(installName: "@rpath/Child", resolvedPath: "/Child")], + rpaths: ["/HostFrameworks"] + )) + // Child has its own rpath ["/ChildOwn"], depends on /Grandchild. + engine.program(path: "/Child", .init( + dependencies: [(installName: "@rpath/Grandchild", resolvedPath: "/Grandchild")], + rpaths: ["/ChildOwn"] + )) + // Grandchild has no deps and no rpaths; just a leaf. + engine.program(path: "/Grandchild", .init()) + + let manager = RuntimeBackgroundIndexingManager(engine: engine) + _ = await manager.expandDependencyGraph(rootPath: "/Root", depth: 5) + + let calls = engine.dependenciesCalls() + + let rootCall = calls.first { $0.path == "/Root" } + #expect(rootCall?.ancestorRpaths == [], + "root has no ancestors above it") + + let childCall = calls.first { $0.path == "/Child" } + #expect(childCall?.ancestorRpaths == ["/HostFrameworks"], + "child must inherit root's LC_RPATH") + + let grandchildCall = calls.first { $0.path == "/Grandchild" } + #expect(grandchildCall?.ancestorRpaths == ["/HostFrameworks", "/ChildOwn"], + "grandchild inherits both root's and child's LC_RPATH, in loader-chain order") + } + + @Test func batchIndexesAllPendingItems() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", + .init(dependencies: [("/A", "/A"), ("/B", "/B")])) + engine.program(path: "/A", .init()) + engine.program(path: "/B", .init()) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let finishedBatch = await runToFinish(manager: manager, + root: "/App", depth: 1, + maxConcurrency: 2) + #expect(finishedBatch.items.allSatisfy { $0.state == .completed }) + let indexed = engine.loadedOrder() + #expect(Set(indexed) == Set(["/App", "/A", "/B"])) + } + + @Test func batchRespectsMaxConcurrency() async { + let engine = keep(MockBackgroundIndexingEngine()) + // 6 dependencies, concurrency cap 2 → never exceed 2 simultaneous loads + let deps = (0..<6).map { (installName: "/D\($0)", resolvedPath: "/D\($0)") } + engine.program(path: "/App", .init(dependencies: deps)) + for dep in deps { engine.program(path: dep.installName, .init()) } + + // Monkey-patch engine with a concurrency-counting wrapper. + let counter = ConcurrencyCounter() + let wrapped = keep(InstrumentedEngine(base: engine, counter: counter)) + let manager = RuntimeBackgroundIndexingManager(engine: wrapped) + + _ = await runToFinish(manager: manager, root: "/App", depth: 1, + maxConcurrency: 2) + #expect(counter.peak <= 2) + } + + @Test func batchFailedLoadYieldsFailedTaskState() async throws { + struct LoadError: Error {} + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", + .init(dependencies: [("/Broken", "/Broken")])) + engine.program(path: "/Broken", .init(shouldFailLoad: LoadError())) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let batch = await runToFinish(manager: manager, + root: "/App", depth: 1, maxConcurrency: 1) + let broken = try #require(batch.items.first { $0.id == "/Broken" }) + guard case .failed(let message) = broken.state else { + Issue.record("expected .failed, got \(broken.state)") + return + } + #expect(!message.isEmpty) + } + + @Test func cancelBatchStopsPendingItemsAndEmitsCancelledEvent() async { + let engine = keep(MockBackgroundIndexingEngine()) + let deps = (0..<5).map { (installName: "/D\($0)", resolvedPath: "/D\($0)") } + engine.program(path: "/App", .init(dependencies: deps)) + for dep in deps { engine.program(path: dep.installName, .init()) } + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let events = manager.events + let consumer = Task { () -> RuntimeIndexingBatch in + for await event in events { + if case .batchCancelled(let b) = event { return b } + if case .batchFinished(let b) = event { return b } + } + fatalError() + } + let id = await manager.startBatch(rootImagePath: "/App", depth: 1, + maxConcurrency: 1, reason: .manual) + try? await Task.sleep(nanoseconds: 10_000_000) + await manager.cancelBatch(id) + let batch = await consumer.value + #expect(batch.isCancelled) + } + + @Test func cancelAllCancelsEveryBatch() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/A", .init(dependencies: [("/A1", "/A1")])) + engine.program(path: "/A1", .init()) + engine.program(path: "/B", .init(dependencies: [("/B1", "/B1")])) + engine.program(path: "/B1", .init()) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let idA = await manager.startBatch(rootImagePath: "/A", depth: 1, + maxConcurrency: 1, reason: .manual) + let idB = await manager.startBatch(rootImagePath: "/B", depth: 1, + maxConcurrency: 1, reason: .manual) + #expect(idA != idB) + await manager.cancelAllBatches() + try? await Task.sleep(nanoseconds: 50_000_000) + let remaining = await manager.currentBatches() + #expect(remaining.isEmpty) + } + + @Test func prioritizeEmitsTaskPrioritizedEvent() async { + // Time-independent assertion: verify the manager emits + // `.taskPrioritized` for a pending path and does NOT emit it for + // running / absent paths. Load order would depend on sleep timing + // and is flaky on CI — event emission is the real contract. + let engine = keep(MockBackgroundIndexingEngine()) + let deps = ["/D0", "/D1", "/D2"] + engine.program(path: "/App", .init( + dependencies: deps.map { ($0, $0) } + )) + for dep in deps { engine.program(path: dep, .init()) } + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let events = manager.events + let consumer = Task { () -> [String] in + var boosted: [String] = [] + for await event in events { + if case .taskPrioritized(_, let path) = event { + boosted.append(path) + } + if case .batchFinished = event { return boosted } + if case .batchCancelled = event { return boosted } + } + return boosted + } + _ = await manager.startBatch(rootImagePath: "/App", depth: 1, + maxConcurrency: 1, reason: .manual) + await manager.prioritize(imagePath: "/D2") + + let boosted = await consumer.value + #expect(boosted == ["/D2"]) + } + + @Test func prioritizeIsNoOpForUnknownPath() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", .init()) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + _ = await manager.startBatch(rootImagePath: "/App", depth: 0, + maxConcurrency: 1, reason: .manual) + await manager.prioritize(imagePath: "/does/not/exist") + // No crash; batch still completes. No .taskPrioritized emitted. + } + + /// Real-world double-batch: `documentDidOpen` dispatches `.appLaunch` on the + /// main executable; concurrently `imageDidLoadPublisher` fires for the same + /// path and `handleImageLoaded` dispatches `.imageLoaded`. Without dedup + /// these become two parallel batches indexing the same dependency graph. + /// The manager must collapse them to a single batch (same id returned). + @Test func startBatchDedupsByRootImagePathAcrossDifferentReasons() async { + let engine = keep(MockBackgroundIndexingEngine()) + // depth 0 with `isIndexed: false` → batch contains one pending item + // whose load awaits 5ms in the mock; batch A stays active during the + // second startBatch call. + engine.program(path: "/App", .init()) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let firstId = await manager.startBatch( + rootImagePath: "/App", depth: 0, + maxConcurrency: 1, reason: .appLaunch) + let secondId = await manager.startBatch( + rootImagePath: "/App", depth: 0, + maxConcurrency: 1, reason: .imageLoaded(path: "/App")) + + #expect( + firstId == secondId, + "Same rootImagePath while batch is active must return the existing id" + ) + + // Cleanup so the test doesn't leave a Task in flight. + await manager.cancelBatch(firstId) + } + + /// After a batch finishes, the same root may be re-batched (e.g. another + /// dlopen of an unloaded dep). Dedup must NOT bind to historical batches. + @Test func startBatchAllowsNewBatchAfterPreviousFinishedForSameRoot() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/App", .init()) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let firstBatch = await runToFinish(manager: manager, + root: "/App", depth: 0, + maxConcurrency: 1) + // After the batch completes, a fresh startBatch on the same root must + // produce a new id — the prior batch is no longer active. + let secondId = await manager.startBatch( + rootImagePath: "/App", depth: 0, + maxConcurrency: 1, reason: .manual) + #expect(firstBatch.id != secondId) + + await manager.cancelBatch(secondId) + } + + // MARK: - Test helpers + private func runToFinish(manager: RuntimeBackgroundIndexingManager, + root: String, depth: Int, + maxConcurrency: Int) async -> RuntimeIndexingBatch + { + let events = manager.events + let consumer = Task { () -> RuntimeIndexingBatch in + for await event in events { + switch event { + case .batchFinished(let b), .batchCancelled(let b): return b + default: break + } + } + fatalError("stream ended without terminal event") + } + _ = await manager.startBatch(rootImagePath: root, depth: depth, + maxConcurrency: maxConcurrency, + reason: .manual) + return await consumer.value + } + + // Concurrency counter and instrumented engine — tiny helpers local to tests. + private final class ConcurrencyCounter: @unchecked Sendable { + private let lock = NSLock() + private var current = 0 + private(set) var peak = 0 + func enter() { lock.lock(); current += 1; peak = max(peak, current); lock.unlock() } + func exit() { lock.lock(); current -= 1; lock.unlock() } + } + + private final class InstrumentedEngine: RuntimeBackgroundIndexingEngineRepresenting, + @unchecked Sendable + { + let base: any RuntimeBackgroundIndexingEngineRepresenting + let counter: ConcurrencyCounter + init(base: any RuntimeBackgroundIndexingEngineRepresenting, counter: ConcurrencyCounter) { + self.base = base; self.counter = counter + } + func isImageIndexed(path: String) async throws -> Bool { + try await base.isImageIndexed(path: path) + } + func loadImageForBackgroundIndexing(at path: String) async throws { + counter.enter() + defer { counter.exit() } + try await Task.sleep(nanoseconds: 20_000_000) + try await base.loadImageForBackgroundIndexing(at: path) + } + func mainExecutablePath() async throws -> String { + try await base.mainExecutablePath() + } + func canOpenImage(at path: String) async -> Bool { + await base.canOpenImage(at: path) + } + func rpaths(for path: String) async throws -> [String] { + try await base.rpaths(for: path) + } + func dependencies(for path: String, + ancestorRpaths: [String], + mainExecutablePath: String) + async throws -> [(installName: String, resolvedPath: String?)] + { + try await base.dependencies(for: path, + ancestorRpaths: ancestorRpaths, + mainExecutablePath: mainExecutablePath) + } + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift new file mode 100644 index 00000000..d92db9a7 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -0,0 +1,209 @@ +import Combine +import Foundation +import Testing +@testable import RuntimeViewerCore + +@Suite struct RuntimeEngineIndexStateTests { + private static let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" + private static let coreText = "/System/Library/Frameworks/CoreText.framework/CoreText" + + @Test func isImageIndexedFalseForUnvisitedPath() async throws { + let engine = RuntimeEngine(source: .local) + let indexed = try await engine.isImageIndexed(path: "/never/seen") + #expect(!indexed) + } + + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: foundation), + "Requires macOS with Foundation.framework present" + ) + ) + func isImageIndexedTrueAfterLoadImage() async throws { + let engine = RuntimeEngine(source: .local) + try await engine.loadImage(at: Self.foundation) + let indexed = try await engine.isImageIndexed(path: Self.foundation) + #expect(indexed) + } + + /// Verifies the contract that `isImageIndexed` normalizes the input path the + /// same way `loadImage(at:)` / `isImageLoaded(path:)` do, so callers don't + /// see false negatives when they hand the engine an unpatched path. + /// + /// On most macOS hosts `DyldUtilities.patchImagePathForDyld` is a no-op for + /// regular system framework paths (it only prepends `DYLD_ROOT_PATH` when + /// that env var is set, e.g. inside a simulator runner). In that case the + /// raw and patched forms are identical and this test still pins the + /// contract: regression coverage triggers if the patcher's behavior ever + /// changes such that the two forms diverge. + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: foundation), + "Requires macOS with Foundation.framework present" + ) + ) + func isImageIndexedNormalizesPath() async throws { + let engine = RuntimeEngine(source: .local) + try await engine.loadImage(at: Self.foundation) + + // After load, both raw and patched forms should report indexed. + let patched = DyldUtilities.patchImagePathForDyld(Self.foundation) + let indexedRaw = try await engine.isImageIndexed(path: Self.foundation) + let indexedPatched = try await engine.isImageIndexed(path: patched) + #expect(indexedRaw, "isImageIndexed must return true for the unpatched path") + #expect(indexedPatched, "isImageIndexed must return true for the patched path too") + } + + @Test func mainExecutablePathReturnsNonEmptyPath() async throws { + // In the test runner this returns the runner's executable path, which + // validates the "return dyld image 0" contract without requiring + // RuntimeViewer.app to be running. + let engine = RuntimeEngine(source: .local) + let path = try await engine.mainExecutablePath() + #expect(!path.isEmpty) + #expect(FileManager.default.fileExists(atPath: path)) + } + + /// Pins three contracts in one shot: + /// 1. `loadImageForBackgroundIndexing` actually marks the image indexed. + /// 2. It does NOT emit `reloadDataPublisher` (otherwise a depth-2+ BFS + /// would storm the sidebar with a refresh per visited image). + /// 3. It does NOT emit `imageDidLoadPublisher` (otherwise the + /// background indexing coordinator's image-loaded pump would + /// recursively spawn a fresh batch for every image we just indexed). + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: coreText), + "Requires macOS with CoreText.framework present" + ) + ) + func loadImageForBackgroundIndexingMarksIndexedAndDoesNotEmitPublishers() async throws { + let engine = RuntimeEngine(source: .local) + + let counters = EmissionCounters() + let imageLoadCancellable = engine.imageDidLoadPublisher.sink { _ in + counters.incrementImageLoad() + } + let reloadDataCancellable = engine.reloadDataPublisher.sink { _ in + counters.incrementReloadData() + } + defer { + imageLoadCancellable.cancel() + reloadDataCancellable.cancel() + } + + try await engine.loadImageForBackgroundIndexing(at: Self.coreText) + + let indexed = try await engine.isImageIndexed(path: Self.coreText) + #expect(indexed, + "loadImageForBackgroundIndexing must populate the section caches") + #expect(counters.imageLoadCount == 0, + "loadImageForBackgroundIndexing must not emit imageDidLoadPublisher") + #expect(counters.reloadDataCount == 0, + "loadImageForBackgroundIndexing must not emit reloadDataPublisher") + } + + /// Test-local thread-safe counter pair. PassthroughSubject delivers to + /// `.sink` synchronously on whatever thread `.send` is called from, so + /// the actor task driving `loadImageForBackgroundIndexing` and the test + /// task can race on these counters. + private final class EmissionCounters: @unchecked Sendable { + private let lock = NSLock() + private var imageLoad = 0 + private var reloadData = 0 + + func incrementImageLoad() { + lock.lock(); defer { lock.unlock() } + imageLoad += 1 + } + + func incrementReloadData() { + lock.lock(); defer { lock.unlock() } + reloadData += 1 + } + + var imageLoadCount: Int { + lock.lock(); defer { lock.unlock() } + return imageLoad + } + + var reloadDataCount: Int { + lock.lock(); defer { lock.unlock() } + return reloadData + } + } + + /// Pins the writer-side normalization contract: `loadImage(at:)` must + /// canonicalize the path before inserting it into `loadedImagePaths`, so + /// that downstream readers (which all canonicalize before lookup) hit. + /// + /// On most macOS hosts `patchImagePathForDyld` is identity, so this test + /// passes trivially. It still pins the contract — if someone removes the + /// writer-side patch or the patch starts diverging in some environment, + /// this test catches the regression. + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: foundation), + "Requires macOS with Foundation.framework present" + ) + ) + func loadImageInsertsCanonicalPathIntoLoadedImagePaths() async throws { + let engine = RuntimeEngine(source: .local) + try await engine.loadImage(at: Self.foundation) + + let canonical = DyldUtilities.patchImagePathForDyld(Self.foundation) + let loaded = await engine.loadedImagePaths + #expect( + loaded.contains(canonical), + "loadImage must store the canonical (patched) form so reader-side lookups hit" + ) + } + + /// Same contract as `loadImageInsertsCanonicalPathIntoLoadedImagePaths`, + /// applied to the background indexing entry point. + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: coreText), + "Requires macOS with CoreText.framework present" + ) + ) + func loadImageForBackgroundIndexingInsertsCanonicalPathIntoLoadedImagePaths() + async throws + { + let engine = RuntimeEngine(source: .local) + try await engine.loadImageForBackgroundIndexing(at: Self.coreText) + + let canonical = DyldUtilities.patchImagePathForDyld(Self.coreText) + let loaded = await engine.loadedImagePaths + #expect( + loaded.contains(canonical), + "loadImageForBackgroundIndexing must store the canonical form" + ) + } + + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: foundation), + "Requires macOS with Foundation.framework present" + ) + ) + func imageDidLoadPublisherFiresAfterLoadImage() async throws { + let engine = RuntimeEngine(source: .local) + + // Buffer publisher emissions into an AsyncStream constructed *before* + // we trigger loadImage, so the subscription is live by the time the + // engine's PassthroughSubject sends. + let stream = AsyncStream { continuation in + let cancellable = engine.imageDidLoadPublisher.sink { path in + continuation.yield(path) + } + continuation.onTermination = { _ in cancellable.cancel() } + } + + try await engine.loadImage(at: Self.foundation) + + var iterator = stream.makeAsyncIterator() + let received = await iterator.next() + #expect(received == Self.foundation) + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift new file mode 100644 index 00000000..b213eb11 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift @@ -0,0 +1,56 @@ +import Testing +@testable import RuntimeViewerCore + +@Suite struct RuntimeIndexingValueTypesTests { + @Test func batchIDIsUnique() { + let a = RuntimeIndexingBatchID() + let b = RuntimeIndexingBatchID() + #expect(a != b) + } + + @Test func taskItemIsNotCompletedWhenPending() { + let item = RuntimeIndexingTaskItem(id: "/foo", resolvedPath: "/foo", + state: .pending, hasPriorityBoost: false) + #expect(!item.state.isTerminal) + } + + @Test func taskStateFailedIsTerminal() { + let state = RuntimeIndexingTaskState.failed(message: "boom") + #expect(state.isTerminal) + } + + @Test func taskStateCancelledIsTerminal() { + #expect(RuntimeIndexingTaskState.cancelled.isTerminal) + } + + @Test func taskStateCompletedIsTerminal() { + #expect(RuntimeIndexingTaskState.completed.isTerminal) + } + + @Test func batchProgressReportsFinishedFraction() { + let items: [RuntimeIndexingTaskItem] = [ + .init(id: "/a", resolvedPath: "/a", state: .completed, hasPriorityBoost: false), + .init(id: "/b", resolvedPath: "/b", state: .completed, hasPriorityBoost: false), + .init(id: "/c", resolvedPath: "/c", state: .pending, hasPriorityBoost: false), + .init(id: "/d", resolvedPath: "/d", state: .failed(message: "x"), hasPriorityBoost: false), + .init(id: "/e", resolvedPath: "/e", state: .cancelled, hasPriorityBoost: false), + ] + let batch = RuntimeIndexingBatch( + id: RuntimeIndexingBatchID(), + rootImagePath: "/root", + depth: 1, + reason: .manual, + items: items, + isCancelled: false, + isFinished: false + ) + #expect(batch.totalCount == 5) + // `finishedCount` powers the progress bar — every terminal state counts + // because the work item has stopped, regardless of outcome. + #expect(batch.finishedCount == 4) + #expect(batch.succeededCount == 2) + #expect(batch.failedCount == 1) + #expect(batch.cancelledCount == 1) + #expect(batch.progress == 0.8) + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift new file mode 100644 index 00000000..df733ab4 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import RuntimeViewerCore + +@Suite("DyldUtilities.patchImagePathForDyld") +struct DyldUtilitiesTests { + // MARK: - Identity cases + + @Test("returns input unchanged when DYLD_ROOT_PATH is nil") + func returnsInputWhenNoRootPath() { + let result = DyldUtilities.patchImagePathForDyld( + "/usr/lib/libobjc.A.dylib", rootPath: nil) + #expect(result == "/usr/lib/libobjc.A.dylib") + } + + @Test("returns input unchanged for non-absolute path") + func returnsInputForRelativePath() { + let result = DyldUtilities.patchImagePathForDyld( + "Foundation", rootPath: "/sim_root") + #expect(result == "Foundation") + } + + // MARK: - Patching + + @Test("prepends root path to absolute path") + func prependsRootPath() { + let result = DyldUtilities.patchImagePathForDyld( + "/usr/lib/libobjc.A.dylib", rootPath: "/sim_root") + #expect(result == "/sim_root/usr/lib/libobjc.A.dylib") + } + + // MARK: - Idempotency + + @Test("calling twice produces the same result as calling once") + func isIdempotent() { + let raw = "/usr/lib/libobjc.A.dylib" + let once = DyldUtilities.patchImagePathForDyld(raw, rootPath: "/sim_root") + let twice = DyldUtilities.patchImagePathForDyld(once, rootPath: "/sim_root") + #expect(twice == once) + #expect(twice == "/sim_root/usr/lib/libobjc.A.dylib") + } + + @Test("calling three times produces the same result as calling once") + func isStableAcrossMultipleCalls() { + let raw = "/usr/lib/libobjc.A.dylib" + let once = DyldUtilities.patchImagePathForDyld(raw, rootPath: "/sim_root") + let twice = DyldUtilities.patchImagePathForDyld(once, rootPath: "/sim_root") + let thrice = DyldUtilities.patchImagePathForDyld(twice, rootPath: "/sim_root") + #expect(thrice == once) + } + + @Test("returns input unchanged when path equals root path itself") + func returnsInputWhenPathEqualsRoot() { + let result = DyldUtilities.patchImagePathForDyld( + "/sim_root", rootPath: "/sim_root") + #expect(result == "/sim_root") + } + + // MARK: - Prefix precision + + @Test("does not mistake sibling prefix for already-patched") + func distinguishesSimilarPrefix() { + // `/sim_root_other` is NOT under `/sim_root`, even though it shares + // the `/sim_root` prefix as a substring. Must be patched normally, + // not treated as already-patched. + let result = DyldUtilities.patchImagePathForDyld( + "/sim_root_other/file", rootPath: "/sim_root") + #expect(result == "/sim_root/sim_root_other/file") + } +} + +/// `imageNames().first` would silently return the wrong path under +/// `DYLD_INSERT_LIBRARIES` (e.g. Xcode injects `libLogRedirect.dylib` during +/// debug runs and it ends up at dyld image index 0, not the host executable). +/// `mainExecutablePath()` must use `_NSGetExecutablePath` so that +/// `@executable_path/...` rpath expansion stays correct in those scenarios. +@Suite("DyldUtilities.mainExecutablePath") +struct DyldUtilitiesMainExecutablePathTests { + @Test("returns absolute path of running test process") + func returnsAbsolutePath() { + let path = DyldUtilities.mainExecutablePath() + #expect(path.hasPrefix("/"), "expected absolute path, got: \(path)") + #expect(!path.isEmpty) + // The test runner exists on disk (no dyld_shared_cache games for the + // main executable itself), so a vanilla file existence check applies. + #expect(FileManager.default.fileExists(atPath: path), + "test runner exe should exist on disk: \(path)") + } +} diff --git a/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index fb3daab2..663c22e7 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bd4df935af2b5e4cf487388024217ae88b99653ca258a531474c95182210beb3", + "originHash" : "3fdd53e06571765e2b65cf3bf5203e39ef3c8fde743ea292edb370feb83f27c3", "pins" : [ { "identity" : "associatedobject", @@ -94,10 +94,10 @@ { "identity" : "frameworktoolbox", "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/FrameworkToolbox", + "location" : "https://github.com/Mx-Iris/FrameworkToolbox.git", "state" : { - "revision" : "d011291f5e8d6430fb91b52296dda50e85dc5c11", - "version" : "0.5.2" + "revision" : "22f92afb2520417e60a464a6a5abb88621c2b43d", + "version" : "0.5.3" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", - "version" : "8.8.1" + "revision" : "cf8be20d07654570554c8a8a4952bc8a5766a8b0", + "version" : "8.9.0" } }, { @@ -253,6 +253,24 @@ "version" : "2.1.1" } }, + { + "identity" : "runningapplicationkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RunningApplicationKit", + "state" : { + "revision" : "5ff991e2b32445cebce2514fbc428594dfa092cd", + "version" : "0.3.2" + } + }, + { + "identity" : "rxappkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RxAppKit", + "state" : { + "revision" : "e4ea5c272acdc4f1c220bcc0a44d79cebf1ed98b", + "version" : "0.3.1" + } + }, { "identity" : "rxcombine", "kind" : "remoteSourceControl", @@ -294,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/RxSwiftPlus", "state" : { - "revision" : "4f3ab85a5ce982d430265004e588e4c7da748d06", - "version" : "0.2.2" + "revision" : "d40a7d551b58ce3006eaabcaa3721b99db72ea78", + "version" : "0.2.3" } }, { @@ -517,10 +535,10 @@ { "identity" : "swift-memberwise-init-macro", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/swift-memberwise-init-macro", + "location" : "https://github.com/gohanlon/swift-memberwise-init-macro", "state" : { - "revision" : "6121b169fb5a83d7262a69b640468606f98c6c6e", - "version" : "0.5.3-fork.1" + "revision" : "d0fb82bb6638051524214fb54524bfcd876735a1", + "version" : "0.6.0" } }, { @@ -613,6 +631,15 @@ "version" : "0.1.0" } }, + { + "identity" : "uifoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/UIFoundation", + "state" : { + "revision" : "d7c490fd668e26ccf82e1776ce77c32e4ca8f3e3", + "version" : "0.5.1" + } + }, { "identity" : "uxkitcoordinator", "kind" : "remoteSourceControl", @@ -627,8 +654,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mxcl/Version", "state" : { - "revision" : "67ce582bb9de70e1eb2ee41fd71aad3b5f86d97b", - "version" : "2.2.0" + "revision" : "3043fcd2a50375db76d89ff206a612471833d1c2", + "version" : "2.2.1" } }, { diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 93a2998d..5dcad523 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -338,8 +338,8 @@ let package = Package( ) ), .package( - url: "https://github.com/MxIris-Library-Forks/swift-memberwise-init-macro", - from: "0.5.3-fork" + url: "https://github.com/gohanlon/swift-memberwise-init-macro", + from: "0.6.0" ), .package( url: "https://github.com/pointfreeco/swift-dependencies", diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/AppCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/AppCoordinator.swift new file mode 100644 index 00000000..635a8f33 --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/AppCoordinator.swift @@ -0,0 +1,41 @@ +#if os(macOS) + +import AppKit +import Dependencies +import CocoaCoordinator +import RuntimeViewerSettingsUI + +public enum AppRoute: Routable { + case settings +} + +private final class AppCoordinator: Coordinator { + static let shared = AppCoordinator(initialRoute: nil) + + @Dependency(\.settingsWindowController) + var settingsWindowController + + override func prepareTransition(for route: AppRoute) -> AppTransition { + switch route { + case .settings: + settingsWindowController.showWindow(nil) + return .none() + } + } +} + +@MainActor +extension DependencyValues { + public var appRouter: any Router { + set { self[AppCoordinatorKey.self] = newValue } + get { self[AppCoordinatorKey.self] } + } +} + +private enum AppCoordinatorKey: @MainActor DependencyKey { + @MainActor + static let liveValue: any Router = AppCoordinator.shared +} + + +#endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift new file mode 100644 index 00000000..4ad484fd --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -0,0 +1,515 @@ +import Foundation +import Observation +import RuntimeViewerCore +import RxSwift +import RxRelay +import Dependencies + +#if canImport(RuntimeViewerSettings) +import RuntimeViewerSettings +#endif + +@MainActor +public final class RuntimeBackgroundIndexingCoordinator { + /// Soft cap on `historyRelay` size. A long-running session that triggers + /// many `imageLoaded` notifications would otherwise grow history without + /// bound; once this cap is exceeded we drop the oldest entries from the + /// tail (history is inserted at index 0, so the tail is the oldest). + /// The user can still manually clear via `clearHistory()`. + private static let maxHistoryEntries = 100 + + public struct AggregateState: Equatable, Sendable { + public var hasActiveBatch: Bool + public var hasAnyFailure: Bool + public var progress: Double? // 0...1, nil when idle + + public init(hasActiveBatch: Bool, hasAnyFailure: Bool, progress: Double?) { + self.hasActiveBatch = hasActiveBatch + self.hasAnyFailure = hasAnyFailure + self.progress = progress + } + } + + private unowned let documentState: DocumentState + /// The engine this coordinator currently drives. Mutable so `MainCoordinator` + /// can switch sources (Local ↔ XPC ↔ Bonjour) without recreating the + /// coordinator: an RxSwift subscription on `documentState.$runtimeEngine` + /// picks up reassignments and rewires the pumps onto the new engine's + /// `backgroundIndexingManager`. + private var engine: RuntimeEngine + private let disposeBag = DisposeBag() + + private let batchesRelay = BehaviorRelay<[RuntimeIndexingBatch]>(value: []) + private let historyRelay = BehaviorRelay<[RuntimeIndexingBatch]>(value: []) + private let aggregateRelay = BehaviorRelay( + value: .init(hasActiveBatch: false, hasAnyFailure: false, progress: nil) + ) + + /// Authoritative active-batches storage. Mutated synchronously inside + /// `apply(event:)`; copied into `batchesRelay` only on flush so that + /// task-level events (one per started/finished image) don't fan out a + /// full subscriber storm 100+ times per second during a busy batch. + private var stagedBatches: [RuntimeIndexingBatch] = [] + /// Pending history archives from `batchFinished` / `batchCancelled`, + /// delivered to `historyRelay` only after the corresponding active-batch + /// removal has been published — see `flushPendingUpdates` for the + /// active-then-history ordering rationale. + private var pendingHistoryAdditions: [RuntimeIndexingBatch] = [] + private var hasPendingActiveChange = false + private var pendingAggregateRefresh = false + /// `true` while `scheduleCoalescedFlush` has a `Task` outstanding that + /// will call `flushPendingUpdates` on the next runloop tick. Guards + /// against piling up redundant flush tasks when events arrive in bursts. + private var hasScheduledFlush = false + + /// One frame at 60Hz. Coalesces task-level events that arrive together + /// (e.g. `taskFinished(A)` immediately followed by `taskStarted(B)` as a + /// worker picks up the next item) into a single relay publish so the + /// popover redraws at a sustainable rate. + private static let coalesceWindowNanos: UInt64 = 16_000_000 + + private var documentBatchIDs: Set = [] + private var eventPumpTask: Task? + private var imageLoadedPumpTask: Task? + private var lastKnownIsEnabled: Bool = false + + public init(documentState: DocumentState) { + self.documentState = documentState + self.engine = documentState.runtimeEngine + startEventPump() + #if canImport(RuntimeViewerSettings) + startImageLoadedPump() + bootstrapSettingsObservation() + #endif + bootstrapEngineObservation() + } + + deinit { + eventPumpTask?.cancel() + imageLoadedPumpTask?.cancel() + } + + // MARK: - Public observables for UI + + public var batchesObservable: Observable<[RuntimeIndexingBatch]> { + batchesRelay.asObservable() + } + + public var aggregateStateObservable: Observable { + aggregateRelay.asObservable() + } + + public var historyObservable: Observable<[RuntimeIndexingBatch]> { + historyRelay.asObservable() + } + + // MARK: - Public command surface + + public func cancelBatch(_ id: RuntimeIndexingBatchID) { + Task { [engine] in + await engine.backgroundIndexingManager.cancelBatch(id) + } + } + + public func cancelAllBatches() { + Task { [engine] in + await engine.backgroundIndexingManager.cancelAllBatches() + } + } + + public func prioritize(imagePath: String) { + Task { [engine] in + await engine.backgroundIndexingManager.prioritize(imagePath: imagePath) + } + } + + public func clearHistory() { + // Drop pending archives too — otherwise a `.batchFinished` whose + // history hop was waiting on the coalesce window would still land + // after the user cleared. + pendingHistoryAdditions.removeAll() + historyRelay.accept([]) + } + + // MARK: - Event pump (AsyncStream → Relay) + + private func startEventPump() { + // The class is `@MainActor`, so this Task and its `for await` loop + // run on the main actor. `apply(event:)` can be called synchronously + // without an extra `MainActor.run` hop. + eventPumpTask = Task { [weak self] in + guard let self else { return } + let stream = await self.engine.backgroundIndexingManager.events + for await event in stream { + self.apply(event: event) + } + } + } + + private func apply(event: RuntimeIndexingEvent) { + // Lifecycle events (batch{Started,Finished,Cancelled}) are rare and + // user-visible, so they bypass the coalesce window and flush the + // current state immediately. Per-task events (task{Started,Finished, + // Prioritized}) only schedule a coalesced flush — see + // `scheduleCoalescedFlush` for the rate cap. + var requiresImmediateFlush = false + + switch event { + case .batchStarted(let batch): + stagedBatches.append(batch) + hasPendingActiveChange = true + pendingAggregateRefresh = true + requiresImmediateFlush = true + case .taskStarted(let id, let path): + if mutateTaskItem(batchID: id, path: path, { item in + item.state = .running + }) { + hasPendingActiveChange = true + pendingAggregateRefresh = true + } + case .taskFinished(let id, let path, let result): + if mutateTaskItem(batchID: id, path: path, { item in + item.state = result + }) { + hasPendingActiveChange = true + pendingAggregateRefresh = true + } + case .taskPrioritized(let id, let path): + // Priority boost doesn't change progress / hasFailure / hasActive, + // so we skip the aggregate refresh. + if mutateTaskItem(batchID: id, path: path, { item in + item.hasPriorityBoost = true + }) { + hasPendingActiveChange = true + } + case .batchFinished(let finished): + stagedBatches.removeAll { $0.id == finished.id } + documentBatchIDs.remove(finished.id) + pendingHistoryAdditions.append(finished) + hasPendingActiveChange = true + pendingAggregateRefresh = true + requiresImmediateFlush = true + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } + case .batchCancelled(let cancelled): + // Cancellation always removes from active. Now also lands in history + // so the user can review what got cancelled. + stagedBatches.removeAll { $0.id == cancelled.id } + documentBatchIDs.remove(cancelled.id) + pendingHistoryAdditions.append(cancelled) + hasPendingActiveChange = true + pendingAggregateRefresh = true + requiresImmediateFlush = true + Task { [engine] in + await engine.reloadData(isReloadImageNodes: false) + } + } + + if requiresImmediateFlush { + flushPendingUpdates() + } else { + scheduleCoalescedFlush() + } + } + + /// Locates `(batchID, path)` inside `stagedBatches` and applies `mutate` + /// in place. Returns `true` on a successful hit so the caller can flip + /// the appropriate dirty flags. Returns `false` when the batch or item + /// can't be found — stale events that arrive after a swap or + /// cancellation must not poison the flush state. + private func mutateTaskItem( + batchID: RuntimeIndexingBatchID, + path: String, + _ mutate: (inout RuntimeIndexingTaskItem) -> Void + ) -> Bool { + guard let batchIndex = stagedBatches.firstIndex(where: { $0.id == batchID }), + let itemIndex = stagedBatches[batchIndex].items.firstIndex(where: { $0.id == path }) + else { return false } + mutate(&stagedBatches[batchIndex].items[itemIndex]) + return true + } + + /// Asks main-actor to call `flushPendingUpdates` on the next runloop tick + /// (~16ms out). Idempotent: bursty events that all arrive inside the + /// window collapse into a single publish. + private func scheduleCoalescedFlush() { + guard !hasScheduledFlush else { return } + hasScheduledFlush = true + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: Self.coalesceWindowNanos) + self?.flushPendingUpdates() + } + } + + /// Publishes `stagedBatches` to `batchesRelay`, then drains pending + /// `historyAdditions` into `historyRelay`. See the active-then-history + /// ordering note: any batch that just transitioned to history must have + /// already disappeared from `batchesRelay` before it appears in + /// `historyRelay`, otherwise `combineLatest(batches, history)` would emit + /// a transient frame with the same `differenceIdentifier` in both + /// sections and DifferenceKit's behavior is undefined. + private func flushPendingUpdates() { + hasScheduledFlush = false + guard hasPendingActiveChange || !pendingHistoryAdditions.isEmpty else { + return + } + let activeChanged = hasPendingActiveChange + let aggregateChanged = pendingAggregateRefresh + hasPendingActiveChange = false + pendingAggregateRefresh = false + + if activeChanged { + batchesRelay.accept(stagedBatches) + } + if aggregateChanged { + refreshAggregate(batches: stagedBatches) + } + // Now safe to push history: subscribers see (new batches without + // finished, new history with finished) — a fully consistent state. + if !pendingHistoryAdditions.isEmpty { + let toArchive = pendingHistoryAdditions + pendingHistoryAdditions = [] + for batch in toArchive { + appendToHistory(batch) + } + } + } + + private func appendToHistory(_ batch: RuntimeIndexingBatch) { + var updatedHistory = historyRelay.value + updatedHistory.insert(batch, at: 0) + if updatedHistory.count > Self.maxHistoryEntries { + updatedHistory.removeLast(updatedHistory.count - Self.maxHistoryEntries) + } + historyRelay.accept(updatedHistory) + } + + private func refreshAggregate(batches: [RuntimeIndexingBatch]) { + let hasActive = !batches.isEmpty + let hasFailure = batches.contains { batch in + batch.items.contains { item in + if case .failed = item.state { return true } + return false + } + } + let totalItems = batches.reduce(0) { $0 + $1.totalCount } + let doneItems = batches.reduce(0) { $0 + $1.finishedCount } + let progress: Double? = totalItems > 0 + ? Double(doneItems) / Double(totalItems) + : nil + aggregateRelay.accept( + .init(hasActiveBatch: hasActive, hasAnyFailure: hasFailure, + progress: progress)) + } + + // MARK: - Engine swap (source switch) + + /// Subscribes to `documentState.$runtimeEngine`. When `MainCoordinator` + /// reassigns the engine on a source switch, `handleEngineSwap` tears down + /// the old pumps, cancels in-flight document batches on the old manager, + /// and rewires onto the new engine's manager. + private func bootstrapEngineObservation() { + // skip(1) — BehaviorRelay replays its current value on subscribe; that + // value matches the engine captured in init, so we don't need to react + // to it. Only subsequent reassignments are real source switches. + documentState.$runtimeEngine + .skip(1) + .subscribeOnNext { [weak self] newEngine in + guard let self else { return } + self.handleEngineSwap(to: newEngine) + } + .disposed(by: disposeBag) + } + + private func handleEngineSwap(to newEngine: RuntimeEngine) { + // Capture the old engine before we overwrite, so we can dispatch a + // cancel to its manager covering both already-tracked batches and + // any swap-window arrivals. + let oldEngine = engine + + // 1) Stop pumps tied to the old engine. The Tasks were `for await` + // looping over an AsyncStream owned by the old manager; cancelling + // them ends the loops cleanly. + eventPumpTask?.cancel() + imageLoadedPumpTask?.cancel() + eventPumpTask = nil + imageLoadedPumpTask = nil + + // 2) Cancel **all** in-flight batches on the old manager — not just + // the ones in `documentBatchIDs`. A `startBatch` Task that + // suspended before its id was inserted into `documentBatchIDs` + // would otherwise leak: the `self.engine === engine` guard in + // `startMainExecutableBatch` / `handleImageLoaded` correctly drops + // its id, but the batch itself remains active on the old manager + // and runs to completion uninterrupted, occupying CPU and the + // section-cache slots until the old engine is finally deinit'd. + // `cancelAllBatches` covers both already-tracked batches and any + // swap-window arrivals. + // + // Fire-and-forget — old engine's manager will deinit shortly. + Task { + await oldEngine.backgroundIndexingManager.cancelAllBatches() + } + + // 3) Drop UI state — the old engine's batches and history no longer apply. + // Also reset the coalescing state so that any flush task currently + // sleeping out the 16ms window sees clean buffers when it wakes. + documentBatchIDs.removeAll() + stagedBatches.removeAll() + pendingHistoryAdditions.removeAll() + hasPendingActiveChange = false + pendingAggregateRefresh = false + batchesRelay.accept([]) + historyRelay.accept([]) + refreshAggregate(batches: []) + + // 4) Switch the captured engine reference. + engine = newEngine + + // 5) Restart pumps on the new engine's manager. + startEventPump() + #if canImport(RuntimeViewerSettings) + startImageLoadedPump() + // If the feature is enabled, treat the swap like a fresh document + // open — the new engine's main executable should be indexed. + documentDidOpen() + #endif + } +} + +#if canImport(RuntimeViewerSettings) +extension RuntimeBackgroundIndexingCoordinator { + public func documentDidOpen() { + startMainExecutableBatch(reason: .appLaunch) + } + + /// Shared logic for "index the main executable" batches. Both the document + /// open path (reason `.appLaunch`) and the off→on settings toggle (reason + /// `.settingsEnabled`) funnel through here so the popover's title-by-reason + /// branch surfaces the correct label instead of always saying "App launch + /// indexing". + private func startMainExecutableBatch(reason: RuntimeIndexingBatchReason) { + // The class is `@MainActor`, so this Task inherits main-actor isolation + // and can mutate `documentBatchIDs` synchronously after the awaits. + // Capture `engine` at task creation so every await below targets the + // same engine even if `handleEngineSwap` reassigns `self.engine` while + // we are suspended — otherwise we could submit the old engine's root + // path to the new engine's manager and leak a stray batch id into + // `documentBatchIDs`. + Task { [weak self, engine] in + guard let self else { return } + let settings = self.currentBackgroundIndexingSettings() + guard settings.isEnabled else { return } + // mainExecutablePath is `async throws` because remote (XPC / TCP) + // sources may fail; on launch we silently skip the batch in that + // case rather than surface the error to the user. + guard let root = try? await engine.mainExecutablePath(), + !root.isEmpty else { return } + let id = await engine.backgroundIndexingManager.startBatch( + rootImagePath: root, + depth: settings.depth, + maxConcurrency: settings.maxConcurrency, + reason: reason) + // If the engine swapped while we were suspended, the batch landed + // on the now-old manager which `handleEngineSwap` has already + // cleaned up; don't pollute `documentBatchIDs` with an id whose + // manager we no longer drive. + guard self.engine === engine else { return } + self.documentBatchIDs.insert(id) + } + } + + public func documentWillClose() { + let ids = documentBatchIDs + documentBatchIDs.removeAll() + Task { [engine] in + for id in ids { + await engine.backgroundIndexingManager.cancelBatch(id) + } + } + } + + private func startImageLoadedPump() { + // Class is `@MainActor`; this Task and `for await` loop run on the main + // actor. `handleImageLoaded` doesn't need a `MainActor.run` hop. + // Capture `engine` so the pump (and the `handleImageLoaded` call below) + // stay bound to the engine that owned this pump at startup, even if + // `self.engine` is reassigned by `handleEngineSwap` mid-flight. + imageLoadedPumpTask = Task { [weak self, engine] in + guard let self else { return } + // Combine.Publisher.values bridges to AsyncSequence on macOS 12+ / + // iOS 15+; the project's deployment targets satisfy this. Errors are + // Never on this publisher, so no try is needed. + for await path in engine.imageDidLoadPublisher.values { + await self.handleImageLoaded(path: path, on: engine) + } + } + } + + private func handleImageLoaded(path: String, on engine: RuntimeEngine) async { + let settings = currentBackgroundIndexingSettings() + guard settings.isEnabled else { return } + // If `documentDidOpen` is currently indexing the same path (e.g. dyld + // fires this notification for the main executable right after launch), + // the manager dedups by `rootImagePath` and returns the existing + // batch's id. Inserting it into `documentBatchIDs` is a no-op on the + // Set when it's already tracked. + let id = await engine.backgroundIndexingManager.startBatch( + rootImagePath: path, + depth: settings.depth, + maxConcurrency: settings.maxConcurrency, + reason: .imageLoaded(path: path)) + // If the engine swapped while we were suspended on `startBatch`, the + // id belongs to the old manager and `handleEngineSwap` has already + // cleared `documentBatchIDs`; don't reintroduce a stale id. + guard self.engine === engine else { return } + self.documentBatchIDs.insert(id) + } + + private func currentBackgroundIndexingSettings() -> Settings.Indexing.BackgroundMode { + @Dependency(\.settings) var settings + return settings.indexing.backgroundMode + } + + private func bootstrapSettingsObservation() { + self.lastKnownIsEnabled = currentBackgroundIndexingSettings().isEnabled + self.subscribeToSettings() + } + + private func subscribeToSettings() { + withObservationTracking { + let snapshot = currentBackgroundIndexingSettings() + _ = snapshot.isEnabled + _ = snapshot.depth + _ = snapshot.maxConcurrency + } onChange: { [weak self] in + // onChange fires off the main actor synchronously after any mutation. + // Hop back to MainActor to (a) handle the change and (b) re-register. + Task { @MainActor [weak self] in + guard let self else { return } + self.handleSettingsChange() + self.subscribeToSettings() + } + } + } + + private func handleSettingsChange() { + let latest = currentBackgroundIndexingSettings() + let wasEnabled = lastKnownIsEnabled + lastKnownIsEnabled = latest.isEnabled + if !wasEnabled && latest.isEnabled { + // Scenario E: off→on. Use `.settingsEnabled` so the popover's + // title-by-reason mapping shows "Settings enabled" instead of + // the misleading "App launch indexing". + startMainExecutableBatch(reason: .settingsEnabled) + } else if wasEnabled && !latest.isEnabled { + Task { [engine] in + await engine.backgroundIndexingManager.cancelAllBatches() + } + } + // depth / maxConcurrency changes: intentional no-op; next startBatch picks + // up the new values. + } +} +#endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 91288c1e..e683d2a0 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -7,6 +7,13 @@ import RuntimeViewerArchitectures public final class DocumentState { public init() {} + /// The runtime engine backing this Document. + /// + /// Reassignable: `MainCoordinator` swaps this when the user changes source + /// (Local ↔ XPC ↔ Bonjour). `RuntimeBackgroundIndexingCoordinator` + /// subscribes to `$runtimeEngine` and rewires its pumps onto the new + /// engine's `backgroundIndexingManager`, cancelling the old engine's + /// in-flight document batches as it goes. @Observed public var runtimeEngine: RuntimeEngine = .local @@ -18,4 +25,20 @@ public final class DocumentState { @Observed public var currentSubtitle: String = "" + + /// Per-Document background indexing coordinator. + /// + /// Force-initialized on the first Document lifecycle hook + /// (`makeWindowControllers` / `close`) and kept alive for the rest of + /// the Document's lifetime, even when the feature is disabled at open + /// time, so it can react to settings off→on toggles. The `lazy` + /// modifier is retained as an init-deferral mechanism, not as a + /// gating-by-enablement: every opened Document instantiates one + /// coordinator regardless of `Settings.Indexing.BackgroundMode.isEnabled`. + /// + /// The coordinator captures `runtimeEngine` initially and rewires onto + /// a new engine via the `$runtimeEngine` subscription on every source + /// switch — see that property's doc comment for the swap contract. + public private(set) lazy var backgroundIndexingCoordinator = + RuntimeBackgroundIndexingCoordinator(documentState: self) } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift index 0ed30d98..82995bf8 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift @@ -41,7 +41,16 @@ public class SidebarRootViewModel: ViewModel { return isFilterEmptyNodes ? !$0.isEmpty : true } .observe(on: ConcurrentDispatchQueueScheduler(qos: .userInteractive)) - .flatMapLatest { nodes -> [String: SidebarRootCellViewModel] in + // `map` (not `flatMapLatest`) — a sync body fed to RxConcurrency's + // `flatMapLatest(_:async)` overload is silently wrapped in + // `Observable.async { Task { ... } }`, and `Task` inherits the + // surrounding `@MainActor` isolation from `ViewModel`. That hop + // overrides `.observe(on: ConcurrentDispatchQueueScheduler(...))` + // and lands the iterator (which lazy-builds every cell view model + // and its NSAttributedString) on the main thread — visible at + // ~386ms in time profiles. `map` keeps the work on the background + // scheduler we asked for. + .map { nodes -> [String: SidebarRootCellViewModel] in #log(.info, "\(Self.self, privacy: .public) Indexing sidebar nodes...") var allNodes: [String: SidebarRootCellViewModel] = [:] for rootNode in nodes { @@ -98,6 +107,20 @@ public class SidebarRootViewModel: ViewModel { } .disposed(by: rx.disposeBag) + // Selecting a leaf node (i.e. an image, not a path-segment folder) + // hints the background indexer to prioritize that image's pending + // tasks. Non-leaf rows correspond to filesystem path segments and + // have no associated image path, so they are filtered out. + // Note: `node.path` strips the synthetic root component (e.g. + // "Dyld Shared Cache") that prefixes `absolutePath`, yielding the + // real dyld image path expected by the indexing manager. + input.selectedNode.emitOnNextMainActor { [weak self] viewModel in + guard let self else { return } + guard viewModel.node.isLeaf else { return } + documentState.backgroundIndexingCoordinator.prioritize(imagePath: viewModel.node.path) + } + .disposed(by: rx.disposeBag) + input.searchString .debounce(.milliseconds(500)) .emitOnNextMainActor { [weak self] filter in diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift index e4645f44..fd4c9080 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift @@ -14,10 +14,17 @@ open class ViewModel: NSObject, ViewModelProtocol { @Dependency(\.appDefaults) public var appDefaults + + #if os(macOS) + @Dependency(\.appRouter) + public var appRouter + #endif + #if canImport(RuntimeViewerSettings) @Dependency(\.settings) public var settings #endif + public let errorRelay = PublishRelay() package let _commonLoading = ActivityIndicator() diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift index a9cf4464..4c9274f3 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -56,10 +56,37 @@ extension Settings { /// The fixed port number to use when useFixedPort is true @Default(9277) public var fixedPort: UInt16 - + public static let `default` = Self() /// The port file name used by both the MCP HTTP server and the settings UI. public static let portFileName = "mcp-http-port" } + + @Codable + @MemberInit + public struct Indexing { + @Codable + @MemberInit + public struct BackgroundMode { + /// Whether background indexing is enabled + @Default(false) + public var isEnabled: Bool + + /// Indexing depth (valid range enforced by the Settings UI: 1...5) + @Default(1) + public var depth: Int + + /// Maximum concurrent indexing tasks (Settings UI clamps to 1...processorCount) + @Default(4) + public var maxConcurrency: Int + + public static let `default` = Self() + } + + @Default(BackgroundMode.default) + public var backgroundMode: BackgroundMode + + public static let `default` = Self() + } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift index 90f08704..b50f4116 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift @@ -31,6 +31,11 @@ public final class Settings { didSet { scheduleAutoSave() } } + @Default(Indexing.default) + public var indexing: Indexing = .init() { + didSet { scheduleAutoSave() } + } + @Default(Update.default) public var update: Update = .init() { didSet { scheduleAutoSave() } @@ -74,6 +79,7 @@ public final class Settings { notifications = decoded.notifications transformer = decoded.transformer mcp = decoded.mcp + indexing = decoded.indexing update = decoded.update #log(.debug, "Settings loaded successfully.") } catch { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift new file mode 100644 index 00000000..5f28d7ec --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift @@ -0,0 +1,40 @@ +#if os(macOS) + +import SwiftUI +import Dependencies +import RuntimeViewerSettings + +struct IndexingSettingsView: View { + @AppSettings(\.indexing) + var indexing + + private static let maxConcurrencyUpperBound = max(1, ProcessInfo.processInfo.processorCount) + + var body: some View { + SettingsForm { + Section { + Toggle("Enable Background Indexing", isOn: $indexing.backgroundMode.isEnabled) + } header: { + Text("Background Indexing") + } footer: { + Text("When enabled, Runtime Viewer parses ObjC and Swift metadata for the dependency closure of loaded images in the background so that lookups are instant.") + } + + Section { + Stepper(value: $indexing.backgroundMode.depth, in: 1...5) { + LabeledContent("Depth", value: "\(indexing.backgroundMode.depth)") + } + .disabled(!indexing.backgroundMode.isEnabled) + + Stepper(value: $indexing.backgroundMode.maxConcurrency, in: 1...Self.maxConcurrencyUpperBound) { + LabeledContent("Max Concurrent Tasks", value: "\(indexing.backgroundMode.maxConcurrency)") + } + .disabled(!indexing.backgroundMode.isEnabled) + } footer: { + Text("Depth controls how many levels of dependencies to index starting from each root image. Max concurrent tasks limits how many images are indexed in parallel; higher values finish faster but use more CPU.") + } + } + } +} + +#endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift index e25541f7..cca0549c 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift @@ -20,6 +20,7 @@ private enum SettingsPage: String, CaseIterable, Identifiable { case general = "General" case notifications = "Notifications" case transformer = "Transformer" + case indexing = "Indexing" case mcp = "MCP" case updates = "Updates" case helper = "Helper" @@ -31,6 +32,7 @@ private enum SettingsPage: String, CaseIterable, Identifiable { case .general: "gearshape" case .notifications: "bell.badge" case .transformer: "arrow.triangle.2.circlepath" + case .indexing: "square.stack.3d.down.right" case .mcp: "network" case .updates: "arrow.down.circle" case .helper: "wrench.and.screwdriver" @@ -43,6 +45,7 @@ private enum SettingsPage: String, CaseIterable, Identifiable { case .general: GeneralSettingsView() case .notifications: NotificationSettingsView() case .transformer: TransformerSettingsView() + case .indexing: IndexingSettingsView() case .mcp: MCPSettingsView() case .updates: UpdateSettingsView() case .helper: HelperServiceSettingsView() diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsWindowController.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsWindowController.swift index 47d1e836..240b079c 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsWindowController.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsWindowController.swift @@ -3,9 +3,9 @@ import SwiftUI import UIFoundation import RuntimeViewerSettings -public final class SettingsWindow: NSWindow {} +package final class SettingsWindow: NSWindow {} -public final class SettingsWindowController: XiblessWindowController { +package final class SettingsWindowController: XiblessWindowController { public static let shared = SettingsWindowController() private lazy var settingsViewController = SettingsViewController() @@ -71,3 +71,16 @@ extension NSSplitViewItem { ) } } + +import Dependencies + +extension DependencyValues { + package var settingsWindowController: SettingsWindowController { + set { self[SettingsWindowControllerKey.self] = newValue } + get { self[SettingsWindowControllerKey.self] } + } +} + +private enum SettingsWindowControllerKey: DependencyKey { + static let liveValue: SettingsWindowController = .shared +} diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Package.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Package.swift old mode 100644 new mode 100755 index 0257f8b7..a5fe6bf1 --- a/RuntimeViewerPrecompiledLibraries/swift-syntax/Package.swift +++ b/RuntimeViewerPrecompiledLibraries/swift-syntax/Package.swift @@ -2,7 +2,7 @@ import PackageDescription -let tag = "601.0.1" +let tag = "603.0.1" let package = Package( name: "swift-syntax", @@ -30,6 +30,7 @@ let package = Package( .library(name: "SwiftSyntaxMacroExpansion", targets: ["SwiftSyntaxMacroExpansion_Aggregation"]), .library(name: "SwiftSyntaxMacrosTestSupport", targets: ["SwiftSyntaxMacrosTestSupport_Aggregation"]), .library(name: "SwiftSyntaxMacrosGenericTestSupport", targets: ["SwiftSyntaxMacrosGenericTestSupport_Aggregation"]), + .library(name: "SwiftWarningControl", targets: ["SwiftWarningControl_Aggregation"]), .library(name: "_SwiftCompilerPluginMessageHandling", targets: ["SwiftCompilerPluginMessageHandling_Aggregation"]), .library(name: "_SwiftLibraryPluginProvider", targets: ["SwiftLibraryPluginProvider_Aggregation"]), ], @@ -44,8 +45,8 @@ let package = Package( ), .binaryTarget( name: "SwiftBasicFormat", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftBasicFormat.xcframework.zip", - checksum: "94365ab0f550e63d788c2379193bcaef059d4c155d587eacc0648deb4dcdf418" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftBasicFormat.xcframework.zip", + checksum: "c618343f8fa52d0e5b7e105c399ebdb1614fe9bfc0b00e979f1899cec016013a" ), // MARK: - SwiftCompilerPlugin @@ -59,8 +60,8 @@ let package = Package( ), .binaryTarget( name: "SwiftCompilerPlugin", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftCompilerPlugin.xcframework.zip", - checksum: "e3cad3e5b8c29b70c85fe05dd85622ad3a82f9ad48789ed7998bee35b34475da" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftCompilerPlugin.xcframework.zip", + checksum: "b111ca056c11148cd35f8c1db7cf811c39a6c1bbe0098f986241f13a15232363" ), // MARK: - SwiftDiagnostics @@ -73,8 +74,8 @@ let package = Package( ), .binaryTarget( name: "SwiftDiagnostics", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftDiagnostics.xcframework.zip", - checksum: "50bf401279fc1f35f177bd40e4a1a107950dbb442fcde7a3fdce47836eb2016b" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftDiagnostics.xcframework.zip", + checksum: "bf3e38730511d9b7d575f274eae7376c75da3740d26013fd81db531fb4a41bf5" ), // MARK: - SwiftIDEUtils @@ -89,8 +90,8 @@ let package = Package( ), .binaryTarget( name: "SwiftIDEUtils", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftIDEUtils.xcframework.zip", - checksum: "82a31659ddf3a24a89a17863aabaeea15024dd92267898c27b3d03a5298e3827" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftIDEUtils.xcframework.zip", + checksum: "9292b83bf44352d41ab44897d4320354d63c4415dd407104dbf84e8d71e9c2bb" ), // MARK: - SwiftIfConfig @@ -102,12 +103,29 @@ let package = Package( "SwiftSyntaxBuilder_Aggregation", "SwiftDiagnostics_Aggregation", "SwiftOperators_Aggregation", + "SwiftParser_Aggregation", ] ), .binaryTarget( name: "SwiftIfConfig", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftIfConfig.xcframework.zip", - checksum: "86d9fb1a73a5c1f7f71d384abdd7f631a0b6a10de7660fe3fd577f1f1650c0a7" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftIfConfig.xcframework.zip", + checksum: "3ea38962cd2575045018c42ed767dcf4f0236980b64dc05815120c9d4828f4da" + ), + + // MARK: - SwiftWarningControl + .target( + name: "SwiftWarningControl_Aggregation", + dependencies: [ + .target(name: "SwiftWarningControl"), + "SwiftSyntax_Aggregation", + "SwiftParser_Aggregation", + "SwiftDiagnostics_Aggregation", + ] + ), + .binaryTarget( + name: "SwiftWarningControl", + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftWarningControl.xcframework.zip", + checksum: "22da29cd1142ca5a6d5f6a83d17e85490aad1e0ca9aa8fba67cc9422e1237031" ), // MARK: - SwiftLexicalLookup @@ -121,8 +139,8 @@ let package = Package( ), .binaryTarget( name: "SwiftLexicalLookup", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftLexicalLookup.xcframework.zip", - checksum: "7f2f318e7caf5e6bc8707b3ddd812f77913852cbd699be6fafdc7e9e4638b0f8" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftLexicalLookup.xcframework.zip", + checksum: "0596aac34ce00959c7ca2118e76c4b83ae10fcb3e9fcb0acfb818f3141b954fc" ), // MARK: - SwiftOperators @@ -137,8 +155,8 @@ let package = Package( ), .binaryTarget( name: "SwiftOperators", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftOperators.xcframework.zip", - checksum: "d6da125f107d2e0109b8f5056ab5f62a57ecc7a6f8760d7068628f0d660084ef" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftOperators.xcframework.zip", + checksum: "5a11e8c3b0dd203ccd305c0eb9ed7aa0d4a23091e23b0b4606fc9f6f526ffa08" ), // MARK: - SwiftParser @@ -151,8 +169,8 @@ let package = Package( ), .binaryTarget( name: "SwiftParser", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftParser.xcframework.zip", - checksum: "873e3a52f51db1f46531877d81d747c0b9c8125e801b0f40472c5b94359d57c1" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftParser.xcframework.zip", + checksum: "9dab752eae2408dd22ec54de4ac5b76fb29c1c60145fda2280f74349a2a2466c" ), // MARK: - SwiftParserDiagnostics @@ -168,8 +186,8 @@ let package = Package( ), .binaryTarget( name: "SwiftParserDiagnostics", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftParserDiagnostics.xcframework.zip", - checksum: "7b6776f6941e1b32250694927c59e72abe952582eaad269da6183118349746ca" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftParserDiagnostics.xcframework.zip", + checksum: "e027f0f544a890c2ea68ee495e0087a106ab2fec29ab8a9e0c32a4aad216c435" ), // MARK: - SwiftRefactor @@ -185,8 +203,8 @@ let package = Package( ), .binaryTarget( name: "SwiftRefactor", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftRefactor.xcframework.zip", - checksum: "b402430b131e6a9133dbb6cbc8760096454b97d3e6d9d97e3f0cfb4ea7bd0b42" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftRefactor.xcframework.zip", + checksum: "adee4dbe5fd80014ace943540cc1d0289d1f0963d66bf0aa2a73d56803bb673a" ), // MARK: - SwiftSyntax @@ -199,12 +217,14 @@ let package = Package( "SwiftSyntax510_Aggregation", "SwiftSyntax600_Aggregation", "SwiftSyntax601_Aggregation", + "SwiftSyntax602_Aggregation", + "SwiftSyntax603_Aggregation", ] ), .binaryTarget( name: "SwiftSyntax", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntax.xcframework.zip", - checksum: "d06ed8d94024fa44041a4ee0bf84610353cbaf1576bb7bfa91e952ef779c870a" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntax.xcframework.zip", + checksum: "6bc4112d83b32001aa02cda91b7095d0b2455a9d7c91f1606377c8db9108124c" ), // MARK: - SwiftSyntaxBuilder @@ -221,8 +241,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntaxBuilder", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntaxBuilder.xcframework.zip", - checksum: "4d9554178485ee66242b68662e92710461a0a1f641e0703c74e24c313a55251d" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntaxBuilder.xcframework.zip", + checksum: "71ab335736b649a03035f8cf2d953063cd184f293a761961834f1e543ccbc5d6" ), // MARK: - SwiftSyntaxMacros @@ -231,6 +251,7 @@ let package = Package( dependencies: [ .target(name: "SwiftSyntaxMacros"), "SwiftDiagnostics_Aggregation", + "SwiftIfConfig_Aggregation", "SwiftParser_Aggregation", "SwiftSyntax_Aggregation", "SwiftSyntaxBuilder_Aggregation", @@ -238,8 +259,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntaxMacros", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntaxMacros.xcframework.zip", - checksum: "428f898a1e7852dec98d4c09185fb1a87e7fc77e0203ba83a0db90b360c6e035" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntaxMacros.xcframework.zip", + checksum: "581516c1ac947fb6445d78053b090fb1db408b38152fd7233552a81caec15275" ), // MARK: - SwiftSyntaxMacroExpansion @@ -256,8 +277,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntaxMacroExpansion", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntaxMacroExpansion.xcframework.zip", - checksum: "2ddcd299bd7523b53f02a005ec066831cbac20677292ee198801a3eafb8cf696" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntaxMacroExpansion.xcframework.zip", + checksum: "a557fd52179897ebc222391112f43698ceef3984debb186edccaafa74c38b1da" ), // MARK: - SwiftSyntaxMacrosTestSupport @@ -273,8 +294,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntaxMacrosTestSupport", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntaxMacrosTestSupport.xcframework.zip", - checksum: "8ae3781cd5ad9e63b653a99a3c7ba1efd1eeaa2d831122d05868ea80b9998529" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntaxMacrosTestSupport.xcframework.zip", + checksum: "d3f88d08d219191ce96b010ff7d536597b7fd530fc3c6235efc8b6afcf6cc879" ), // MARK: - SwiftSyntaxMacrosGenericTestSupport @@ -285,6 +306,7 @@ let package = Package( "_SwiftSyntaxGenericTestSupport_Aggregation", "SwiftDiagnostics_Aggregation", "SwiftIDEUtils_Aggregation", + "SwiftIfConfig_Aggregation", "SwiftParser_Aggregation", "SwiftSyntaxMacros_Aggregation", "SwiftSyntaxMacroExpansion_Aggregation", @@ -292,8 +314,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntaxMacrosGenericTestSupport", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntaxMacrosGenericTestSupport.xcframework.zip", - checksum: "5601a9d686cc84f5b32e1c6c04a01f3fe16c8f5216f1edec648b6d2ce0aa1d04" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntaxMacrosGenericTestSupport.xcframework.zip", + checksum: "2e5c562015c4f4a87fa702319e57824252169317282561d82c41a89cd6d77b0e" ), // MARK: - _SwiftCompilerPluginMessageHandling @@ -303,8 +325,8 @@ let package = Package( ), .binaryTarget( name: "_SwiftCompilerPluginMessageHandling", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/_SwiftCompilerPluginMessageHandling.xcframework.zip", - checksum: "df02239aac44cb97402c49d04ebdb8d63880d1cf6d2730bdafb0c71d594b31b9" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/_SwiftCompilerPluginMessageHandling.xcframework.zip", + checksum: "b6ffe3faee8d00d06ab378fc5d21a3f9372ffed426ccf3f3afde20b649708748" ), // MARK: - _SwiftLibraryPluginProvider @@ -314,41 +336,8 @@ let package = Package( ), .binaryTarget( name: "_SwiftLibraryPluginProvider", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/_SwiftLibraryPluginProvider.xcframework.zip", - checksum: "d5aeedeefa4aa7f424054147f4d55809f65f30174a3235bfd7cde957b4e8631f" - ), - - // MARK: - _SwiftLibraryPluginProviderCShims - .target( - name: "_SwiftLibraryPluginProviderCShims_Aggregation", - dependencies: [.target(name: "_SwiftLibraryPluginProviderCShims")] - ), - .binaryTarget( - name: "_SwiftLibraryPluginProviderCShims", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/_SwiftLibraryPluginProviderCShims.xcframework.zip", - checksum: "889ee8bf53509090f75fe39e5a74784af8eacdd896f7a314f1dff4fa60a5a8ca" - ), - - // MARK: - _SwiftSyntaxCShims - .target( - name: "_SwiftSyntaxCShims_Aggregation", - dependencies: [.target(name: "_SwiftSyntaxCShims")] - ), - .binaryTarget( - name: "_SwiftSyntaxCShims", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/_SwiftSyntaxCShims.xcframework.zip", - checksum: "f4d14eabe1bec36dfe7ebc13f4a159dbb046e966579af5d8d807e151c2aa6c9b" - ), - - // MARK: - _SwiftSyntaxGenericTestSupport - .target( - name: "_SwiftSyntaxGenericTestSupport_Aggregation", - dependencies: [.target(name: "_SwiftSyntaxGenericTestSupport")] - ), - .binaryTarget( - name: "_SwiftSyntaxGenericTestSupport", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/_SwiftSyntaxGenericTestSupport.xcframework.zip", - checksum: "884d1c5983a63e1863d38049174933f5c734a2537c91c26a1d241c08c4aeeeac" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/_SwiftLibraryPluginProvider.xcframework.zip", + checksum: "7154bcca61bbfab015dea3171dd0bfdc741e6dd3133ad492146a3411b208ea11" ), // MARK: - SwiftCompilerPluginMessageHandling @@ -367,8 +356,8 @@ let package = Package( ), .binaryTarget( name: "SwiftCompilerPluginMessageHandling", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftCompilerPluginMessageHandling.xcframework.zip", - checksum: "d405da850c46662e7995110bfa1b21d2c900d61d24864bd4f4a47f3d94097014" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftCompilerPluginMessageHandling.xcframework.zip", + checksum: "5fa2ede4d41c836b479e1958d43f15796d9a054e55c647911183dc1e7a2cd8a3" ), // MARK: - SwiftLibraryPluginProvider @@ -383,8 +372,8 @@ let package = Package( ), .binaryTarget( name: "SwiftLibraryPluginProvider", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftLibraryPluginProvider.xcframework.zip", - checksum: "8cdccb3839eb1f94eb601f347ada3839848add1b6c92cf415fab57c11727f396" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftLibraryPluginProvider.xcframework.zip", + checksum: "536d4e9f39008539d2511e460a19d741d133a6f174237be90b6badb6242ef125" ), // MARK: - SwiftSyntax509 @@ -394,8 +383,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntax509", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntax509.xcframework.zip", - checksum: "9c362169c3e677e0670c3630d1215d26b31191250db76516ea399f350d9b45ad" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntax509.xcframework.zip", + checksum: "aefb80f9df4e2edcbe2e56820b7fe3f19c086d3cd5dc08a0cc30ca4baaf04076" ), // MARK: - SwiftSyntax510 @@ -405,8 +394,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntax510", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntax510.xcframework.zip", - checksum: "d3a578ad0c7d352b6940397480d8f040b5095065af3836de66b6961eea28501c" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntax510.xcframework.zip", + checksum: "bfbb9cc7985a8ab35615a63a7151b6ac056f70e6bead6beff762d618b5d62167" ), // MARK: - SwiftSyntax600 @@ -416,8 +405,8 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntax600", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntax600.xcframework.zip", - checksum: "83c09d90b60f67c001d6f598a657c6cf0b457acad466f823074112b40ff678cf" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntax600.xcframework.zip", + checksum: "25c9a883a6c19665339810adaaa18ae93feb291e65a727e368198adb90e3ebec" ), // MARK: - SwiftSyntax601 @@ -427,8 +416,63 @@ let package = Package( ), .binaryTarget( name: "SwiftSyntax601", - url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/601.0.1/SwiftSyntax601.xcframework.zip", - checksum: "eed0abae3c33170a43441bd4c35f95e7591e05b7482cb10833803551dab5ebbd" + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntax601.xcframework.zip", + checksum: "c0fcf94b38dd1360de4792006a6032c94a5348e63f65d115e44e5a181e6d2f9d" + ), + + // MARK: - SwiftSyntax602 + .target( + name: "SwiftSyntax602_Aggregation", + dependencies: [.target(name: "SwiftSyntax602")] + ), + .binaryTarget( + name: "SwiftSyntax602", + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntax602.xcframework.zip", + checksum: "0ddf958a9e254a12e43db2ed20d23f3a36411aa6ef26f960d16f4a5f5e045326" + ), + + // MARK: - SwiftSyntax603 + .target( + name: "SwiftSyntax603_Aggregation", + dependencies: [.target(name: "SwiftSyntax603")] + ), + .binaryTarget( + name: "SwiftSyntax603", + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/SwiftSyntax603.xcframework.zip", + checksum: "180fdab9b4d6379aecadbbf981a322044160931f64a0067644ae0da01db0caf9" + ), + + // MARK: - _SwiftLibraryPluginProviderCShims + .target( + name: "_SwiftLibraryPluginProviderCShims_Aggregation", + dependencies: [.target(name: "_SwiftLibraryPluginProviderCShims")] + ), + .binaryTarget( + name: "_SwiftLibraryPluginProviderCShims", + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/_SwiftLibraryPluginProviderCShims.xcframework.zip", + checksum: "85e4d61db781898fd4618f2e122d23919c2d94b4025852ab0472bd72e5dc333d" + ), + + // MARK: - _SwiftSyntaxCShims + .target( + name: "_SwiftSyntaxCShims_Aggregation", + dependencies: [.target(name: "_SwiftSyntaxCShims")] + ), + .binaryTarget( + name: "_SwiftSyntaxCShims", + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/_SwiftSyntaxCShims.xcframework.zip", + checksum: "f28b4f9298979aa3d437e6bda6254f580cb41bf87e3e62c47558bf97658aac29" + ), + + // MARK: - _SwiftSyntaxGenericTestSupport + .target( + name: "_SwiftSyntaxGenericTestSupport_Aggregation", + dependencies: [.target(name: "_SwiftSyntaxGenericTestSupport")] + ), + .binaryTarget( + name: "_SwiftSyntaxGenericTestSupport", + url: "https://github.com/MxIris-DeveloperTool/swift-syntax-builder/releases/download/603.0.1/_SwiftSyntaxGenericTestSupport.xcframework.zip", + checksum: "590862887165d6c114ff0adc4dbf8049f7f9e5f86c7ccba325c477054a616980" ), ] diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftBasicFormat_Aggregation/SwiftBasicFormat_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftBasicFormat_Aggregation/SwiftBasicFormat_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftCompilerPluginMessageHandling_Aggregation/SwiftCompilerPluginMessageHandling_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftCompilerPluginMessageHandling_Aggregation/SwiftCompilerPluginMessageHandling_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftCompilerPlugin_Aggregation/SwiftCompilerPlugin_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftCompilerPlugin_Aggregation/SwiftCompilerPlugin_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftDiagnostics_Aggregation/SwiftDiagnostics_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftDiagnostics_Aggregation/SwiftDiagnostics_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftIDEUtils_Aggregation/SwiftIDEUtils_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftIDEUtils_Aggregation/SwiftIDEUtils_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftIfConfig_Aggregation/SwiftIfConfig_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftIfConfig_Aggregation/SwiftIfConfig_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftLexicalLookup_Aggregation/SwiftLexicalLookup_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftLexicalLookup_Aggregation/SwiftLexicalLookup_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftLibraryPluginProvider_Aggregation/SwiftLibraryPluginProvider_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftLibraryPluginProvider_Aggregation/SwiftLibraryPluginProvider_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftOperators_Aggregation/SwiftOperators_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftOperators_Aggregation/SwiftOperators_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftParserDiagnostics_Aggregation/SwiftParserDiagnostics_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftParserDiagnostics_Aggregation/SwiftParserDiagnostics_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftParser_Aggregation/SwiftParser_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftParser_Aggregation/SwiftParser_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftRefactor_Aggregation/SwiftRefactor_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftRefactor_Aggregation/SwiftRefactor_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax509_Aggregation/SwiftSyntax509_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax509_Aggregation/SwiftSyntax509_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax510_Aggregation/SwiftSyntax510_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax510_Aggregation/SwiftSyntax510_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax600_Aggregation/SwiftSyntax600_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax600_Aggregation/SwiftSyntax600_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax601_Aggregation/SwiftSyntax601_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax601_Aggregation/SwiftSyntax601_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax602_Aggregation/SwiftSyntax602_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax602_Aggregation/SwiftSyntax602_Aggregation.swift new file mode 100755 index 00000000..81cb8d26 --- /dev/null +++ b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax602_Aggregation/SwiftSyntax602_Aggregation.swift @@ -0,0 +1,3 @@ +// This file is intentionally empty. +// It exists only to satisfy SwiftPM's requirement for source files in targets. +// The actual implementation is provided by the binary target. diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax603_Aggregation/SwiftSyntax603_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax603_Aggregation/SwiftSyntax603_Aggregation.swift new file mode 100755 index 00000000..81cb8d26 --- /dev/null +++ b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax603_Aggregation/SwiftSyntax603_Aggregation.swift @@ -0,0 +1,3 @@ +// This file is intentionally empty. +// It exists only to satisfy SwiftPM's requirement for source files in targets. +// The actual implementation is provided by the binary target. diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxBuilder_Aggregation/SwiftSyntaxBuilder_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxBuilder_Aggregation/SwiftSyntaxBuilder_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacroExpansion_Aggregation/SwiftSyntaxMacroExpansion_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacroExpansion_Aggregation/SwiftSyntaxMacroExpansion_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacrosGenericTestSupport_Aggregation/SwiftSyntaxMacrosGenericTestSupport_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacrosGenericTestSupport_Aggregation/SwiftSyntaxMacrosGenericTestSupport_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacrosTestSupport_Aggregation/SwiftSyntaxMacrosTestSupport_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacrosTestSupport_Aggregation/SwiftSyntaxMacrosTestSupport_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacros_Aggregation/SwiftSyntaxMacros_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacros_Aggregation/SwiftSyntaxMacros_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax_Aggregation/SwiftSyntax_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax_Aggregation/SwiftSyntax_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftWarningControl_Aggregation/SwiftWarningControl_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftWarningControl_Aggregation/SwiftWarningControl_Aggregation.swift new file mode 100755 index 00000000..81cb8d26 --- /dev/null +++ b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftWarningControl_Aggregation/SwiftWarningControl_Aggregation.swift @@ -0,0 +1,3 @@ +// This file is intentionally empty. +// It exists only to satisfy SwiftPM's requirement for source files in targets. +// The actual implementation is provided by the binary target. diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftCompilerPluginMessageHandling_Aggregation/_SwiftCompilerPluginMessageHandling_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftCompilerPluginMessageHandling_Aggregation/_SwiftCompilerPluginMessageHandling_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftLibraryPluginProviderCShims_Aggregation/_SwiftLibraryPluginProviderCShims_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftLibraryPluginProviderCShims_Aggregation/_SwiftLibraryPluginProviderCShims_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftLibraryPluginProvider_Aggregation/_SwiftLibraryPluginProvider_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftLibraryPluginProvider_Aggregation/_SwiftLibraryPluginProvider_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftSyntaxCShims_Aggregation/_SwiftSyntaxCShims_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftSyntaxCShims_Aggregation/_SwiftSyntaxCShims_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftSyntaxGenericTestSupport_Aggregation/_SwiftSyntaxGenericTestSupport_Aggregation.swift b/RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftSyntaxGenericTestSupport_Aggregation/_SwiftSyntaxGenericTestSupport_Aggregation.swift old mode 100644 new mode 100755 diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index 31b7c9c9..166b9f54 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -74,6 +74,10 @@ E9A9D7F62F5F110300A10DD3 /* MCPStatusPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A9D7F52F5F110300A10DD3 /* MCPStatusPopoverViewController.swift */; }; E9AA36222C3089AB00A9B2E4 /* InspectorPlaceholderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9AA36212C3089AB00A9B2E4 /* InspectorPlaceholderViewController.swift */; }; E9B4C6562F35E9C800823FE0 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B4C6542F35E9C800823FE0 /* main.swift */; }; + E9BD1A112FA000020000ABCD /* BackgroundIndexingNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */; }; + E9BD1A132FA000040000ABCD /* BackgroundIndexingPopoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */; }; + E9BD1A172FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A162FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift */; }; + E9BD1A1B2FA0000A0000ABCD /* BackgroundIndexingToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */; }; E9C9E9D72C2D161000C4AA34 /* RuntimeViewerCatalystHelperPlugin.bundle in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = E9E900DC2C2CF9A500FADDCC /* RuntimeViewerCatalystHelperPlugin.bundle */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E9C9E9DD2C2D169D00C4AA34 /* AppKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C9E9DB2C2D169D00C4AA34 /* AppKitPlugin.swift */; }; E9C9E9DE2C2D169D00C4AA34 /* AppKitPluginImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C9E9DC2C2D169D00C4AA34 /* AppKitPluginImpl.swift */; }; @@ -283,6 +287,10 @@ E9A9D7F52F5F110300A10DD3 /* MCPStatusPopoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPStatusPopoverViewController.swift; sourceTree = ""; }; E9AA36212C3089AB00A9B2E4 /* InspectorPlaceholderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorPlaceholderViewController.swift; sourceTree = ""; }; E9B4C6542F35E9C800823FE0 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingNode.swift; sourceTree = ""; }; + E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingPopoverViewModel.swift; sourceTree = ""; }; + E9BD1A162FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingPopoverViewController.swift; sourceTree = ""; }; + E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingToolbarItem.swift; sourceTree = ""; }; E9C9E9C92C2D0E3C00C4AA34 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Config.xcconfig; path = ../Configurations/RuntimeViewerService/Config.xcconfig; sourceTree = SOURCE_ROOT; }; E9C9E9CE2C2D10C600C4AA34 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E9C9E9DB2C2D169D00C4AA34 /* AppKitPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppKitPlugin.swift; sourceTree = ""; }; @@ -425,6 +433,7 @@ E97544A22C42D0F600CC9DDD /* Load Frameworks */, E94E36C42CF84A9F006101C8 /* Attach Process */, E9A9D8032F5F254800A10DD3 /* MCP */, + E9BD1A142FA000050000ABCD /* BackgroundIndexing */, E92CB2E52F41E7560091450B /* Exporting */, E9CE07BF2C14981D0070A6E8 /* Utils */, E94E36C72CF87BBC006101C8 /* Resources */, @@ -522,6 +531,17 @@ path = com.mxiris.runtimeviewer.service; sourceTree = ""; }; + E9BD1A142FA000050000ABCD /* BackgroundIndexing */ = { + isa = PBXGroup; + children = ( + E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */, + E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */, + E9BD1A162FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift */, + E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */, + ); + path = BackgroundIndexing; + sourceTree = ""; + }; E9CE07BA2C1497EA0070A6E8 /* Base */ = { isa = PBXGroup; children = ( @@ -950,6 +970,10 @@ E9935B972F448910006DB4EC /* ExportingCompletionViewController.swift in Sources */, E94330182C0DA62500362862 /* SidebarRootDirectoryViewController.swift in Sources */, E96BF91D2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift in Sources */, + E9BD1A112FA000020000ABCD /* BackgroundIndexingNode.swift in Sources */, + E9BD1A132FA000040000ABCD /* BackgroundIndexingPopoverViewModel.swift in Sources */, + E9BD1A172FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift in Sources */, + E9BD1A1B2FA0000A0000ABCD /* BackgroundIndexingToolbarItem.swift in Sources */, E9F11E0B2F123EEC0052B0A3 /* SidebarRootCoordinator.swift in Sources */, E921246B2F447BA1007481E4 /* ExportingConfigurationViewModel.swift in Sources */, E99E61612C129DC2002C1A3D /* ContentTextViewController.swift in Sources */, diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/AppDelegate.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/AppDelegate.swift index fd328820..aae91c2b 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/AppDelegate.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/AppDelegate.swift @@ -11,9 +11,13 @@ import RuntimeViewerArchitectures import RuntimeViewerMCPBridge import RuntimeViewerHelperClient +@MainActor @Loggable(.private) @main final class AppDelegate: NSObject, NSApplicationDelegate { + @Dependency(\.appRouter) + private var appRouter + @Dependency(\.settings) private var settings @@ -65,7 +69,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task.detached { do { let store = try OSLogStore(scope: .currentProcessIdentifier) - let position = store.position(date: Self.launchDate) + let position = await store.position(date: Self.launchDate) let entries = try store.getEntries(at: position) var content = "" @@ -148,7 +152,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } @IBAction func showSettings(_ sender: Any?) { - SettingsWindowController.shared.showWindow(nil) + appRouter.trigger(.settings) } @IBAction func showSimulatorInstaller(_ sender: Any?) { diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift index 68cae6d1..8ec84f9f 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift @@ -18,6 +18,12 @@ final class Document: NSDocument { override func makeWindowControllers() { addWindowController(mainCoordinator.windowController) + documentState.backgroundIndexingCoordinator.documentDidOpen() + } + + override func close() { + documentState.backgroundIndexingCoordinator.documentWillClose() + super.close() } override func data(ofType typeName: String) throws -> Data { diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift new file mode 100644 index 00000000..e4a2a29b --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift @@ -0,0 +1,46 @@ +import RuntimeViewerCore +import RxAppKit + +enum BackgroundIndexingNode: Hashable { + case section(SectionKind, batches: [BackgroundIndexingNode]) + case batch(RuntimeIndexingBatch, items: [BackgroundIndexingNode]) + case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) + + enum SectionKind: Hashable { + case active + case history + } +} + +extension BackgroundIndexingNode: OutlineNodeType { + var children: [BackgroundIndexingNode] { + switch self { + case .section(_, let batches): return batches + case .batch(_, let items): return items + case .item: return [] + } + } +} + +extension BackgroundIndexingNode: Differentiable { + enum Identifier: Hashable { + case section(SectionKind) + case batch(RuntimeIndexingBatchID) + case item(batchID: RuntimeIndexingBatchID, itemID: String) + } + + // Identifier for `.section` is intentionally kind-only — not derived + // from children. RxAppKit's staged changeset detects child insertions + // and removals as nested diffs without recreating the section row, + // which preserves the user's expand / collapse state across updates. + var differenceIdentifier: Identifier { + switch self { + case .section(let kind, _): + return .section(kind) + case .batch(let batch, _): + return .batch(batch.id) + case .item(let batchID, let item): + return .item(batchID: batchID, itemID: item.id) + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift new file mode 100644 index 00000000..324e472d --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -0,0 +1,531 @@ +import AppKit +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerSettingsUI +import RuntimeViewerUI +import RxCocoa +import RxSwift +import SnapKit + +final class BackgroundIndexingPopoverViewController: UXKitViewController { + // MARK: - Relays + + private let cancelBatchRelay = PublishRelay() + + private let (scrollView, outlineView): (ScrollView, OutlineView) = OutlineView.scrollableSingleColumnOutlineView() + + // MARK: - Views + + private let titleLabel = Label("Background Indexing").then { + $0.font = .systemFont(ofSize: 13, weight: .semibold) + } + + private let subtitleLabel = Label("").then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .secondaryLabelColor + } + + private let headerSeparator = NSBox().then { + $0.boxType = .separator + } + + private let footerSeparator = NSBox().then { + $0.boxType = .separator + } + + private let emptyDisabledView = Label("Background indexing is disabled").then { + $0.alignment = .center + $0.textColor = .secondaryLabelColor + } + + private let openSettingsButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Open Settings" + } + + private let emptyIdleView = Label("No active indexing tasks").then { + $0.alignment = .center + $0.textColor = .secondaryLabelColor + } + + private let cancelAllButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Cancel All" + } + + private let clearHistoryButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Clear History" + $0.isHidden = true + } + + private let closeButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Close" + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupLayout() + setupOutlineView() + preferredContentSize = NSSize(width: 380, height: 320) + } + + private func setupLayout() { + let headerStack = VStackView(alignment: .leading, spacing: 2) { + titleLabel + subtitleLabel + } + + let buttonStack = HStackView(spacing: 8) { + cancelAllButton + clearHistoryButton + closeButton + } + buttonStack.alignment = .centerY + + let emptyDisabledStack = VStackView(alignment: .centerX, spacing: 8) { + emptyDisabledView + openSettingsButton + } + + scrollView.documentView = outlineView + + contentView.hierarchy { + headerStack + headerSeparator + scrollView + emptyDisabledStack + emptyIdleView + footerSeparator + buttonStack + } + + headerStack.snp.makeConstraints { make in + make.top.equalToSuperview().inset(12) + make.leading.trailing.equalToSuperview().inset(16) + } + + headerSeparator.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview() + make.height.equalTo(1) + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(headerSeparator.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview().inset(12) + make.bottom.equalTo(footerSeparator.snp.top).offset(-10) + } + + emptyDisabledStack.snp.makeConstraints { make in + make.center.equalTo(scrollView) + make.width.lessThanOrEqualTo(scrollView).offset(-32) + } + + emptyIdleView.snp.makeConstraints { make in + make.center.equalTo(scrollView) + } + + footerSeparator.snp.makeConstraints { make in + make.bottom.equalTo(buttonStack.snp.top).offset(-10) + make.leading.trailing.equalToSuperview() + make.height.equalTo(1) + } + + buttonStack.snp.makeConstraints { make in + make.trailing.bottom.equalToSuperview().inset(12) + } + } + + private func setupOutlineView() { + outlineView.headerView = nil + outlineView.backgroundColor = .clear + outlineView.usesAutomaticRowHeights = true + } + + // MARK: - Bindings + + override func setupBindings(for viewModel: BackgroundIndexingPopoverViewModel) { + super.setupBindings(for: viewModel) + + let input = BackgroundIndexingPopoverViewModel.Input( + cancelBatch: cancelBatchRelay.asSignal(), + cancelAll: cancelAllButton.rx.click.asSignal(), + clearHistory: clearHistoryButton.rx.click.asSignal(), + openSettings: openSettingsButton.rx.click.asSignal(), + close: closeButton.rx.click.asSignal() + ) + let output = viewModel.transform(input) + + outlineView.rx.setDelegate(self).disposed(by: rx.disposeBag) + + output.subtitle + .drive(subtitleLabel.rx.stringValue) + .disposed(by: rx.disposeBag) + + output.isEnabled + .drive(emptyDisabledView.rx.isHidden) + .disposed(by: rx.disposeBag) + + output.isEnabled + .drive(openSettingsButton.rx.isHidden) + .disposed(by: rx.disposeBag) + + output.hasAnyHistory.not() + .drive(clearHistoryButton.rx.isHidden) + .disposed(by: rx.disposeBag) + + let hasAnyContent = Driver.combineLatest(output.hasAnyBatch, output.hasAnyHistory) { + $0 || $1 + } + + Driver.combineLatest(output.isEnabled, hasAnyContent) { enabled, hasContent in + !enabled || hasContent + } + .drive(emptyIdleView.rx.isHidden) + .disposed(by: rx.disposeBag) + + Driver.combineLatest(output.isEnabled, hasAnyContent) { enabled, hasContent in + !enabled || !hasContent + } + .drive(scrollView.rx.isHidden) + .disposed(by: rx.disposeBag) + + // Cell provider only handles cell creation + binding. Live updates + // happen through per-cell driver subscriptions because RxAppKit's + // staged-changeset path calls `reloadItem(_:)` (redraw only, no + // `viewFor:item:` re-invocation) for content updates. + output.nodes.drive(outlineView.rx.nodes) { [weak self] (outlineView: NSOutlineView, _: NSTableColumn?, node: BackgroundIndexingNode) -> NSView? in + switch node { + case .section(let kind, let batches): + let cell = outlineView.box.makeView(ofClass: SectionHeaderCellView.self) + cell.configure(kind: kind, count: batches.count) + return cell + case .batch(let batch, _): + let cell = outlineView.box.makeView(ofClass: BatchCellView.self) + cell.bind( + batch: viewModel.batch(for: batch.id), + onCancel: { [weak self] in + guard let self else { return } + cancelBatchRelay.accept(batch.id) + } + ) + return cell + case .item(let batchID, let item): + let cell = outlineView.box.makeView(ofClass: ItemCellView.self) + cell.bind(item: viewModel.item(for: batchID, itemID: item.id)) + return cell + } + } + .disposed(by: rx.disposeBag) + + output.nodes.driveOnNext { [weak self] nodes in + guard let self else { return } + // Auto-expand only the ACTIVE section and its batches. HISTORY stays + // collapsed by default; once the user expands it, NSOutlineView + // preserves that state across diffs (the section identifier is + // kind-only, see BackgroundIndexingNode.differenceIdentifier). + for node in nodes { + if case .section(.active, _) = node { + outlineView.expandItem(node, expandChildren: true) + } + } + } + .disposed(by: rx.disposeBag) + } +} + +extension BackgroundIndexingPopoverViewController: NSOutlineViewDelegate { + func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { + return false + } +} + +extension BackgroundIndexingPopoverViewController { + private final class SectionHeaderCellView: TableCellView { + private let titleLabel = Label("").then { + $0.font = .systemFont(ofSize: 11, weight: .semibold) + $0.textColor = .secondaryLabelColor + } + + private let countLabel = Label("").then { + $0.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + $0.textColor = .tertiaryLabelColor + } + + override func setup() { + super.setup() + + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + countLabel.setContentHuggingPriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let stack = HStackView(alignment: .centerY, spacing: 6) { + titleLabel + countLabel + } + + addSubview(stack) + stack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(4) + make.bottom.equalToSuperview().offset(-4) + make.leading.trailing.equalToSuperview() + } + } + + func configure(kind: BackgroundIndexingNode.SectionKind, count: Int) { + switch kind { + case .active: titleLabel.stringValue = "ACTIVE" + case .history: titleLabel.stringValue = "HISTORY" + } + countLabel.stringValue = "\(count)" + } + } + + private final class BatchCellView: TableCellView { + private let titleLabel = Label("").then { + $0.font = .systemFont(ofSize: 12, weight: .semibold) + } + + private let countLabel = Label("").then { + $0.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + $0.textColor = .secondaryLabelColor + } + + private let progressIndicator = NSProgressIndicator().then { + $0.style = .bar + $0.isIndeterminate = false + $0.controlSize = .small + $0.minValue = 0 + } + + private let cancelButton = NSButton().then { + $0.bezelStyle = .accessoryBar + $0.isBordered = false + $0.image = NSImage( + systemSymbolName: "xmark.circle", + accessibilityDescription: "Cancel batch" + ) + $0.imagePosition = .imageOnly + $0.toolTip = "Cancel this batch" + $0.contentTintColor = .secondaryLabelColor + } + + private var onCancel: (() -> Void)? + + override func setup() { + super.setup() + + cancelButton.target = self + cancelButton.action = #selector(cancelButtonClicked) + + // Title takes remaining space; count + cancel hug their intrinsic size. + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + countLabel.setContentHuggingPriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + cancelButton.setContentHuggingPriority(.required, for: .horizontal) + cancelButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + let topRow = HStackView(alignment: .centerY, spacing: 6) { + titleLabel + countLabel + cancelButton + } + + let stack = VStackView(spacing: 4) { + topRow + progressIndicator + } + + addSubview(stack) + stack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(4) + make.bottom.equalToSuperview().offset(-4) + make.leading.trailing.equalToSuperview() + } + + // VStackView's default alignment is .centerX, which leaves children + // sized by their horizontal intrinsicContentSize. NSProgressIndicator + // and HStackView return noIntrinsicMetric horizontally, so without + // these explicit width pins Auto Layout reports an ambiguous width + // for the cell. Pin both rows to the stack's leading/trailing. + topRow.snp.makeConstraints { make in + make.leading.trailing.equalTo(stack) + } + progressIndicator.snp.makeConstraints { make in + make.leading.trailing.equalTo(stack) + } + } + + func bind(batch: Driver, + onCancel: @escaping () -> Void) { + // Reset on every bind so cell reuse drops the prior subscription. + rx.disposeBag = DisposeBag() + self.onCancel = onCancel + + batch.driveOnNext { [weak self] batch in + guard let self else { return } + update(with: batch) + } + .disposed(by: rx.disposeBag) + } + + private func update(with batch: RuntimeIndexingBatch) { + cancelButton.isHidden = batch.isFinished + titleLabel.stringValue = Self.title(for: batch.reason) + countLabel.attributedStringValue = Self.countText(for: batch) + + progressIndicator.maxValue = max(Double(batch.totalCount), 1) + progressIndicator.doubleValue = Double(batch.finishedCount) + // Only meaningful while the batch is active; finished batches drop + // the bar so the row collapses to the title row alone. + progressIndicator.isHidden = batch.isFinished + } + + /// `succeededCount/totalCount` reads as "X succeeded out of Y" — the + /// failure / cancellation tail is rendered in a tinted suffix so a + /// fully-failed batch can no longer masquerade as a fully-succeeded + /// one (which the older `finishedCount/totalCount` label did). + private static func countText(for batch: RuntimeIndexingBatch) -> NSAttributedString { + let font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + let prefix = NSMutableAttributedString( + string: "\(batch.succeededCount)/\(batch.totalCount)", + attributes: [ + .font: font, + .foregroundColor: NSColor.secondaryLabelColor + ] + ) + if batch.failedCount > 0 { + prefix.append(NSAttributedString( + string: " · \(batch.failedCount) failed", + attributes: [ + .font: font, + .foregroundColor: NSColor.systemRed + ] + )) + } + if batch.cancelledCount > 0 { + prefix.append(NSAttributedString( + string: " · \(batch.cancelledCount) cancelled", + attributes: [ + .font: font, + .foregroundColor: NSColor.systemOrange + ] + )) + } + return prefix + } + + @objc private func cancelButtonClicked() { + onCancel?() + } + + private static func title(for reason: RuntimeIndexingBatchReason) -> String { + switch reason { + case .appLaunch: + return "App launch indexing" + case .imageLoaded(let path): + return "\((path as NSString).lastPathComponent) deps" + case .settingsEnabled: + return "Settings enabled" + case .manual: + return "Manual indexing" + } + } + } + + private final class ItemCellView: TableCellView { + /// Raw NSImageView (not the project's ImageView wrapper): the wrapper + /// sets `wantsUpdateLayer = true`, which flattens the image into + /// `layer.contents` and destroys the per-part sublayer hierarchy that + /// SF Symbol effects (`.rotate`, `.bounce`, etc.) depend on. + private let iconImageView = NSImageView().then { + $0.imageScaling = .scaleProportionallyDown + } + + private let titleLabel = Label("") + + override func setup() { + super.setup() + + iconImageView.setContentHuggingPriority(.required, for: .horizontal) + iconImageView.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let stack = HStackView(alignment: .centerY, spacing: 6) { + iconImageView + titleLabel + } + + addSubview(stack) + + stack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + iconImageView.snp.makeConstraints { make in + make.size.equalTo(12) + } + + titleLabel.maximumNumberOfLines = 1 + } + + func bind(item: Driver) { + rx.disposeBag = DisposeBag() + item.driveOnNext { [weak self] item in + guard let self else { return } + update(with: item) + } + .disposed(by: rx.disposeBag) + } + + private func update(with item: RuntimeIndexingTaskItem) { + iconImageView.image = Self.iconImage(for: item.state) + iconImageView.contentTintColor = Self.iconTint(for: item.state) + + // Cell can be reused or transition between states; clear any prior + // effect before deciding whether to attach a fresh one. + iconImageView.removeAllSymbolEffects() + if case .running = item.state { + iconImageView.addSymbolEffect(.rotate, options: .repeating) + } + + let nameSource = item.resolvedPath ?? item.id + let name = (nameSource as NSString).lastPathComponent + var text = name + if case .failed(let message) = item.state { + text = "\(item.id) — \(message)" + } + if item.hasPriorityBoost, case .pending = item.state { + text += " (priority)" + } + titleLabel.stringValue = text + } + + private static func iconImage(for state: RuntimeIndexingTaskState) -> NSImage? { + let symbolName: String + switch state { + case .pending: symbolName = "circle" + case .running: symbolName = "arrow.triangle.2.circlepath" + case .completed: symbolName = "checkmark.circle.fill" + case .failed: symbolName = "xmark.circle.fill" + case .cancelled: symbolName = "minus.circle.fill" + } + return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + } + + private static func iconTint(for state: RuntimeIndexingTaskState) -> NSColor { + switch state { + case .pending: return .tertiaryLabelColor + case .running: return .systemBlue + case .completed: return .systemGreen + case .failed: return .systemRed + case .cancelled: return .systemOrange + } + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift new file mode 100644 index 00000000..499f81d4 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -0,0 +1,201 @@ +import Foundation +import Observation +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerSettings +import RxCocoa +import RxSwift + +final class BackgroundIndexingPopoverViewModel: ViewModel { + @Observed private(set) var nodes: [BackgroundIndexingNode] = [] + @Observed private(set) var isEnabled: Bool = false + @Observed private(set) var hasAnyBatch: Bool = false + @Observed private(set) var hasAnyHistory: Bool = false + @Observed private(set) var subtitle: String = "" + + private let coordinator: RuntimeBackgroundIndexingCoordinator + + init( + documentState: DocumentState, + router: any Router, + coordinator: RuntimeBackgroundIndexingCoordinator + ) { + self.coordinator = coordinator + super.init(documentState: documentState, router: router) + } + + struct Input { + let cancelBatch: Signal + let cancelAll: Signal + let clearHistory: Signal + let openSettings: Signal + let close: Signal + } + + struct Output { + let nodes: Driver<[BackgroundIndexingNode]> + let isEnabled: Driver + let hasAnyBatch: Driver + let hasAnyHistory: Driver + let subtitle: Driver + } + + func transform(_ input: Input) -> Output { + Observable.combineLatest( + coordinator.batchesObservable, + coordinator.historyObservable + ) + .map { active, history in + (Self.renderNodes(active: active, history: history), active, history) + } + .asDriver(onErrorJustReturn: ([], [], [])) + .driveOnNext { [weak self] newNodes, active, history in + guard let self else { return } + nodes = newNodes + hasAnyBatch = !active.isEmpty + hasAnyHistory = !history.isEmpty + } + .disposed(by: rx.disposeBag) + + coordinator.aggregateStateObservable + .asDriver(onErrorDriveWith: .empty()) + .driveOnNext { [weak self] state in + guard let self else { return } + subtitle = Self.subtitleFor(state) + } + .disposed(by: rx.disposeBag) + + // ViewModel base class is `@MainActor`, so `transform` runs on the + // main actor; we can subscribe synchronously and seed the initial + // value below. + bootstrapIsEnabledObservation() + + input.openSettings.emit(to: appRouter.rx.trigger(.settings)).disposed(by: rx.disposeBag) + + input.close.emit(to: router.rx.trigger(.dismiss)).disposed(by: rx.disposeBag) + + input.cancelBatch.emitOnNext { [weak self] id in + guard let self else { return } + coordinator.cancelBatch(id) + } + .disposed(by: rx.disposeBag) + + input.cancelAll.emitOnNext { [weak self] in + guard let self else { return } + coordinator.cancelAllBatches() + } + .disposed(by: rx.disposeBag) + + input.clearHistory.emitOnNext { [weak self] in + guard let self else { return } + coordinator.clearHistory() + } + .disposed(by: rx.disposeBag) + + return Output( + nodes: $nodes.asDriver(), + isEnabled: $isEnabled.asDriver(), + hasAnyBatch: $hasAnyBatch.asDriver(), + hasAnyHistory: $hasAnyHistory.asDriver(), + subtitle: $subtitle.asDriver() + ) + } + + /// Fine-grained driver scoped to a single batch. Cells subscribe to this + /// directly because RxAppKit's `elementUpdated` path uses + /// `NSOutlineView.reloadItem(_:)`, which only marks the row for redisplay — + /// it does not re-invoke `viewFor:item:`, so the cell would otherwise show + /// stale data until scroll/click forces a relayout. + /// Searches both active and history relays so HISTORY rows render their + /// archived final state instead of the empty placeholder. + func batch(for id: RuntimeIndexingBatchID) -> Driver { + Observable.combineLatest( + coordinator.batchesObservable, + coordinator.historyObservable + ) + .compactMap { active, history in + active.first(where: { $0.id == id }) + ?? history.first(where: { $0.id == id }) + } + .distinctUntilChanged() + .asDriver(onErrorDriveWith: .empty()) + } + + /// Same rationale as `batch(for:)`, scoped to one item inside a batch. + func item(for batchID: RuntimeIndexingBatchID, itemID: String) + -> Driver { + Observable.combineLatest( + coordinator.batchesObservable, + coordinator.historyObservable + ) + .compactMap { active, history in + let batch = active.first(where: { $0.id == batchID }) + ?? history.first(where: { $0.id == batchID }) + return batch?.items.first(where: { $0.id == itemID }) + } + .distinctUntilChanged() + .asDriver(onErrorDriveWith: .empty()) + } + + /// Seeds `isEnabled` from settings once and registers the observation. + /// Mirrors `RuntimeBackgroundIndexingCoordinator.bootstrapSettingsObservation`'s + /// "seed on bootstrap, only re-register on change" pattern. + private func bootstrapIsEnabledObservation() { + isEnabled = settings.indexing.backgroundMode.isEnabled + registerIsEnabledObservation() + } + + /// Registers a one-shot Observation tracker. Re-registers itself on every + /// change because Observation's `withObservationTracking` is single-fire — + /// the `onChange` closure runs once, then the tracker is gone. + private func registerIsEnabledObservation() { + withObservationTracking { + _ = settings.indexing.backgroundMode.isEnabled + } onChange: { [weak self] in + // `onChange` fires off the main actor right after a mutation; + // hop back to the main actor to read the latest value and + // re-register the observation. + Task { @MainActor [weak self] in + guard let self else { return } + self.isEnabled = self.settings.indexing.backgroundMode.isEnabled + self.registerIsEnabledObservation() + } + } + } + + private static func renderNodes( + active: [RuntimeIndexingBatch], + history: [RuntimeIndexingBatch] + ) + -> [BackgroundIndexingNode] { + let activeBatchNodes = active.map(makeBatchNode) + var nodes: [BackgroundIndexingNode] = [.section(.active, batches: activeBatchNodes)] + // History section is omitted entirely when empty so it doesn't clutter + // the popover with an empty header. Active is always present so the + // user always has the "ACTIVE" group as context. + if !history.isEmpty { + let historyBatchNodes = history.map(makeBatchNode) + nodes.append(.section(.history, batches: historyBatchNodes)) + } + return nodes + } + + private static func makeBatchNode(_ batch: RuntimeIndexingBatch) + -> BackgroundIndexingNode { + let itemNodes = batch.items.map { item in + BackgroundIndexingNode.item(batchID: batch.id, item: item) + } + return .batch(batch, items: itemNodes) + } + + private static func subtitleFor( + _ state: RuntimeBackgroundIndexingCoordinator.AggregateState + ) -> String { + guard state.hasActiveBatch, let progress = state.progress else { + return "Idle" + } + let percent = Int(progress * 100) + return "\(percent)% complete" + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift new file mode 100644 index 00000000..3829ab16 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift @@ -0,0 +1,13 @@ +import AppKit +import RuntimeViewerUI + +final class BackgroundIndexingToolbarItem: MainToolbarController.IconButtonToolbarItem { + static let identifier = NSToolbarItem.Identifier("backgroundIndexing") + + init() { + super.init(itemIdentifier: Self.identifier, icon: .squareStack3dDownRight) + label = "Indexing" + paletteLabel = "Background Indexing" + toolTip = "Background indexing" + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift index a219eda2..6ce6c993 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift @@ -196,10 +196,5 @@ final class MCPStatusPopoverViewController: AppKitViewController: ViewModel { @Observed private(set) var state: MCPServerState = MCPService.shared.serverState - private let openSettingsRelay = PublishRelay() - struct Input { let actionButtonClick: Signal let copyPortClick: Signal @@ -64,7 +62,6 @@ final class MCPStatusPopoverViewModel: ViewModel { struct Output { let state: Driver - let openSettings: Signal } func transform(_ input: Input) -> Output { @@ -77,7 +74,7 @@ final class MCPStatusPopoverViewModel: ViewModel { guard let self else { return } switch state { case .disabled: - openSettingsRelay.accept(()) + appRouter.trigger(.settings) case .stopped: MCPService.shared.start(for: AppMCPBridgeDocumentProvider()) case .running: @@ -103,8 +100,7 @@ final class MCPStatusPopoverViewModel: ViewModel { .disposed(by: rx.disposeBag) return Output( - state: $state.asDriver(), - openSettings: openSettingsRelay.asSignal() + state: $state.asDriver() ) } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift index 32da1303..07f923c9 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift @@ -66,6 +66,15 @@ final class MainCoordinator: SceneCoordinator, LateRe let viewModel = MCPStatusPopoverViewModel(documentState: documentState, router: self) viewController.setupBindings(for: viewModel) return .presentOnRoot(viewController, mode: .asPopover(relativeToRect: sender.bounds, ofView: sender, preferredEdge: .maxY, behavior: .transient)) + case .backgroundIndexing(let sender): + let viewController = BackgroundIndexingPopoverViewController() + let viewModel = BackgroundIndexingPopoverViewModel( + documentState: documentState, + router: self, + coordinator: documentState.backgroundIndexingCoordinator + ) + viewController.setupBindings(for: viewModel) + return .presentOnRoot(viewController, mode: .asPopover(relativeToRect: sender.bounds, ofView: sender, preferredEdge: .maxY, behavior: .transient)) case .loadFramework: return .none() case .attachToProcess: diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift index 61a6be57..1468efaf 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift @@ -16,6 +16,7 @@ public enum MainRoute: Routable { case loadFramework case attachToProcess case mcpStatus(sender: NSView) + case backgroundIndexing(sender: NSView) case dismiss case exportInterfaces } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift index ecb146fd..543c8ae3 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift @@ -186,6 +186,10 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { $0.label = "MCP Status" } + let backgroundIndexingItem = BackgroundIndexingToolbarItem().then { + $0.label = "Background Indexing" + } + init(delegate: Delegate) { self.delegate = delegate self.toolbar = NSToolbar() @@ -215,6 +219,7 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { .Main.save, .Main.share, .Main.mcpStatus, + .Main.backgroundIndexing, .inspectorTrackingSeparator, .flexibleSpace, .toggleInspector, @@ -240,6 +245,7 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { .Main.loadFrameworks, .Main.attach, .Main.mcpStatus, + .Main.backgroundIndexing, ] } @@ -271,6 +277,8 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { return attachItem case .Main.mcpStatus: return mcpStatusItem + case .Main.backgroundIndexing: + return backgroundIndexingItem default: return nil } @@ -299,5 +307,6 @@ extension NSToolbarItem.Identifier { static let helperStatus: NSToolbarItem.Identifier = "helperStatus" static let attach: NSToolbarItem.Identifier = "attach" static let mcpStatus: NSToolbarItem.Identifier = "mcpStatus" + static let backgroundIndexing: NSToolbarItem.Identifier = "backgroundIndexing" } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift index aa22bd8d..8f4db5b1 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift @@ -49,6 +49,7 @@ final class MainViewModel: ViewModel { // let installHelperClick: Signal let attachToProcessClick: Signal let mcpStatusClick: Signal + let backgroundIndexingClick: Signal let frameworksSelected: Signal<[URL]> let saveLocationSelected: Signal } @@ -170,7 +171,9 @@ final class MainViewModel: ViewModel { input.generationOptionsClick.emit(with: self) { $0.router.trigger(.generationOptions(sender: $1)) }.disposed(by: rx.disposeBag) input.mcpStatusClick.emit(with: self) { $0.router.trigger(.mcpStatus(sender: $1)) }.disposed(by: rx.disposeBag) - + + input.backgroundIndexingClick.emit(with: self) { $0.router.trigger(.backgroundIndexing(sender: $1)) }.disposed(by: rx.disposeBag) + let requestSaveLocation = input.saveClick .withLatestFrom($selectedRuntimeObject.asSignalOnErrorJustComplete()) .filterNil() diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift index a3bfa347..9337ebdc 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift @@ -4,6 +4,8 @@ import RuntimeViewerArchitectures import RuntimeViewerApplication import RuntimeViewerCommunication import RuntimeViewerCatalystExtensions +import RxCocoa +import RxSwift import UniformTypeIdentifiers final class MainWindow: NSWindow { @@ -106,6 +108,7 @@ final class MainWindowController: XiblessWindowController { loadFrameworksClick: toolbarController.loadFrameworksItem.button.rx.click.asSignal(), attachToProcessClick: toolbarController.attachItem.button.rx.click.asSignal(), mcpStatusClick: toolbarController.mcpStatusItem.button.rx.clickWithSelf.asSignal().map { $0 }, + backgroundIndexingClick: toolbarController.backgroundIndexingItem.button.rx.clickWithSelf.asSignal().map { $0 }, frameworksSelected: frameworksSelectedRelay.asSignal(), saveLocationSelected: saveLocationSelectedRelay.asSignal() )