From d57cce552b043e9f595ce3401735fc3c1237432e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 11:54:42 +0800 Subject: [PATCH 01/78] docs(background-indexing): promote design to Evolution 0002 and align plan/review Move the design draft into Evolution proposal 0002 (Accepted), update the implementation plan to reflect the review decisions, and sync the review document with the finalized scope. --- .../Evolution/0002-background-indexing.md | 753 +++++++++++++++++ .../2026-04-24-background-indexing-design.md | 630 -------------- .../2026-04-24-background-indexing-plan.md | 789 +++++++++++------- .../2026-04-24-background-indexing-review.md | 235 +++--- 4 files changed, 1357 insertions(+), 1050 deletions(-) create mode 100644 Documentations/Evolution/0002-background-indexing.md delete mode 100644 Documentations/Plans/2026-04-24-background-indexing-design.md diff --git a/Documentations/Evolution/0002-background-indexing.md b/Documentations/Evolution/0002-background-indexing.md new file mode 100644 index 00000000..cf69f2db --- /dev/null +++ b/Documentations/Evolution/0002-background-indexing.md @@ -0,0 +1,753 @@ +# 0002 - Background Indexing + +- **Status**: Accepted +- **Author**: JH +- **Date**: 2026-04-24 +- **Last Updated**: 2026-04-24 + +## Summary + +Add an opt-in **Background Indexing** feature that eagerly parses ObjC and Swift metadata for the dependency closure of images already loaded in the target process. Work is driven by a per-`RuntimeEngine` Swift-Concurrency actor (`RuntimeBackgroundIndexingManager`), configured from Settings, surfaced in a Toolbar popover with live progress, and cancellable on demand. + +## Motivation + +Runtime Viewer currently indexes an image (parses ObjC/Swift metadata) only when the user explicitly opens it. For images that the target process has already loaded via dyld — e.g. UIKit, Foundation, and their transitive dependency closure — the first lookup pays a visible parsing cost because the work was never amortized. + +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 + +- 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. + +## Proposed Solution + +### Background Context + +Source of truth captured during brainstorming and code verification: + +- `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]` gives the dependency list. MachOKit collapses `LC_LOAD_WEAK_DYLIB` into `DependType.load`, so only `.load`, `.reexport`, `.upwardLoad`, `.lazyLoad` are ever observed. +- The `Semaphore` package (`groue/Semaphore`) is already resolved for `RuntimeViewerCommunication`. It must be re-declared as an explicit product dependency of the `RuntimeViewerCore` target before the manager can import it. +- `MCPStatusPopoverViewController` + `MCPStatusToolbarItem` are the template for a Toolbar-anchored, RxSwift-driven popover. +- `RuntimeEngine` exposes a `request(local:remote:)` dispatch primitive (`RuntimeEngine.swift:468`) used by every public method whose result depends on the target process (local vs. XPC/TCP). All new public engine methods introduced here use the same primitive. + +### Terminology: Loaded vs. Indexed + +This distinction is load-bearing. + +- **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 **successfully-parsed** cached section for the image's path. Failure to parse does **not** count as indexed, which means failed paths will be retried on the next batch (see alternative D for why this is intentional). + +A new API — `RuntimeEngine.isImageIndexed(path:)` — 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 (ViewModel) +│ + 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 +│ · Observes Settings.backgroundIndexing via withObservationTracking +│ · 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:) 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, 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 (all Hashable): │ +│ RuntimeIndexingBatch, RuntimeIndexingBatchID, │ +│ RuntimeIndexingTaskItem, RuntimeIndexingTaskState, │ +│ RuntimeIndexingEvent, RuntimeIndexingBatchReason, │ +│ ResolvedDependency │ +│ │ +│ Utility: │ +│ DylibPathResolver — resolves @rpath / @executable_path / │ +│ @loader_path install names against rpaths + image path │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### Remote Dispatch Model + +All new `RuntimeEngine` public methods — `isImageIndexed`, `mainExecutablePath`, `loadImageForBackgroundIndexing` — are wrapped in the existing `request(local:remote:)` primitive: + +```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) + } +} +``` + +Three new `CommandNames` cases — `.isImageIndexed`, `.mainExecutablePath`, `.loadImageForBackgroundIndexing` — are added, and the server-side handler table (`RuntimeEngine.swift:276-302`) gains: + +```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` itself runs **server-side only**. The manager's events, batches, and cancellation APIs are not mirrored over XPC in this proposal; the UI consumes manager state from the hosting process via the coordinator. Mirroring is left to a follow-up if needed. + +### Components + +#### `RuntimeBackgroundIndexingManager` (actor) + +Owns every running batch and every event stream. Created by `RuntimeEngine` at init, holds an unowned reference back to the engine. + +```swift +public actor RuntimeBackgroundIndexingManager { + public nonisolated var events: AsyncStream { ... } + + 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] +} +``` + +#### Sendable value types + +```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 // image path (install name if unresolved) + 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) +} +``` + +All value types are `Hashable` so they compose into `BackgroundIndexingNode: Hashable` without extra conformance work. + +#### `RuntimeBackgroundIndexingCoordinator` + +Created once per Document (held by `DocumentState`). Responsibilities: + +1. Observe `Settings.backgroundIndexing` via `withObservationTracking` (see Settings section) → enable / disable / restart. +2. Listen for the engine's `imageDidLoadPublisher` → 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. **Finished batches that contain any failed item are retained** in `batchesRelay` until the user explicitly dismisses them via "Clear Failed" in the popover; clean finishes and cancels drop out immediately. +6. Expose `aggregateStateDriver: Driver`. `hasFailures` is derived from the retained failed batches. +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 = try 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 path completes) + → Engine emits imageDidLoadPublisher(path) + → 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, adds to priorityBoostPaths set + 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 (via `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() // re-register + } + } + +handleSettingsChange: + isEnabled false → true: + for every open Document: run Scenario A (root = mainExecutablePath) + (do NOT replay historical loadImage calls) + isEnabled true → false: + await manager.cancelAllBatches() + depth / maxConcurrency change while enabled: + no-op against running batches; values apply to the next startBatch. +``` + +Rationale: `Settings` is declared `@Observable`, so `withObservationTracking` is the native fit. Re-registering on each change is the documented one-shot-observer recovery pattern; it keeps the observer alive across each settings mutation without adding Combine infrastructure. + +#### 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 + 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: [(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 +} +``` + +`Array.removeFirst()` is sufficient for the depths we allow (≤ 5); a deque is not warranted. + +#### Dependency type filter + +- **Included**: `.load`, `.reexport`, `.upwardLoad`. +- **Skipped**: `.lazyLoad` — lazy-loaded dylibs may never actually load at runtime, so eagerly parsing them is speculative and wasteful. + +`LC_LOAD_WEAK_DYLIB` is decoded by MachOKit as `DependType.load` (see `MachOImage.swift:168-173`); the `.weakLoad` enum case never arrives from `dependencies`, so no explicit branch is needed. + +#### Path resolution (`DylibPathResolver`) + +Install names come in four 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` maps to a `.failed("path unresolved")` task item that does not recurse. + +### 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 (sketch) +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 an `Array` of pending paths and a `Set` of priority-boost members. `prioritize(imagePath:)` only mutates the set (and emits `.taskPrioritized`); the pop helper scans the pending array for the first boosted path, falling back to the array head when none is boosted. 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. + +#### `AsyncSemaphore` + +From `groue/Semaphore`. The dependency is already resolved at package level but is only declared for `RuntimeViewerCommunication`; this proposal adds an explicit `.product(name: "Semaphore", package: "Semaphore")` entry to the `RuntimeViewerCore` target's dependency list. + +#### 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` / `.batchCancelled` event so the sidebar picks up the newly-indexed icons in a single update. + +### Settings + +#### `BackgroundIndexing` struct (`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` class (which is `@Observable`) as: + +```swift +@Default(BackgroundIndexing.default) +public var backgroundIndexing: BackgroundIndexing = .init() { + didSet { scheduleAutoSave() } +} +``` + +Persisted by the existing `SettingsFileSystemStorage` auto-save. No Combine publisher is added to `Settings`. + +#### `BackgroundIndexingSettingsView` (SwiftUI) + +At `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift`. Reached via a new `SettingsPage.backgroundIndexing` 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 stays in the popover footer, not in Settings. + +#### Settings change propagation + +The coordinator subscribes via `withObservationTracking` on `Settings.shared.backgroundIndexing`, re-registering inside `onChange`. See Scenario E for the concrete flow. + +### UI: Toolbar Item + Popover + +#### `BackgroundIndexingToolbarItem` + +`NSToolbarItem` subclass registered in `MainToolbarController.swift`. Identifier `backgroundIndexing`. Placed next to the existing `mcpStatus` item in default and allowed identifier lists (the existing case is literally `mcpStatus(sender:)`, not `mcpStatusPopover`). + +`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 triggers the **existing** `MainRoute` surface with a new case: + +```swift +case backgroundIndexing(sender: NSView) +``` + +Note the name has **no `Popover` suffix**, matching the sibling `mcpStatus(sender:)` precedent. + +#### `BackgroundIndexingPopoverViewController` + +Base class `UXKitViewController`. The ViewModel is `ViewModel` — there is **no** separate `BackgroundIndexingPopoverRoute`. All routing goes through `MainRoute` cases (`openSettings`, `dismiss`, etc.) that already exist at the main level. 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), `Clear Failed` button (visible only when there are retained failed batches), and `Close` button. + +`BackgroundIndexingNode`: + +```swift +enum BackgroundIndexingNode: Hashable { + case batch(RuntimeIndexingBatch) + case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) +} +``` + +Outline cells: + +- Batch row: title derived from `reason`, `"{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. + +Defensive outline-view data source branches use `preconditionFailure("unexpected outline item type")` rather than returning a zero-initialized batch, so mis-wired callers surface immediately. + +#### `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 clearFailed: Signal + let openSettings: Signal + } + struct Output { + let nodes: Driver<[BackgroundIndexingNode]> + let isEnabled: Driver + let hasAnyBatch: Driver + let subtitle: Driver + } + + func transform(_ input: Input) -> Output { ... } +} +``` + +`isEnabled` is kept in sync with `Settings.shared.backgroundIndexing.isEnabled` via the **same** `withObservationTracking` re-registration loop used by the coordinator — it is not read once in `transform` and forgotten. The popover's empty states therefore react to the Settings toggle while open. + +`input.openSettings.emitOnNext` fires `router.trigger(.openSettings)` — the existing `MainRoute.openSettings` case. + +### Error Handling + +| Failure site | Behavior | UI | +|---|---|---| +| `MachOImage(name: path)` returns nil during graph expansion | 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(path:)` requires **both** factories to have a successfully-cached entry. Failure to parse leaves no cache entry, so the path re-enters the next batch's frontier. This is intentional — see alternative D. + +### 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 verifies (and, if needed, introduces 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` then returns false and a future explicit load redoes 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. + +### Assumptions + +1. **`DocumentState.runtimeEngine` is immutable for the lifetime of a Document.** The property is declared `@Observed public var runtimeEngine: RuntimeEngine = .local` (`DocumentState.swift:10-11`) for historical reasons, but callers do not reassign it after Document creation. The coordinator captures `engine = documentState.runtimeEngine` once at init; if this assumption is violated, batches are dispatched to the wrong engine. A doc comment on the property reinforces this contract. + +2. **`RuntimeBackgroundIndexingManager` runs in the engine's hosting process only.** For remote (XPC / directTCP) sources, the *engine methods* are mirrored via `request { local } remote: { RPC }`, but the *manager* lives in the server-side engine's actor. UI clients consume manager state only from their local engine reference. + +3. **Settings mutation frequency is low.** `withObservationTracking` re-registration fires once per property mutation. Because Settings sliders / toggles run at human-UI cadence, the re-registration cost is negligible. + +### 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` (`@unchecked Sendable`) conforming to a new internal `BackgroundIndexingEngineRepresenting` protocol. + - Graph expansion at depth 0, 1, 2; already-indexed short-circuit. + - `prioritize` causes the next dispatch to pick a boosted path. **Timing-based assertions are replaced with event-order assertions** (`taskStarted` sequence) to avoid CI flakiness. + - `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 includes 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 + 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 +``` + +Note the absence of a `BackgroundIndexingPopoverRoute.swift` — routing is via `MainRoute`. + +#### Modified files + +``` +RuntimeViewerCore/Package.swift + + add .product(name: "Semaphore", package: "Semaphore") to RuntimeViewerCore target + +RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift + + BackgroundIndexing struct + +RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift + + backgroundIndexing property + +RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift + + SettingsPage.backgroundIndexing case and contentView branch + +RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift + + backgroundIndexingManager stored property (set at end of init) + + isImageIndexed(path:) with request/remote dispatch + + mainExecutablePath() with request/remote dispatch + + loadImageForBackgroundIndexing(at:) with request/remote dispatch + + imageDidLoadPublisher (PassthroughSubject) + + emit imageDidLoadSubject.send(path) on loadImage(at:) success + + access level bumped to internal on objcSectionFactory / swiftSectionFactory + + new CommandNames + setMessageHandlerBinding handlers for the three new methods + +RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift + + hasCachedSection(for:) inspector + + optional per-path in-flight dedupe (plan verifies) + +RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift + + backgroundIndexingCoordinator property + + doc comment asserting runtimeEngine immutability + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift + + backgroundIndexing(sender:) case (no "Popover" suffix) + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift + + backgroundIndexing item identifier + factory + + wireBackgroundIndexing(item:) hookup + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift + + backgroundIndexing(sender:) transition case + +RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift + + invoke coordinator.documentDidOpen / documentWillClose +``` + +All new files under `RuntimeViewerUsingAppKit/.../BackgroundIndexing/` must be added to the Xcode project manually (consistent with the MCPServer pattern noted in project memory). + +## Alternatives Considered + +### A. Subscribe to `Settings` via a new `Combine.PassthroughSubject` + +Add a `PassthroughSubject` to `Settings`, emit from `scheduleAutoSave`, and let the coordinator subscribe with Combine. Rejected because `Settings` is already `@Observable` — adding a parallel Combine channel would duplicate the source of truth and force future readers to pick one. `withObservationTracking` is the native fit and scales to the few properties we observe. + +### B. Separate `BackgroundIndexingPopoverRoute` enum + +Mirror the `MCPStatusPopover` structure and define a dedicated Route enum. Rejected because `MainCoordinator` is already bound to `SceneCoordinator`; adding a second, conditional `Router` conformance would not compile. Forwarding via a separate adapter was considered but is heavier than just adding a case to `MainRoute`, which costs one line. + +### C. Non-dispatching local-only engine extensions + +Keep `isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` as pure local reads (no `request { local } remote: { RPC }` wrapping). Rejected because this would silently return wrong data when the document targets a remote source (XPC / directTCP) — the local engine has no knowledge of the remote process's loaded images. + +### D. Cache empty/nil parse results to create an "attempted" bit + +Let `hasCachedSection(for:)` count failed parses as indexed, so failures are not retried. Rejected: the factory cache currently stores a successful `Section` value, and introducing a `Result` or parallel `attemptedFailures` set propagates through many call sites. The simpler semantics — "indexed" = "parsed successfully" — means failed paths retry on the next batch, which is acceptable given how rare deterministic-but-recoverable parse failures are in practice. + +### E. Drop finished/cancelled batches from the UI immediately + +Simpler reducer logic: when `.batchFinished` / `.batchCancelled` arrives, remove the batch from the coordinator relay and the popover forgets it existed. Rejected because failed batches carry actionable information; silently losing them means the toolbar's `hasFailures` indicator never surfaces. Instead, finished batches with any `.failed` item are retained until the user clicks `Clear Failed` in the popover. + +## Impact + +- **Breaking changes**: No. The feature is opt-in (default off) and does not alter the existing `loadImage(at:)` semantics. +- **Files affected**: see File Inventory above. +- **Migration needed**: No. Settings defaults are written by the existing `@Codable` path; absent keys fall back to the `@Default` values. + +## Decision Log + +| Date | Decision | Reason | +|------|----------|--------| +| 2026-04-24 | Created as Draft | Spec derived from brainstorming on opt-in, Swift-Concurrency-based background indexing for dyld-loaded dependency closures | +| 2026-04-24 | Settings subscription → `withObservationTracking` | `Settings` is `@Observable`; avoid parallel Combine channel | +| 2026-04-24 | `BackgroundIndexingPopoverRoute` merged into `MainRoute` | `MainCoordinator` is `SceneCoordinator`; conditional second conformance not compilable | +| 2026-04-24 | All new engine methods use `request { local } remote: { RPC }` | Remote (XPC / directTCP) sources would otherwise read local-process data | +| 2026-04-24 | `isImageIndexed` = "successfully parsed" only | Avoids Result-wrapping every factory cache entry; failed paths retry | +| 2026-04-24 | `DocumentState.runtimeEngine` treated as immutable | Coordinator captures engine once at init; reassignment is out of scope | +| 2026-04-24 | Finished batches with failures retained until dismissed | Preserves actionable failure information; drives toolbar `hasFailures` state | +| 2026-04-24 | Status → Accepted | Review decisions incorporated; plan regenerated to match | 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..37c40a1b 100644 --- a/Documentations/Plans/2026-04-24-background-indexing-plan.md +++ b/Documentations/Plans/2026-04-24-background-indexing-plan.md @@ -2,7 +2,7 @@ > **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. +**Goal:** Build the opt-in background indexing feature per [0002-background-indexing.md](../Evolution/0002-background-indexing.md) — a per-`RuntimeEngine` Swift-Concurrency `RuntimeBackgroundIndexingManager` actor, Settings controls, and a Toolbar popover. **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. @@ -23,9 +23,45 @@ --- +## Phase 0 — Package wiring + +### Task 0: Declare Semaphore as an explicit dependency of `RuntimeViewerCore` + +**Files:** +- Modify: `RuntimeViewerCore/Package.swift` + +**Why:** The `groue/Semaphore` package is already resolved for the `RuntimeViewerCommunication` target (see `Package.swift:163`), but `RuntimeViewerCore`'s own target does not declare it. `RuntimeBackgroundIndexingManager.swift` (Task 6) will `import Semaphore`; relying on transitive visibility is brittle (breaks the moment `.memberImportVisibility` is enabled, which is already defined at `Package.swift:200`). Make the dependency explicit before any code uses it. + +- [ ] **Step 1: Edit the `RuntimeViewerCore` target's `dependencies` array** + +In `RuntimeViewerCore/Package.swift`, inside `.target(name: "RuntimeViewerCore", dependencies: [...])` (currently lines 142-157), append: + +```swift +.product(name: "Semaphore", package: "Semaphore"), +``` + +after the existing `MetaCodable` product. + +- [ ] **Step 2: Resolve & build** + +```bash +cd RuntimeViewerCore && swift package update && swift build 2>&1 | xcsift +``` + +Expected: clean build (no code changes yet). + +- [ ] **Step 3: Commit** + +```bash +git add RuntimeViewerCore/Package.swift +git commit -m "chore(core): add Semaphore as explicit RuntimeViewerCore dependency" +``` + +--- + ## Phase 1 — Foundation value types -### Task 1: Create Sendable value types for indexing events and batches +### Task 1: Create Sendable + Hashable value types for indexing events and batches **Files:** - Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchID.swift` @@ -34,8 +70,11 @@ - Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskItem.swift` - Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift` - Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingEvent.swift` +- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift` - Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift` +**Why Hashable everywhere:** `BackgroundIndexingNode` (Task 18) is declared `Hashable` so it can key `NSOutlineView` / `NSDiffableDataSource` updates. Its associated values transitively need `Hashable`. Declaring it up front is cheaper than backfilling later. + - [ ] **Step 1: Write failing tests for value type invariants** File `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift`: @@ -116,7 +155,7 @@ public struct RuntimeIndexingBatchID: Hashable, Sendable { File `RuntimeIndexingBatchReason.swift`: ```swift -public enum RuntimeIndexingBatchReason: Sendable, Equatable { +public enum RuntimeIndexingBatchReason: Sendable, Hashable { case appLaunch case imageLoaded(path: String) case settingsEnabled @@ -127,7 +166,7 @@ public enum RuntimeIndexingBatchReason: Sendable, Equatable { File `RuntimeIndexingTaskState.swift`: ```swift -public enum RuntimeIndexingTaskState: Sendable, Equatable { +public enum RuntimeIndexingTaskState: Sendable, Hashable { case pending case running case completed @@ -146,7 +185,7 @@ public enum RuntimeIndexingTaskState: Sendable, Equatable { File `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 +202,24 @@ public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Equatable { } ``` +File `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 + } +} +``` + File `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 @@ -237,7 +290,7 @@ git commit -m "feat(core): add Sendable value types for background indexing" - [ ] **Step 1: Explore `LC_RPATH` / executable path API on `MachOImage`** ```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]`. @@ -419,18 +472,21 @@ git commit -m "feat(core): add DylibPathResolver for @rpath / @executable_path / ## Phase 2 — Engine extensions -### Task 3: Expose `hasCachedSection` on both section factories; add `isImageIndexed` to engine +### Task 3: Expose `hasCachedSection` on both section factories; add `isImageIndexed` to engine with `request/remote` dispatch **Files:** - Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift` (factory area) - Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift` (factory area) +- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` (bump factories to `internal`; add `.isImageIndexed` to `CommandNames`; register handler) - Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` - Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift` +**Why `request/remote`:** When the document targets a remote source (XPC / directTCP), the local engine's factory caches are empty — only the server process has the truth. Every existing public engine method uses the `request(local:remote:)` primitive (`RuntimeEngine.swift:468`); skipping it here would return wrong data for remote sources. + - [ ] **Step 1: Read the factory classes for their caching layout** ```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. @@ -444,9 +500,9 @@ 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,7 +510,7 @@ 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) } } @@ -480,7 +536,34 @@ func hasCachedSection(for path: String) -> Bool { Match the exact storage name observed in Step 1. If a factory uses `cache` or `_sections`, substitute. -- [ ] **Step 4: Create the engine extension** +- [ ] **Step 4: Widen factory access level (must-do)** + +`RuntimeEngine.swift:147-149` currently declares both factories as `private`: + +```swift +private let objcSectionFactory: RuntimeObjCSectionFactory +private let swiftSectionFactory: RuntimeSwiftSectionFactory +``` + +Change both to `internal` (drop the `private` keyword; default is `internal`). This is required for the `+BackgroundIndexing.swift` extension below. Verified against current code — the factories are definitely `private` today. + +- [ ] **Step 5: Add `.isImageIndexed` to `CommandNames` and register the server handler** + +In `RuntimeEngine.swift`, find the `CommandNames` enum (around line 62). Add: + +```swift +case isImageIndexed +``` + +In the `setMessageHandlerBinding(...)` block near line 276, add: + +```swift +setMessageHandlerBinding(forName: .isImageIndexed, of: self) { $0.isImageIndexed(path:) } +``` + +This slots in next to the existing `.isImageLoaded` binding. + +- [ ] **Step 6: Create the engine extension using `request/remote` dispatch** File `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift`: @@ -489,16 +572,21 @@ 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. +Note: the test in Step 2 has been updated above to `try await engine.isImageIndexed(path:)` since the method now throws. -- [ ] **Step 5: Run tests — expect pass** +- [ ] **Step 7: Run tests — expect pass** ```bash cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift @@ -506,26 +594,29 @@ cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | 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. -- [ ] **Step 6: Commit** +- [ ] **Step 8: Commit** ```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 +### Task 4: Add `mainExecutablePath` and `loadImageForBackgroundIndexing` to engine (with `request/remote` dispatch) **Files:** - Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` +- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` (add two `CommandNames` cases + handlers) - Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift` (only if helper missing) - Test: append to `RuntimeEngineIndexStateTests.swift` +**Why `request/remote`:** Same rationale as Task 3. `mainExecutablePath` must reflect the target process, not the local process; for a remote source the correct answer is only known on the server side. `loadImageForBackgroundIndexing` must also execute inside the target process. + - [ ] **Step 1: Explore `DyldUtilities` and `MachOImage` for main-executable lookup** ```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))`. @@ -535,57 +626,79 @@ Note the canonical call sequence. On macOS the main executable is dyld image at In `RuntimeEngineIndexStateTests.swift`, append: ```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: Add `CommandNames` cases + server handlers** + +In `RuntimeEngine.swift` `CommandNames` enum: + +```swift +case mainExecutablePath +case loadImageForBackgroundIndexing +``` + +In the `setMessageHandlerBinding(...)` block: + +```swift +setMessageHandlerBinding(forName: .mainExecutablePath, + of: self) { $0.mainExecutablePath } +setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, + of: self) { $0.loadImageForBackgroundIndexing(at:) } +``` -- [ ] **Step 3: Implement the new engine methods** +- [ ] **Step 4: Implement the new engine methods with `request/remote` dispatch** Append to `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. +Note the `try await` on both factory calls — matches the verified signature `section(for:progressContinuation:) async throws -> (isExisted: Bool, section: ...)` at `RuntimeObjCSection.swift:704` / `RuntimeSwiftSection.swift:802`. -- [ ] **Step 4: Run tests — expect pass** +- [ ] **Step 5: Run tests — expect pass** ```bash cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift @@ -593,11 +706,101 @@ cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | Expected: all tests in that file pass. -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```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" +``` + +--- + +### Task 4.5: Add `imageDidLoadPublisher` on `RuntimeEngine` + +**Files:** +- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` +- Test: append to `RuntimeEngineIndexStateTests.swift` + +**Why:** The coordinator (Task 16) needs a signal that carries the path of the newly-loaded image. `RuntimeEngine` today only exposes `reloadDataPublisher` (no payload) and `imageNodesPublisher` (full list); there is no per-image signal. Task 16 will subscribe to this new publisher. The local branch emits after `loadImage(at:)` succeeds; the remote branch's `setMessageHandlerBinding(forName: .imageDidLoad)` handler emits on the client side when the server forwards the event. + +- [ ] **Step 1: Inspect the existing `reloadDataPublisher` wiring for pattern parity** + +```bash +rg -n "reloadDataPublisher|reloadDataSubject|PassthroughSubject" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift | head +``` + +Expected finding: `private nonisolated let reloadDataSubject = PassthroughSubject()` with a `nonisolated` public property exposing it, and a `setMessageHandlerBinding(forName: .reloadData) { $0.reloadDataSubject.send() }` on the server handler table. + +- [ ] **Step 2: Add the subject + publisher** + +In `RuntimeEngine.swift` next to the existing `reloadDataSubject`: + +```swift +private nonisolated let imageDidLoadSubject = PassthroughSubject() + +public nonisolated var imageDidLoadPublisher: some Publisher { + imageDidLoadSubject.eraseToAnyPublisher() +} +``` + +- [ ] **Step 3: Add `.imageDidLoad` to `CommandNames` and wire both sides** + +In `CommandNames`: + +```swift +case imageDidLoad +``` + +In the handler table, mirror the `reloadData` pattern so remote clients also receive the event: + +```swift +setMessageHandlerBinding(forName: .imageDidLoad) { (engine: RuntimeEngine, path: String) in + engine.imageDidLoadSubject.send(path) +} +``` + +In `loadImage(at:)` (currently `RuntimeEngine.swift:485-495`), after the existing `reloadData(isReloadImageNodes: false)` call, emit: + +```swift +imageDidLoadSubject.send(path) +sendRemoteDataIfNeeded(name: .imageDidLoad, payload: path) +// or inline the remote push similar to sendRemoteDataIfNeeded(isReloadImageNodes:) +``` + +Verify the existing `sendRemoteDataIfNeeded(...)` signature — if it doesn't accept an arbitrary command name, add a small `sendRemoteImageDidLoad(_ path: String)` helper beside it. + +- [ ] **Step 4: Append a test** + +```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? + let cancellable = await 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) + } +``` + +Add `import Combine` at the top of the test file if not present. + +- [ ] **Step 5: Run tests** + +```bash +cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift +``` + +- [ ] **Step 6: Commit** + +```bash +git add RuntimeViewerCore +git commit -m "feat(core): imageDidLoadPublisher for per-path load notifications" ``` --- @@ -680,7 +883,11 @@ 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 @@ -1156,7 +1363,9 @@ 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) { @@ -1413,57 +1622,37 @@ git commit -m "feat(core): cancelBatch and cancelAllBatches on indexing manager" 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,7 +1662,7 @@ 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. } ``` @@ -1521,26 +1710,25 @@ git commit -m "feat(core): prioritize pending item to head of queue" - [ ] **Step 1: Inspect 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. -- [ ] **Step 2: Add the property and wire it up** +- [ ] **Step 2: Add an explicit stored property and initialize it at the end of `init`** -In `RuntimeEngine.swift`, add inside the actor: +`lazy var` on an actor forces every first access through actor-isolation, which makes the initialization point non-obvious and interacts awkwardly with `nonisolated` accessors. Use an explicit implicitly-unwrapped stored property set as the last line of `init`: ```swift -public private(set) lazy var backgroundIndexingManager: RuntimeBackgroundIndexingManager = - RuntimeBackgroundIndexingManager(engine: self) -``` - -`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`: +// Near the other stored properties: +public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingManager! -```swift +// Last line of init(source:...): self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) ``` +Rationale for IUO: the actor cannot hand `self` to the manager before `init` finishes registering the other stored properties, and the manager is read-only after init — no reassignment paths, no nil access paths outside the one-line bootstrap. + - [ ] **Step 3: Build** ```bash @@ -1568,7 +1756,7 @@ git commit -m "feat(core): expose backgroundIndexingManager on RuntimeEngine" - [ ] **Step 1: Read the existing MCP struct to match its style** ```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** @@ -1616,7 +1804,7 @@ git commit -m "feat(settings): add BackgroundIndexing settings struct" - [ ] **Step 1: Read the existing Settings root view** ```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** @@ -1717,7 +1905,7 @@ git commit -m "feat(settings-ui): Background Indexing settings page" - [ ] **Step 1: Read DocumentState to understand the environment the coordinator will live in** ```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. @@ -1834,6 +2022,12 @@ public final class RuntimeBackgroundIndexingCoordinator { refreshAggregate(batches: batches) } + private func mutating(_ value: T, _ mutate: (inout T) -> Void) -> T { + var copy = value + mutate(©) + return copy + } + @MainActor private func refreshAggregate(batches: [RuntimeIndexingBatch]) { let hasActive = !batches.isEmpty @@ -1852,14 +2046,10 @@ public final class RuntimeBackgroundIndexingCoordinator { progress: progress)) } } - -private func mutating(_ value: T, _ mutate: (inout T) -> Void) -> T { - var copy = value - mutate(©) - return copy -} ``` +The `mutating(_:_:)` helper is now a private method on the coordinator (see earlier insertion). It is not a global function — `private` file-scope would still pollute any future file in the same module, and a private method keeps the utility scoped to the coordinator that needs it. + - [ ] **Step 3: Build** ```bash @@ -1945,7 +2135,7 @@ git commit -m "feat(application): documentDidOpen / documentWillClose hooks for - [ ] **Step 1: Inspect the engine's image-loaded signal** ```bash -rg -n "didLoadImage|imageLoaded|imageDidLoad|PublishSubject.*String" /Volumes/Code/Personal/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/ | head +rg -n "didLoadImage|imageLoaded|imageDidLoad|PublishSubject.*String" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/ | head ``` Record the exact Rx observable or async sequence name. Adapt the subscription below to match. @@ -2005,113 +2195,103 @@ git commit -m "feat(application): subscribe to engine image-loaded events to spa --- -### Task 17: Expose `prioritize` entry point for sidebar selection +### Task 17: React to Settings changes via `withObservationTracking` **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. +**Why `withObservationTracking` (not Combine):** `Settings` at `RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift:6` is declared `@Observable`. It has no Combine publisher, and the `scheduleAutoSave` path only fires through `didSet`. Adding a parallel `PassthroughSubject` would duplicate the source of truth. `withObservationTracking` is the native fit — the coordinator reads the tracked properties inside the `apply` closure, and Swift Observation registers a one-shot observer. We re-register inside `onChange` to keep observing across each mutation. -- [ ] **Step 2: No commit. This is a checklist item, not a code change.** +- [ ] **Step 1: Add observation imports and state** ---- - -### Task 18: React to Settings changes - -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` - -- [ ] **Step 1: Find the Settings change notification hook** +At the top of `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. - -Whichever you choose, document the decision in the commit message. +Add private state on the coordinator class: -- [ ] **Step 2: Implement the subscription** +```swift +@MainActor private var lastKnownIsEnabled: Bool = false +``` -Example with an assumed Combine publisher `Settings.shared.publisher`: +- [ ] **Step 2: Implement the observation loop** ```swift +@MainActor 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() +@MainActor +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: - -In `RuntimeViewerSettings/Settings.swift`, next to the storage: - -```swift -public let publisher: PassthroughSubject = .init() -``` +- [ ] **Step 3: Seed initial state and register from init** -And in `scheduleAutoSave()`: +At the end of `init`: ```swift -publisher.send(self) +Task { @MainActor [weak self] in + guard let self else { return } + self.lastKnownIsEnabled = Settings.shared.backgroundIndexing.isEnabled + self.subscribeToSettings() +} ``` -- [ ] **Step 3: Build** +- [ ] **Step 4: Build** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 4: Commit** +- [ ] **Step 5: Commit** ```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 -### Task 19: Create `BackgroundIndexingNode` and popover ViewModel +### Task 18: Create `BackgroundIndexingNode` and popover ViewModel (on `MainRoute`) **Files:** - Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift` -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverRoute.swift` - Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift` +**Why no separate Route:** `MainCoordinator` is declared `final class MainCoordinator: SceneCoordinator` (`MainCoordinator.swift:11`). Its `Route` is already bound to `MainRoute`; a second conditional `Router` conformance for a `BackgroundIndexingPopoverRoute` would not compile. Instead, add a case to `MainRoute` (Task 21) and let the ViewModel be `ViewModel`. + - [ ] **Step 1: Create `BackgroundIndexingNode`** ```swift @@ -2123,32 +2303,19 @@ 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: Create the ViewModel on `MainRoute`** ```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 @@ -2157,7 +2324,7 @@ final class BackgroundIndexingPopoverViewModel: private let coordinator: RuntimeBackgroundIndexingCoordinator init(documentState: DocumentState, - router: any Router, + router: any Router, coordinator: RuntimeBackgroundIndexingCoordinator) { self.coordinator = coordinator @@ -2167,6 +2334,7 @@ final class BackgroundIndexingPopoverViewModel: struct Input { let cancelBatch: Signal let cancelAll: Signal + let clearFailed: Signal let openSettings: Signal } struct Output { @@ -2196,9 +2364,10 @@ final class BackgroundIndexingPopoverViewModel: } .disposed(by: rx.disposeBag) - // Settings isEnabled observation — reuse the same stream; - // alternatively project it from appDefaults. - isEnabled = Settings.shared.backgroundIndexing.isEnabled + // isEnabled must stay reactive — the popover's empty states + // depend on it. Use withObservationTracking like the coordinator + // so toggling Settings while the popover is open updates the view. + subscribeToIsEnabled() input.cancelBatch.emitOnNext { [weak self] id in guard let self else { return } @@ -2210,6 +2379,11 @@ 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) + input.openSettings.emitOnNext { [weak self] in guard let self else { return } router.trigger(.openSettings) @@ -2223,6 +2397,21 @@ final class BackgroundIndexingPopoverViewModel: ) } + @MainActor + 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,19 +2437,20 @@ final class BackgroundIndexingPopoverViewModel: } ``` -- [ ] **Step 4: Add the new files to the Xcode project** +Note: `coordinator.clearFailedBatches()` is added in Task 24 together with the "retain failed batches until dismissed" reducer change. If you reach Task 18 before Task 24, leave the `clearFailed` binding as a TODO pass-through and circle back. + +- [ ] **Step 3: Add the two new files to the Xcode project** -Using xcodeproj MCP, add the three files: +Using xcodeproj MCP, add: ``` RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift -RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverRoute.swift RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift ``` -Each to the `RuntimeViewerUsingAppKit` target. +Each to the `RuntimeViewerUsingAppKit` target. There is **no** `BackgroundIndexingPopoverRoute.swift` — routing is via `MainRoute`. -- [ ] **Step 5: Build the app target** +- [ ] **Step 4: Build the app target** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift @@ -2268,16 +2458,16 @@ xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcod Expected: clean build. -- [ ] **Step 6: Commit** +- [ ] **Step 5: Commit** ```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 +### Task 19: Build the popover ViewController **Files:** - Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift` @@ -2475,9 +2665,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]) @@ -2555,7 +2744,7 @@ git commit -m "feat(ui): popover view controller for background indexing" --- -### Task 21: Build the Toolbar item view with `NSProgressIndicator` overlay +### Task 20: Build the Toolbar item view with `NSProgressIndicator` overlay **Files:** - Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift` @@ -2706,27 +2895,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 +### Task 21: Register the toolbar item and add the `MainRoute.backgroundIndexing` case **Files:** +- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift` - Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift` - Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift` +**Why it's one route case, not a separate `Router` conformance:** `MainCoordinator` is already `SceneCoordinator`. A conditional `extension MainCoordinator: Router where Route == BackgroundIndexingPopoverRoute` cannot compile — `Route` is pinned to `MainRoute`. The plan therefore extends `MainRoute` directly with one case and routes the popover's `.openSettings` through the existing `MainRoute.openSettings` case. + - [ ] **Step 1: Inspect the existing MCPStatus wiring** ```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 +``` + +Also check `MainRoute.swift:18` — the existing case is literally `case mcpStatus(sender: NSView)`, not `mcpStatusPopover`. Match that naming style. + +- [ ] **Step 2: Add the route case on `MainRoute`** + +In `MainRoute.swift`, next to `case mcpStatus(sender: NSView)`, add: + +```swift +case backgroundIndexing(sender: NSView) ``` -- [ ] **Step 2: Register the new item** +(No `Popover` suffix — matches the sibling `mcpStatus` precedent.) -In `MainToolbarController.swift`: +- [ ] **Step 3: Register the toolbar item in `MainToolbarController`** ```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,7 +2969,7 @@ 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) } @@ -2776,28 +2977,14 @@ private func wireBackgroundIndexing(item: BackgroundIndexingToolbarItem) { The exact field names (`documentState`, `mainCoordinator`) must match `MainToolbarController`'s existing fields — adjust if the property is spelled differently. -- [ ] **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: Handle the new case in `MainCoordinator.prepareTransition`** ```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,59 +2995,48 @@ 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. +No `extension MainCoordinator: Router where Route == ...` wrapper is needed — `self` is already `Router`, and the popover's `openSettings` button triggers `router.trigger(.openSettings)` directly (the case already exists on `MainRoute`). -- [ ] **Step 4: Build** +- [ ] **Step 5: Build** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```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 -### Task 23: Hold a coordinator on `DocumentState` and invoke lifecycle hooks +### Task 22: Hold a coordinator on `DocumentState` and invoke lifecycle hooks **Files:** - Modify: `RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift` - Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift` -- [ ] **Step 1: Add the coordinator property to `DocumentState`** +- [ ] **Step 1: Add the coordinator property to `DocumentState` and reinforce the `runtimeEngine` invariant** ```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) ``` +Edit the existing declaration of `runtimeEngine` at `DocumentState.swift:10-11` to include the doc comment above; leave the type and initial value unchanged. + - [ ] **Step 2: Invoke lifecycle hooks from `Document`** In `Document.swift`: @@ -2883,7 +3059,7 @@ Check the current `makeWindowControllers` / `close` implementation before editin ```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 ``` @@ -2896,7 +3072,7 @@ git commit -m "feat(app): wire background indexing coordinator into Document lif --- -### Task 24: Wire sidebar selection → `prioritize` +### Task 23: Wire sidebar selection → `prioritize` **Files:** - Modify: the coordinator or VC that observes sidebar selection (likely `MainCoordinator` or `SidebarCoordinator`) @@ -2904,7 +3080,7 @@ git commit -m "feat(app): wire background indexing coordinator into Document lif - [ ] **Step 1: Find the sidebar image selection signal** ```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. @@ -2937,40 +3113,76 @@ git commit -m "feat(app): prioritize indexing when user selects an image in side --- -### Task 25: Trigger a single `reloadData` per batch finish +### Task 24: Retain failed batches; refresh image list once per batch finish **Files:** - Modify: `RuntimeBackgroundIndexingCoordinator.swift` -- [ ] **Step 1: After `apply(event:)` handles `.batchFinished` / `.batchCancelled`, invoke `engine.reloadData` once** +**Why retain failed batches:** The toolbar state `.hasFailures(...)` is derived from the coordinator's `aggregateState`. If `.batchFinished` immediately removes the batch — even one containing `.failed` items — the toolbar never surfaces the failure. This task changes the `.batchFinished` / `.batchCancelled` reducer: clean finishes and cancels drop out; finishes with any `.failed` item stay in `batchesRelay` until the user calls `clearFailedBatches()` from the popover. -Change the existing `apply(event:)` branch: +- [ ] **Step 1: Update the `apply(event:)` reducer for `.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: Build** +- [ ] **Step 2: Add `clearFailedBatches()` to the coordinator's public surface** + +```swift +public func clearFailedBatches() { + Task { @MainActor [weak self] in + guard let self else { return } + let remaining = batchesRelay.value.filter { batch in + !batch.items.contains { if case .failed = $0.state { true } else { false } } + } + batchesRelay.accept(remaining) + refreshAggregate(batches: remaining) + } +} +``` + +This is the method the Task 18 popover ViewModel calls from its `Clear Failed` button input. + +- [ ] **Step 3: Update `refreshAggregate` so `hasAnyFailure` considers retained batches** + +The existing `hasAnyFailure` computation already scans `batches` for `.failed` items, so no change is required — the retained failed batches stay visible in the aggregate state. + +- [ ] **Step 4: Build** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 3: Commit** +- [ ] **Step 5: Commit** ```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 +### Task 25: Full build, run tests, manual QA - [ ] **Step 1: Run the full Core test suite** @@ -2983,13 +3195,13 @@ Expected: all tests pass. - [ ] **Step 2: Run the full Packages build** ```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** ```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** @@ -3013,7 +3225,7 @@ If all boxes tick, no code change is required. Otherwise, fix the failing item i --- -### Task 27: Open a pull request +### Task 26: Open a pull request - [ ] **Step 1: Push the branch** @@ -3033,10 +3245,10 @@ 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 )" ``` @@ -3045,17 +3257,20 @@ 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. +- **Spec coverage:** every section of the evolution proposal has at least one task. + - Package wiring (Semaphore dependency) → Task 0. + - Value types (all `Hashable`) + `ResolvedDependency` → Task 1. - `DylibPathResolver` → Task 2. - - Engine new APIs → Task 4. - - Manager (protocol, skeleton, BFS, concurrency, cancel, prioritize) → Tasks 5-10. - - Engine integration → Task 11. + - `Loaded vs Indexed` + `request/remote` dispatch for `isImageIndexed` → Task 3. + - Engine new APIs (`mainExecutablePath`, `loadImageForBackgroundIndexing`) with `request/remote` → Task 4; `imageDidLoadPublisher` → Task 4.5. + - Manager (protocol + mock, skeleton, BFS, concurrency, cancel, prioritize) → Tasks 5-10. + - Engine integration (non-`lazy` stored manager) → 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. + - Coordinator (lifecycle, image-loaded, Settings via `withObservationTracking`) → Tasks 14-17. + - UI (Node + ViewModel on `MainRoute`, VC with `preconditionFailure` data source, toolbar view + item, `MainRoute.backgroundIndexing` registration) → Tasks 18-21. + - Integration (Document wiring + `runtimeEngine` immutability doc comment) → Task 22. + - Sidebar → prioritize → Task 23. + - Retain failed batches + refresh image list → Task 24. + - Manual QA → Task 25. +- **Review decisions embedded:** all three header decisions from the 2026-04-24 review — Settings via `withObservationTracking` (Task 17), `BackgroundIndexingPopoverRoute` merged into `MainRoute` (Task 18/21), and `request/remote` dispatch for engine methods (Tasks 3/4) — have dedicated tasks and explicit rationale paragraphs. +- **Type consistency:** `RuntimeIndexingBatchID`, `RuntimeIndexingBatch`, `RuntimeIndexingTaskState`, `RuntimeIndexingEvent`, `RuntimeIndexingBatchReason`, `RuntimeIndexingTaskItem`, `ResolvedDependency`, `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. No `BackgroundIndexingPopoverRoute` type is introduced anywhere. 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 记录,不要追加到本文件。 From 69df72fe299426a30e952de8565a912bd5489a16 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 11:54:46 +0800 Subject: [PATCH 02/78] docs(background-indexing): translate evolution proposal and plan to Chinese Translate the Evolution 0002 proposal and the implementation plan into Chinese while preserving all code blocks, file paths, commands, and identifiers in their original form. --- .../Evolution/0002-background-indexing.md | 524 +++++------ .../2026-04-24-background-indexing-plan.md | 812 +++++++++--------- 2 files changed, 667 insertions(+), 669 deletions(-) diff --git a/Documentations/Evolution/0002-background-indexing.md b/Documentations/Evolution/0002-background-indexing.md index cf69f2db..9aaa2665 100644 --- a/Documentations/Evolution/0002-background-indexing.md +++ b/Documentations/Evolution/0002-background-indexing.md @@ -1,98 +1,98 @@ -# 0002 - Background Indexing +# 0002 - 后台索引 -- **Status**: Accepted -- **Author**: JH -- **Date**: 2026-04-24 -- **Last Updated**: 2026-04-24 +- **状态**: Accepted +- **作者**: JH +- **日期**: 2026-04-24 +- **最后更新**: 2026-04-24 -## Summary +## 摘要 -Add an opt-in **Background Indexing** feature that eagerly parses ObjC and Swift metadata for the dependency closure of images already loaded in the target process. Work is driven by a per-`RuntimeEngine` Swift-Concurrency actor (`RuntimeBackgroundIndexingManager`), configured from Settings, surfaced in a Toolbar popover with live progress, and cancellable on demand. +新增一个可选的 **后台索引(Background Indexing)** 功能,针对目标进程已加载镜像的依赖闭包,主动解析其 ObjC 与 Swift 元数据。工作由每个 `RuntimeEngine` 持有的 Swift Concurrency actor(`RuntimeBackgroundIndexingManager`)驱动,可在 Settings 中配置,通过 Toolbar 弹出框实时显示进度,并支持随时取消。 -## Motivation +## 动机 -Runtime Viewer currently indexes an image (parses ObjC/Swift metadata) only when the user explicitly opens it. For images that the target process has already loaded via dyld — e.g. UIKit, Foundation, and their transitive dependency closure — the first lookup pays a visible parsing cost because the work was never amortized. +Runtime Viewer 当前仅在用户显式打开某个镜像时才对其进行索引(解析 ObjC/Swift 元数据)。对于目标进程已经通过 dyld 加载的镜像 —— 例如 UIKit、Foundation 及其传递依赖闭包 —— 首次查询会因从未摊销的解析成本而出现可见延迟。 -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. +- 通过预解析常用镜像,降低用户在常见查询路径上感知到的延迟。 +- 保留现有按需 `loadImage(at:)` 路径及其语义。 +- 让用户通过 Settings 在 CPU 占用与响应速度之间权衡(depth、并发数)。 +- 为运行中的工作提供实时可见性以及一键取消能力。 -### Non-goals +### 非目标 -- 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. +- 不在应用重启之间持久化索引历史(每次会话从干净状态开始)。 +- 不支持单镜像(子批次)级取消 —— 仅支持批次级取消。 +- 不支持暂停/恢复,仅支持启动 / 取消。 +- 不自动重试失败项。 +- 除单一手动 `prioritize(path:)` 钩子外,不引入额外 QoS 等级。 +- 不引入空闲 / 低功耗启发式策略。无论系统负载如何,索引都会运行。 +- 不向 MCP 工具暴露索引进度(MCP 消费的是结果,而不是进程状态)。 +- 不在跨 Document / 跨 Engine 之间共享缓存(保留 dyld 层面已有的复用)。 +- 不为旧调用方"loadImage == indexed"的混淆假设提供向后兼容垫片。 -## Proposed Solution +## 提议方案 -### Background Context +### 背景上下文 -Source of truth captured during brainstorming and code verification: +来自头脑风暴和代码核验的事实来源: -- `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]` gives the dependency list. MachOKit collapses `LC_LOAD_WEAK_DYLIB` into `DependType.load`, so only `.load`, `.reexport`, `.upwardLoad`, `.lazyLoad` are ever observed. -- The `Semaphore` package (`groue/Semaphore`) is already resolved for `RuntimeViewerCommunication`. It must be re-declared as an explicit product dependency of the `RuntimeViewerCore` target before the manager can import it. -- `MCPStatusPopoverViewController` + `MCPStatusToolbarItem` are the template for a Toolbar-anchored, RxSwift-driven popover. -- `RuntimeEngine` exposes a `request(local:remote:)` dispatch primitive (`RuntimeEngine.swift:468`) used by every public method whose result depends on the target process (local vs. XPC/TCP). All new public engine methods introduced here use the same primitive. +- `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 之分)。本提案新增的所有引擎公共方法都使用同一原语。 -### Terminology: Loaded vs. Indexed +### 术语:Loaded vs. Indexed -This distinction is load-bearing. +这一区分至关重要。 -- **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 **successfully-parsed** cached section for the image's path. Failure to parse does **not** count as indexed, which means failed paths will be retried on the next batch (see alternative D for why this is intentional). +- **Loaded** —— 镜像已在目标进程中向 dyld 注册(出现在 `DyldUtilities.imageNames()` 中)。Loaded 并不能说明 Runtime Viewer 是否解析过其 ObjC / Swift 元数据。 +- **Indexed** —— `RuntimeObjCSectionFactory` 和 `RuntimeSwiftSectionFactory` 都拥有针对该镜像路径的**成功解析后**缓存 section。解析失败**不**算作 indexed,这意味着失败路径会在下一批次中被重试(参见替代方案 D 解释为什么这是有意为之)。 -A new API — `RuntimeEngine.isImageIndexed(path:)` — answers the indexed question. The existing `isImageLoaded(path:)` continues to answer the loaded question. Background indexing deduplication always uses `isImageIndexed`. +新增 API —— `RuntimeEngine.isImageIndexed(path:)` —— 回答 indexed 这一问题。已有的 `isImageLoaded(path:)` 继续回答 loaded 这一问题。后台索引的去重始终使用 `isImageIndexed`。 -### Architecture +### 架构 ``` ┌───────────────────────────────────────────────────────────────────┐ -│ RuntimeViewerUsingAppKit (App target — no Runtime prefix) │ +│ RuntimeViewerUsingAppKit (App target — 不带 Runtime 前缀) │ │ │ -│ Toolbar: BackgroundIndexingToolbarItem (NSToolbarItem subclass) +│ Toolbar: BackgroundIndexingToolbarItem (NSToolbarItem 子类) │ + BackgroundIndexingToolbarItemView (NSProgressIndicator -│ overlaid on SFSymbol icon) │ +│ 覆盖在 SFSymbol 图标上) │ │ │ │ Popover: BackgroundIndexingPopoverViewController │ │ + BackgroundIndexingPopoverViewModel (ViewModel) -│ + BackgroundIndexingNode enum (batch / item) │ +│ + BackgroundIndexingNode 枚举 (batch / item) │ └───────────────────────────────────────────────────────────────────┘ - ↕ RxSwift (UI binding layer only) + ↕ RxSwift(仅用于 UI 绑定层) ┌───────────────────────────────────────────────────────────────────┐ -│ RuntimeViewerApplication (new types carry Runtime prefix) │ +│ RuntimeViewerApplication(新类型带 Runtime 前缀) │ │ │ │ RuntimeBackgroundIndexingCoordinator (class) │ -│ · Subscribes to Document lifecycle and engine image-load events -│ · Observes Settings.backgroundIndexing via withObservationTracking -│ · Calls engine.backgroundIndexingManager.startBatch(...) │ -│ · Bridges the manager's AsyncStream into an RxSwift │ -│ Observable<[RuntimeIndexingBatch]> consumed by the popover │ -│ · Exposes aggregate state (Driver) │ +│ · 订阅 Document 生命周期与引擎镜像加载事件 │ +│ · 通过 withObservationTracking 观察 Settings.backgroundIndexing +│ · 调用 engine.backgroundIndexingManager.startBatch(...) │ +│ · 将管理器的 AsyncStream 桥接为弹出框消费的 │ +│ Observable<[RuntimeIndexingBatch]>(RxSwift) │ +│ · 暴露聚合状态 (Driver) │ └───────────────────────────────────────────────────────────────────┘ ↕ async / await ┌───────────────────────────────────────────────────────────────────┐ -│ RuntimeViewerCore (new types carry Runtime prefix) │ +│ RuntimeViewerCore(新类型带 Runtime 前缀) │ │ │ -│ RuntimeEngine (actor, existing) │ +│ 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, new — core) │ -│ public API: │ +│ RuntimeBackgroundIndexingManager (actor,新增 —— 核心) │ +│ 公共 API: │ │ · events: AsyncStream │ │ · batches: [RuntimeIndexingBatch] │ │ · startBatch(rootImagePath:depth:maxConcurrency:reason:) │ @@ -100,26 +100,26 @@ A new API — `RuntimeEngine.isImageIndexed(path:)` — answers the indexed ques │ · cancelBatch(_:) │ │ · cancelAllBatches() │ │ · prioritize(imagePath:) │ -│ internals: │ +│ 内部: │ │ · activeBatches: [RuntimeIndexingBatchID: BatchState] │ -│ · AsyncSemaphore per batch for concurrency control │ -│ · per-batch driving Task hosting a TaskGroup │ +│ · 每批次一个 AsyncSemaphore 控制并发 │ +│ · 每批次一个驱动 Task,托管一个 TaskGroup │ │ │ -│ Sendable value types (all Hashable): │ +│ Sendable 值类型(全部 Hashable): │ │ RuntimeIndexingBatch, RuntimeIndexingBatchID, │ │ RuntimeIndexingTaskItem, RuntimeIndexingTaskState, │ │ RuntimeIndexingEvent, RuntimeIndexingBatchReason, │ │ ResolvedDependency │ │ │ -│ Utility: │ -│ DylibPathResolver — resolves @rpath / @executable_path / │ -│ @loader_path install names against rpaths + image path │ +│ 工具: │ +│ DylibPathResolver —— 基于 rpaths 与镜像路径解析 │ +│ @rpath / @executable_path / @loader_path 形式的 install name │ └───────────────────────────────────────────────────────────────────┘ ``` -### Remote Dispatch Model +### 远程分发模型 -All new `RuntimeEngine` public methods — `isImageIndexed`, `mainExecutablePath`, `loadImageForBackgroundIndexing` — are wrapped in the existing `request(local:remote:)` primitive: +新增的所有 `RuntimeEngine` 公共方法 —— `isImageIndexed`、`mainExecutablePath`、`loadImageForBackgroundIndexing` —— 都包裹在已有的 `request(local:remote:)` 原语之内: ```swift public func isImageIndexed(path: String) async throws -> Bool { @@ -133,7 +133,7 @@ public func isImageIndexed(path: String) async throws -> Bool { } ``` -Three new `CommandNames` cases — `.isImageIndexed`, `.mainExecutablePath`, `.loadImageForBackgroundIndexing` — are added, and the server-side handler table (`RuntimeEngine.swift:276-302`) gains: +新增三个 `CommandNames` 枚举值 —— `.isImageIndexed`、`.mainExecutablePath`、`.loadImageForBackgroundIndexing` —— 同时服务端处理表(`RuntimeEngine.swift:276-302`)增加: ```swift setMessageHandlerBinding(forName: .isImageIndexed, of: self) { $0.isImageIndexed(path:) } @@ -141,13 +141,13 @@ setMessageHandlerBinding(forName: .mainExecutablePath, of: self) { $0.mai setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, of: self) { $0.loadImageForBackgroundIndexing(at:) } ``` -`RuntimeBackgroundIndexingManager` itself runs **server-side only**. The manager's events, batches, and cancellation APIs are not mirrored over XPC in this proposal; the UI consumes manager state from the hosting process via the coordinator. Mirroring is left to a follow-up if needed. +`RuntimeBackgroundIndexingManager` 本身**仅运行在服务端**。本提案中管理器的事件、批次以及取消 API 不通过 XPC 镜像;UI 通过 coordinator 在宿主进程中消费管理器状态。如有需要,镜像化留作后续工作。 -### Components +### 组件 -#### `RuntimeBackgroundIndexingManager` (actor) +#### `RuntimeBackgroundIndexingManager`(actor) -Owns every running batch and every event stream. Created by `RuntimeEngine` at init, holds an unowned reference back to the engine. +持有所有运行中的批次以及所有事件流。在 `RuntimeEngine` init 时创建,对引擎持有 unowned 反向引用。 ```swift public actor RuntimeBackgroundIndexingManager { @@ -167,7 +167,7 @@ public actor RuntimeBackgroundIndexingManager { } ``` -#### Sendable value types +#### Sendable 值类型 ```swift public struct RuntimeIndexingBatchID: Hashable, Sendable { public let raw: UUID } @@ -188,7 +188,7 @@ public enum RuntimeIndexingTaskState: Sendable, Hashable { } public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Hashable { - public let id: String // image path (install name if unresolved) + public let id: String // 镜像路径(未解析时为 install name) public let resolvedPath: String? public var state: RuntimeIndexingTaskState public var hasPriorityBoost: Bool @@ -220,76 +220,76 @@ public enum RuntimeIndexingEvent: Sendable { } ``` -All value types are `Hashable` so they compose into `BackgroundIndexingNode: Hashable` without extra conformance work. +所有值类型都是 `Hashable`,因此可以无需额外 conformance 工作就组合成 `BackgroundIndexingNode: Hashable`。 #### `RuntimeBackgroundIndexingCoordinator` -Created once per Document (held by `DocumentState`). Responsibilities: +每个 Document 创建一份(由 `DocumentState` 持有)。职责: -1. Observe `Settings.backgroundIndexing` via `withObservationTracking` (see Settings section) → enable / disable / restart. -2. Listen for the engine's `imageDidLoadPublisher` → 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. **Finished batches that contain any failed item are retained** in `batchesRelay` until the user explicitly dismisses them via "Clear Failed" in the popover; clean finishes and cancels drop out immediately. -6. Expose `aggregateStateDriver: Driver`. `hasFailures` is derived from the retained failed batches. -7. Own per-Document batch tracking: `[Document.ID: Set]`. +1. 通过 `withObservationTracking` 观察 `Settings.backgroundIndexing`(参见 Settings 章节)→ 启用 / 禁用 / 重启。 +2. 监听引擎的 `imageDidLoadPublisher` → 为该镜像启动一次依赖批次。 +3. 监听 Sidebar 的镜像选中信号 → 调用 `manager.prioritize(path:)`。 +4. 将 `manager.events`(AsyncStream)桥接到 `eventRelay: PublishRelay`(RxSwift)。 +5. 维护从事件归约而来的 `batchesRelay: BehaviorRelay<[RuntimeIndexingBatch]>`。**包含任意失败项的已完成批次会被保留**在 `batchesRelay` 中,直到用户在弹出框中通过"Clear Failed"显式清除;干净完成与取消会立即移除。 +6. 暴露 `aggregateStateDriver: Driver`。`hasFailures` 由保留下来的失败批次推导。 +7. 持有按 Document 维度的批次跟踪:`[Document.ID: Set]`。 -### Data Flow Scenarios +### 数据流场景 -#### Scenario A — App launch / Document opened with indexing enabled +#### 场景 A —— 启用了索引时的应用启动 / Document 打开 ``` -Document opens - → DocumentState ready, RuntimeEngine available +Document 打开 + → DocumentState ready,RuntimeEngine 可用 → Coordinator.documentDidOpen(documentState) - reads Settings.backgroundIndexing - if !isEnabled → return + 读取 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 item transitions idle → indexing + Toolbar 项从 idle 切换到 indexing ``` -#### Scenario B — User loads a new image at runtime +#### 场景 B —— 用户在运行时加载新镜像 ``` -User action → documentState.loadImage(at: path) - → RuntimeEngine.loadImage(at:) (existing path completes) - → Engine emits imageDidLoadPublisher(path) - → Coordinator (if isEnabled): +用户操作 → 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)) - Dependency graph expansion skips items already indexed + 依赖图扩展会跳过已索引的项 ``` -#### Scenario C — User selects an image already queued +#### 场景 C —— 用户选中已经在队列中的镜像 ``` -Sidebar selection change → SidebarViewModel emits imageSelected(path) +Sidebar 选中变化 → SidebarViewModel 发出 imageSelected(path) → Coordinator → manager.prioritize(imagePath: path) - manager walks activeBatches, finds pending items matching path - marks hasPriorityBoost = true, adds to priorityBoostPaths set - emits .taskPrioritized - running / completed / absent paths: silent no-op + manager 遍历 activeBatches,找到匹配 path 的 pending 项 + 标记 hasPriorityBoost = true,加入 priorityBoostPaths 集合 + 发出 .taskPrioritized + 正在运行 / 已完成 / 不存在的路径:静默 no-op ``` -#### Scenario D — Document closed +#### 场景 D —— Document 关闭 ``` Document.close() → Coordinator.documentWillClose(documentState) for batchID in Coordinator.batchesFor(document): await manager.cancelBatch(batchID) - remove document entry + 移除 document 条目 ``` -#### Scenario E — Settings toggle (via `withObservationTracking`) +#### 场景 E —— Settings 切换(通过 `withObservationTracking`) ``` Coordinator.subscribeToSettings(): @@ -301,40 +301,40 @@ Coordinator.subscribeToSettings(): } onChange: { [weak self] in Task { @MainActor in self?.handleSettingsChange() - self?.subscribeToSettings() // re-register + self?.subscribeToSettings() // 重新注册 } } handleSettingsChange: isEnabled false → true: - for every open Document: run Scenario A (root = mainExecutablePath) - (do NOT replay historical loadImage calls) + 对每个打开的 Document 执行场景 A(root = mainExecutablePath) + (不要回放历史 loadImage 调用) isEnabled true → false: await manager.cancelAllBatches() - depth / maxConcurrency change while enabled: - no-op against running batches; values apply to the next startBatch. + 启用状态下 depth / maxConcurrency 变化: + 对运行中的批次为 no-op;新值在下一次 startBatch 生效。 ``` -Rationale: `Settings` is declared `@Observable`, so `withObservationTracking` is the native fit. Re-registering on each change is the documented one-shot-observer recovery pattern; it keeps the observer alive across each settings mutation without adding Combine infrastructure. +理由:`Settings` 已经声明为 `@Observable`,`withObservationTracking` 是原生匹配。在 `onChange` 中重新注册是文档化的"一次性观察者"恢复模式;它在每次 settings 变化中都让观察者保持存活,且不引入 Combine 基础设施。 -#### Scenario F — User cancels from the popover +#### 场景 F —— 用户从弹出框取消 ``` -Popover cancel button → ViewModel cancelBatchRelay.accept(batchID) +弹出框 Cancel 按钮 → 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 - emits .batchCancelled + 批次的驱动 Task → task.cancel() + TaskGroup 子任务继承取消 + runSingleIndex 捕获 CancellationError → 项状态 .cancelled + 已完成项保留 .completed + 发出 .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. +由 manager 内部的 `expandDependencyGraph(rootPath:depth:)` 实现。在 `startBatch` 开始时同步运行,因此在第一个 `taskStarted` 事件触发之前批次的总项数就已知 —— 这让弹出框的进度条从第一帧就保持准确。 ```swift -// Pseudocode +// 伪代码 func expandDependencyGraph(rootPath: String, depth: Int) async -> [RuntimeIndexingTaskItem] { @@ -368,34 +368,34 @@ func expandDependencyGraph(rootPath: String, depth: Int) async } ``` -`Array.removeFirst()` is sufficient for the depths we allow (≤ 5); a deque is not warranted. +我们允许的深度(≤ 5)下,`Array.removeFirst()` 已经够用;不需要双端队列。 -#### Dependency type filter +#### 依赖类型筛选 -- **Included**: `.load`, `.reexport`, `.upwardLoad`. -- **Skipped**: `.lazyLoad` — lazy-loaded dylibs may never actually load at runtime, so eagerly parsing them is speculative and wasteful. +- **包含**: `.load`、`.reexport`、`.upwardLoad`。 +- **跳过**: `.lazyLoad` —— 懒加载的 dylib 在运行时可能从不真正加载,主动解析它们既是猜测又是浪费。 -`LC_LOAD_WEAK_DYLIB` is decoded by MachOKit as `DependType.load` (see `MachOImage.swift:168-173`); the `.weakLoad` enum case never arrives from `dependencies`, so no explicit branch is needed. +`LC_LOAD_WEAK_DYLIB` 被 MachOKit 解码为 `DependType.load`(参见 `MachOImage.swift:168-173`);`.weakLoad` 这一枚举值永远不会从 `dependencies` 出现,无需显式分支。 -#### Path resolution (`DylibPathResolver`) +#### 路径解析(`DylibPathResolver`) -Install names come in four shapes: +install name 有四种形态: -| 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. | +| `/System/Library/...`(绝对路径) | 原样使用,校验文件存在。 | +| `@rpath/Foo.framework/Foo` | 对根镜像上每个 `LC_RPATH` 进行替换,取第一个存在的路径。 | +| `@executable_path/...` | 用主可执行文件所在目录替换。 | +| `@loader_path/...` | 用当前镜像所在目录替换。 | -Returns `String?` — `nil` maps to a `.failed("path unresolved")` task item that does not recurse. +返回 `String?` —— `nil` 映射为 `.failed("path unresolved")` 且不递归的 task item。 -### 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 Concurrency —— 工作路径中没有 `OperationQueue`、没有 `DispatchQueue`、没有 RxSwift。RxSwift 仅用于 coordinator 内的 UI 绑定层。 ```swift -// Manager internals (sketch) +// Manager 内部(草图) private func runBatch(id: RuntimeIndexingBatchID) async { let state = activeBatches[id]! eventsContinuation.yield(.batchStarted(state.batch)) @@ -412,7 +412,7 @@ private func runBatch(id: RuntimeIndexingBatchID) async { } } - finalizeBatch(id) // emits .batchFinished or .batchCancelled + finalizeBatch(id) // 发出 .batchFinished 或 .batchCancelled } private func runSingleIndex(batchID: RuntimeIndexingBatchID, @@ -436,32 +436,32 @@ private func runSingleIndex(batchID: RuntimeIndexingBatchID, } ``` -#### Priority queue mechanics +#### 优先级队列机制 -Each batch state owns an `Array` of pending paths and a `Set` of priority-boost members. `prioritize(imagePath:)` only mutates the set (and emits `.taskPrioritized`); the pop helper scans the pending array for the first boosted path, falling back to the array head when none is boosted. 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. +每个批次状态持有一个 pending 路径的 `Array` 以及 priority-boost 成员的 `Set`。`prioritize(imagePath:)` 仅修改集合(并发出 `.taskPrioritized`);pop 辅助函数会先在 pending 数组中扫描第一个被 boost 的路径,没有 boost 时退化为数组头部。优先级无法抢占已经在运行的子任务 —— Swift 结构化并发不支持。对运行中或已完成的路径调用 `prioritize` 是静默 no-op。 #### `AsyncSemaphore` -From `groue/Semaphore`. The dependency is already resolved at package level but is only declared for `RuntimeViewerCommunication`; this proposal adds an explicit `.product(name: "Semaphore", package: "Semaphore")` entry to the `RuntimeViewerCore` target's dependency list. +来自 `groue/Semaphore`。该依赖在 package 层已经解析,但仅声明给 `RuntimeViewerCommunication`;本提案在 `RuntimeViewerCore` target 的 dependencies 列表中显式添加 `.product(name: "Semaphore", package: "Semaphore")`。 -#### UI refresh suppression +#### UI 刷新抑制 -`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` / `.batchCancelled` event so the sidebar picks up the newly-indexed icons in a single update. +`loadImageForBackgroundIndexing(at:)` **不**调用 `reloadData()`。在一次批次中调用 N 次会让 sidebar 被洪水攻击。Coordinator 在每次 `.batchFinished` / `.batchCancelled` 事件触发时调用一次 `await engine.reloadData(isReloadImageNodes: false)`,让 sidebar 在一次更新中拉起新索引的图标。 ### Settings -#### `BackgroundIndexing` struct (`Settings+Types.swift`) +#### `BackgroundIndexing` 结构体(`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 + @Default(1) public var depth: Int // 有效区间 1...5 + @Default(4) public var maxConcurrency: Int // 有效区间 1...8 public static let `default` = Self() } ``` -Added to the root `Settings` class (which is `@Observable`) as: +添加到根 `Settings` 类(已为 `@Observable`)作为: ```swift @Default(BackgroundIndexing.default) @@ -470,55 +470,55 @@ public var backgroundIndexing: BackgroundIndexing = .init() { } ``` -Persisted by the existing `SettingsFileSystemStorage` auto-save. No Combine publisher is added to `Settings`. +由已有的 `SettingsFileSystemStorage` 自动保存持久化。不向 `Settings` 添加 Combine publisher。 -#### `BackgroundIndexingSettingsView` (SwiftUI) +#### `BackgroundIndexingSettingsView`(SwiftUI) -At `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift`. Reached via a new `SettingsPage.backgroundIndexing` case in `SettingsRootView.swift` (icon `square.stack.3d.down.right`, title `"Background Indexing"`). +位于 `RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift`。通过在 `SettingsRootView.swift` 新增的 `SettingsPage.backgroundIndexing` case 进入(图标 `square.stack.3d.down.right`,标题 `"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. +Form 内容: +- `Toggle "Enable background indexing"` 绑定 `$settings.isEnabled`。 +- 解释行为的说明段落。 +- depth 的 `Stepper`(1...5),附带说明语义。 +- maxConcurrency 的 `Stepper`(1...8),附带说明 CPU 取舍。 -Cancel-all stays in the popover footer, not in Settings. +Cancel-all 留在弹出框页脚,不放入 Settings。 -#### Settings change propagation +#### Settings 变更传播 -The coordinator subscribes via `withObservationTracking` on `Settings.shared.backgroundIndexing`, re-registering inside `onChange`. See Scenario E for the concrete flow. +Coordinator 通过 `withObservationTracking` 订阅 `Settings.shared.backgroundIndexing`,并在 `onChange` 内重新注册。具体流程参见场景 E。 -### UI: Toolbar Item + Popover +### UI: Toolbar Item + 弹出框 #### `BackgroundIndexingToolbarItem` -`NSToolbarItem` subclass registered in `MainToolbarController.swift`. Identifier `backgroundIndexing`. Placed next to the existing `mcpStatus` item in default and allowed identifier lists (the existing case is literally `mcpStatus(sender:)`, not `mcpStatusPopover`). +`NSToolbarItem` 子类,在 `MainToolbarController.swift` 注册。标识符 `backgroundIndexing`。在默认与允许的标识符列表中放置在已有的 `mcpStatus` 项旁边(已有的 case 字面量是 `mcpStatus(sender:)`,而非 `mcpStatusPopover`)。 -`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`. +`view` 是 `BackgroundIndexingToolbarItemView`(NSView),中间放一个 16pt 的图标(SF Symbol `square.stack.3d.down.right`),当状态为 `indexing` 或 `hasFailures` 时叠加一个 `NSProgressIndicator(style: .spinning)`。`hasFailures` 时会在右下角绘制一个小红点徽标。 -`IndexingToolbarState` enum: `.idle`, `.disabled`, `.indexing(percent: Double?)`, `.hasFailures(percent: Double?)`. +`IndexingToolbarState` 枚举:`.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. +view 通过 toolbar 构建时弱持有的 observer 集合绑定到 coordinator 推送的 `Driver`。 -Clicking the item triggers the **existing** `MainRoute` surface with a new case: +点击该项触发**已有**的 `MainRoute` 表面新增的 case: ```swift case backgroundIndexing(sender: NSView) ``` -Note the name has **no `Popover` suffix**, matching the sibling `mcpStatus(sender:)` precedent. +注意名称**没有 `Popover` 后缀**,与同级的 `mcpStatus(sender:)` 保持一致。 #### `BackgroundIndexingPopoverViewController` -Base class `UXKitViewController`. The ViewModel is `ViewModel` — there is **no** separate `BackgroundIndexingPopoverRoute`. All routing goes through `MainRoute` cases (`openSettings`, `dismiss`, etc.) that already exist at the main level. Fixed width 380, height from ~120 (empty state) up to 400 (outline view with scroll). +基类 `UXKitViewController`。ViewModel 是 `ViewModel` —— **没有**单独的 `BackgroundIndexingPopoverRoute`。所有路由都走主层级已经存在的 `MainRoute` case(`openSettings`、`dismiss` 等)。固定宽度 380,高度从约 120(空状态)到 400(带滚动的大纲视图)。 -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), `Clear Failed` button (visible only when there are retained failed batches), and `Close` button. +- 头部:`Label("Background Indexing")` 加一个读取聚合进度的副标题 `Label`。 +- 空状态 A(已禁用):图标 + "Background indexing is disabled" + `"Open Settings"` 按钮。 +- 空状态 B(已启用、无批次):图标 + "No active indexing tasks"。 +- 主体:渲染 `BackgroundIndexingNode` 的 `StatefulOutlineView`。 +- 页脚:`HStackView`,包含 `Cancel All` 按钮(无活动批次时禁用)、`Clear Failed` 按钮(仅当存在保留的失败批次时可见)以及 `Close` 按钮。 `BackgroundIndexingNode`: @@ -529,12 +529,12 @@ enum BackgroundIndexingNode: Hashable { } ``` -Outline cells: +大纲单元格: -- Batch row: title derived from `reason`, `"{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. +- Batch 行:标题由 `reason` 派生、`"{completed}/{total}"`,以及一个 cancel 按钮。点击 cancel 会触发 `cancelBatchRelay.accept(batchID)`。 +- Item 行:状态图标(pending 灰点 / running 旋转 / completed 绿色 ✓ / failed 红色 ✗ / cancelled 灰色 ⊘)+ 显示名 + 副标签。失败行展示完整 install name 与错误信息。`hasPriorityBoost == true` 的行展示一个 `"priority"` 标签。 -Defensive outline-view data source branches use `preconditionFailure("unexpected outline item type")` rather than returning a zero-initialized batch, so mis-wired callers surface immediately. +防御性的大纲数据源分支使用 `preconditionFailure("unexpected outline item type")`,而不是返回零初始化的 batch,这样错误绑定的调用方会立即暴露。 #### `BackgroundIndexingPopoverViewModel` @@ -562,67 +562,67 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { } ``` -`isEnabled` is kept in sync with `Settings.shared.backgroundIndexing.isEnabled` via the **same** `withObservationTracking` re-registration loop used by the coordinator — it is not read once in `transform` and forgotten. The popover's empty states therefore react to the Settings toggle while open. +`isEnabled` 通过与 coordinator **相同**的 `withObservationTracking` 重新注册循环与 `Settings.shared.backgroundIndexing.isEnabled` 保持同步 —— 不是在 `transform` 中读一次后遗忘。这样弹出框打开时它的空状态会随 Settings 切换而响应。 -`input.openSettings.emitOnNext` fires `router.trigger(.openSettings)` — the existing `MainRoute.openSettings` case. +`input.openSettings.emitOnNext` 触发 `router.trigger(.openSettings)` —— 已有的 `MainRoute.openSettings` case。 -### Error Handling +### 错误处理 -| Failure site | Behavior | UI | +| 失败位置 | 行为 | UI | |---|---|---| -| `MachOImage(name: path)` returns nil during graph expansion | 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 | — | +| 图扩展时 `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:)` requires **both** factories to have a successfully-cached entry. Failure to parse leaves no cache entry, so the path re-enters the next batch's frontier. This is intentional — see alternative D. +`isImageIndexed(path:)` 要求**两个** factory 都有成功缓存的条目。解析失败不会留下缓存项,因此该路径会重新进入下一批次的 frontier。这是有意为之 —— 参见替代方案 D。 -### 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 verifies (and, if needed, introduces a `[String: Task]` in-flight map inside each factory). +1. **用户对正在被后台批次索引的相同路径执行手动 `loadImage(path)`。** + ObjC / Swift factory 必须按路径串行化解析,使两个并发调用方不会同时解析。规划阶段会核验(如有需要,会在每个 factory 中引入 `[String: Task]` 形式的 in-flight map)。 -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` then returns false and a future explicit load redoes the work. +2. **批次取消时部分项已完成。** + 已完成项保留 `.completed`;`loadedImagePaths` 的插入不会回滚。在解析过程中收到 `CancellationError` 的 in-flight 项可能在 factory 中留下部分 section —— 本次迭代可接受;`isImageIndexed` 之后会返回 false,未来的显式加载会重做工作。 -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. +3. **同一根镜像的多个批次。** + manager 去重:如果某活动批次的 `rootImagePath == root` 且 `reason` 的判别式匹配,返回其已有 `RuntimeIndexingBatchID` 而非新启动一个。 -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. +4. **事件传输中 Document 关闭。** + 引擎(及其 manager)deinit 时会调用 `AsyncStream.Continuation.finish()`。Coordinator 的 `Task { for await event in manager.events }` 会干净退出。 -### Assumptions +### 假设 -1. **`DocumentState.runtimeEngine` is immutable for the lifetime of a Document.** The property is declared `@Observed public var runtimeEngine: RuntimeEngine = .local` (`DocumentState.swift:10-11`) for historical reasons, but callers do not reassign it after Document creation. The coordinator captures `engine = documentState.runtimeEngine` once at init; if this assumption is violated, batches are dispatched to the wrong engine. A doc comment on the property reinforces this contract. +1. **`DocumentState.runtimeEngine` 在 Document 整个生命周期内不可变。** 该属性出于历史原因被声明为 `@Observed public var runtimeEngine: RuntimeEngine = .local`(`DocumentState.swift:10-11`),但调用方在 Document 创建后不会重新赋值。Coordinator 在 init 时一次性捕获 `engine = documentState.runtimeEngine`;如果该假设被打破,批次会被分发到错误的 engine。在该属性上加一段文档注释强化此契约。 -2. **`RuntimeBackgroundIndexingManager` runs in the engine's hosting process only.** For remote (XPC / directTCP) sources, the *engine methods* are mirrored via `request { local } remote: { RPC }`, but the *manager* lives in the server-side engine's actor. UI clients consume manager state only from their local engine reference. +2. **`RuntimeBackgroundIndexingManager` 仅运行在引擎的宿主进程内。** 对于远程(XPC / directTCP)来源,*引擎方法*通过 `request { local } remote: { RPC }` 镜像,但 *manager* 存活在服务端引擎的 actor 中。UI 客户端只通过本地引擎引用消费 manager 状态。 -3. **Settings mutation frequency is low.** `withObservationTracking` re-registration fires once per property mutation. Because Settings sliders / toggles run at human-UI cadence, the re-registration cost is negligible. +3. **Settings 修改频率较低。** `withObservationTracking` 重新注册在每次属性变更时触发一次。由于 Settings 的滑块 / toggle 以人类 UI 节奏运行,重新注册的成本可忽略不计。 -### Testing Strategy +### 测试策略 -Added under `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/`. +放在 `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` (`@unchecked Sendable`) conforming to a new internal `BackgroundIndexingEngineRepresenting` protocol. - - Graph expansion at depth 0, 1, 2; already-indexed short-circuit. - - `prioritize` causes the next dispatch to pick a boosted path. **Timing-based assertions are replaced with event-order assertions** (`taskStarted` sequence) to avoid CI flakiness. - - `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. + - `@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 is not automated (no existing UI test harness); the plan includes a manual verification checklist. +UI 不做自动化(没有现成的 UI 测试 harness);plan 包含一份手动验证清单。 -### File Inventory +### 文件清单 -#### New files +#### 新增文件 ``` RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ @@ -659,95 +659,95 @@ RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/ BackgroundIndexingNode.swift ``` -Note the absence of a `BackgroundIndexingPopoverRoute.swift` — routing is via `MainRoute`. +注意没有 `BackgroundIndexingPopoverRoute.swift` —— 路由通过 `MainRoute`。 -#### Modified files +#### 修改的文件 ``` RuntimeViewerCore/Package.swift - + add .product(name: "Semaphore", package: "Semaphore") to RuntimeViewerCore target + + 在 RuntimeViewerCore target 增加 .product(name: "Semaphore", package: "Semaphore") RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift - + BackgroundIndexing struct + + BackgroundIndexing 结构体 RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift - + backgroundIndexing property + + backgroundIndexing 属性 RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift - + SettingsPage.backgroundIndexing case and contentView branch + + SettingsPage.backgroundIndexing case 与 contentView 分支 RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift - + backgroundIndexingManager stored property (set at end of init) - + isImageIndexed(path:) with request/remote dispatch - + mainExecutablePath() with request/remote dispatch - + loadImageForBackgroundIndexing(at:) with request/remote dispatch - + imageDidLoadPublisher (PassthroughSubject) - + emit imageDidLoadSubject.send(path) on loadImage(at:) success - + access level bumped to internal on objcSectionFactory / swiftSectionFactory - + new CommandNames + setMessageHandlerBinding handlers for the three new methods + + 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:) inspector - + optional per-path in-flight dedupe (plan verifies) + + hasCachedSection(for:) 查询接口 + + 可选的按路径 in-flight 去重(plan 验证) RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift - + backgroundIndexingCoordinator property - + doc comment asserting runtimeEngine immutability + + backgroundIndexingCoordinator 属性 + + 文档注释,断言 runtimeEngine 不可变 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift - + backgroundIndexing(sender:) case (no "Popover" suffix) + + backgroundIndexing(sender:) case(不带 "Popover" 后缀) RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift - + backgroundIndexing item identifier + factory - + wireBackgroundIndexing(item:) hookup + + backgroundIndexing 项标识符 + 工厂 + + wireBackgroundIndexing(item:) 绑定 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift - + backgroundIndexing(sender:) transition case + + backgroundIndexing(sender:) 转换 case RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift - + invoke coordinator.documentDidOpen / documentWillClose + + 调用 coordinator.documentDidOpen / documentWillClose ``` -All new files under `RuntimeViewerUsingAppKit/.../BackgroundIndexing/` must be added to the Xcode project manually (consistent with the MCPServer pattern noted in project memory). +`RuntimeViewerUsingAppKit/.../BackgroundIndexing/` 下所有新文件必须手动加入 Xcode 项目(与 project memory 中提到的 MCPServer 模式一致)。 -## Alternatives Considered +## 替代方案考量 -### A. Subscribe to `Settings` via a new `Combine.PassthroughSubject` +### A. 通过新增的 `Combine.PassthroughSubject` 订阅 `Settings` -Add a `PassthroughSubject` to `Settings`, emit from `scheduleAutoSave`, and let the coordinator subscribe with Combine. Rejected because `Settings` is already `@Observable` — adding a parallel Combine channel would duplicate the source of truth and force future readers to pick one. `withObservationTracking` is the native fit and scales to the few properties we observe. +在 `Settings` 上加一个 `PassthroughSubject`,从 `scheduleAutoSave` 中发出,让 coordinator 用 Combine 订阅。被否决,因为 `Settings` 已是 `@Observable` —— 增加一条平行 Combine 通道会复制事实来源,并迫使未来的读者二选一。`withObservationTracking` 是原生匹配,且对我们观察的少量属性可以扩展。 -### B. Separate `BackgroundIndexingPopoverRoute` enum +### B. 单独的 `BackgroundIndexingPopoverRoute` 枚举 -Mirror the `MCPStatusPopover` structure and define a dedicated Route enum. Rejected because `MainCoordinator` is already bound to `SceneCoordinator`; adding a second, conditional `Router` conformance would not compile. Forwarding via a separate adapter was considered but is heavier than just adding a case to `MainRoute`, which costs one line. +镜像 `MCPStatusPopover` 的结构,定义一个专属的 Route 枚举。被否决,因为 `MainCoordinator` 已经绑定到 `SceneCoordinator`;增加第二个、有条件的 `Router` conformance 无法编译。考虑过通过单独的 adapter 转发,但比直接给 `MainRoute` 加一个 case(仅一行成本)更重。 -### C. Non-dispatching local-only engine extensions +### C. 不分发的、仅本地的 engine 扩展 -Keep `isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` as pure local reads (no `request { local } remote: { RPC }` wrapping). Rejected because this would silently return wrong data when the document targets a remote source (XPC / directTCP) — the local engine has no knowledge of the remote process's loaded images. +让 `isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` 保持纯本地读取(不包裹 `request { local } remote: { RPC }`)。被否决,因为当 document 目标是远程源(XPC / directTCP)时这会静默返回错误数据 —— 本地 engine 对远程进程已加载的镜像一无所知。 -### D. Cache empty/nil parse results to create an "attempted" bit +### D. 缓存空 / nil 解析结果以建立"已尝试"位 -Let `hasCachedSection(for:)` count failed parses as indexed, so failures are not retried. Rejected: the factory cache currently stores a successful `Section` value, and introducing a `Result` or parallel `attemptedFailures` set propagates through many call sites. The simpler semantics — "indexed" = "parsed successfully" — means failed paths retry on the next batch, which is acceptable given how rare deterministic-but-recoverable parse failures are in practice. +让 `hasCachedSection(for:)` 把解析失败也算作已索引,从而避免重试。被否决:factory 缓存目前存的是成功的 `Section` 值,引入 `Result` 或并行的 `attemptedFailures` 集合会传播到许多调用点。更简单的语义 —— "indexed" = "成功解析" —— 意味着失败路径会在下一批次中重试,鉴于实际中确定性但可恢复的解析失败相当少见,这一选择可接受。 -### E. Drop finished/cancelled batches from the UI immediately +### E. UI 立即丢弃已完成 / 已取消的批次 -Simpler reducer logic: when `.batchFinished` / `.batchCancelled` arrives, remove the batch from the coordinator relay and the popover forgets it existed. Rejected because failed batches carry actionable information; silently losing them means the toolbar's `hasFailures` indicator never surfaces. Instead, finished batches with any `.failed` item are retained until the user clicks `Clear Failed` in the popover. +更简单的归约逻辑:`.batchFinished` / `.batchCancelled` 到达时从 coordinator relay 中移除批次,弹出框就忘掉它存在过。被否决,因为失败的批次承载着可操作信息;静默丢失它们意味着 toolbar 的 `hasFailures` 指示器永远不会浮现。改为:包含任何 `.failed` 项的已完成批次会被保留,直到用户点击弹出框中的 `Clear Failed`。 -## Impact +## 影响 -- **Breaking changes**: No. The feature is opt-in (default off) and does not alter the existing `loadImage(at:)` semantics. -- **Files affected**: see File Inventory above. -- **Migration needed**: No. Settings defaults are written by the existing `@Codable` path; absent keys fall back to the `@Default` values. +- **破坏性变更**: 无。该功能是可选的(默认关闭),且不修改既有 `loadImage(at:)` 的语义。 +- **受影响文件**: 见上文文件清单。 +- **是否需要迁移**: 不需要。Settings 默认值由已有的 `@Codable` 路径写入;缺失键回退到 `@Default` 值。 -## Decision Log +## 决策日志 -| Date | Decision | Reason | +| 日期 | 决策 | 理由 | |------|----------|--------| -| 2026-04-24 | Created as Draft | Spec derived from brainstorming on opt-in, Swift-Concurrency-based background indexing for dyld-loaded dependency closures | -| 2026-04-24 | Settings subscription → `withObservationTracking` | `Settings` is `@Observable`; avoid parallel Combine channel | -| 2026-04-24 | `BackgroundIndexingPopoverRoute` merged into `MainRoute` | `MainCoordinator` is `SceneCoordinator`; conditional second conformance not compilable | -| 2026-04-24 | All new engine methods use `request { local } remote: { RPC }` | Remote (XPC / directTCP) sources would otherwise read local-process data | -| 2026-04-24 | `isImageIndexed` = "successfully parsed" only | Avoids Result-wrapping every factory cache entry; failed paths retry | -| 2026-04-24 | `DocumentState.runtimeEngine` treated as immutable | Coordinator captures engine once at init; reassignment is out of scope | -| 2026-04-24 | Finished batches with failures retained until dismissed | Preserves actionable failure information; drives toolbar `hasFailures` state | -| 2026-04-24 | Status → Accepted | Review decisions incorporated; plan regenerated to match | +| 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 重新生成以匹配 | diff --git a/Documentations/Plans/2026-04-24-background-indexing-plan.md b/Documentations/Plans/2026-04-24-background-indexing-plan.md index 37c40a1b..ad3aed3a 100644 --- a/Documentations/Plans/2026-04-24-background-indexing-plan.md +++ b/Documentations/Plans/2026-04-24-background-indexing-plan.md @@ -1,56 +1,54 @@ -# 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 [0002-background-indexing.md](../Evolution/0002-background-indexing.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 0 — Package wiring +## Phase 0 —— Package 接线 -### Task 0: Declare Semaphore as an explicit dependency of `RuntimeViewerCore` +### 任务 0: 将 Semaphore 声明为 `RuntimeViewerCore` 的显式依赖 -**Files:** -- Modify: `RuntimeViewerCore/Package.swift` +**文件:** +- 修改: `RuntimeViewerCore/Package.swift` -**Why:** The `groue/Semaphore` package is already resolved for the `RuntimeViewerCommunication` target (see `Package.swift:163`), but `RuntimeViewerCore`'s own target does not declare it. `RuntimeBackgroundIndexingManager.swift` (Task 6) will `import Semaphore`; relying on transitive visibility is brittle (breaks the moment `.memberImportVisibility` is enabled, which is already defined at `Package.swift:200`). Make the dependency explicit before any code uses it. +**为什么:** `groue/Semaphore` 包已经为 `RuntimeViewerCommunication` target 解析(参见 `Package.swift:163`),但 `RuntimeViewerCore` 自身的 target 并未声明。`RuntimeBackgroundIndexingManager.swift`(任务 6)会 `import Semaphore`;依赖传递可见性是脆弱的(一旦启用 `.memberImportVisibility` 就会失效,而该选项已经在 `Package.swift:200` 定义)。在任何代码使用之前先把依赖显式化。 -- [ ] **Step 1: Edit the `RuntimeViewerCore` target's `dependencies` array** +- [ ] **Step 1: 编辑 `RuntimeViewerCore` target 的 `dependencies` 数组** -In `RuntimeViewerCore/Package.swift`, inside `.target(name: "RuntimeViewerCore", dependencies: [...])` (currently lines 142-157), append: +在 `RuntimeViewerCore/Package.swift` 的 `.target(name: "RuntimeViewerCore", dependencies: [...])`(当前行 142-157)内,在已有的 `MetaCodable` 产品之后追加: ```swift .product(name: "Semaphore", package: "Semaphore"), ``` -after the existing `MetaCodable` product. - -- [ ] **Step 2: Resolve & build** +- [ ] **Step 2: 解析并构建** ```bash cd RuntimeViewerCore && swift package update && swift build 2>&1 | xcsift ``` -Expected: clean build (no code changes yet). +预期:构建无报错(尚未变更代码)。 -- [ ] **Step 3: Commit** +- [ ] **Step 3: 提交** ```bash git add RuntimeViewerCore/Package.swift @@ -59,25 +57,25 @@ git commit -m "chore(core): add Semaphore as explicit RuntimeViewerCore dependen --- -## Phase 1 — Foundation value types +## Phase 1 —— 基础值类型 -### Task 1: Create Sendable + Hashable value types for indexing events and batches +### 任务 1: 为索引事件与批次创建 Sendable + Hashable 值类型 -**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` -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift` -- Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift` +**文件:** +- 创建: `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` -**Why Hashable everywhere:** `BackgroundIndexingNode` (Task 18) is declared `Hashable` so it can key `NSOutlineView` / `NSDiffableDataSource` updates. Its associated values transitively need `Hashable`. Declaring it up front is cheaper than backfilling later. +**为什么处处都是 Hashable:** `BackgroundIndexingNode`(任务 18)声明为 `Hashable`,以便用作 `NSOutlineView` / `NSDiffableDataSource` 的更新键。它的关联值需要传递性的 `Hashable`。提前声明比后续补回更便宜。 -- [ ] **Step 1: Write failing tests for value type invariants** +- [ ] **Step 1: 写出针对值类型不变量的失败测试** -File `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift`: +文件 `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift`: ```swift import XCTest @@ -125,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 @@ -152,7 +150,7 @@ public struct RuntimeIndexingBatchID: Hashable, Sendable { } ``` -File `RuntimeIndexingBatchReason.swift`: +文件 `RuntimeIndexingBatchReason.swift`: ```swift public enum RuntimeIndexingBatchReason: Sendable, Hashable { @@ -163,7 +161,7 @@ public enum RuntimeIndexingBatchReason: Sendable, Hashable { } ``` -File `RuntimeIndexingTaskState.swift`: +文件 `RuntimeIndexingTaskState.swift`: ```swift public enum RuntimeIndexingTaskState: Sendable, Hashable { @@ -182,7 +180,7 @@ public enum RuntimeIndexingTaskState: Sendable, Hashable { } ``` -File `RuntimeIndexingTaskItem.swift`: +文件 `RuntimeIndexingTaskItem.swift`: ```swift public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Hashable { @@ -202,7 +200,7 @@ public struct RuntimeIndexingTaskItem: Sendable, Identifiable, Hashable { } ``` -File `ResolvedDependency.swift`: +文件 `ResolvedDependency.swift`: ```swift public struct ResolvedDependency: Sendable, Hashable { @@ -216,7 +214,7 @@ public struct ResolvedDependency: Sendable, Hashable { } ``` -File `RuntimeIndexingBatch.swift`: +文件 `RuntimeIndexingBatch.swift`: ```swift public struct RuntimeIndexingBatch: Sendable, Identifiable, Hashable { @@ -250,7 +248,7 @@ public struct RuntimeIndexingBatch: Sendable, Identifiable, Hashable { } ``` -File `RuntimeIndexingEvent.swift`: +文件 `RuntimeIndexingEvent.swift`: ```swift public enum RuntimeIndexingEvent: Sendable { @@ -264,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 @@ -281,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/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 @@ -388,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 @@ -453,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 @@ -470,30 +468,30 @@ git commit -m "feat(core): add DylibPathResolver for @rpath / @executable_path / --- -## Phase 2 — Engine extensions +## Phase 2 —— Engine 扩展 -### Task 3: Expose `hasCachedSection` on both section factories; add `isImageIndexed` to engine with `request/remote` dispatch +### 任务 3: 在两个 section factory 上暴露 `hasCachedSection`;在 engine 上加 `isImageIndexed`,使用 `request/remote` 分发 -**Files:** -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift` (factory area) -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift` (factory area) -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` (bump factories to `internal`; add `.isImageIndexed` to `CommandNames`; register handler) -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` -- Test: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift` +**文件:** +- 修改: `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` -**Why `request/remote`:** When the document targets a remote source (XPC / directTCP), the local engine's factory caches are empty — only the server process has the truth. Every existing public engine method uses the `request(local:remote:)` primitive (`RuntimeEngine.swift:468`); skipping it here would return wrong data for remote sources. +**为什么要 `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/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 @@ -516,9 +514,9 @@ final class RuntimeEngineIndexStateTests: XCTestCase { } ``` -- [ ] **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 { @@ -526,7 +524,7 @@ func hasCachedSection(for path: String) -> Bool { } ``` -In `RuntimeSwiftSection.swift`, same pattern: +在 `RuntimeSwiftSection.swift`,相同模式: ```swift func hasCachedSection(for path: String) -> Bool { @@ -534,38 +532,38 @@ 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: Widen factory access level (must-do)** +- [ ] **Step 4: 放宽 factory 的访问级别(必做)** -`RuntimeEngine.swift:147-149` currently declares both factories as `private`: +`RuntimeEngine.swift:147-149` 当前将两个 factory 都声明为 `private`: ```swift private let objcSectionFactory: RuntimeObjCSectionFactory private let swiftSectionFactory: RuntimeSwiftSectionFactory ``` -Change both to `internal` (drop the `private` keyword; default is `internal`). This is required for the `+BackgroundIndexing.swift` extension below. Verified against current code — the factories are definitely `private` today. +将两者都改为 `internal`(去掉 `private` 关键字;默认即 `internal`)。下面的 `+BackgroundIndexing.swift` 扩展需要这一改动。已经核验过当前代码 —— 这两个 factory 现在确为 `private`。 -- [ ] **Step 5: Add `.isImageIndexed` to `CommandNames` and register the server handler** +- [ ] **Step 5: 在 `CommandNames` 中加 `.isImageIndexed` 并注册服务端处理器** -In `RuntimeEngine.swift`, find the `CommandNames` enum (around line 62). Add: +在 `RuntimeEngine.swift` 中找到 `CommandNames` 枚举(约第 62 行)。添加: ```swift case isImageIndexed ``` -In the `setMessageHandlerBinding(...)` block near line 276, add: +在第 276 行附近的 `setMessageHandlerBinding(...)` 块中添加: ```swift setMessageHandlerBinding(forName: .isImageIndexed, of: self) { $0.isImageIndexed(path:) } ``` -This slots in next to the existing `.isImageLoaded` binding. +正好和已有的 `.isImageLoaded` 绑定相邻。 -- [ ] **Step 6: Create the engine extension using `request/remote` dispatch** +- [ ] **Step 6: 创建使用 `request/remote` 分发的 engine 扩展** -File `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift`: +文件 `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift`: ```swift import Foundation @@ -584,17 +582,17 @@ extension RuntimeEngine { } ``` -Note: the test in Step 2 has been updated above to `try await engine.isImageIndexed(path:)` since the method now throws. +注意:上面 Step 2 中的测试已更新为 `try await engine.isImageIndexed(path:)`,因为该方法现在 throws。 -- [ ] **Step 7: 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 8: Commit** +- [ ] **Step 8: 提交** ```bash git add RuntimeViewerCore @@ -603,27 +601,27 @@ git commit -m "feat(core): add isImageIndexed with request/remote dispatch + fac --- -### Task 4: Add `mainExecutablePath` and `loadImageForBackgroundIndexing` to engine (with `request/remote` dispatch) +### 任务 4: 在 engine 上加 `mainExecutablePath` 与 `loadImageForBackgroundIndexing`(带 `request/remote` 分发) -**Files:** -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` (add two `CommandNames` cases + handlers) -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift` (only if helper missing) -- Test: append to `RuntimeEngineIndexStateTests.swift` +**文件:** +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift` +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift`(增加两个 `CommandNames` case + 处理器) +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift`(仅当辅助缺失时) +- 测试: 追加到 `RuntimeEngineIndexStateTests.swift` -**Why `request/remote`:** Same rationale as Task 3. `mainExecutablePath` must reflect the target process, not the local process; for a remote source the correct answer is only known on the server side. `loadImageForBackgroundIndexing` must also execute inside the target process. +**为什么要 `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/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 throws { @@ -645,16 +643,16 @@ In `RuntimeEngineIndexStateTests.swift`, append: } ``` -- [ ] **Step 3: Add `CommandNames` cases + server handlers** +- [ ] **Step 3: 增加 `CommandNames` case + 服务端处理器** -In `RuntimeEngine.swift` `CommandNames` enum: +在 `RuntimeEngine.swift` 的 `CommandNames` 枚举: ```swift case mainExecutablePath case loadImageForBackgroundIndexing ``` -In the `setMessageHandlerBinding(...)` block: +在 `setMessageHandlerBinding(...)` 块中: ```swift setMessageHandlerBinding(forName: .mainExecutablePath, @@ -663,9 +661,9 @@ setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, of: self) { $0.loadImageForBackgroundIndexing(at:) } ``` -- [ ] **Step 4: Implement the new engine methods with `request/remote` dispatch** +- [ ] **Step 4: 用 `request/remote` 分发实现新的 engine 方法** -Append to `RuntimeEngine+BackgroundIndexing.swift`: +追加到 `RuntimeEngine+BackgroundIndexing.swift`: ```swift extension RuntimeEngine { @@ -696,17 +694,17 @@ extension RuntimeEngine { } ``` -Note the `try await` on both factory calls — matches the verified signature `section(for:progressContinuation:) async throws -> (isExisted: Bool, section: ...)` at `RuntimeObjCSection.swift:704` / `RuntimeSwiftSection.swift:802`. +注意两次 factory 调用的 `try await` —— 与已核验的签名 `section(for:progressContinuation:) async throws -> (isExisted: Bool, section: ...)` 一致(`RuntimeObjCSection.swift:704` / `RuntimeSwiftSection.swift:802`)。 -- [ ] **Step 5: Run tests — expect pass** +- [ ] **Step 5: 运行测试 —— 预期通过** ```bash cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift ``` -Expected: all tests in that file pass. +预期:该文件中的所有测试通过。 -- [ ] **Step 6: Commit** +- [ ] **Step 6: 提交** ```bash git add RuntimeViewerCore @@ -715,25 +713,25 @@ git commit -m "feat(core): mainExecutablePath + loadImageForBackgroundIndexing w --- -### Task 4.5: Add `imageDidLoadPublisher` on `RuntimeEngine` +### 任务 4.5: 在 `RuntimeEngine` 上添加 `imageDidLoadPublisher` -**Files:** -- Modify: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` -- Test: append to `RuntimeEngineIndexStateTests.swift` +**文件:** +- 修改: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift` +- 测试: 追加到 `RuntimeEngineIndexStateTests.swift` -**Why:** The coordinator (Task 16) needs a signal that carries the path of the newly-loaded image. `RuntimeEngine` today only exposes `reloadDataPublisher` (no payload) and `imageNodesPublisher` (full list); there is no per-image signal. Task 16 will subscribe to this new publisher. The local branch emits after `loadImage(at:)` succeeds; the remote branch's `setMessageHandlerBinding(forName: .imageDidLoad)` handler emits on the client side when the server forwards the event. +**为什么:** Coordinator(任务 16)需要一个携带新加载镜像路径的信号。当今 `RuntimeEngine` 只暴露 `reloadDataPublisher`(无负载)和 `imageNodesPublisher`(完整列表);没有按镜像的信号。任务 16 会订阅这一新 publisher。本地分支在 `loadImage(at:)` 成功后发出;远程分支的 `setMessageHandlerBinding(forName: .imageDidLoad)` 处理器在服务器转发事件时由客户端发出。 -- [ ] **Step 1: Inspect the existing `reloadDataPublisher` wiring for pattern parity** +- [ ] **Step 1: 检查现有的 `reloadDataPublisher` 接线,作为模式参照** ```bash rg -n "reloadDataPublisher|reloadDataSubject|PassthroughSubject" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift | head ``` -Expected finding: `private nonisolated let reloadDataSubject = PassthroughSubject()` with a `nonisolated` public property exposing it, and a `setMessageHandlerBinding(forName: .reloadData) { $0.reloadDataSubject.send() }` on the server handler table. +预期发现:`private nonisolated let reloadDataSubject = PassthroughSubject()`、暴露它的 `nonisolated` 公共属性,以及在服务端处理器表上的 `setMessageHandlerBinding(forName: .reloadData) { $0.reloadDataSubject.send() }`。 -- [ ] **Step 2: Add the subject + publisher** +- [ ] **Step 2: 添加 subject + publisher** -In `RuntimeEngine.swift` next to the existing `reloadDataSubject`: +在 `RuntimeEngine.swift` 中已有的 `reloadDataSubject` 旁: ```swift private nonisolated let imageDidLoadSubject = PassthroughSubject() @@ -743,15 +741,15 @@ public nonisolated var imageDidLoadPublisher: some Publisher { } ``` -- [ ] **Step 3: Add `.imageDidLoad` to `CommandNames` and wire both sides** +- [ ] **Step 3: 在 `CommandNames` 加 `.imageDidLoad` 并双向接线** -In `CommandNames`: +在 `CommandNames`: ```swift case imageDidLoad ``` -In the handler table, mirror the `reloadData` pattern so remote clients also receive the event: +在处理器表中,与 `reloadData` 模式镜像,让远程客户端也接收事件: ```swift setMessageHandlerBinding(forName: .imageDidLoad) { (engine: RuntimeEngine, path: String) in @@ -759,7 +757,7 @@ setMessageHandlerBinding(forName: .imageDidLoad) { (engine: RuntimeEngine, path: } ``` -In `loadImage(at:)` (currently `RuntimeEngine.swift:485-495`), after the existing `reloadData(isReloadImageNodes: false)` call, emit: +在 `loadImage(at:)`(当前位于 `RuntimeEngine.swift:485-495`)中,在已有的 `reloadData(isReloadImageNodes: false)` 调用之后发出: ```swift imageDidLoadSubject.send(path) @@ -767,9 +765,9 @@ sendRemoteDataIfNeeded(name: .imageDidLoad, payload: path) // or inline the remote push similar to sendRemoteDataIfNeeded(isReloadImageNodes:) ``` -Verify the existing `sendRemoteDataIfNeeded(...)` signature — if it doesn't accept an arbitrary command name, add a small `sendRemoteImageDidLoad(_ path: String)` helper beside it. +核验现有 `sendRemoteDataIfNeeded(...)` 签名 —— 如果它不接受任意命令名,在它旁边新增一个小辅助 `sendRemoteImageDidLoad(_ path: String)`。 -- [ ] **Step 4: Append a test** +- [ ] **Step 4: 追加测试** ```swift func test_imageDidLoadPublisher_firesAfterLoadImage() async throws { @@ -788,15 +786,15 @@ Verify the existing `sendRemoteDataIfNeeded(...)` signature — if it doesn't ac } ``` -Add `import Combine` at the top of the test file if not present. +如果测试文件顶部尚无 `import Combine`,请添加。 -- [ ] **Step 5: Run tests** +- [ ] **Step 5: 运行测试** ```bash cd RuntimeViewerCore && swift test --filter RuntimeEngineIndexStateTests 2>&1 | xcsift ``` -- [ ] **Step 6: Commit** +- [ ] **Step 6: 提交** ```bash git add RuntimeViewerCore @@ -805,17 +803,17 @@ git commit -m "feat(core): imageDidLoadPublisher for per-path load notifications --- -## Phase 3 — The indexing manager +## Phase 3 —— 索引管理器 -### Task 5: Declare the engine-representing protocol and mock +### 任务 5: 声明 engine 表示协议与 mock -**Files:** -- Create: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift` -- Create: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift` +**文件:** +- 创建: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift` +- 创建: `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift` -- [ ] **Step 1: Create the protocol** +- [ ] **Step 1: 创建协议** -File `BackgroundIndexingEngineRepresenting.swift`: +文件 `BackgroundIndexingEngineRepresenting.swift`: ```swift import MachOKit @@ -839,9 +837,9 @@ protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable { } ``` -- [ ] **Step 2: Conform `RuntimeEngine` to the protocol** +- [ ] **Step 2: 让 `RuntimeEngine` 遵循该协议** -Append to `RuntimeEngine+BackgroundIndexing.swift`: +追加到 `RuntimeEngine+BackgroundIndexing.swift`: ```swift extension RuntimeEngine: BackgroundIndexingEngineRepresenting { @@ -872,11 +870,11 @@ 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. +如果实际的 MachOImage API 将 `rpaths` 返回为如 `[RpathCommand]`、其 `.path` 为字符串,请把 `image.rpaths` 替换为正确的访问器(如 `image.rpaths.map { $0.path }`)。本任务开头先做探索,并坚持使用已核验的 API。 -- [ ] **Step 3: Create the mock** +- [ ] **Step 3: 创建 mock** -File `MockBackgroundIndexingEngine.swift`: +文件 `MockBackgroundIndexingEngine.swift`: ```swift import Foundation @@ -937,15 +935,15 @@ final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, } ``` -- [ ] **Step 4: Compile check** +- [ ] **Step 4: 编译检查** ```bash cd RuntimeViewerCore && swift build 2>&1 | xcsift ``` -Expected: build succeeds. +预期:构建成功。 -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerCore @@ -954,15 +952,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 @@ -1007,17 +1005,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 @@ -1114,15 +1112,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 @@ -1131,15 +1129,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 { @@ -1210,9 +1208,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) @@ -1257,15 +1255,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 @@ -1274,15 +1272,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 { @@ -1393,11 +1391,11 @@ Append: } ``` -Add `import MachOKit` at the top of the test file if not already present. +如果测试文件顶部尚未添加 `import MachOKit`,请添加。 -- [ ] **Step 2: Replace the `runBatch` stub with real execution** +- [ ] **Step 2: 用真正的执行替换 `runBatch` 桩** -In `RuntimeBackgroundIndexingManager.swift` replace `runBatch` and introduce a helper `runSingleIndex`: +在 `RuntimeBackgroundIndexingManager.swift` 中替换 `runBatch` 并引入辅助 `runSingleIndex`: ```swift private func runBatch(id: RuntimeIndexingBatchID) async { @@ -1481,15 +1479,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 @@ -1498,15 +1496,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 { @@ -1551,9 +1549,9 @@ Append: } ``` -- [ ] **Step 2: Implement cancellation** +- [ ] **Step 2: 实现取消** -Add these methods to `RuntimeBackgroundIndexingManager`: +在 `RuntimeBackgroundIndexingManager` 中加入: ```swift public func cancelBatch(_ id: RuntimeIndexingBatchID) { @@ -1569,7 +1567,7 @@ public func cancelAllBatches() { } ``` -Update `finalize` to propagate the already-set `isCancelled` flag: +更新 `finalize` 以传播已经设置的 `isCancelled` 标志: ```swift private func finalize(id: RuntimeIndexingBatchID, cancelled: Bool) { @@ -1596,13 +1594,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 @@ -1611,15 +1609,15 @@ 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_emitsTaskPrioritizedEvent() async { @@ -1666,9 +1664,9 @@ Append: } ``` -- [ ] **Step 2: Implement prioritize** +- [ ] **Step 2: 实现 prioritize** -Add to `RuntimeBackgroundIndexingManager`: +在 `RuntimeBackgroundIndexingManager` 中加入: ```swift public func prioritize(imagePath: String) { @@ -1685,13 +1683,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 @@ -1700,24 +1698,24 @@ 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/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 an explicit stored property and initialize it at the end of `init`** +- [ ] **Step 2: 增加显式存储属性,并在 `init` 末尾初始化** -`lazy var` on an actor forces every first access through actor-isolation, which makes the initialization point non-obvious and interacts awkwardly with `nonisolated` accessors. Use an explicit implicitly-unwrapped stored property set as the last line of `init`: +actor 上的 `lazy var` 强制每次首次访问都通过 actor 隔离,使初始化点不直观,且与 `nonisolated` 访问器交互不顺畅。改用一个显式的隐式可解包存储属性,作为 `init` 的最后一行赋值: ```swift // Near the other stored properties: @@ -1727,17 +1725,17 @@ public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingMana self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) ``` -Rationale for IUO: the actor cannot hand `self` to the manager before `init` finishes registering the other stored properties, and the manager is read-only after init — no reassignment paths, no nil access paths outside the one-line bootstrap. +IUO 的理由:actor 不能在 `init` 完成对其他存储属性的注册前把 `self` 交给 manager;而 manager 在 init 之后是只读的 —— 不存在重新赋值的路径,也不存在一行 bootstrap 之外的 nil 访问路径。 -- [ ] **Step 3: Build** +- [ ] **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 @@ -1746,22 +1744,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/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 { @@ -1772,21 +1770,21 @@ 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` 加入新存储属性: ```swift @Default(BackgroundIndexing.default) public var backgroundIndexing: BackgroundIndexing ``` -- [ ] **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 @@ -1795,23 +1793,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/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 { @@ -1831,15 +1829,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 @@ -1880,13 +1878,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 @@ -1895,24 +1893,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/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 @@ -2048,15 +2046,15 @@ public final class RuntimeBackgroundIndexingCoordinator { } ``` -The `mutating(_:_:)` helper is now a private method on the coordinator (see earlier insertion). It is not a global function — `private` file-scope would still pollute any future file in the same module, and a private method keeps the utility scoped to the coordinator that needs it. +`mutating(_:_:)` 辅助函数现在是 coordinator 上的私有方法(参见上面插入位置)。它不是全局函数 —— 文件作用域的 `private` 仍会污染同模块未来文件,而私有方法把工具范围限定在需要它的 coordinator 内。 -- [ ] **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 @@ -2065,14 +2063,14 @@ 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 { @@ -2110,15 +2108,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 @@ -2127,20 +2125,20 @@ 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** +- [ ] **Step 1: 检查 engine 的镜像加载信号** ```bash rg -n "didLoadImage|imageLoaded|imageDidLoad|PublishSubject.*String" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/ | head ``` -Record the exact Rx observable or async sequence name. Adapt the subscription below to match. +记录精确的 Rx observable 或 async sequence 名称,调整下面的订阅以匹配。 -- [ ] **Step 2: Add the subscription in the coordinator init, after `startEventPump()`** +- [ ] **Step 2: 在 coordinator init 的 `startEventPump()` 之后增加订阅** ```swift private func subscribeToImageLoadedEvents() { @@ -2167,9 +2165,9 @@ private func handleImageLoaded(path: String) async { } ``` -Call `subscribeToImageLoadedEvents()` at the end of `init`. +在 `init` 末尾调用 `subscribeToImageLoadedEvents()`。 -If the engine exposes only an `AsyncSequence` (not Rx), replace the subscription with: +如果 engine 仅暴露 `AsyncSequence`(不是 Rx),把订阅替换为: ```swift imageEventPumpTask = Task { [weak self] in @@ -2180,13 +2178,13 @@ imageEventPumpTask = Task { [weak self] in } ``` -- [ ] **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 @@ -2195,29 +2193,29 @@ git commit -m "feat(application): subscribe to engine image-loaded events to spa --- -### Task 17: React to Settings changes via `withObservationTracking` +### 任务 17: 通过 `withObservationTracking` 响应 Settings 变更 -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingCoordinator.swift` -**Why `withObservationTracking` (not Combine):** `Settings` at `RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift:6` is declared `@Observable`. It has no Combine publisher, and the `scheduleAutoSave` path only fires through `didSet`. Adding a parallel `PassthroughSubject` would duplicate the source of truth. `withObservationTracking` is the native fit — the coordinator reads the tracked properties inside the `apply` closure, and Swift Observation registers a one-shot observer. We re-register inside `onChange` to keep observing across each mutation. +**为什么用 `withObservationTracking`(不用 Combine):** `RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift:6` 的 `Settings` 声明为 `@Observable`。它没有 Combine publisher,`scheduleAutoSave` 路径只通过 `didSet` 触发。增加平行的 `PassthroughSubject` 会复制事实来源。`withObservationTracking` 是原生匹配 —— coordinator 在 `apply` 闭包内读取被跟踪的属性,Swift Observation 注册一次性观察者。我们在 `onChange` 内重新注册以在每次变更后保持观察。 -- [ ] **Step 1: Add observation imports and state** +- [ ] **Step 1: 添加 observation 导入与状态** -At the top of `RuntimeBackgroundIndexingCoordinator.swift`: +在 `RuntimeBackgroundIndexingCoordinator.swift` 顶部: ```swift import Observation import RuntimeViewerSettings ``` -Add private state on the coordinator class: +在 coordinator 类上加私有状态: ```swift @MainActor private var lastKnownIsEnabled: Bool = false ``` -- [ ] **Step 2: Implement the observation loop** +- [ ] **Step 2: 实现 observation 循环** ```swift @MainActor @@ -2255,9 +2253,9 @@ private func handleSettingsChange() { } ``` -- [ ] **Step 3: Seed initial state and register from init** +- [ ] **Step 3: 在 init 中播种初始状态并注册** -At the end of `init`: +在 `init` 末尾: ```swift Task { @MainActor [weak self] in @@ -2267,13 +2265,13 @@ Task { @MainActor [weak self] in } ``` -- [ ] **Step 4: Build** +- [ ] **Step 4: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -2282,17 +2280,17 @@ git commit -m "feat(application): observe Settings.backgroundIndexing via withOb --- -## Phase 7 — Toolbar popover UI +## Phase 7 —— Toolbar 弹出框 UI -### Task 18: Create `BackgroundIndexingNode` and popover ViewModel (on `MainRoute`) +### 任务 18: 创建 `BackgroundIndexingNode` 与弹出框 ViewModel(在 `MainRoute` 上) -**Files:** -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift` -- Create: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift` +**文件:** +- 创建: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift` +- 创建: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift` -**Why no separate Route:** `MainCoordinator` is declared `final class MainCoordinator: SceneCoordinator` (`MainCoordinator.swift:11`). Its `Route` is already bound to `MainRoute`; a second conditional `Router` conformance for a `BackgroundIndexingPopoverRoute` would not compile. Instead, add a case to `MainRoute` (Task 21) and let the ViewModel be `ViewModel`. +**为什么没有单独的 Route:** `MainCoordinator` 声明为 `final class MainCoordinator: SceneCoordinator`(`MainCoordinator.swift:11`)。它的 `Route` 已经绑定到 `MainRoute`;为 `BackgroundIndexingPopoverRoute` 增加第二个、有条件的 `Router` conformance 无法编译。改为给 `MainRoute` 加一个 case(任务 21),让 ViewModel 是 `ViewModel`。 -- [ ] **Step 1: Create `BackgroundIndexingNode`** +- [ ] **Step 1: 创建 `BackgroundIndexingNode`** ```swift import RuntimeViewerCore @@ -2303,7 +2301,7 @@ enum BackgroundIndexingNode: Hashable { } ``` -- [ ] **Step 2: Create the ViewModel on `MainRoute`** +- [ ] **Step 2: 在 `MainRoute` 上创建 ViewModel** ```swift import Foundation @@ -2437,28 +2435,28 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { } ``` -Note: `coordinator.clearFailedBatches()` is added in Task 24 together with the "retain failed batches until dismissed" reducer change. If you reach Task 18 before Task 24, leave the `clearFailed` binding as a TODO pass-through and circle back. +注意:`coordinator.clearFailedBatches()` 在任务 24 与"保留失败批次直至被清除"的 reducer 变更一起加入。如果你在任务 24 之前到达任务 18,把 `clearFailed` 绑定保留为 TODO 直通,回头再补。 -- [ ] **Step 3: Add the two new files to the Xcode project** +- [ ] **Step 3: 把两个新文件加入 Xcode 项目** -Using xcodeproj MCP, add: +使用 xcodeproj MCP,加入: ``` RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift ``` -Each to the `RuntimeViewerUsingAppKit` target. There is **no** `BackgroundIndexingPopoverRoute.swift` — routing is via `MainRoute`. +均加入 `RuntimeViewerUsingAppKit` target。**不存在** `BackgroundIndexingPopoverRoute.swift` —— 路由通过 `MainRoute`。 -- [ ] **Step 4: 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 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerUsingAppKit @@ -2467,12 +2465,12 @@ git commit -m "feat(ui): popover ViewModel on MainRoute + BackgroundIndexingNode --- -### Task 19: 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 @@ -2725,17 +2723,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 @@ -2744,13 +2742,13 @@ git commit -m "feat(ui): popover view controller for background indexing" --- -### Task 20: 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 @@ -2838,7 +2836,7 @@ final class BackgroundIndexingToolbarItemView: NSView { } ``` -- [ ] **Step 2: Create the `NSToolbarItem` subclass** +- [ ] **Step 2: 创建 `NSToolbarItem` 子类** ```swift import AppKit @@ -2876,17 +2874,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 @@ -2895,34 +2893,34 @@ git commit -m "feat(ui): toolbar item view and item class for background indexin --- -### Task 21: Register the toolbar item and add the `MainRoute.backgroundIndexing` case +### 任务 21: 注册 toolbar item 并增加 `MainRoute.backgroundIndexing` case -**Files:** -- Modify: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift` -- 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` -**Why it's one route case, not a separate `Router` conformance:** `MainCoordinator` is already `SceneCoordinator`. A conditional `extension MainCoordinator: Router where Route == BackgroundIndexingPopoverRoute` cannot compile — `Route` is pinned to `MainRoute`. The plan therefore extends `MainRoute` directly with one case and routes the popover's `.openSettings` through the existing `MainRoute.openSettings` case. +**为什么是一个 route case 而不是单独的 `Router` conformance:** `MainCoordinator` 已是 `SceneCoordinator`。一个有条件的 `extension MainCoordinator: Router where Route == BackgroundIndexingPopoverRoute` 无法编译 —— `Route` 已固定到 `MainRoute`。因此本计划直接在 `MainRoute` 上扩展一个 case,并把弹出框的 `.openSettings` 通过已有的 `MainRoute.openSettings` case 路由。 -- [ ] **Step 1: Inspect the existing MCPStatus wiring** +- [ ] **Step 1: 检查现有的 MCPStatus 接线** ```bash rg -n "mcpStatus|MCPStatusToolbarItem|toolbarDefaultItemIdentifiers|itemForItemIdentifier" /Volumes/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift | head -30 ``` -Also check `MainRoute.swift:18` — the existing case is literally `case mcpStatus(sender: NSView)`, not `mcpStatusPopover`. Match that naming style. +也查看 `MainRoute.swift:18` —— 已有 case 字面量是 `case mcpStatus(sender: NSView)`,而非 `mcpStatusPopover`。匹配该命名风格。 -- [ ] **Step 2: Add the route case on `MainRoute`** +- [ ] **Step 2: 在 `MainRoute` 上添加 route case** -In `MainRoute.swift`, next to `case mcpStatus(sender: NSView)`, add: +在 `MainRoute.swift` 中、紧挨 `case mcpStatus(sender: NSView)` 加入: ```swift case backgroundIndexing(sender: NSView) ``` -(No `Popover` suffix — matches the sibling `mcpStatus` precedent.) +(无 `Popover` 后缀 —— 与同级 `mcpStatus` 先例一致。) -- [ ] **Step 3: Register the toolbar item in `MainToolbarController`** +- [ ] **Step 3: 在 `MainToolbarController` 中注册 toolbar item** ```swift override func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) @@ -2975,9 +2973,9 @@ private func wireBackgroundIndexing(item: BackgroundIndexingToolbarItem) { } ``` -The exact field names (`documentState`, `mainCoordinator`) must match `MainToolbarController`'s existing fields — adjust if the property is spelled differently. +精确字段名(`documentState`、`mainCoordinator`)必须匹配 `MainToolbarController` 已有字段 —— 如果属性拼写不同请相应调整。 -- [ ] **Step 4: Handle the new case in `MainCoordinator.prepareTransition`** +- [ ] **Step 4: 在 `MainCoordinator.prepareTransition` 处理新 case** ```swift case .backgroundIndexing(let sender): @@ -2995,15 +2993,15 @@ case .backgroundIndexing(let sender): behavior: .transient)) ``` -No `extension MainCoordinator: Router where Route == ...` wrapper is needed — `self` is already `Router`, and the popover's `openSettings` button triggers `router.trigger(.openSettings)` directly (the case already exists on `MainRoute`). +不需要 `extension MainCoordinator: Router where Route == ...` 包装 —— `self` 已经是 `Router`,弹出框的 `openSettings` 按钮直接触发 `router.trigger(.openSettings)`(`MainRoute` 上已有该 case)。 -- [ ] **Step 5: Build** +- [ ] **Step 5: 构建** ```bash xcodebuild build -project RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj -scheme RuntimeViewerUsingAppKit -configuration Debug -destination 'generic/platform=macOS' 2>&1 | xcsift ``` -- [ ] **Step 6: Commit** +- [ ] **Step 6: 提交** ```bash git add RuntimeViewerUsingAppKit @@ -3012,15 +3010,15 @@ git commit -m "feat(ui): toolbar item + MainRoute.backgroundIndexing popover rou --- -## Phase 8 — Integration and QA +## Phase 8 —— 集成与 QA -### Task 22: 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` and reinforce the `runtimeEngine` invariant** +- [ ] **Step 1: 给 `DocumentState` 添加 coordinator 属性并强化 `runtimeEngine` 不变量** ```swift /// Immutable for the lifetime of the Document. The property is declared @@ -3035,11 +3033,11 @@ public private(set) lazy var backgroundIndexingCoordinator = RuntimeBackgroundIndexingCoordinator(documentState: self) ``` -Edit the existing declaration of `runtimeEngine` at `DocumentState.swift:10-11` to include the doc comment above; leave the type and initial value unchanged. +编辑 `DocumentState.swift:10-11` 处 `runtimeEngine` 的现有声明,加入上面的 doc comment;保留类型与初值不变。 -- [ ] **Step 2: Invoke lifecycle hooks from `Document`** +- [ ] **Step 2: 在 `Document` 中调用生命周期钩子** -In `Document.swift`: +在 `Document.swift`: ```swift override func makeWindowControllers() { @@ -3053,9 +3051,9 @@ 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 @@ -3063,7 +3061,7 @@ 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 @@ -3072,20 +3070,20 @@ git commit -m "feat(app): wire background indexing coordinator into Document lif --- -### Task 23: 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/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 @@ -3096,15 +3094,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 . @@ -3113,14 +3111,14 @@ git commit -m "feat(app): prioritize indexing when user selects an image in side --- -### Task 24: Retain failed batches; refresh image list once per batch finish +### 任务 24: 保留失败批次;每个批次结束时刷新一次镜像列表 -**Files:** -- Modify: `RuntimeBackgroundIndexingCoordinator.swift` +**文件:** +- 修改: `RuntimeBackgroundIndexingCoordinator.swift` -**Why retain failed batches:** The toolbar state `.hasFailures(...)` is derived from the coordinator's `aggregateState`. If `.batchFinished` immediately removes the batch — even one containing `.failed` items — the toolbar never surfaces the failure. This task changes the `.batchFinished` / `.batchCancelled` reducer: clean finishes and cancels drop out; finishes with any `.failed` item stay in `batchesRelay` until the user calls `clearFailedBatches()` from the popover. +**为什么保留失败批次:** Toolbar 状态 `.hasFailures(...)` 由 coordinator 的 `aggregateState` 派生。如果 `.batchFinished` 立即移除批次 —— 即便包含 `.failed` 项 —— toolbar 永远不会浮现失败。本任务修改 `.batchFinished` / `.batchCancelled` reducer:干净完成与取消会移除;含任意 `.failed` 项的完成保留在 `batchesRelay` 中,直到用户从弹出框调用 `clearFailedBatches()`。 -- [ ] **Step 1: Update the `apply(event:)` reducer for `.batchFinished` / `.batchCancelled`** +- [ ] **Step 1: 更新 `apply(event:)` reducer 中的 `.batchFinished` / `.batchCancelled`** ```swift case .batchFinished(let finished): @@ -3146,7 +3144,7 @@ case .batchCancelled(let cancelled): } ``` -- [ ] **Step 2: Add `clearFailedBatches()` to the coordinator's public surface** +- [ ] **Step 2: 在 coordinator 公共表面加入 `clearFailedBatches()`** ```swift public func clearFailedBatches() { @@ -3161,19 +3159,19 @@ public func clearFailedBatches() { } ``` -This is the method the Task 18 popover ViewModel calls from its `Clear Failed` button input. +这是任务 18 中弹出框 ViewModel 从 `Clear Failed` 按钮输入调用的方法。 -- [ ] **Step 3: Update `refreshAggregate` so `hasAnyFailure` considers retained batches** +- [ ] **Step 3: 更新 `refreshAggregate`,使 `hasAnyFailure` 考虑保留的批次** -The existing `hasAnyFailure` computation already scans `batches` for `.failed` items, so no change is required — the retained failed batches stay visible in the aggregate state. +已有的 `hasAnyFailure` 计算已经扫描 `batches` 中的 `.failed` 项,无需更改 —— 保留的失败批次会留在聚合状态中。 -- [ ] **Step 4: Build** +- [ ] **Step 4: 构建** ```bash cd RuntimeViewerPackages && swift build 2>&1 | xcsift ``` -- [ ] **Step 5: Commit** +- [ ] **Step 5: 提交** ```bash git add RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -3182,58 +3180,58 @@ git commit -m "feat(application): retain failed batches + single reloadData per --- -### Task 25: 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/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/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 26: 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' @@ -3255,22 +3253,22 @@ EOF --- -## Self-Review Summary - -- **Spec coverage:** every section of the evolution proposal has at least one task. - - Package wiring (Semaphore dependency) → Task 0. - - Value types (all `Hashable`) + `ResolvedDependency` → Task 1. - - `DylibPathResolver` → Task 2. - - `Loaded vs Indexed` + `request/remote` dispatch for `isImageIndexed` → Task 3. - - Engine new APIs (`mainExecutablePath`, `loadImageForBackgroundIndexing`) with `request/remote` → Task 4; `imageDidLoadPublisher` → Task 4.5. - - Manager (protocol + mock, skeleton, BFS, concurrency, cancel, prioritize) → Tasks 5-10. - - Engine integration (non-`lazy` stored manager) → Task 11. - - Settings → Tasks 12-13. - - Coordinator (lifecycle, image-loaded, Settings via `withObservationTracking`) → Tasks 14-17. - - UI (Node + ViewModel on `MainRoute`, VC with `preconditionFailure` data source, toolbar view + item, `MainRoute.backgroundIndexing` registration) → Tasks 18-21. - - Integration (Document wiring + `runtimeEngine` immutability doc comment) → Task 22. - - Sidebar → prioritize → Task 23. - - Retain failed batches + refresh image list → Task 24. - - Manual QA → Task 25. -- **Review decisions embedded:** all three header decisions from the 2026-04-24 review — Settings via `withObservationTracking` (Task 17), `BackgroundIndexingPopoverRoute` merged into `MainRoute` (Task 18/21), and `request/remote` dispatch for engine methods (Tasks 3/4) — have dedicated tasks and explicit rationale paragraphs. -- **Type consistency:** `RuntimeIndexingBatchID`, `RuntimeIndexingBatch`, `RuntimeIndexingTaskState`, `RuntimeIndexingEvent`, `RuntimeIndexingBatchReason`, `RuntimeIndexingTaskItem`, `ResolvedDependency`, `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. No `BackgroundIndexingPopoverRoute` type is introduced anywhere. +## 自审小结 + +- **规范覆盖:** 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` 类型。 From f74649d397d870b02efbcea4da6cb41c5d3021ae Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 20:18:47 +0800 Subject: [PATCH 03/78] docs(background-indexing): refine engine API notes and add review docs --- .../Evolution/0002-background-indexing.md | 54 ++- .../2026-04-24-background-indexing-plan.md | 312 ++++++++++++------ .../2026-04-25-background-indexing-review.md | 231 +++++++++++++ .../2026-04-26-background-indexing-review.md | 190 +++++++++++ 4 files changed, 671 insertions(+), 116 deletions(-) create mode 100644 Documentations/Reviews/2026-04-25-background-indexing-review.md create mode 100644 Documentations/Reviews/2026-04-26-background-indexing-review.md diff --git a/Documentations/Evolution/0002-background-indexing.md b/Documentations/Evolution/0002-background-indexing.md index 9aaa2665..6ee5b436 100644 --- a/Documentations/Evolution/0002-background-indexing.md +++ b/Documentations/Evolution/0002-background-indexing.md @@ -119,7 +119,7 @@ Runtime Viewer 当前仅在用户显式打开某个镜像时才对其进行索 ### 远程分发模型 -新增的所有 `RuntimeEngine` 公共方法 —— `isImageIndexed`、`mainExecutablePath`、`loadImageForBackgroundIndexing` —— 都包裹在已有的 `request(local:remote:)` 原语之内: +新增的所有 `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 { @@ -141,18 +141,22 @@ setMessageHandlerBinding(forName: .mainExecutablePath, of: self) { $0.mai setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, of: self) { $0.loadImageForBackgroundIndexing(at:) } ``` -`RuntimeBackgroundIndexingManager` 本身**仅运行在服务端**。本提案中管理器的事件、批次以及取消 API 不通过 XPC 镜像;UI 通过 coordinator 在宿主进程中消费管理器状态。如有需要,镜像化留作后续工作。 +`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 时创建,对引擎持有 unowned 反向引用。 +持有所有运行中的批次以及所有事件流。在 `RuntimeEngine` init 时创建,**通过协议 `BackgroundIndexingEngineRepresenting` 按值持有引擎**(`engine: any BackgroundIndexingEngineRepresenting`):manager 不直接依赖具体的 `RuntimeEngine` 类型,只通过协议表面消费 `isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` / `canOpenImage` / `rpaths` / `dependencies` 等方法。`RuntimeEngine`(actor)只是该协议的一个 conformance,测试用 `MockBackgroundIndexingEngine`(`@unchecked Sendable`)与 `InstrumentedEngine` 同样 conform。这条 seam 让 manager 单元测试不需要真实 dyld I/O,也避免 actor↔actor 之间的 `unowned` 反向引用。 ```swift public actor RuntimeBackgroundIndexingManager { + private let engine: any BackgroundIndexingEngineRepresenting + public nonisolated var events: AsyncStream { ... } + init(engine: any BackgroundIndexingEngineRepresenting) + public func startBatch( rootImagePath: String, depth: Int, @@ -167,6 +171,31 @@ public actor RuntimeBackgroundIndexingManager { } ``` +#### `BackgroundIndexingEngineRepresenting`(协议) + +manager 与具体 engine 类型之间的抽象 seam。仅 `: Sendable`(无 `AnyObject` —— manager 按值持有,无引用语义需求;参见决策日志 2026-04-26)。 + +```swift +protocol BackgroundIndexingEngineRepresenting: 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) + - `final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, @unchecked Sendable`(单元测试) + - `final class InstrumentedEngine: BackgroundIndexingEngineRepresenting, @unchecked Sendable`(并发计数测试包装器) + #### Sendable 值类型 ```swift @@ -224,7 +253,7 @@ public enum RuntimeIndexingEvent: Sendable { #### `RuntimeBackgroundIndexingCoordinator` -每个 Document 创建一份(由 `DocumentState` 持有)。职责: +每个 Document 创建一份(由 `DocumentState` 持有)。**`@MainActor` 隔离类**(与 `DocumentState` 一致),所有事件归约、Settings 观察、UI 状态发布都在主线程,不需要内部 `MainActor.run` 跳转。职责: 1. 通过 `withObservationTracking` 观察 `Settings.backgroundIndexing`(参见 Settings 章节)→ 启用 / 禁用 / 重启。 2. 监听引擎的 `imageDidLoadPublisher` → 为该镜像启动一次依赖批次。 @@ -510,7 +539,7 @@ case backgroundIndexing(sender: NSView) #### `BackgroundIndexingPopoverViewController` -基类 `UXKitViewController`。ViewModel 是 `ViewModel` —— **没有**单独的 `BackgroundIndexingPopoverRoute`。所有路由都走主层级已经存在的 `MainRoute` case(`openSettings`、`dismiss` 等)。固定宽度 380,高度从约 120(空状态)到 400(带滚动的大纲视图)。 +基类 `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(带滚动的大纲视图)。 内容布局: @@ -543,6 +572,7 @@ 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 = "" struct Input { @@ -555,16 +585,20 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { let nodes: Driver<[BackgroundIndexingNode]> let isEnabled: Driver let hasAnyBatch: Driver + let hasAnyFailure: 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 切换而响应。 +`isEnabled` 通过与 coordinator **相同**的 `withObservationTracking` 重新注册循环与 `Settings.shared.backgroundIndexing.isEnabled` 保持同步 —— 不是在 `transform` 中读一次后遗忘。这样弹出框打开时它的空状态会随 Settings 切换而响应。`hasAnyFailure` 由 coordinator 的 `aggregateState` 派生,驱动 `Clear Failed` 按钮的可见性。 -`input.openSettings.emitOnNext` 触发 `router.trigger(.openSettings)` —— 已有的 `MainRoute.openSettings` case。 +`input.openSettings` 在 `transform` 内被中转到 `output.openSettings`(经一个内部 `PublishRelay`);ViewController 在 `setupBindings` 中订阅 `output.openSettings` 并直接调用 `SettingsWindowController.shared.showWindow(nil)` —— 见 `MCPStatusPopoverViewController.swift:200-203` 的同款先例。**不**经 `router.trigger(.openSettings)`,因为 `MainRoute` 没有该 case。 ### 错误处理 @@ -598,7 +632,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { 1. **`DocumentState.runtimeEngine` 在 Document 整个生命周期内不可变。** 该属性出于历史原因被声明为 `@Observed public var runtimeEngine: RuntimeEngine = .local`(`DocumentState.swift:10-11`),但调用方在 Document 创建后不会重新赋值。Coordinator 在 init 时一次性捕获 `engine = documentState.runtimeEngine`;如果该假设被打破,批次会被分发到错误的 engine。在该属性上加一段文档注释强化此契约。 -2. **`RuntimeBackgroundIndexingManager` 仅运行在引擎的宿主进程内。** 对于远程(XPC / directTCP)来源,*引擎方法*通过 `request { local } remote: { RPC }` 镜像,但 *manager* 存活在服务端引擎的 actor 中。UI 客户端只通过本地引擎引用消费 manager 状态。 +2. **`RuntimeBackgroundIndexingManager` 与 engine 一对一构造,在客户端进程内活着。** 对于远程(XPC / directTCP)来源,manager 实例仍在客户端运行,但其内部调用的 engine 公共方法(`isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` / `dependencies(for:)` 等)都走 `request { local } remote: { RPC }` 分发,真正的索引工作由服务端目标进程执行。UI 客户端通过本地引擎引用消费 manager 事件流。 3. **Settings 修改频率较低。** `withObservationTracking` 重新注册在每次属性变更时触发一次。由于 Settings 的滑块 / toggle 以人类 UI 节奏运行,重新注册的成本可忽略不计。 @@ -751,3 +785,7 @@ RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift | 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,协议先于实现 | diff --git a/Documentations/Plans/2026-04-24-background-indexing-plan.md b/Documentations/Plans/2026-04-24-background-indexing-plan.md index ad3aed3a..82047495 100644 --- a/Documentations/Plans/2026-04-24-background-indexing-plan.md +++ b/Documentations/Plans/2026-04-24-background-indexing-plan.md @@ -534,7 +534,7 @@ func hasCachedSection(for path: String) -> Bool { 匹配 Step 1 中观察到的精确存储名。如果 factory 使用 `cache` 或 `_sections`,请相应替换。 -- [ ] **Step 4: 放宽 factory 的访问级别(必做)** +- [ ] **Step 4: 放宽 factory 与 `request` 分发原语的访问级别(必做)** `RuntimeEngine.swift:147-149` 当前将两个 factory 都声明为 `private`: @@ -543,7 +543,15 @@ private let objcSectionFactory: RuntimeObjCSectionFactory private let swiftSectionFactory: RuntimeSwiftSectionFactory ``` -将两者都改为 `internal`(去掉 `private` 关键字;默认即 `internal`)。下面的 `+BackgroundIndexing.swift` 扩展需要这一改动。已经核验过当前代码 —— 这两个 factory 现在确为 `private`。 +`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` 并注册服务端处理器** @@ -775,7 +783,9 @@ sendRemoteDataIfNeeded(name: .imageDidLoad, payload: path) let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" let expectation = expectation(description: "imageDidLoad") var received: String? - let cancellable = await engine.imageDidLoadPublisher.sink { path in + // 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() } @@ -816,46 +826,63 @@ git commit -m "feat(core): imageDidLoadPublisher for per-path load notifications 文件 `BackgroundIndexingEngineRepresenting.swift`: ```swift -import MachOKit - /// 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: 让 `RuntimeEngine` 遵循该协议** -追加到 `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 } @@ -870,7 +897,7 @@ extension RuntimeEngine: BackgroundIndexingEngineRepresenting { } ``` -如果实际的 MachOImage API 将 `rpaths` 返回为如 `[RpathCommand]`、其 `.path` 为字符串,请把 `image.rpaths` 替换为正确的访问器(如 `image.rpaths.map { $0.path }`)。本任务开头先做探索,并坚持使用已核验的 API。 +注:`canOpenImage` 与 `rpaths` 的 conformance 实现保留为 non-throwing,Swift 允许 sync / non-throwing 函数满足 `async throws` 协议要求。`dependencies` 必须是 `async throws`,因为它内部 `try await mainExecutablePath()`(远端分发可能抛错)。`MachOImage` 类型自身不出现在协议表面 —— 它是非 Sendable 的结构体,仅在 actor-isolated 实现内部使用。 - [ ] **Step 3: 创建 mock** @@ -924,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?)] @@ -935,6 +965,8 @@ final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, } ``` +注:mock 的所有方法保留为 non-throwing 形式(`async -> ...` 而非 `async throws -> ...`)—— Swift 允许更弱的实现满足更强的协议要求。这样测试代码内调用 mock 时仍需 `try await`(因为通过 protocol 调用),但 mock 内部不必显式 throw。`MachOImage` 不再出现在 mock 的接口或导入中。 + - [ ] **Step 4: 编译检查** ```bash @@ -1066,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)] } @@ -1224,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)) @@ -1369,8 +1411,8 @@ git commit -m "feat(core): implement dependency graph BFS for background indexin 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() @@ -1378,21 +1420,23 @@ git commit -m "feat(core): implement dependency graph BFS for background indexin 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) } } ``` -如果测试文件顶部尚未添加 `import MachOKit`,请添加。 - - [ ] **Step 2: 用真正的执行替换 `runBatch` 桩** 在 `RuntimeBackgroundIndexingManager.swift` 中替换 `runBatch` 并引入辅助 `runSingleIndex`: @@ -1715,7 +1759,7 @@ rg -n "init\(source|actor RuntimeEngine" /Volumes/Repositories/Private/Org/MxIri - [ ] **Step 2: 增加显式存储属性,并在 `init` 末尾初始化** -actor 上的 `lazy var` 强制每次首次访问都通过 actor 隔离,使初始化点不直观,且与 `nonisolated` 访问器交互不顺畅。改用一个显式的隐式可解包存储属性,作为 `init` 的最后一行赋值: +actor 上的 `lazy var` 强制每次首次访问都通过 actor 隔离,初始化时机变得不直观,且与 `nonisolated` 属性访问器交互不顺畅。改用一个显式的隐式可解包存储属性,作为 `init` 的最后一行赋值: ```swift // Near the other stored properties: @@ -1725,7 +1769,7 @@ public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingMana self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) ``` -IUO 的理由:actor 不能在 `init` 完成对其他存储属性的注册前把 `self` 交给 manager;而 manager 在 init 之后是只读的 —— 不存在重新赋值的路径,也不存在一行 bootstrap 之外的 nil 访问路径。 +为什么 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: 构建** @@ -1770,10 +1814,13 @@ rg -n "public struct MCP|public struct Notifications|public var mcp" /Volumes/Re } ``` -在根 `Settings` 结构体中、紧挨 `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: 构建 packages** @@ -1919,6 +1966,7 @@ import RuntimeViewerSettings import RxSwift import RxRelay +@MainActor public final class RuntimeBackgroundIndexingCoordinator { public struct AggregateState: Equatable, Sendable { public var hasActiveBatch: Bool @@ -1979,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 { @@ -2026,7 +2076,6 @@ public final class RuntimeBackgroundIndexingCoordinator { return copy } - @MainActor private func refreshAggregate(batches: [RuntimeIndexingBatch]) { let hasActive = !batches.isEmpty let hasFailure = batches.contains { @@ -2075,18 +2124,23 @@ git commit -m "feat(application): coordinator skeleton for background indexing" ```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) } } @@ -2100,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 @@ -2130,53 +2184,59 @@ git commit -m "feat(application): documentDidOpen / documentWillClose hooks for **文件:** - 修改: `RuntimeBackgroundIndexingCoordinator.swift` -- [ ] **Step 1: 检查 engine 的镜像加载信号** +**为什么用 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/Repositories/Private/Org/MxIris-Reverse-Engineering/RuntimeViewer/RuntimeViewerCore/Sources/RuntimeViewerCore/ | head +- [ ] **Step 1: 添加按 path 的事件泵存储** + +在 coordinator 类内、与 `eventPumpTask` 并列: + +```swift +private var imageLoadedPumpTask: Task? ``` -记录精确的 Rx observable 或 async sequence 名称,调整下面的订阅以匹配。 +更新 `deinit` 一并取消: + +```swift +deinit { + eventPumpTask?.cancel() + imageLoadedPumpTask?.cancel() +} +``` - [ ] **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) } ``` -在 `init` 末尾调用 `subscribeToImageLoadedEvents()`。 - -如果 engine 仅暴露 `AsyncSequence`(不是 Rx),把订阅替换为: - -```swift -imageEventPumpTask = Task { [weak self] in - guard let self else { return } - for await path in self.engine.imageLoadedAsyncSequence { - await self.handleImageLoaded(path: path) - } -} -``` +在 `init` 末尾、`startEventPump()` 之后调用 `startImageLoadedPump()`。 - [ ] **Step 3: 构建** @@ -2212,13 +2272,14 @@ import RuntimeViewerSettings 在 coordinator 类上加私有状态: ```swift -@MainActor private var lastKnownIsEnabled: Bool = false +private var lastKnownIsEnabled: Bool = false ``` - [ ] **Step 2: 实现 observation 循环** +类已是 `@MainActor`,所有方法默认在主线程运行,不必再单独标 `@MainActor`。 + ```swift -@MainActor private func subscribeToSettings() { withObservationTracking { let snapshot = Settings.shared.backgroundIndexing @@ -2236,7 +2297,6 @@ private func subscribeToSettings() { } } -@MainActor private func handleSettingsChange() { let latest = Settings.shared.backgroundIndexing let wasEnabled = lastKnownIsEnabled @@ -2255,14 +2315,12 @@ private func handleSettingsChange() { - [ ] **Step 3: 在 init 中播种初始状态并注册** -在 `init` 末尾: +类是 `@MainActor`,init 也在主线程,直接同步播种与订阅: ```swift -Task { @MainActor [weak self] in - guard let self else { return } - self.lastKnownIsEnabled = Settings.shared.backgroundIndexing.isEnabled - self.subscribeToSettings() -} +// At end of init(documentState:) +self.lastKnownIsEnabled = Settings.shared.backgroundIndexing.isEnabled +self.subscribeToSettings() ``` - [ ] **Step 4: 构建** @@ -2317,9 +2375,11 @@ 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, @@ -2339,7 +2399,13 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { 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 { @@ -2354,17 +2420,19 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { .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) - // isEnabled must stay reactive — the popover's empty states - // depend on it. Use withObservationTracking like the coordinator - // so toggling Settings while the popover is open updates the view. + // 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 @@ -2382,20 +2450,23 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { 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() ) } - @MainActor private func subscribeToIsEnabled() { withObservationTracking { _ = Settings.shared.backgroundIndexing.isEnabled @@ -2476,6 +2547,7 @@ git commit -m "feat(ui): popover ViewModel on MainRoute + BackgroundIndexingNode import AppKit import RuntimeViewerArchitectures import RuntimeViewerCore +import RuntimeViewerSettingsUI // SettingsWindowController.shared import RuntimeViewerUI import RxCocoa import RxSwift @@ -2487,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 @@ -2520,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" @@ -2541,6 +2619,7 @@ final class BackgroundIndexingPopoverViewController: } let buttonStack = HStackView(spacing: 8) { cancelAllButton + clearFailedButton closeButton } buttonStack.alignment = .centerY @@ -2589,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 @@ -2596,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(()) } @@ -2604,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) @@ -2619,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() @@ -2993,7 +3090,7 @@ case .backgroundIndexing(let sender): behavior: .transient)) ``` -不需要 `extension MainCoordinator: Router where Route == ...` 包装 —— `self` 已经是 `Router`,弹出框的 `openSettings` 按钮直接触发 `router.trigger(.openSettings)`(`MainRoute` 上已有该 case)。 +不需要 `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 5: 构建** @@ -3148,14 +3245,13 @@ case .batchCancelled(let cancelled): ```swift public func clearFailedBatches() { - Task { @MainActor [weak self] in - guard let self else { return } - let remaining = batchesRelay.value.filter { batch in - !batch.items.contains { if case .failed = $0.state { true } else { false } } - } - batchesRelay.accept(remaining) - refreshAggregate(batches: remaining) + // 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) } ``` 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-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/` 下另开一份记录,不要追加到本文件。 From 0c3e6680f04019d654a2ece56ed11a4760153cb4 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 20:39:41 +0800 Subject: [PATCH 04/78] chore(core): add Semaphore as explicit RuntimeViewerCore dependency --- RuntimeViewerCore/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index dab0b276..71733b45 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -165,6 +165,7 @@ let package = Package( .product(name: "MachOSwiftSection", package: "MachOSwiftSection"), .product(name: "SwiftInterface", package: "MachOSwiftSection"), .product(name: "MetaCodable", package: "MetaCodable"), + .product(name: "Semaphore", package: "Semaphore"), ], swiftSettings: [ .internalImportsByDefault, From 13647e2897c8341e827a170397547ca852c08961 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 20:44:21 +0800 Subject: [PATCH 05/78] feat(core): add Sendable value types for background indexing --- .../ResolvedDependency.swift | 9 ++++ .../RuntimeIndexingBatch.swift | 29 +++++++++++ .../RuntimeIndexingBatchID.swift | 6 +++ .../RuntimeIndexingBatchReason.swift | 6 +++ .../RuntimeIndexingEvent.swift | 9 ++++ .../RuntimeIndexingTaskItem.swift | 15 ++++++ .../RuntimeIndexingTaskState.swift | 14 ++++++ .../RuntimeIndexingValueTypesTests.swift | 49 +++++++++++++++++++ 8 files changed, 137 insertions(+) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchID.swift create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingEvent.swift create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskItem.swift create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingTaskState.swift create mode 100644 RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift new file mode 100644 index 00000000..3f9135a5 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift @@ -0,0 +1,9 @@ +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 + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift new file mode 100644 index 00000000..e29103db --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift @@ -0,0 +1,29 @@ +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 } + public var completedCount: Int { items.lazy.filter { $0.state.isTerminal }.count } + public var progress: Double { + guard totalCount > 0 else { return 1 } + return Double(completedCount) / 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/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift new file mode 100644 index 00000000..5c3d9472 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import RuntimeViewerCore + +final class RuntimeIndexingValueTypesTests: XCTestCase { + func test_batchID_isUnique() { + let a = RuntimeIndexingBatchID() + let b = RuntimeIndexingBatchID() + XCTAssertNotEqual(a, b) + } + + func test_taskItem_isNotCompletedWhenPending() { + let item = RuntimeIndexingTaskItem(id: "/foo", resolvedPath: "/foo", + state: .pending, hasPriorityBoost: false) + XCTAssertFalse(item.state.isTerminal) + } + + func test_taskState_failedIsTerminal() { + let state = RuntimeIndexingTaskState.failed(message: "boom") + XCTAssertTrue(state.isTerminal) + } + + func test_taskState_cancelledIsTerminal() { + XCTAssertTrue(RuntimeIndexingTaskState.cancelled.isTerminal) + } + + func test_taskState_completedIsTerminal() { + XCTAssertTrue(RuntimeIndexingTaskState.completed.isTerminal) + } + + func test_batch_progress_reportsCompletedFraction() { + 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), + ] + let batch = RuntimeIndexingBatch( + id: RuntimeIndexingBatchID(), + rootImagePath: "/root", + depth: 1, + reason: .manual, + items: items, + isCancelled: false, + isFinished: false + ) + XCTAssertEqual(batch.completedCount, 3) // completed + failed both count toward "done" + XCTAssertEqual(batch.totalCount, 4) + } +} From f8acb90d1d73a345fc9cc43e099fd20df53fc124 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 20:50:32 +0800 Subject: [PATCH 06/78] feat(core): add DylibPathResolver for @rpath / @executable_path / @loader_path --- .../Utils/DylibPathResolver.swift | 58 +++++++++++++ .../DylibPathResolverTests.swift | 87 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift create mode 100644 RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift new file mode 100644 index 00000000..4e02f7a4 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift @@ -0,0 +1,58 @@ +import Foundation + +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)) + for rpath in rpaths { + let candidate = expand(rpath, imagePath: imagePath, + mainExecutablePath: mainExecutablePath) + + "/" + tail + if fileManager.fileExists(atPath: candidate) { + return candidate + } + } + return nil + } + if installName.hasPrefix("@executable_path/") { + let tail = String(installName.dropFirst("@executable_path/".count)) + let candidate = (mainExecutablePath as NSString) + .deletingLastPathComponent + "/" + tail + return fileManager.fileExists(atPath: candidate) ? candidate : nil + } + if installName.hasPrefix("@loader_path/") { + let tail = String(installName.dropFirst("@loader_path/".count)) + let candidate = (imagePath as NSString) + .deletingLastPathComponent + "/" + tail + return fileManager.fileExists(atPath: candidate) ? candidate : nil + } + return fileManager.fileExists(atPath: installName) ? installName : nil + } + + 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..a25e6563 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import RuntimeViewerCore + +final class DylibPathResolverTests: XCTestCase { + private let resolver = DylibPathResolver() + + func test_absolutePath_returnsAsIsWhenExists() throws { + // 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" + XCTAssertTrue(FileManager.default.fileExists(atPath: path), + "precondition: /usr/lib/dyld exists in this test env") + XCTAssertEqual( + resolver.resolve(installName: path, + imagePath: "/any", rpaths: [], + mainExecutablePath: "/any"), + path + ) + } + + func test_absolutePath_returnsNilWhenMissing() { + XCTAssertNil(resolver.resolve(installName: "/nonexistent/Foo.dylib", + imagePath: "/any", rpaths: [], + mainExecutablePath: "/any")) + } + + func test_executablePath_substitutesMainExecutableDir() 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) + XCTAssertEqual(resolved, frameworkPath) + } + + func test_loaderPath_substitutesImageDir() 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") + XCTAssertEqual(resolved, siblingPath) + } + + func test_rpath_usesFirstMatchingRpath() 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") + XCTAssertEqual(resolved, target) + } + + func test_rpath_returnsNilWhenNoMatch() { + XCTAssertNil(resolver.resolve( + installName: "@rpath/Missing", + imagePath: "/any", rpaths: ["/nope1", "/nope2"], + mainExecutablePath: "/any")) + } +} From 7d8ef379aaeb45af1ae812e7fb334a4b97867e32 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 20:57:55 +0800 Subject: [PATCH 07/78] feat(core): add isImageIndexed with request/remote dispatch + factory predicate --- .../Core/RuntimeObjCSection.swift | 4 ++++ .../Core/RuntimeSwiftSection.swift | 4 ++++ .../RuntimeEngine+BackgroundIndexing.swift | 14 ++++++++++++++ .../RuntimeViewerCore/RuntimeEngine.swift | 8 +++++--- .../RuntimeEngineIndexStateTests.swift | 18 ++++++++++++++++++ 5 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift create mode 100644 RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift index f5b059b2..160dd76a 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift @@ -703,6 +703,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..e31fd091 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -799,6 +799,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..2feab6b4 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -0,0 +1,14 @@ +import Foundation +import MachOKit + +extension RuntimeEngine { + public func isImageIndexed(path: String) async throws -> Bool { + try await request { + let hasObjC = await objcSectionFactory.hasCachedSection(for: path) + let hasSwift = await swiftSectionFactory.hasCachedSection(for: path) + return hasObjC && hasSwift + } remote: { senderConnection in + try await senderConnection.sendMessage(name: .isImageIndexed, request: path) + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 7fa330fb..1d3a5049 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -51,6 +51,7 @@ public actor RuntimeEngine { case imageNodes case loadImage case isImageLoaded + case isImageIndexed case patchImagePathForDyld case runtimeObjectHierarchy case runtimeObjectInfo @@ -144,9 +145,9 @@ public actor RuntimeEngine { private nonisolated let objectsLoadingProgressSubject = PassthroughSubject() - private let objcSectionFactory: RuntimeObjCSectionFactory + let objcSectionFactory: RuntimeObjCSectionFactory - private let swiftSectionFactory: RuntimeSwiftSectionFactory + let swiftSectionFactory: RuntimeSwiftSectionFactory private let communicator = RuntimeCommunicator() @@ -274,6 +275,7 @@ 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: .loadImage, of: self) { $0.loadImage(at:) } setMessageHandlerBinding(forName: .imageNameOfClassName, of: self) { $0.imageName(ofObjectName:) } @@ -465,7 +467,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) diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift new file mode 100644 index 00000000..c04c570e --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import RuntimeViewerCore + +final class RuntimeEngineIndexStateTests: XCTestCase { + func test_isImageIndexed_falseForUnvisitedPath() async throws { + let engine = RuntimeEngine(source: .local) + let indexed = try await engine.isImageIndexed(path: "/never/seen") + XCTAssertFalse(indexed) + } + + func test_isImageIndexed_trueAfterLoadImage() async throws { + let engine = RuntimeEngine(source: .local) + let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" + try await engine.loadImage(at: foundation) + let indexed = try await engine.isImageIndexed(path: foundation) + XCTAssertTrue(indexed) + } +} From 964e98135d67418ddd5b3d6fee757d6165d183c3 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 21:05:00 +0800 Subject: [PATCH 08/78] fix(core): normalize path in isImageIndexed; guard test on Foundation availability --- .../RuntimeEngine+BackgroundIndexing.swift | 5 +-- .../RuntimeEngineIndexStateTests.swift | 33 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 2feab6b4..aab808ca 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -4,8 +4,9 @@ import MachOKit extension RuntimeEngine { public func isImageIndexed(path: String) async throws -> Bool { try await request { - let hasObjC = await objcSectionFactory.hasCachedSection(for: path) - let hasSwift = await swiftSectionFactory.hasCachedSection(for: path) + 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) diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift index c04c570e..db7e121c 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -9,10 +9,41 @@ final class RuntimeEngineIndexStateTests: XCTestCase { } func test_isImageIndexed_trueAfterLoadImage() async throws { - let engine = RuntimeEngine(source: .local) let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" + try XCTSkipUnless( + FileManager.default.fileExists(atPath: foundation), + "Requires macOS with Foundation.framework present" + ) + let engine = RuntimeEngine(source: .local) try await engine.loadImage(at: foundation) let indexed = try await engine.isImageIndexed(path: foundation) XCTAssertTrue(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. + func test_isImageIndexed_normalizesPath() async throws { + let raw = "/System/Library/Frameworks/Foundation.framework/Foundation" + try XCTSkipUnless( + FileManager.default.fileExists(atPath: raw), + "Requires macOS with Foundation.framework present" + ) + let engine = RuntimeEngine(source: .local) + try await engine.loadImage(at: raw) + + // After load, both raw and patched forms should report indexed. + let patched = DyldUtilities.patchImagePathForDyld(raw) + let indexedRaw = try await engine.isImageIndexed(path: raw) + let indexedPatched = try await engine.isImageIndexed(path: patched) + XCTAssertTrue(indexedRaw, "isImageIndexed must return true for the unpatched path") + XCTAssertTrue(indexedPatched, "isImageIndexed must return true for the patched path too") + } } From 50a919524fa5752f30088d38217d9e9340010141 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 21:12:52 +0800 Subject: [PATCH 09/78] feat(core): mainExecutablePath + loadImageForBackgroundIndexing with request/remote --- .../RuntimeEngine+BackgroundIndexing.swift | 25 +++++++++++++++++++ .../RuntimeViewerCore/RuntimeEngine.swift | 8 +++++- .../RuntimeEngineIndexStateTests.swift | 21 ++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index aab808ca..8030a8a9 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -12,4 +12,29 @@ extension RuntimeEngine { try await senderConnection.sendMessage(name: .isImageIndexed, request: path) } } + + /// Path of the target process's main executable (dyld image at index 0). + 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. + public func loadImageForBackgroundIndexing(at path: String) async throws { + try await request { + // Mirror loadImage(at:) byte-for-byte sans reloadData(isReloadImageNodes:). + 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) + } + } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 1d3a5049..be0d1af0 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -52,6 +52,8 @@ public actor RuntimeEngine { case loadImage case isImageLoaded case isImageIndexed + case mainExecutablePath + case loadImageForBackgroundIndexing case patchImagePathForDyld case runtimeObjectHierarchy case runtimeObjectInfo @@ -123,7 +125,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>([]) @@ -276,7 +278,11 @@ public actor RuntimeEngine { #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 diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift index db7e121c..37fad245 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -46,4 +46,25 @@ final class RuntimeEngineIndexStateTests: XCTestCase { XCTAssertTrue(indexedRaw, "isImageIndexed must return true for the unpatched path") XCTAssertTrue(indexedPatched, "isImageIndexed must return true for the patched path too") } + + 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 = RuntimeEngine(source: .local) + let path = try await engine.mainExecutablePath() + XCTAssertFalse(path.isEmpty) + XCTAssertTrue(FileManager.default.fileExists(atPath: path)) + } + + func test_loadImageForBackgroundIndexing_doesNotTriggerReloadData() async throws { + // CoreText is reliable across macOS versions; if it's absent, skip. + let path = "/System/Library/Frameworks/CoreText.framework/CoreText" + try XCTSkipUnless(FileManager.default.fileExists(atPath: path), + "Requires macOS with CoreText.framework present") + let engine = RuntimeEngine(source: .local) + try await engine.loadImageForBackgroundIndexing(at: path) + let indexed = try await engine.isImageIndexed(path: path) + XCTAssertTrue(indexed) + } } From 98a1bc371e54b6d653f34b5ff73c387810111b9b Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 21:21:37 +0800 Subject: [PATCH 10/78] feat(core): imageDidLoadPublisher for per-path load notifications --- .../RuntimeViewerCore/RuntimeEngine.swift | 32 +++++++++++++++++++ .../RuntimeEngineIndexStateTests.swift | 19 +++++++++++ 2 files changed, 51 insertions(+) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index be0d1af0..d7f9ced1 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -63,6 +63,7 @@ public actor RuntimeEngine { case runtimeObjectsOfKindInImage case runtimeObjectsInImage case reloadData + case imageDidLoad case memberAddresses case engineList case engineListChanged @@ -145,6 +146,21 @@ 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() let objcSectionFactory: RuntimeObjCSectionFactory @@ -306,6 +322,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)") @@ -419,6 +438,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) @@ -497,6 +527,8 @@ extension RuntimeEngine { _ = try await swiftSectionFactory.section(for: path) reloadData(isReloadImageNodes: false) loadedImagePaths.insert(path) + imageDidLoadSubject.send(path) + sendRemoteImageDidLoadIfNeeded(path: path) } remote: { try await $0.sendMessage(name: .loadImage, request: path) } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift index 37fad245..fd19f310 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -1,4 +1,5 @@ import XCTest +import Combine @testable import RuntimeViewerCore final class RuntimeEngineIndexStateTests: XCTestCase { @@ -67,4 +68,22 @@ final class RuntimeEngineIndexStateTests: XCTestCase { let indexed = try await engine.isImageIndexed(path: path) XCTAssertTrue(indexed) } + + func test_imageDidLoadPublisher_firesAfterLoadImage() async throws { + let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" + try XCTSkipUnless(FileManager.default.fileExists(atPath: foundation), + "Requires macOS with Foundation.framework present") + let engine = RuntimeEngine(source: .local) + let expectation = expectation(description: "imageDidLoad") + var received: String? + // imageDidLoadPublisher is `nonisolated` — no await needed. + 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) + } } From 5904d6141538a59496cacca259f47bca458b2e61 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 21:51:25 +0800 Subject: [PATCH 11/78] feat(core): protocol and mock engine for background indexing --- ...BackgroundIndexingEngineRepresenting.swift | 32 ++++++++++ .../RuntimeEngine+BackgroundIndexing.swift | 47 +++++++++++++++ .../MockBackgroundIndexingEngine.swift | 58 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift create mode 100644 RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift new file mode 100644 index 00000000..7d2e153b --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift @@ -0,0 +1,32 @@ +/// 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 `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 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. + func dependencies(for path: String) + async throws -> [(installName: String, resolvedPath: String?)] +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 8030a8a9..0bde06ff 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -1,4 +1,5 @@ import Foundation +import FoundationToolbox import MachOKit extension RuntimeEngine { @@ -38,3 +39,49 @@ extension RuntimeEngine { } } } + +// MARK: - BackgroundIndexingEngineRepresenting + +extension RuntimeEngine: BackgroundIndexingEngineRepresenting { + /// `MachOImage(name:)` matches the basename of a loaded image (without the + /// dylib / framework extension). Mirrors the conversion done in + /// `RuntimeObjCSection` / `RuntimeSwiftSection` so the protocol callers can + /// pass a full filesystem path. + private static func machOImageName(forPath path: String) -> String { + path.lastPathComponent.deletingPathExtension.deletingPathExtension + } + + func canOpenImage(at path: String) -> Bool { + MachOImage(name: Self.machOImageName(forPath: path)) != nil + } + + func rpaths(for path: String) -> [String] { + guard let image = MachOImage(name: Self.machOImageName(forPath: path)) else { + return [] + } + return image.rpaths + } + + func dependencies(for path: String) async throws + -> [(installName: String, resolvedPath: String?)] + { + guard let image = MachOImage(name: Self.machOImageName(forPath: path)) else { + return [] + } + let resolver = DylibPathResolver() + let main = try await mainExecutablePath() + let rpathList = image.rpaths + return image.dependencies + .filter { $0.type != .lazyLoad } + .map { dependency in + let installName = dependency.dylib.name + let resolvedPath = resolver.resolve( + installName: installName, + imagePath: path, + rpaths: rpathList, + mainExecutablePath: main + ) + return (installName, resolvedPath) + } + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift new file mode 100644 index 00000000..9bae5c13 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift @@ -0,0 +1,58 @@ +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: BackgroundIndexingEngineRepresenting, + @unchecked Sendable +{ + struct ProgrammedPath: Sendable { + var isIndexed: Bool = false + var shouldFailLoad: Error? = nil + var dependencies: [(installName: String, resolvedPath: String?)] = [] + } + + private let lock = NSLock() + private var paths: [String: ProgrammedPath] = [:] + private var loadOrder: [String] = [] + 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 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] { [] } + func dependencies(for path: String) + async -> [(installName: String, resolvedPath: String?)] + { + lock.lock(); defer { lock.unlock() } + return paths[path]?.dependencies ?? [] + } +} From febdcd2b36ab696364fdef467c4799966d9fb18a Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 21:59:21 +0800 Subject: [PATCH 12/78] docs(core): expand machOImageName helper rationale and consolidation TODO --- .../RuntimeEngine+BackgroundIndexing.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 0bde06ff..edc6761a 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -47,6 +47,13 @@ extension RuntimeEngine: BackgroundIndexingEngineRepresenting { /// dylib / framework extension). Mirrors the conversion done in /// `RuntimeObjCSection` / `RuntimeSwiftSection` so the protocol callers can /// pass a full filesystem path. + /// + /// Examples: + /// - `Foundation.framework/Foundation` → `Foundation` (single extension) + /// - `libobjc.A.dylib` → `libobjc.A` → `libobjc` (versioned dylib needs both strips) + /// + /// TODO: Consolidate with the identical conversion in `RuntimeObjCSection` + /// and `RuntimeSwiftSection` once we have a stable home in `DyldUtilities`. private static func machOImageName(forPath path: String) -> String { path.lastPathComponent.deletingPathExtension.deletingPathExtension } From 2189817d07d1b11b78f269d59ac8ae8c4089ade5 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:02:32 +0800 Subject: [PATCH 13/78] feat(core): manager actor skeleton with AsyncStream plumbing --- .../RuntimeBackgroundIndexingManager.swift | 93 +++++++++++++++++++ ...untimeBackgroundIndexingManagerTests.swift | 40 ++++++++ 2 files changed, 133 insertions(+) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift create mode 100644 RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift new file mode 100644 index 00000000..cc63a22c --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -0,0 +1,93 @@ +import Foundation +import Semaphore + +public actor RuntimeBackgroundIndexingManager { + private let engine: any BackgroundIndexingEngineRepresenting + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var activeBatches: [RuntimeIndexingBatchID: BatchState] = [:] + + init(engine: any BackgroundIndexingEngineRepresenting) { + self.engine = engine + var cont: AsyncStream.Continuation! + self.stream = AsyncStream { cont = $0 } + self.continuation = cont + } + + deinit { continuation.finish() } + + public nonisolated var events: AsyncStream { stream } + + public func currentBatches() -> [RuntimeIndexingBatch] { + activeBatches.values.map(\.batch) + } + + public func startBatch( + rootImagePath: String, + depth: Int, + maxConcurrency: Int, + reason: RuntimeIndexingBatchReason + ) async -> RuntimeIndexingBatchID { + let id = RuntimeIndexingBatchID() + let items = await expandDependencyGraph(rootPath: rootImagePath, depth: depth) + 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 + } + + // Placeholder — Task 7 replaces with real BFS. + func expandDependencyGraph(rootPath: String, depth: Int) + async -> [RuntimeIndexingTaskItem] + { + if (try? await engine.isImageIndexed(path: rootPath)) == true { return [] } + return [.init(id: rootPath, resolvedPath: rootPath, + state: .pending, hasPriorityBoost: false)] + } + + private func runBatch(id: RuntimeIndexingBatchID) async { + guard var state = activeBatches[id] else { return } + // Empty batch finishes immediately. + if state.batch.items.isEmpty { + finalize(id: id, cancelled: false) + return + } + // Task 8 implements real execution. For now mark all items completed. + for index in state.batch.items.indices { + state.batch.items[index].state = .completed + } + activeBatches[id] = state + finalize(id: id, cancelled: false) + } + + private func finalize(id: RuntimeIndexingBatchID, cancelled: Bool) { + guard var state = activeBatches[id] else { return } + state.batch.isFinished = true + state.batch.isCancelled = cancelled + activeBatches[id] = state + if cancelled { + continuation.yield(.batchCancelled(state.batch)) + } else { + continuation.yield(.batchFinished(state.batch)) + } + activeBatches[id] = nil + } + + struct BatchState { + var batch: RuntimeIndexingBatch + var maxConcurrency: Int + var drivingTask: Task? + var priorityBoostPaths: Set = [] + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift new file mode 100644 index 00000000..42487f7c --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -0,0 +1,40 @@ +import XCTest +import Semaphore +@testable import RuntimeViewerCore + +final class RuntimeBackgroundIndexingManagerTests: XCTestCase { + func test_currentBatches_initiallyEmpty() async { + let engine = MockBackgroundIndexingEngine() + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let batches = await manager.currentBatches() + XCTAssertTrue(batches.isEmpty) + } + + func test_events_streamYieldsBatchStarted_thenFinished_forEmptyGraph() async { + let engine = 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 + } + + let id = await manager.startBatch(rootImagePath: "/fake/Root", + depth: 0, maxConcurrency: 1, + reason: .manual) + XCTAssertNotNil(id) + let finalSeen = await consumer.value + XCTAssertEqual(finalSeen, ["started", "finished"]) + } +} From f7d515cb415b11bce285b9b2144c4524d2a08b68 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:10:26 +0800 Subject: [PATCH 14/78] feat(core): implement dependency graph BFS for background indexing --- .../RuntimeBackgroundIndexingManager.swift | 53 +++++++++++++-- ...untimeBackgroundIndexingManagerTests.swift | 67 +++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index cc63a22c..acce50f0 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -47,13 +47,58 @@ public actor RuntimeBackgroundIndexingManager { return id } - // Placeholder — Task 7 replaces with real BFS. func expandDependencyGraph(rootPath: String, depth: Int) async -> [RuntimeIndexingTaskItem] { - if (try? await engine.isImageIndexed(path: rootPath)) == true { return [] } - return [.init(id: rootPath, resolvedPath: rootPath, - state: .pending, hasPriorityBoost: false)] + 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 } + + // `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)) ?? [] + for dep in deps { + 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 } private func runBatch(id: RuntimeIndexingBatchID) async { diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index 42487f7c..4cf1f2e9 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -37,4 +37,71 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { let finalSeen = await consumer.value XCTAssertEqual(finalSeen, ["started", "finished"]) } + + func test_expand_emptyWhenRootAlreadyIndexed() async { + let engine = MockBackgroundIndexingEngine() + engine.program(path: "/App", .init(isIndexed: true)) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 5) + XCTAssertTrue(items.isEmpty) + } + + func test_expand_depth1_includesRootAndDirectDeps() async { + let engine = 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) + XCTAssertEqual(Set(items.map(\.id)), + Set(["/App", "/UIKit", "/Foundation"])) + } + + func test_expand_depth1_doesNotIncludeSecondLevel() async { + let engine = 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) + XCTAssertEqual(Set(items.map(\.id)), Set(["/App", "/UIKit"])) + } + + func test_expand_skipsAlreadyIndexedDeps() async { + let engine = 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) + XCTAssertEqual(Set(items.map(\.id)), Set(["/App", "/Foundation"])) + } + + func test_expand_unresolvedInstallNameBecomesFailedItem() async { + let engine = 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 = items.first { $0.id == "@rpath/Missing" } + XCTAssertNotNil(missing) + if case .failed = missing?.state {} else { XCTFail("expected failed state") } + } + + func test_expand_dedupsSharedDependencies() async { + let engine = 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 + XCTAssertEqual(sharedCount, 1) + } } From 5af9cb596d8791642a2c6be1b238c99403ac538f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:18:43 +0800 Subject: [PATCH 15/78] feat(core): concurrent batch execution with AsyncSemaphore --- .../RuntimeBackgroundIndexingManager.swift | 81 +++++++++++-- ...untimeBackgroundIndexingManagerTests.swift | 111 ++++++++++++++++++ 2 files changed, 184 insertions(+), 8 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index acce50f0..0742699e 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -102,18 +102,83 @@ public actor RuntimeBackgroundIndexingManager { } private func runBatch(id: RuntimeIndexingBatchID) async { - guard var state = activeBatches[id] else { return } - // Empty batch finishes immediately. - if state.batch.items.isEmpty { + 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 } - // Task 8 implements real execution. For now mark all items completed. - for index in state.batch.items.indices { - state.batch.items[index].state = .completed + + 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 boostedIdx = pending.firstIndex(where: { state.priorityBoostPaths.contains($0) }) + { + return pending.remove(at: boostedIdx) + } + 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 idx = batchState.batch.items.firstIndex(where: { $0.id == path }) { + batchState.batch.items[idx].state = state + activeBatches[batchID] = batchState } - activeBatches[id] = state - finalize(id: id, cancelled: false) } private func finalize(id: RuntimeIndexingBatchID, cancelled: Bool) { diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index 4cf1f2e9..be0d24a6 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -104,4 +104,115 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { let sharedCount = items.filter { $0.id == "/Shared" }.count XCTAssertEqual(sharedCount, 1) } + + func test_batch_indexesAllPendingItems() async { + let engine = 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) + XCTAssertTrue(finishedBatch.items.allSatisfy { $0.state == .completed }) + let indexed = engine.loadedOrder() + XCTAssertEqual(Set(indexed), Set(["/App", "/A", "/B"])) + } + + func test_batch_respectsMaxConcurrency() async { + let engine = 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 = InstrumentedEngine(base: engine, counter: counter) + let manager = RuntimeBackgroundIndexingManager(engine: wrapped) + + _ = await runToFinish(manager: manager, root: "/App", depth: 1, + maxConcurrency: 2) + XCTAssertLessThanOrEqual(counter.peak, 2) + } + + func test_batch_failedLoad_yieldsFailedTaskState() async { + struct LoadError: Error {} + let engine = 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 = batch.items.first { $0.id == "/Broken" } + XCTAssertNotNil(broken) + if case .failed = broken?.state {} else { XCTFail("expected .failed") } + } + + // 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: 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 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) + async throws -> [(installName: String, resolvedPath: String?)] + { + try await base.dependencies(for: path) + } + } } From 8a1a5ae8c08de1e8217fb92cd95263683bfc65b0 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:27:17 +0800 Subject: [PATCH 16/78] refactor(core): unabbreviate locals; tighten failed-load test assertion --- .../RuntimeBackgroundIndexingManager.swift | 8 ++++---- .../RuntimeBackgroundIndexingManagerTests.swift | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index 0742699e..f9b0a695 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -143,9 +143,9 @@ public actor RuntimeBackgroundIndexingManager { batchID: RuntimeIndexingBatchID, pending: inout [String] ) -> String { if let state = activeBatches[batchID], - let boostedIdx = pending.firstIndex(where: { state.priorityBoostPaths.contains($0) }) + let boostedPendingIndex = pending.firstIndex(where: { state.priorityBoostPaths.contains($0) }) { - return pending.remove(at: boostedIdx) + return pending.remove(at: boostedPendingIndex) } return pending.removeFirst() } @@ -175,8 +175,8 @@ public actor RuntimeBackgroundIndexingManager { state: RuntimeIndexingTaskState) { guard var batchState = activeBatches[batchID] else { return } - if let idx = batchState.batch.items.firstIndex(where: { $0.id == path }) { - batchState.batch.items[idx].state = state + if let itemIndex = batchState.batch.items.firstIndex(where: { $0.id == path }) { + batchState.batch.items[itemIndex].state = state activeBatches[batchID] = batchState } } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index be0d24a6..38855ae2 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -150,7 +150,10 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { root: "/App", depth: 1, maxConcurrency: 1) let broken = batch.items.first { $0.id == "/Broken" } XCTAssertNotNil(broken) - if case .failed = broken?.state {} else { XCTFail("expected .failed") } + guard case .failed(let message) = broken?.state else { + XCTFail("expected .failed"); return + } + XCTAssertFalse(message.isEmpty) } // MARK: - Test helpers From c9b9fcd32f1aaa24ac6c60a126628763b6bbb2e7 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:29:37 +0800 Subject: [PATCH 17/78] feat(core): cancelBatch and cancelAllBatches on indexing manager --- .../RuntimeBackgroundIndexingManager.swift | 26 +++++++++++- ...untimeBackgroundIndexingManagerTests.swift | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index f9b0a695..85257604 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -23,6 +23,18 @@ public actor RuntimeBackgroundIndexingManager { 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) } + } + public func startBatch( rootImagePath: String, depth: Int, @@ -183,10 +195,20 @@ public actor RuntimeBackgroundIndexingManager { 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 = cancelled + 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 cancelled { + if effectiveCancel { continuation.yield(.batchCancelled(state.batch)) } else { continuation.yield(.batchFinished(state.batch)) diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index 38855ae2..f0670732 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -156,6 +156,47 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { XCTAssertFalse(message.isEmpty) } + func test_cancelBatch_stopsPendingItemsAndEmitsCancelledEvent() async { + let engine = 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 + XCTAssertTrue(batch.isCancelled) + } + + func test_cancelAll_cancelsEveryBatch() async { + let engine = 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) + XCTAssertNotEqual(idA, idB) + await manager.cancelAllBatches() + try? await Task.sleep(nanoseconds: 50_000_000) + let remaining = await manager.currentBatches() + XCTAssertTrue(remaining.isEmpty) + } + // MARK: - Test helpers private func runToFinish(manager: RuntimeBackgroundIndexingManager, root: String, depth: Int, From 368b82475546cd940c03538b6b5a3ed22f6b0910 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:34:25 +0800 Subject: [PATCH 18/78] feat(core): prioritize pending item to head of queue --- .../RuntimeBackgroundIndexingManager.swift | 13 ++++++ ...untimeBackgroundIndexingManagerTests.swift | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index 85257604..39d7f0dc 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -35,6 +35,19 @@ public actor RuntimeBackgroundIndexingManager { for id in ids { cancelBatch(id) } } + 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, diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index f0670732..469ab356 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -197,6 +197,49 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { XCTAssertTrue(remaining.isEmpty) } + 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 = ["/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 + XCTAssertEqual(boosted, ["/D2"]) + } + + func test_prioritize_isNoOpForUnknownPath() async { + let engine = 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. + } + // MARK: - Test helpers private func runToFinish(manager: RuntimeBackgroundIndexingManager, root: String, depth: Int, From f7acf91f80dbc0a113c318feb947b948f0c9762d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:37:05 +0800 Subject: [PATCH 19/78] feat(core): expose backgroundIndexingManager on RuntimeEngine --- .../Sources/RuntimeViewerCore/RuntimeEngine.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index d7f9ced1..1501625a 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -179,6 +179,12 @@ 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. Created at the end of + /// `init` so it can capture `self` after all other stored properties are + /// initialized. + public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingManager! + public init( source: RuntimeSource, engineID: String = UUID().uuidString, @@ -197,6 +203,7 @@ public actor RuntimeEngine { self.objcSectionFactory = .init() self.swiftSectionFactory = .init() #log(.info, "Initializing RuntimeEngine with source: \(String(describing: source), privacy: .public)") + self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) } public func connect(bonjourEndpoint: RuntimeNetworkEndpoint? = nil, xpcServerEndpoint: (any Sendable)? = nil) async throws { From df7324a3549fc6b03deb60927e968a07ea5e0cb9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:40:15 +0800 Subject: [PATCH 20/78] feat(settings): add BackgroundIndexing settings struct --- .../Settings+Types.swift | 20 ++++++++++++++++++- .../RuntimeViewerSettings/Settings.swift | 6 ++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift index a9cf4464..fd9496c5 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -56,10 +56,28 @@ 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 BackgroundIndexing { + /// 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 (valid range enforced by the Settings UI: 1...8) + @Default(4) + public var maxConcurrency: Int + + public static let `default` = Self() + } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift index 90f08704..7a4b5e7c 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift @@ -31,6 +31,11 @@ public final class Settings { didSet { scheduleAutoSave() } } + @Default(BackgroundIndexing.default) + public var backgroundIndexing: BackgroundIndexing = .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 + backgroundIndexing = decoded.backgroundIndexing update = decoded.update #log(.debug, "Settings loaded successfully.") } catch { From a74fe4f4efbc523b51d47033c0686d95b67aa3c9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:42:20 +0800 Subject: [PATCH 21/78] feat(settings-ui): Background Indexing settings page --- .../BackgroundIndexingSettingsView.swift | 38 +++++++++++++++++++ .../SettingsRootView.swift | 3 ++ 2 files changed, 41 insertions(+) create mode 100644 RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift new file mode 100644 index 00000000..f20ee0e3 --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift @@ -0,0 +1,38 @@ +#if os(macOS) + +import SwiftUI +import Dependencies +import RuntimeViewerSettings + +struct BackgroundIndexingSettingsView: View { + @AppSettings(\.backgroundIndexing) + var settings + + var body: some View { + SettingsForm { + Section { + Toggle("Enable Background Indexing", isOn: $settings.isEnabled) + } 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: $settings.depth, in: 1...5) { + LabeledContent("Depth", value: "\(settings.depth)") + } + .disabled(!settings.isEnabled) + + Stepper(value: $settings.maxConcurrency, in: 1...8) { + LabeledContent("Max Concurrent Tasks", value: "\(settings.maxConcurrency)") + } + .disabled(!settings.isEnabled) + } header: { + Text("Indexing") + } 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..561893c2 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 backgroundIndexing = "Background 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 .backgroundIndexing: "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 .backgroundIndexing: BackgroundIndexingSettingsView() case .mcp: MCPSettingsView() case .updates: UpdateSettingsView() case .helper: HelperServiceSettingsView() From 3f7fd6ee7847f5c85880954743ad4e007fd893d1 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:47:40 +0800 Subject: [PATCH 22/78] feat(application): coordinator skeleton for background indexing --- ...RuntimeBackgroundIndexingCoordinator.swift | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift new file mode 100644 index 00000000..ed1e1e32 --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -0,0 +1,139 @@ +import Foundation +import RuntimeViewerCore +import RxSwift +import RxRelay + +@MainActor +public final class RuntimeBackgroundIndexingCoordinator { + 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 + private let engine: RuntimeEngine + private let disposeBag = DisposeBag() + + private let batchesRelay = BehaviorRelay<[RuntimeIndexingBatch]>(value: []) + private let aggregateRelay = BehaviorRelay( + value: .init(hasActiveBatch: false, hasAnyFailure: false, progress: nil) + ) + + private var documentBatchIDs: Set = [] + private var eventPumpTask: Task? + + public init(documentState: DocumentState) { + self.documentState = documentState + self.engine = documentState.runtimeEngine + startEventPump() + } + + deinit { eventPumpTask?.cancel() } + + // MARK: - Public observables for UI + + public var batchesObservable: Observable<[RuntimeIndexingBatch]> { + batchesRelay.asObservable() + } + + public var aggregateStateObservable: Observable { + aggregateRelay.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) + } + } + + // 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) { + var batches = batchesRelay.value + switch event { + case .batchStarted(let batch): + batches.append(batch) + case .taskStarted(let id, let path): + batches = batches.map { mutating($0) { batch in + guard batch.id == id, let itemIndex = batch.items.firstIndex(where: { $0.id == path }) + else { return } + batch.items[itemIndex].state = .running + }} + case .taskFinished(let id, let path, let result): + batches = batches.map { mutating($0) { batch in + guard batch.id == id, let itemIndex = batch.items.firstIndex(where: { $0.id == path }) + else { return } + batch.items[itemIndex].state = result + }} + case .taskPrioritized(let id, let path): + batches = batches.map { mutating($0) { batch in + guard batch.id == id, let itemIndex = batch.items.firstIndex(where: { $0.id == path }) + else { return } + batch.items[itemIndex].hasPriorityBoost = true + }} + case .batchFinished(let finished), .batchCancelled(let finished): + batches.removeAll { $0.id == finished.id } + documentBatchIDs.remove(finished.id) + } + batchesRelay.accept(batches) + refreshAggregate(batches: batches) + } + + private func mutating(_ value: Value, _ mutate: (inout Value) -> Void) -> Value { + var copy = value + mutate(©) + return copy + } + + 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.completedCount } + let progress: Double? = totalItems > 0 + ? Double(doneItems) / Double(totalItems) + : nil + aggregateRelay.accept( + .init(hasActiveBatch: hasActive, hasAnyFailure: hasFailure, + progress: progress)) + } +} From b7c7556255dc27de86ae8ec18202f0e2e559a9bf Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:50:52 +0800 Subject: [PATCH 23/78] feat(application): documentDidOpen / documentWillClose hooks for indexing --- ...RuntimeBackgroundIndexingCoordinator.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index ed1e1e32..cb4f4d09 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -2,6 +2,11 @@ import Foundation import RuntimeViewerCore import RxSwift import RxRelay +import Dependencies + +#if canImport(RuntimeViewerSettings) +import RuntimeViewerSettings +#endif @MainActor public final class RuntimeBackgroundIndexingCoordinator { @@ -137,3 +142,43 @@ public final class RuntimeBackgroundIndexingCoordinator { progress: progress)) } } + +#if canImport(RuntimeViewerSettings) +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 = 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: .appLaunch) + 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 currentBackgroundIndexingSettings() -> Settings.BackgroundIndexing { + @Dependency(\.settings) var settings + return settings.backgroundIndexing + } +} +#endif From 7f4b252c78c78253b8ac9a3a2dbae66b4a1d6d89 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:53:38 +0800 Subject: [PATCH 24/78] feat(application): subscribe to engine image-loaded events to spawn batches --- ...RuntimeBackgroundIndexingCoordinator.swift | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index cb4f4d09..7413164c 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -33,14 +33,21 @@ public final class RuntimeBackgroundIndexingCoordinator { private var documentBatchIDs: Set = [] private var eventPumpTask: Task? + private var imageLoadedPumpTask: Task? public init(documentState: DocumentState) { self.documentState = documentState self.engine = documentState.runtimeEngine startEventPump() + #if canImport(RuntimeViewerSettings) + startImageLoadedPump() + #endif } - deinit { eventPumpTask?.cancel() } + deinit { + eventPumpTask?.cancel() + imageLoadedPumpTask?.cancel() + } // MARK: - Public observables for UI @@ -176,6 +183,35 @@ extension RuntimeBackgroundIndexingCoordinator { } } + 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) + } + } + } + + private func handleImageLoaded(path: String) async { + 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. 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)) + self.documentBatchIDs.insert(id) + } + private func currentBackgroundIndexingSettings() -> Settings.BackgroundIndexing { @Dependency(\.settings) var settings return settings.backgroundIndexing From eaeccf4f0839a826e62b5617c23442af969d1025 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 22:55:21 +0800 Subject: [PATCH 25/78] feat(application): observe Settings.backgroundIndexing via withObservationTracking --- ...RuntimeBackgroundIndexingCoordinator.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 7413164c..03819e52 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -1,4 +1,5 @@ import Foundation +import Observation import RuntimeViewerCore import RxSwift import RxRelay @@ -34,6 +35,7 @@ public final class RuntimeBackgroundIndexingCoordinator { private var documentBatchIDs: Set = [] private var eventPumpTask: Task? private var imageLoadedPumpTask: Task? + private var lastKnownIsEnabled: Bool = false public init(documentState: DocumentState) { self.documentState = documentState @@ -41,6 +43,7 @@ public final class RuntimeBackgroundIndexingCoordinator { startEventPump() #if canImport(RuntimeViewerSettings) startImageLoadedPump() + bootstrapSettingsObservation() #endif } @@ -216,5 +219,42 @@ extension RuntimeBackgroundIndexingCoordinator { @Dependency(\.settings) var settings return settings.backgroundIndexing } + + 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 { + documentDidOpen() // Scenario E: 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. + } } #endif From 8843bfa8985820165d801ffbcfcc79b74e9f4c57 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 23:05:36 +0800 Subject: [PATCH 26/78] feat(ui): popover ViewModel on MainRoute + BackgroundIndexingNode --- .../project.pbxproj | 16 ++ .../BackgroundIndexingNode.swift | 6 + .../BackgroundIndexingPopoverViewModel.swift | 152 ++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index 31b7c9c9..c269ff59 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -35,6 +35,8 @@ E9530A3C2D9D5898008FBC7F /* SIPChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9530A3B2D9D5898008FBC7F /* SIPChecker.swift */; }; E961FEEC2F54513E00ED3419 /* RuntimeViewerServer.framework in Copy RuntimeViewerServer Framework */ = {isa = PBXBuildFile; fileRef = E98BF6372F1D4ABB0041DB20 /* RuntimeViewerServer.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E96BF91D2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96BF91C2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift */; }; + E9BD1A112FA000020000ABCD /* BackgroundIndexingNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */; }; + E9BD1A132FA000040000ABCD /* BackgroundIndexingPopoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */; }; E96CF5332EC7A4A600CBC159 /* RuntimeSource+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */; }; E96DE1E32F0ACE8D00F9BAB2 /* CheckboxButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */; }; E975449A2C42BA5B00CC9DDD /* LoadFrameworksViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E97544992C42BA5B00CC9DDD /* LoadFrameworksViewController.xib */; }; @@ -250,6 +252,8 @@ E9668FFB2CEF7140007B344A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E9668FFC2CEF7140007B344A /* launchd.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchd.plist; sourceTree = ""; }; E96BF91C2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPStatusPopoverViewModel.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 = ""; }; E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RuntimeSource+.swift"; sourceTree = ""; }; E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CheckboxButton+.swift"; sourceTree = ""; }; E97544982C42BA5B00CC9DDD /* LoadFrameworksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFrameworksViewController.swift; sourceTree = ""; }; @@ -425,6 +429,7 @@ E97544A22C42D0F600CC9DDD /* Load Frameworks */, E94E36C42CF84A9F006101C8 /* Attach Process */, E9A9D8032F5F254800A10DD3 /* MCP */, + E9BD1A142FA000050000ABCD /* BackgroundIndexing */, E92CB2E52F41E7560091450B /* Exporting */, E9CE07BF2C14981D0070A6E8 /* Utils */, E94E36C72CF87BBC006101C8 /* Resources */, @@ -514,6 +519,15 @@ path = MCP; sourceTree = ""; }; + E9BD1A142FA000050000ABCD /* BackgroundIndexing */ = { + isa = PBXGroup; + children = ( + E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */, + E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */, + ); + path = BackgroundIndexing; + sourceTree = ""; + }; E9B4C6552F35E9C800823FE0 /* com.mxiris.runtimeviewer.service */ = { isa = PBXGroup; children = ( @@ -950,6 +964,8 @@ 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 */, E9F11E0B2F123EEC0052B0A3 /* SidebarRootCoordinator.swift in Sources */, E921246B2F447BA1007481E4 /* ExportingConfigurationViewModel.swift in Sources */, E99E61612C129DC2002C1A3D /* ContentTextViewController.swift in Sources */, diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift new file mode 100644 index 00000000..4d67ca87 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift @@ -0,0 +1,6 @@ +import RuntimeViewerCore + +enum BackgroundIndexingNode: Hashable { + case batch(RuntimeIndexingBatch) + case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift new file mode 100644 index 00000000..fe7d9c4d --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -0,0 +1,152 @@ +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 hasAnyFailure: Bool = false + @Observed private(set) var subtitle: String = "" + + private let coordinator: RuntimeBackgroundIndexingCoordinator + private let openSettingsRelay = PublishRelay() + + 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 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 { + coordinator.batchesObservable + .map(Self.renderNodes) + .asDriver(onErrorJustReturn: []) + .driveOnNext { [weak self] newNodes in + guard let self else { return } + nodes = newNodes + hasAnyBatch = !newNodes.isEmpty + } + .disposed(by: rx.disposeBag) + + 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) + + // ViewModel base class is `@MainActor`, so `transform` runs on the + // main actor; we can subscribe synchronously and seed the initial + // value below. + subscribeToIsEnabled() + + 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.clearFailed.emitOnNext { [weak self] in + guard let self else { return } + // Task 24 will add `coordinator.clearFailedBatches()`; for now + // this is a TODO no-op. Reading `self` keeps the closure + // well-formed and silences a "weak self captured but not used" + // warning. + _ = self + } + .disposed(by: rx.disposeBag) + + // Forward openSettings to output so the ViewController can call + // `SettingsWindowController.shared.showWindow(nil)` directly. + input.openSettings.emitOnNext { [weak self] in + guard let self else { return } + openSettingsRelay.accept(()) + } + .disposed(by: rx.disposeBag) + + return Output( + nodes: $nodes.asDriver(), + isEnabled: $isEnabled.asDriver(), + hasAnyBatch: $hasAnyBatch.asDriver(), + hasAnyFailure: $hasAnyFailure.asDriver(), + subtitle: $subtitle.asDriver(), + openSettings: openSettingsRelay.asSignal() + ) + } + + private func subscribeToIsEnabled() { + withObservationTracking { + _ = settings.backgroundIndexing.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.backgroundIndexing.isEnabled + self.subscribeToIsEnabled() + } + } + // Seed the current value synchronously on initial subscribe. + isEnabled = settings.backgroundIndexing.isEnabled + } + + private static func renderNodes(from batches: [RuntimeIndexingBatch]) + -> [BackgroundIndexingNode] + { + var out: [BackgroundIndexingNode] = [] + for batch in batches { + out.append(.batch(batch)) + for item in batch.items { + out.append(.item(batchID: batch.id, item: item)) + } + } + return out + } + + 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" + } +} From 878ab34fd8a24f72a1b1b2012d47d9c9ae087bca Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 23:13:26 +0800 Subject: [PATCH 27/78] feat(ui): popover view controller for background indexing --- .../project.pbxproj | 4 + ...kgroundIndexingPopoverViewController.swift | 336 ++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index c269ff59..61986c6e 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ E96BF91D2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96BF91C2F60541F009C40D2 /* MCPStatusPopoverViewModel.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 */; }; E96CF5332EC7A4A600CBC159 /* RuntimeSource+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */; }; E96DE1E32F0ACE8D00F9BAB2 /* CheckboxButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */; }; E975449A2C42BA5B00CC9DDD /* LoadFrameworksViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E97544992C42BA5B00CC9DDD /* LoadFrameworksViewController.xib */; }; @@ -254,6 +255,7 @@ E96BF91C2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPStatusPopoverViewModel.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 = ""; }; E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RuntimeSource+.swift"; sourceTree = ""; }; E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CheckboxButton+.swift"; sourceTree = ""; }; E97544982C42BA5B00CC9DDD /* LoadFrameworksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFrameworksViewController.swift; sourceTree = ""; }; @@ -524,6 +526,7 @@ children = ( E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */, E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */, + E9BD1A162FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift */, ); path = BackgroundIndexing; sourceTree = ""; @@ -966,6 +969,7 @@ E96BF91D2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift in Sources */, E9BD1A112FA000020000ABCD /* BackgroundIndexingNode.swift in Sources */, E9BD1A132FA000040000ABCD /* BackgroundIndexingPopoverViewModel.swift in Sources */, + E9BD1A172FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift in Sources */, E9F11E0B2F123EEC0052B0A3 /* SidebarRootCoordinator.swift in Sources */, E921246B2F447BA1007481E4 /* ExportingConfigurationViewModel.swift in Sources */, E99E61612C129DC2002C1A3D /* ContentTextViewController.swift in Sources */, diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift new file mode 100644 index 00000000..06b8dc8b --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -0,0 +1,336 @@ +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 cancelAllRelay = PublishRelay() + private let clearFailedRelay = PublishRelay() + private let openSettingsRelay = PublishRelay() + + // 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 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 outlineView = NSOutlineView().then { + $0.headerView = nil + $0.rowSizeStyle = .small + $0.selectionHighlightStyle = .regular + $0.indentationPerLevel = 16 + } + + private let scrollView = ScrollView() + + private let cancelAllButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Cancel All" + } + + private let clearFailedButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Clear Failed" + $0.isHidden = true + } + + private let closeButton = NSButton().then { + $0.bezelStyle = .accessoryBarAction + $0.title = "Close" + } + + // MARK: - Outline data + + private var renderedNodes: [BackgroundIndexingNode] = [] + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupLayout() + setupOutlineView() + setupActions() + preferredContentSize = NSSize(width: 380, height: 300) + } + + private func setupLayout() { + let headerStack = VStackView(alignment: .leading, spacing: 2) { + titleLabel + subtitleLabel + } + + let buttonStack = HStackView(spacing: 8) { + cancelAllButton + clearFailedButton + closeButton + } + buttonStack.alignment = .centerY + + let emptyDisabledStack = VStackView(alignment: .centerX, spacing: 8) { + emptyDisabledView + openSettingsButton + } + + scrollView.documentView = outlineView + + contentView.hierarchy { + headerStack + emptyDisabledStack + emptyIdleView + scrollView + buttonStack + } + + headerStack.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(12) + } + + emptyDisabledStack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.lessThanOrEqualToSuperview().offset(-32) + } + + emptyIdleView.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(8) + make.bottom.equalTo(buttonStack.snp.top).offset(-8) + } + + buttonStack.snp.makeConstraints { make in + make.trailing.bottom.equalToSuperview().inset(12) + } + } + + private func setupOutlineView() { + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("status")) + column.resizingMask = .autoresizingMask + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = self + outlineView.delegate = self + } + + 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 + openSettingsButton.action = #selector(openSettingsClicked) + } + + // MARK: - Actions + + @objc private func cancelAllClicked() { + cancelAllRelay.accept(()) + } + + @objc private func clearFailedClicked() { + clearFailedRelay.accept(()) + } + + @objc private func closeClicked() { + dismiss(nil) + } + + @objc private func openSettingsClicked() { + openSettingsRelay.accept(()) + } + + // MARK: - Bindings + + override func setupBindings(for viewModel: BackgroundIndexingPopoverViewModel) { + super.setupBindings(for: viewModel) + + let input = BackgroundIndexingPopoverViewModel.Input( + cancelBatch: cancelBatchRelay.asSignal(), + cancelAll: cancelAllRelay.asSignal(), + clearFailed: clearFailedRelay.asSignal(), + openSettings: openSettingsRelay.asSignal() + ) + let output = viewModel.transform(input) + + output.subtitle + .drive(subtitleLabel.rx.stringValue) + .disposed(by: rx.disposeBag) + + output.isEnabled + .driveOnNext { [weak self] enabled in + guard let self else { return } + emptyDisabledView.isHidden = enabled + openSettingsButton.isHidden = enabled + } + .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 for the same pattern. + output.openSettings + .emitOnNext { + SettingsWindowController.shared.showWindow(nil) + } + .disposed(by: rx.disposeBag) + + Observable + .combineLatest( + output.isEnabled.asObservable(), + output.hasAnyBatch.asObservable() + ) + .subscribeOnNext { [weak self] enabled, hasBatches in + guard let self else { return } + emptyIdleView.isHidden = !enabled || hasBatches + scrollView.isHidden = !enabled || !hasBatches + } + .disposed(by: rx.disposeBag) + + output.nodes + .driveOnNext { [weak self] nodes in + guard let self else { return } + renderedNodes = nodes + outlineView.reloadData() + outlineView.expandItem(nil, expandChildren: true) + } + .disposed(by: rx.disposeBag) + } +} + +// MARK: - NSOutlineViewDataSource & Delegate + +extension BackgroundIndexingPopoverViewController: NSOutlineViewDataSource, NSOutlineViewDelegate { + func outlineView(_ outlineView: NSOutlineView, + numberOfChildrenOfItem item: Any?) -> Int + { + if item == nil { + return renderedNodes.filter { + if case .batch = $0 { return true } else { return false } + }.count + } + guard let node = item as? BackgroundIndexingNode, + case .batch(let batch) = node + else { return 0 } + return batch.items.count + } + + func outlineView(_ outlineView: NSOutlineView, + child index: Int, + ofItem item: Any?) -> Any + { + if item == nil { + let batches = renderedNodes.compactMap { node -> RuntimeIndexingBatch? in + if case .batch(let batch) = node { return batch } else { return nil } + } + return BackgroundIndexingNode.batch(batches[index]) + } + guard let node = item as? BackgroundIndexingNode, + case .batch(let batch) = node + else { + preconditionFailure("unexpected outline item type: \(type(of: item))") + } + return BackgroundIndexingNode.item(batchID: batch.id, + item: batch.items[index]) + } + + func outlineView(_ outlineView: NSOutlineView, + isItemExpandable item: Any) -> Bool + { + if let node = item as? BackgroundIndexingNode, + case .batch = node { return true } + return false + } + + func outlineView(_ outlineView: NSOutlineView, + viewFor tableColumn: NSTableColumn?, + item: Any) -> NSView? + { + guard let node = item as? BackgroundIndexingNode else { return nil } + + let cell = NSTableCellView() + let label = Label("") + cell.hierarchy { label } + label.snp.makeConstraints { make in + make.leading.trailing.centerY.equalToSuperview() + } + + switch node { + case .batch(let batch): + let title = Self.title(for: batch.reason) + label.stringValue = "\(title) \(batch.completedCount)/\(batch.totalCount)" + case .item(_, let item): + let nameSource = item.resolvedPath ?? item.id + let name = (nameSource as NSString).lastPathComponent + let prefix: String = { + switch item.state { + case .pending: return "·" + case .running: return "↻" + case .completed: return "✓" + case .failed: return "✗" + case .cancelled: return "⊘" + } + }() + var text = "\(prefix) \(name)" + if case .failed(let message) = item.state { + text = "\(prefix) \(item.id) — \(message)" + } + if item.hasPriorityBoost, case .pending = item.state { + text += " (priority)" + } + label.stringValue = text + } + + return cell + } + + 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" + } + } +} From 9fa84e61e2ad6e281f10022bcde07cf583d6d90a Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 23:17:45 +0800 Subject: [PATCH 28/78] feat(ui): toolbar item view and item class for background indexing --- .../project.pbxproj | 8 ++ .../BackgroundIndexingToolbarItem.swift | 33 ++++++++ .../BackgroundIndexingToolbarItemView.swift | 83 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index 61986c6e..f8942d93 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -38,6 +38,8 @@ 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 */; }; + E9BD1A192FA000080000ABCD /* BackgroundIndexingToolbarItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A182FA000070000ABCD /* BackgroundIndexingToolbarItemView.swift */; }; + E9BD1A1B2FA0000A0000ABCD /* BackgroundIndexingToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */; }; E96CF5332EC7A4A600CBC159 /* RuntimeSource+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */; }; E96DE1E32F0ACE8D00F9BAB2 /* CheckboxButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */; }; E975449A2C42BA5B00CC9DDD /* LoadFrameworksViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E97544992C42BA5B00CC9DDD /* LoadFrameworksViewController.xib */; }; @@ -256,6 +258,8 @@ 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 = ""; }; + E9BD1A182FA000070000ABCD /* BackgroundIndexingToolbarItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingToolbarItemView.swift; sourceTree = ""; }; + E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingToolbarItem.swift; sourceTree = ""; }; E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RuntimeSource+.swift"; sourceTree = ""; }; E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CheckboxButton+.swift"; sourceTree = ""; }; E97544982C42BA5B00CC9DDD /* LoadFrameworksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFrameworksViewController.swift; sourceTree = ""; }; @@ -527,6 +531,8 @@ E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */, E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */, E9BD1A162FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift */, + E9BD1A182FA000070000ABCD /* BackgroundIndexingToolbarItemView.swift */, + E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */, ); path = BackgroundIndexing; sourceTree = ""; @@ -970,6 +976,8 @@ E9BD1A112FA000020000ABCD /* BackgroundIndexingNode.swift in Sources */, E9BD1A132FA000040000ABCD /* BackgroundIndexingPopoverViewModel.swift in Sources */, E9BD1A172FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift in Sources */, + E9BD1A192FA000080000ABCD /* BackgroundIndexingToolbarItemView.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/BackgroundIndexing/BackgroundIndexingToolbarItem.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift new file mode 100644 index 00000000..cd1f094f --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift @@ -0,0 +1,33 @@ +import AppKit +import RxCocoa +import RxSwift + +final class BackgroundIndexingToolbarItem: NSToolbarItem { + static let identifier = NSToolbarItem.Identifier("backgroundIndexing") + + let itemView = BackgroundIndexingToolbarItemView() + let tapRelay = PublishRelay() + private let disposeBag = DisposeBag() + + init() { + super.init(itemIdentifier: Self.identifier) + label = "Indexing" + paletteLabel = "Background Indexing" + toolTip = "Background indexing status" + view = itemView + target = self + action = #selector(clicked) + } + + func bindState(_ driver: Driver) { + driver.driveOnNext { [weak self] state in + guard let self else { return } + itemView.state = state + } + .disposed(by: disposeBag) + } + + @objc private func clicked() { + tapRelay.accept(itemView) + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift new file mode 100644 index 00000000..4cd6f22c --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift @@ -0,0 +1,83 @@ +import AppKit +import SnapKit + +enum BackgroundIndexingToolbarState: Equatable { + case idle + case disabled + case indexing + case hasFailures +} + +final class BackgroundIndexingToolbarItemView: NSView { + private let iconView = NSImageView().then { + $0.image = NSImage(systemSymbolName: "square.stack.3d.down.right", + accessibilityDescription: nil) + $0.symbolConfiguration = .init(pointSize: 15, weight: .regular) + $0.contentTintColor = .secondaryLabelColor + } + private let spinner = NSProgressIndicator().then { + $0.style = .spinning + $0.controlSize = .small + $0.isIndeterminate = true + $0.isDisplayedWhenStopped = false + } + private let failureDot = NSView() + + var state: BackgroundIndexingToolbarState = .idle { + didSet { applyState() } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setupLayout() + applyState() + } + required init?(coder: NSCoder) { fatalError() } + + private func setupLayout() { + hierarchy { + iconView + spinner + failureDot + } + iconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(18) + } + spinner.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(14) + } + failureDot.snp.makeConstraints { make in + make.width.height.equalTo(6) + make.trailing.bottom.equalTo(iconView) + } + failureDot.wantsLayer = true + failureDot.layer?.cornerRadius = 3 + failureDot.layer?.backgroundColor = NSColor.systemRed.cgColor + } + + private func applyState() { + switch state { + case .idle: + iconView.contentTintColor = .secondaryLabelColor + spinner.stopAnimation(nil) + failureDot.isHidden = true + case .disabled: + iconView.contentTintColor = .tertiaryLabelColor + spinner.stopAnimation(nil) + failureDot.isHidden = true + case .indexing: + iconView.contentTintColor = .controlAccentColor + spinner.startAnimation(nil) + failureDot.isHidden = true + case .hasFailures: + iconView.contentTintColor = .controlAccentColor + spinner.startAnimation(nil) + failureDot.isHidden = false + } + } + + override var intrinsicContentSize: NSSize { NSSize(width: 28, height: 28) } +} From 9059e26f014f69ea63d5b36125f465aa1decb0de Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 23:23:36 +0800 Subject: [PATCH 29/78] feat(ui): toolbar item + MainRoute.backgroundIndexing popover route --- .../DocumentState.swift | 18 +++++++++++++++ .../Main/MainCoordinator.swift | 9 ++++++++ .../Main/MainRoute.swift | 1 + .../Main/MainToolbarController.swift | 9 ++++++++ .../Main/MainViewModel.swift | 5 ++++- .../Main/MainWindowController.swift | 22 +++++++++++++++++++ 6 files changed, 63 insertions(+), 1 deletion(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 91288c1e..678bf704 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -7,6 +7,17 @@ import RuntimeViewerArchitectures public final class DocumentState { public init() {} + /// The runtime engine backing this Document. + /// + /// Per Evolution 0002 (Background Indexing) Assumption #1, this property + /// is treated as **immutable for the lifetime of the Document**. The + /// declaration uses `@Observed public var` for historical reasons (early + /// callers needed to swap in a remote engine after init), but current + /// callers MUST NOT reassign it after the Document is opened. + /// + /// `RuntimeBackgroundIndexingCoordinator` (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 @@ -18,4 +29,11 @@ public final class DocumentState { @Observed public var currentSubtitle: String = "" + + /// Per-Document background indexing coordinator. Created lazily on first + /// access so that opening a Document does not pay the cost when the + /// feature is disabled. The coordinator captures `runtimeEngine` at + /// init — see the doc comment on that property. + public private(set) lazy var backgroundIndexingCoordinator = + RuntimeBackgroundIndexingCoordinator(documentState: self) } 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..eee73dd1 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.tapRelay.asSignal(), frameworksSelected: frameworksSelectedRelay.asSignal(), saveLocationSelected: saveLocationSelectedRelay.asSignal() ) @@ -148,6 +151,25 @@ final class MainWindowController: XiblessWindowController { output.isContentBackHidden.drive(toolbarController.contentBackItem.rx.isHidden).disposed(by: rx.disposeBag) + // Bind background indexing toolbar item state to the per-Document + // coordinator's aggregate observable. The popover route case + // (`MainRoute.backgroundIndexing`) reuses the same coordinator. + // Drive directly into the item view via `rx.disposeBag` (which is + // recreated each `setupBindings` call) so a source switch does not + // accumulate stale subscriptions on the toolbar item itself. + documentState.backgroundIndexingCoordinator + .aggregateStateObservable + .map { state -> BackgroundIndexingToolbarState in + if !state.hasActiveBatch { return .idle } + return state.hasAnyFailure ? .hasFailures : .indexing + } + .asDriver(onErrorJustReturn: .idle) + .driveOnNext { [weak self] state in + guard let self else { return } + toolbarController.backgroundIndexingItem.itemView.state = state + } + .disposed(by: rx.disposeBag) + // Bind menu content + selection from sections and switchSourceState Driver.combineLatest(output.runtimeEngineSections, output.switchSourceState) .driveOnNext { [weak self] sections, state in From c0a22df3b26c3a467faf740e838f3f0275a902f9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 23:26:12 +0800 Subject: [PATCH 30/78] feat(app): wire background indexing coordinator into Document lifecycle --- .../RuntimeViewerUsingAppKit/App/Document.swift | 6 ++++++ 1 file changed, 6 insertions(+) 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 { From 521cd0a7d122e601609de029f46ef3dea7eb2a8a Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 23:31:17 +0800 Subject: [PATCH 31/78] feat(app): prioritize indexing when user selects an image in sidebar --- .../Sidebar/SidebarRootViewModel.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift index 0ed30d98..b06bb723 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift @@ -98,6 +98,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 From d93ecda22e014b9574d9455425ed77f25064ab1d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Apr 2026 23:34:24 +0800 Subject: [PATCH 32/78] feat(application): retain failed batches + single reloadData per batch finish --- ...RuntimeBackgroundIndexingCoordinator.swift | 38 +++++++++++++++++-- .../BackgroundIndexingPopoverViewModel.swift | 6 +-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 03819e52..36a8b3f8 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -82,6 +82,18 @@ public final class RuntimeBackgroundIndexingCoordinator { } } + 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 { item in + if case .failed = item.state { return true } else { return false } + } + } + batchesRelay.accept(remaining) + refreshAggregate(batches: remaining) + } + // MARK: - Event pump (AsyncStream → Relay) private func startEventPump() { @@ -120,9 +132,29 @@ public final class RuntimeBackgroundIndexingCoordinator { else { return } batch.items[itemIndex].hasPriorityBoost = true }} - 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 { 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) + } + + 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) + } } batchesRelay.accept(batches) refreshAggregate(batches: batches) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index fe7d9c4d..c7fcc9c9 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -84,11 +84,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { input.clearFailed.emitOnNext { [weak self] in guard let self else { return } - // Task 24 will add `coordinator.clearFailedBatches()`; for now - // this is a TODO no-op. Reading `self` keeps the closure - // well-formed and silences a "weak self captured but not used" - // warning. - _ = self + coordinator.clearFailedBatches() } .disposed(by: rx.disposeBag) From 3f2077994f02b669077d242bbf2d91e71b0c22e6 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 26 Apr 2026 01:15:56 +0800 Subject: [PATCH 33/78] docs(review): final implementation review for background indexing --- ...ckground-indexing-implementation-review.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md 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..f0d3f863 --- /dev/null +++ b/Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md @@ -0,0 +1,215 @@ +# 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 处理。 + +**验证结果**: +- `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 + +`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`。 + +### 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 From f468ebcf0a7f911d4f414f15384b511c56072353 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 26 Apr 2026 22:29:07 +0800 Subject: [PATCH 34/78] docs(review): ultrareview findings for background indexing Capture 8 findings from cloud ultrareview (4 normal, 3 nit, 1 pre-existing). Cross-references the internal implementation review and surfaces 5 new issues (engine/manager retain cycle, dyld shared-cache resolver gap, dead per-batch cancel relay, unused .settingsEnabled reason, documentBatchIDs leak on retained failures). --- ...6-04-26-background-indexing-ultrareview.md | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md 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..d89e46ce --- /dev/null +++ b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md @@ -0,0 +1,231 @@ +# 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 跟进项。 + +--- + +## Normal + +### N1. `RuntimeEngine` ↔ `RuntimeBackgroundIndexingManager` 循环引用导致每个远程 engine 泄漏 + +**文件**: `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 修正。 + +### N2. Coordinator 跨 source 切换捕获过时 `RuntimeEngine` + +**文件**: `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"心智模型一致。 + +### N3. Manager batch dedup 注释/spec 都说有,代码中没实现 + +**文件**: `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 的误导注释**,不要让未来维护者以为有保护。 + +### N4. `DylibPathResolver` 拒绝所有 dyld-shared-cache 系统 framework + +**文件**: `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` —— 让"失败"项有意义。 + +--- + +## Nit + +### Nit-1. 每批次 Cancel 按钮缺失,`cancelBatchRelay` 是死代码 + +**文件**: `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) 是正确选择 —— 基础设施已经全部就位,只缺一个按钮。 + +### Nit-2. Settings off→on 触发用错误 `reason` + +**文件**: `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`。 + +### Nit-3. `documentBatchIDs` 泄漏失败完成批次的 ID + +**文件**: `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` 减。 + +--- + +## Pre-existing(P2 跟进) + +### Pre-1. `isImageIndexed` 与 `loadImageForBackgroundIndexing` 路径规范化不对称 + +**文件**: `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。 + +绑 iOS Simulator 支持工作,本 PR 不阻塞。 + +--- + +## 与 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。 From c96f229b3678c0d6f1a7c4cc605fdda12f109588 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 11:12:35 +0800 Subject: [PATCH 35/78] fix(core): break engine retain cycle; resolve dyld shared cache deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two post-review fixes from ultrareview N1 + N4: - BackgroundIndexingEngineRepresenting now requires AnyObject; manager holds engine via `unowned let`. Engine still strongly owns the manager through `RuntimeEngine.backgroundIndexingManager`, so the back-reference cannot dangle in production — engine deinit releases the manager synchronously. Previously the strong cycle leaked engine + manager + section caches on every source switch / detach. - DyldUtilities exposes `isInDyldSharedCache(_:)` (Set-cached, literal comparison, no version normalization). DylibPathResolver routes every filesystem check through `pathExists` so install names that are baked into the dyld shared cache (and have no on-disk file on Apple Silicon) no longer fail with "path unresolved". Manager tests gain a `keep(_:)` helper because parallel test locals can be released by ARC across `await` suspensions, leaving the unowned reference dangling. --- ...BackgroundIndexingEngineRepresenting.swift | 12 ++-- .../RuntimeBackgroundIndexingManager.swift | 6 +- .../Utils/DyldUtilities.swift | 30 +++++++++- .../Utils/DylibPathResolver.swift | 18 ++++-- .../DylibPathResolverTests.swift | 34 ++++++++++++ ...untimeBackgroundIndexingManagerTests.swift | 55 +++++++++++++------ 6 files changed, 127 insertions(+), 28 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift index 7d2e153b..9bf027b8 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift @@ -10,12 +10,12 @@ /// 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 { +/// 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 BackgroundIndexingEngineRepresenting: AnyObject, Sendable { func isImageIndexed(path: String) async throws -> Bool func loadImageForBackgroundIndexing(at path: String) async throws func mainExecutablePath() async throws -> String diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index 39d7f0dc..04c04562 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -2,7 +2,11 @@ import Foundation import Semaphore public actor RuntimeBackgroundIndexingManager { - private let engine: any BackgroundIndexingEngineRepresenting + /// `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 BackgroundIndexingEngineRepresenting private let stream: AsyncStream private let continuation: AsyncStream.Continuation diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift index 911e0363..d4908e1b 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift @@ -66,7 +66,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 +84,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 index 4e02f7a4..d7ddc476 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift @@ -19,7 +19,7 @@ struct DylibPathResolver { let candidate = expand(rpath, imagePath: imagePath, mainExecutablePath: mainExecutablePath) + "/" + tail - if fileManager.fileExists(atPath: candidate) { + if pathExists(candidate) { return candidate } } @@ -29,15 +29,25 @@ struct DylibPathResolver { let tail = String(installName.dropFirst("@executable_path/".count)) let candidate = (mainExecutablePath as NSString) .deletingLastPathComponent + "/" + tail - return fileManager.fileExists(atPath: candidate) ? candidate : nil + return pathExists(candidate) ? candidate : nil } if installName.hasPrefix("@loader_path/") { let tail = String(installName.dropFirst("@loader_path/".count)) let candidate = (imagePath as NSString) .deletingLastPathComponent + "/" + tail - return fileManager.fileExists(atPath: candidate) ? candidate : nil + return pathExists(candidate) ? candidate : nil } - return fileManager.fileExists(atPath: installName) ? installName : nil + return pathExists(installName) ? 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, diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift index a25e6563..e8df0a39 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift @@ -25,6 +25,40 @@ final class DylibPathResolverTests: XCTestCase { mainExecutablePath: "/any")) } + func test_absolutePath_acceptsDyldSharedCachePath() 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. + // + // Try a handful of well-known cache residents — pick the first one + // this host's cache reports membership for. Empty `picked` means + // the test process couldn't load DyldCacheLoaded.current at all + // (sandboxed test runners on some CI configs), in which case skip. + let candidates = [ + "/System/Library/Frameworks/Foundation.framework/Foundation", + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", + "/usr/lib/libobjc.A.dylib", + "/usr/lib/libSystem.B.dylib", + ] + let picked = candidates.first(where: DyldUtilities.isInDyldSharedCache) + try XCTSkipUnless( + picked != nil, + "no candidate found in this host's dyld shared cache (test env may lack cache access)" + ) + let candidate = picked! + XCTAssertFalse( + FileManager.default.fileExists(atPath: candidate), + "precondition: \(candidate) should NOT exist on disk on this host" + ) + XCTAssertEqual( + resolver.resolve(installName: candidate, + imagePath: "/any", rpaths: [], + mainExecutablePath: "/any"), + candidate + ) + } + func test_executablePath_substitutesMainExecutableDir() throws { let tempDir = FileManager.default.temporaryDirectory.path let exePath = tempDir + "/FakeExe" diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index 469ab356..7f52a162 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -3,15 +3,38 @@ import Semaphore @testable import RuntimeViewerCore final class RuntimeBackgroundIndexingManagerTests: XCTestCase { + /// 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 test instance's lifetime. + private var aliveObjects: [AnyObject] = [] + + @discardableResult + private func keep(_ object: T) -> T { + aliveObjects.append(object) + return object + } + + override func tearDown() async throws { + aliveObjects.removeAll() + try await super.tearDown() + } + func test_currentBatches_initiallyEmpty() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) let manager = RuntimeBackgroundIndexingManager(engine: engine) let batches = await manager.currentBatches() XCTAssertTrue(batches.isEmpty) } func test_events_streamYieldsBatchStarted_thenFinished_forEmptyGraph() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/fake/Root", .init(isIndexed: true)) // short-circuit immediately let manager = RuntimeBackgroundIndexingManager(engine: engine) @@ -39,7 +62,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_expand_emptyWhenRootAlreadyIndexed() async { - let engine = MockBackgroundIndexingEngine() + 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) @@ -47,7 +70,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_expand_depth1_includesRootAndDirectDeps() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init( dependencies: [("/UIKit", "/UIKit"), ("/Foundation", "/Foundation")] )) @@ -58,7 +81,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_expand_depth1_doesNotIncludeSecondLevel() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/UIKit", "/UIKit")])) engine.program(path: "/UIKit", @@ -69,7 +92,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_expand_skipsAlreadyIndexedDeps() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/UIKit", "/UIKit"), ("/Foundation", "/Foundation")])) @@ -80,7 +103,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_expand_unresolvedInstallNameBecomesFailedItem() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init( dependencies: [("@rpath/Missing", nil)] )) @@ -92,7 +115,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_expand_dedupsSharedDependencies() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/A", "/A"), ("/B", "/B")])) engine.program(path: "/A", @@ -106,7 +129,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_batch_indexesAllPendingItems() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/A", "/A"), ("/B", "/B")])) engine.program(path: "/A", .init()) @@ -122,7 +145,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_batch_respectsMaxConcurrency() async { - let engine = MockBackgroundIndexingEngine() + 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)) @@ -130,7 +153,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { // Monkey-patch engine with a concurrency-counting wrapper. let counter = ConcurrencyCounter() - let wrapped = InstrumentedEngine(base: engine, counter: counter) + let wrapped = keep(InstrumentedEngine(base: engine, counter: counter)) let manager = RuntimeBackgroundIndexingManager(engine: wrapped) _ = await runToFinish(manager: manager, root: "/App", depth: 1, @@ -140,7 +163,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { func test_batch_failedLoad_yieldsFailedTaskState() async { struct LoadError: Error {} - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/Broken", "/Broken")])) engine.program(path: "/Broken", .init(shouldFailLoad: LoadError())) @@ -157,7 +180,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_cancelBatch_stopsPendingItemsAndEmitsCancelledEvent() async { - let engine = MockBackgroundIndexingEngine() + 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()) } @@ -180,7 +203,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_cancelAll_cancelsEveryBatch() async { - let engine = MockBackgroundIndexingEngine() + 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")])) @@ -202,7 +225,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { // `.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 engine = keep(MockBackgroundIndexingEngine()) let deps = ["/D0", "/D1", "/D2"] engine.program(path: "/App", .init( dependencies: deps.map { ($0, $0) } @@ -231,7 +254,7 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { } func test_prioritize_isNoOpForUnknownPath() async { - let engine = MockBackgroundIndexingEngine() + let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init()) let manager = RuntimeBackgroundIndexingManager(engine: engine) _ = await manager.startBatch(rootImagePath: "/App", depth: 0, From ea4b33be125d8a2339240f9e20dd383bb326dde7 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 11:12:47 +0800 Subject: [PATCH 36/78] fix(application): rebind background indexing coordinator on source switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ultrareview N2 / implementation-review I3: MainCoordinator reassigns documentState.runtimeEngine when the user switches source (Local ↔ XPC ↔ Bonjour), but the coordinator captured the initial engine in init and kept routing pumps / cancels / prioritize calls to the dead manager. Toolbar silently drifted out of sync with reality. Coordinator now subscribes to documentState.\$runtimeEngine.skip(1) (BehaviorRelay exposed by @Observed) and runs handleEngineSwap(to:): cancel old pumps, fire-and-forget cancel of doc batches on the old manager, clear UI relays, swap the engine reference, restart pumps, re-trigger documentDidOpen() if the feature is enabled. DocumentState.runtimeEngine doc comment updated — the prior "treat as immutable" assumption is retired (Evolution 0002 assumption #1). --- ...RuntimeBackgroundIndexingCoordinator.swift | 69 ++++++++++++++++++- .../DocumentState.swift | 14 ++-- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 36a8b3f8..438648e3 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -24,7 +24,12 @@ public final class RuntimeBackgroundIndexingCoordinator { } private unowned let documentState: DocumentState - private let engine: RuntimeEngine + /// 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: []) @@ -45,6 +50,7 @@ public final class RuntimeBackgroundIndexingCoordinator { startImageLoadedPump() bootstrapSettingsObservation() #endif + bootstrapEngineObservation() } deinit { @@ -183,6 +189,67 @@ public final class RuntimeBackgroundIndexingCoordinator { .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 + // best-effort cancel to its manager for any document batches we own. + let oldEngine = engine + let oldBatchIDs = documentBatchIDs + + // 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) Best-effort cancel of in-flight batches on the old manager. + // Fire-and-forget — old engine's manager will deinit shortly. + if !oldBatchIDs.isEmpty { + Task { + for id in oldBatchIDs { + await oldEngine.backgroundIndexingManager.cancelBatch(id) + } + } + } + + // 3) Drop UI state — the old engine's batches no longer apply. + documentBatchIDs.removeAll() + batchesRelay.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) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 678bf704..facee12e 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -9,15 +9,11 @@ public final class DocumentState { /// The runtime engine backing this Document. /// - /// Per Evolution 0002 (Background Indexing) Assumption #1, this property - /// is treated as **immutable for the lifetime of the Document**. The - /// declaration uses `@Observed public var` for historical reasons (early - /// callers needed to swap in a remote engine after init), but current - /// callers MUST NOT reassign it after the Document is opened. - /// - /// `RuntimeBackgroundIndexingCoordinator` (and any future per-engine - /// actor) captures this reference at init time; reassignment would - /// silently route work to a stale engine. + /// 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 From 895a0e2b3faae25cd79720ecf75109afb2eac19f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 11:13:00 +0800 Subject: [PATCH 37/78] docs(background-indexing): record post-review fixes for N1 / I3-N2 / N4 Sync spec, plan and review docs with the three post-review fixes shipped in this batch. Captures rationale future readers will need when the implementation surface drifts: - Evolution 0002: protocol/manager wording switches to AnyObject + unowned, scenario G (source switch) added, assumption #1 retracted in favour of "reassignable", assumption #4 documents the deliberate no-normalization choice for dyld shared cache lookups, three new decision-log entries dated 2026-04-28. - Plan: appended a "Post-review fixes (2026-04-28)" section listing each fix's changed files and why the surrounding plan tasks were not reopened. - Reviews: I3 / N1 / N2 / N4 marked FIXED inline with the original finding so the issue trail stays attached. The N4 entry also explains why the reviewer's suggested versioned/unversioned normalization was rejected in favour of literal matching. --- .../Evolution/0002-background-indexing.md | 75 ++++++++++++++++--- .../2026-04-24-background-indexing-plan.md | 52 +++++++++++++ ...ckground-indexing-implementation-review.md | 10 ++- ...6-04-26-background-indexing-ultrareview.md | 29 ++++++- 4 files changed, 151 insertions(+), 15 deletions(-) diff --git a/Documentations/Evolution/0002-background-indexing.md b/Documentations/Evolution/0002-background-indexing.md index 6ee5b436..8788bfff 100644 --- a/Documentations/Evolution/0002-background-indexing.md +++ b/Documentations/Evolution/0002-background-indexing.md @@ -3,7 +3,7 @@ - **状态**: Accepted - **作者**: JH - **日期**: 2026-04-24 -- **最后更新**: 2026-04-24 +- **最后更新**: 2026-04-28 ## 摘要 @@ -147,11 +147,15 @@ setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, of: self) { $ #### `RuntimeBackgroundIndexingManager`(actor) -持有所有运行中的批次以及所有事件流。在 `RuntimeEngine` init 时创建,**通过协议 `BackgroundIndexingEngineRepresenting` 按值持有引擎**(`engine: any BackgroundIndexingEngineRepresenting`):manager 不直接依赖具体的 `RuntimeEngine` 类型,只通过协议表面消费 `isImageIndexed` / `mainExecutablePath` / `loadImageForBackgroundIndexing` / `canOpenImage` / `rpaths` / `dependencies` 等方法。`RuntimeEngine`(actor)只是该协议的一个 conformance,测试用 `MockBackgroundIndexingEngine`(`@unchecked Sendable`)与 `InstrumentedEngine` 同样 conform。这条 seam 让 manager 单元测试不需要真实 dyld I/O,也避免 actor↔actor 之间的 `unowned` 反向引用。 +持有所有运行中的批次以及所有事件流。在 `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 { - private let engine: any BackgroundIndexingEngineRepresenting + /// `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 { ... } @@ -173,10 +177,10 @@ public actor RuntimeBackgroundIndexingManager { #### `BackgroundIndexingEngineRepresenting`(协议) -manager 与具体 engine 类型之间的抽象 seam。仅 `: Sendable`(无 `AnyObject` —— manager 按值持有,无引用语义需求;参见决策日志 2026-04-26)。 +manager 与具体 engine 类型之间的抽象 seam。`: AnyObject, Sendable` —— manager 通过 `unowned let engine` 持有,需要类受限的 existential。参见决策日志 2026-04-28(回退了 2026-04-26 的暂时性"仅 Sendable"决议,因为 ultrareview N1 暴露了真实的 RuntimeEngine ↔ Manager 强引用环)。 ```swift -protocol BackgroundIndexingEngineRepresenting: Sendable { +protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable { func isImageIndexed(path: String) async throws -> Bool func loadImageForBackgroundIndexing(at path: String) async throws func mainExecutablePath() async throws -> String @@ -192,9 +196,10 @@ protocol BackgroundIndexingEngineRepresenting: Sendable { - **不暴露 `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) + - `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 值类型 @@ -262,6 +267,7 @@ public enum RuntimeIndexingEvent: Sendable { 5. 维护从事件归约而来的 `batchesRelay: BehaviorRelay<[RuntimeIndexingBatch]>`。**包含任意失败项的已完成批次会被保留**在 `batchesRelay` 中,直到用户在弹出框中通过"Clear Failed"显式清除;干净完成与取消会立即移除。 6. 暴露 `aggregateStateDriver: Driver`。`hasFailures` 由保留下来的失败批次推导。 7. 持有按 Document 维度的批次跟踪:`[Document.ID: Set]`。 +8. 通过 RxSwift 订阅 `documentState.$runtimeEngine.skip(1)`(BehaviorRelay) 响应 source switch:取消旧 manager 上的 doc batches、停掉旧 pumps、清空 `batchesRelay` / `aggregateRelay`、把 `self.engine` 切到新 engine、重启 pumps、若 isEnabled 则重新触发 `documentDidOpen()`。`engine` 字段为 `var`,每次 swap 时被覆盖。参见决策日志 2026-04-28(I3 / N2)。 ### 数据流场景 @@ -346,6 +352,44 @@ handleSettingsChange: 理由:`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([]) + 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 —— 用户从弹出框取消 ``` @@ -412,13 +456,17 @@ install name 有四种形态: | 形态 | 解析 | |-------|------------| -| `/System/Library/...`(绝对路径) | 原样使用,校验文件存在。 | -| `@rpath/Foo.framework/Foo` | 对根镜像上每个 `LC_RPATH` 进行替换,取第一个存在的路径。 | -| `@executable_path/...` | 用主可执行文件所在目录替换。 | -| `@loader_path/...` | 用当前镜像所在目录替换。 | +| `/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 绑定层。 @@ -630,12 +678,14 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { ### 假设 -1. **`DocumentState.runtimeEngine` 在 Document 整个生命周期内不可变。** 该属性出于历史原因被声明为 `@Observed public var runtimeEngine: RuntimeEngine = .local`(`DocumentState.swift:10-11`),但调用方在 Document 创建后不会重新赋值。Coordinator 在 init 时一次性捕获 `engine = documentState.runtimeEngine`;如果该假设被打破,批次会被分发到错误的 engine。在该属性上加一段文档注释强化此契约。 +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/` 下。 @@ -789,3 +839,6 @@ RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift | 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` 这类无歧义路径在两平台都直接命中 | diff --git a/Documentations/Plans/2026-04-24-background-indexing-plan.md b/Documentations/Plans/2026-04-24-background-indexing-plan.md index 82047495..0ad527be 100644 --- a/Documentations/Plans/2026-04-24-background-indexing-plan.md +++ b/Documentations/Plans/2026-04-24-background-indexing-plan.md @@ -3368,3 +3368,55 @@ EOF - 手动 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/Reviews/2026-04-26-background-indexing-implementation-review.md b/Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md index f0d3f863..5adf505e 100644 --- a/Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md +++ b/Documentations/Reviews/2026-04-26-background-indexing-implementation-review.md @@ -10,6 +10,12 @@ **判定**: 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 警告。 @@ -65,7 +71,7 @@ Coordinator 在 [`RuntimeBackgroundIndexingCoordinator.swift:240-241`](../../Run - doc comment 只提了不调 `reloadData`,没提不发 `imageDidLoadSubject` —— 后者对正确性同样关键。加一行说明。 - `RuntimeEngineIndexStateTests.swift:61-70`(`test_loadImageForBackgroundIndexing_doesNotTriggerReloadData`)名字是 reloadData 跳过,但断言只检查"image 变成 indexed",既没断言"无 reload 通知"也没断言"无 imageDidLoad 通知"。补一个 `Combine.sink` 断言"调用期间 publisher 不发火"。 -### I3. Source-switch 时 coordinator 抓住旧 engine +### I3. Source-switch 时 coordinator 抓住旧 engine ✅ FIXED 2026-04-28 `MainCoordinator.swift:34` 在 `.main(let runtimeEngine)` 时 reassign `documentState.runtimeEngine`。`backgroundIndexingCoordinator` 是 `lazy var`,首次访问后捕获了那时候的 engine + manager。后果: @@ -81,6 +87,8 @@ Coordinator 在 [`RuntimeBackgroundIndexingCoordinator.swift:240-241`](../../Run 建议第一种作为紧随 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 项。 diff --git a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md index d89e46ce..6451dc3f 100644 --- a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md +++ b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md @@ -9,11 +9,19 @@ **判定**: 没有阻塞 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),Nit-1 (per-batch cancel 按钮),Nit-2 (.settingsEnabled reason),Nit-3 (documentBatchIDs 失败保留泄漏)** — 未处理,follow-up +- ⏳ **Pre-1 (path normalization)** — 仅 iOS Simulator 激活,绑 iOS Simulator 支持工作,本轮不修 + --- ## Normal -### N1. `RuntimeEngine` ↔ `RuntimeBackgroundIndexingManager` 循环引用导致每个远程 engine 泄漏 +### N1. `RuntimeEngine` ↔ `RuntimeBackgroundIndexingManager` 循环引用导致每个远程 engine 泄漏 ✅ FIXED 2026-04-28 **文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:4-16` @@ -32,7 +40,9 @@ Evolution 0002 决议 N4 主动把协议从 `AnyObject, Sendable` 改成纯 `Sen **修法**:回退 N4 决议,把协议恢复为 `AnyObject, Sendable`,manager 持有改为 `private weak var engine: (any BackgroundIndexingEngineRepresenting)?`(或 `unowned` 如果文档约定 engine 寿命包住 manager)。所有 callsite `try await engine?.…`,nil 时直接 bail。约 3 行核心改动 + doc comment 修正。 -### N2. Coordinator 跨 source 切换捕获过时 `RuntimeEngine` +**修复 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` @@ -58,6 +68,8 @@ Evolution 0002 决议 N4 主动把协议从 `AnyObject, Sendable` 改成纯 `Sen 推荐 (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 都说有,代码中没实现 **文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:51-73` @@ -83,7 +95,7 @@ Evolution 0002 第 626 行:*"manager 去重:如果某活动批次的 `rootImageP - 把规则放宽为"任意匹配 `rootImagePath`",抓住 `.appLaunch` ↔ `.imageLoaded` 这一对。 - 否则**至少删掉 coordinator 的误导注释**,不要让未来维护者以为有保护。 -### N4. `DylibPathResolver` 拒绝所有 dyld-shared-cache 系统 framework +### N4. `DylibPathResolver` 拒绝所有 dyld-shared-cache 系统 framework ✅ FIXED 2026-04-28 **文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift:36-41` @@ -113,6 +125,17 @@ Task 24 后 batch 含 `.failed` 即被保留,toolbar 永久 `hasFailures` 红徽 - 让绝对路径也接受 `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 From 16e80e9b099b661af7a8753d2b38756bbbcd394c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 11:13:05 +0800 Subject: [PATCH 38/78] docs(evolution): renumber bonjour reliability proposal to 0000 Align Bonjour reliability proposal with the numeric evolution scheme adopted by 0002-background-indexing.md. Pure rename; content unchanged. --- ...6-03-03-bonjour-reliability.md => 0000-bonjour-reliability.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Documentations/Evolution/{2026-03-03-bonjour-reliability.md => 0000-bonjour-reliability.md} (100%) 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 From 01c0cf1e24006baa73ac662c29dc929fdb992f13 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 11:13:12 +0800 Subject: [PATCH 39/78] chore(build): force package update against Distribution workspace XCFramework script now builds against RuntimeViewer-Distribution.xcworkspace and explicitly resolves package dependencies before each archive. Without the explicit resolve, SPM happily reuses the default DerivedData SourcePackages/checkouts directory and pins stale transitive versions (e.g. swift-dyld-private 1.1.0 instead of the available 1.2.0). Adds --no-update-packages to opt out of the resolve step when reproducing a specific Package.resolved is desired. --- BuildRuntimeViewerServerXCFramework.sh | 63 ++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) 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 # ========================================== From 889c74e0df23f2e4f9685aee455fe28ba2605c95 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 11:44:59 +0800 Subject: [PATCH 40/78] test(core): migrate background indexing tests to Swift Testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the four BackgroundIndexing test files (36 tests total) from XCTest to Swift Testing. Suites become @Suite struct (or final class where mutable state is needed), XCTAssert* turns into #expect, and XCTSkipUnless turns into .enabled(if:) traits — preserving the "skipped" status when host conditions aren't met (e.g. system frameworks absent on Apple Silicon hosts). RuntimeBackgroundIndexingManagerTests stays a class because it carries mutable aliveObjects + keep(_:) helper that pin mock engines past async suspension points (the manager's unowned engine reference would otherwise dangle when ARC eagerly releases parallel test locals). Drops underscore separators from method names per project convention. --- .../DylibPathResolverTests.swift | 97 ++++++++------- ...untimeBackgroundIndexingManagerTests.swift | 97 ++++++++------- .../RuntimeEngineIndexStateTests.swift | 111 ++++++++++-------- .../RuntimeIndexingValueTypesTests.swift | 30 ++--- 4 files changed, 172 insertions(+), 163 deletions(-) diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift index e8df0a39..76619d87 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/DylibPathResolverTests.swift @@ -1,65 +1,60 @@ -import XCTest +import Foundation +import Testing @testable import RuntimeViewerCore -final class DylibPathResolverTests: XCTestCase { +@Suite struct DylibPathResolverTests { private let resolver = DylibPathResolver() - func test_absolutePath_returnsAsIsWhenExists() throws { + /// 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" - XCTAssertTrue(FileManager.default.fileExists(atPath: path), - "precondition: /usr/lib/dyld exists in this test env") - XCTAssertEqual( - resolver.resolve(installName: path, - imagePath: "/any", rpaths: [], - mainExecutablePath: "/any"), - path - ) + #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) } - func test_absolutePath_returnsNilWhenMissing() { - XCTAssertNil(resolver.resolve(installName: "/nonexistent/Foo.dylib", - imagePath: "/any", rpaths: [], - mainExecutablePath: "/any")) + @Test func absolutePathReturnsNilWhenMissing() { + #expect(resolver.resolve(installName: "/nonexistent/Foo.dylib", + imagePath: "/any", rpaths: [], + mainExecutablePath: "/any") == nil) } - func test_absolutePath_acceptsDyldSharedCachePath() throws { + @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. - // - // Try a handful of well-known cache residents — pick the first one - // this host's cache reports membership for. Empty `picked` means - // the test process couldn't load DyldCacheLoaded.current at all - // (sandboxed test runners on some CI configs), in which case skip. - let candidates = [ - "/System/Library/Frameworks/Foundation.framework/Foundation", - "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", - "/usr/lib/libobjc.A.dylib", - "/usr/lib/libSystem.B.dylib", - ] - let picked = candidates.first(where: DyldUtilities.isInDyldSharedCache) - try XCTSkipUnless( - picked != nil, - "no candidate found in this host's dyld shared cache (test env may lack cache access)" - ) - let candidate = picked! - XCTAssertFalse( - FileManager.default.fileExists(atPath: candidate), - "precondition: \(candidate) should NOT exist on disk on this host" - ) - XCTAssertEqual( - resolver.resolve(installName: candidate, - imagePath: "/any", rpaths: [], - mainExecutablePath: "/any"), - candidate + 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) } - func test_executablePath_substitutesMainExecutableDir() throws { + @Test func executablePathSubstitutesMainExecutableDir() throws { let tempDir = FileManager.default.temporaryDirectory.path let exePath = tempDir + "/FakeExe" let frameworkPath = tempDir + "/Foo" @@ -73,10 +68,10 @@ final class DylibPathResolverTests: XCTestCase { installName: "@executable_path/Foo", imagePath: "/any", rpaths: [], mainExecutablePath: exePath) - XCTAssertEqual(resolved, frameworkPath) + #expect(resolved == frameworkPath) } - func test_loaderPath_substitutesImageDir() throws { + @Test func loaderPathSubstitutesImageDir() throws { let tempDir = FileManager.default.temporaryDirectory.path let imagePath = tempDir + "/FakeLib" let siblingPath = tempDir + "/Sibling" @@ -90,10 +85,10 @@ final class DylibPathResolverTests: XCTestCase { installName: "@loader_path/Sibling", imagePath: imagePath, rpaths: [], mainExecutablePath: "/any") - XCTAssertEqual(resolved, siblingPath) + #expect(resolved == siblingPath) } - func test_rpath_usesFirstMatchingRpath() throws { + @Test func rpathUsesFirstMatchingRpath() throws { let tempDir = FileManager.default.temporaryDirectory.path let rpath1 = tempDir + "/DoesNotExist" let rpath2 = tempDir + "/RPath2" @@ -109,13 +104,13 @@ final class DylibPathResolverTests: XCTestCase { installName: "@rpath/MyLib", imagePath: "/any", rpaths: [rpath1, rpath2], mainExecutablePath: "/any") - XCTAssertEqual(resolved, target) + #expect(resolved == target) } - func test_rpath_returnsNilWhenNoMatch() { - XCTAssertNil(resolver.resolve( + @Test func rpathReturnsNilWhenNoMatch() { + #expect(resolver.resolve( installName: "@rpath/Missing", imagePath: "/any", rpaths: ["/nope1", "/nope2"], - mainExecutablePath: "/any")) + mainExecutablePath: "/any") == nil) } } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index 7f52a162..0bdbac56 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -1,8 +1,9 @@ -import XCTest +import Foundation import Semaphore +import Testing @testable import RuntimeViewerCore -final class RuntimeBackgroundIndexingManagerTests: XCTestCase { +@Suite final class RuntimeBackgroundIndexingManagerTests { /// Keepalives for engines / wrappers passed to a manager. /// /// Production safety: `RuntimeBackgroundIndexingManager.engine` is `unowned` @@ -12,7 +13,8 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { /// 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 test instance's lifetime. + /// 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 @@ -21,19 +23,14 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { return object } - override func tearDown() async throws { - aliveObjects.removeAll() - try await super.tearDown() - } - - func test_currentBatches_initiallyEmpty() async { + @Test func currentBatchesInitiallyEmpty() async { let engine = keep(MockBackgroundIndexingEngine()) let manager = RuntimeBackgroundIndexingManager(engine: engine) let batches = await manager.currentBatches() - XCTAssertTrue(batches.isEmpty) + #expect(batches.isEmpty) } - func test_events_streamYieldsBatchStarted_thenFinished_forEmptyGraph() async { + @Test func eventsStreamYieldsBatchStartedThenFinishedForEmptyGraph() async { let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/fake/Root", .init(isIndexed: true)) // short-circuit immediately @@ -53,34 +50,32 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { return seen } - let id = await manager.startBatch(rootImagePath: "/fake/Root", - depth: 0, maxConcurrency: 1, - reason: .manual) - XCTAssertNotNil(id) + _ = await manager.startBatch(rootImagePath: "/fake/Root", + depth: 0, maxConcurrency: 1, + reason: .manual) let finalSeen = await consumer.value - XCTAssertEqual(finalSeen, ["started", "finished"]) + #expect(finalSeen == ["started", "finished"]) } - func test_expand_emptyWhenRootAlreadyIndexed() async { + @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) - XCTAssertTrue(items.isEmpty) + #expect(items.isEmpty) } - func test_expand_depth1_includesRootAndDirectDeps() async { + @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) - XCTAssertEqual(Set(items.map(\.id)), - Set(["/App", "/UIKit", "/Foundation"])) + #expect(Set(items.map(\.id)) == Set(["/App", "/UIKit", "/Foundation"])) } - func test_expand_depth1_doesNotIncludeSecondLevel() async { + @Test func expandDepth1DoesNotIncludeSecondLevel() async { let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/UIKit", "/UIKit")])) @@ -88,10 +83,10 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { .init(dependencies: [("/CoreGraphics", "/CoreGraphics")])) let manager = RuntimeBackgroundIndexingManager(engine: engine) let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 1) - XCTAssertEqual(Set(items.map(\.id)), Set(["/App", "/UIKit"])) + #expect(Set(items.map(\.id)) == Set(["/App", "/UIKit"])) } - func test_expand_skipsAlreadyIndexedDeps() async { + @Test func expandSkipsAlreadyIndexedDeps() async { let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/UIKit", "/UIKit"), @@ -99,22 +94,24 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { engine.program(path: "/UIKit", .init(isIndexed: true)) let manager = RuntimeBackgroundIndexingManager(engine: engine) let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 1) - XCTAssertEqual(Set(items.map(\.id)), Set(["/App", "/Foundation"])) + #expect(Set(items.map(\.id)) == Set(["/App", "/Foundation"])) } - func test_expand_unresolvedInstallNameBecomesFailedItem() async { + @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 = items.first { $0.id == "@rpath/Missing" } - XCTAssertNotNil(missing) - if case .failed = missing?.state {} else { XCTFail("expected failed state") } + 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 + } } - func test_expand_dedupsSharedDependencies() async { + @Test func expandDedupsSharedDependencies() async { let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/A", "/A"), ("/B", "/B")])) @@ -125,10 +122,10 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { let manager = RuntimeBackgroundIndexingManager(engine: engine) let items = await manager.expandDependencyGraph(rootPath: "/App", depth: 2) let sharedCount = items.filter { $0.id == "/Shared" }.count - XCTAssertEqual(sharedCount, 1) + #expect(sharedCount == 1) } - func test_batch_indexesAllPendingItems() async { + @Test func batchIndexesAllPendingItems() async { let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init(dependencies: [("/A", "/A"), ("/B", "/B")])) @@ -139,12 +136,12 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { let finishedBatch = await runToFinish(manager: manager, root: "/App", depth: 1, maxConcurrency: 2) - XCTAssertTrue(finishedBatch.items.allSatisfy { $0.state == .completed }) + #expect(finishedBatch.items.allSatisfy { $0.state == .completed }) let indexed = engine.loadedOrder() - XCTAssertEqual(Set(indexed), Set(["/App", "/A", "/B"])) + #expect(Set(indexed) == Set(["/App", "/A", "/B"])) } - func test_batch_respectsMaxConcurrency() async { + @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)") } @@ -158,10 +155,10 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { _ = await runToFinish(manager: manager, root: "/App", depth: 1, maxConcurrency: 2) - XCTAssertLessThanOrEqual(counter.peak, 2) + #expect(counter.peak <= 2) } - func test_batch_failedLoad_yieldsFailedTaskState() async { + @Test func batchFailedLoadYieldsFailedTaskState() async throws { struct LoadError: Error {} let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", @@ -171,15 +168,15 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { let batch = await runToFinish(manager: manager, root: "/App", depth: 1, maxConcurrency: 1) - let broken = batch.items.first { $0.id == "/Broken" } - XCTAssertNotNil(broken) - guard case .failed(let message) = broken?.state else { - XCTFail("expected .failed"); return + 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 } - XCTAssertFalse(message.isEmpty) + #expect(!message.isEmpty) } - func test_cancelBatch_stopsPendingItemsAndEmitsCancelledEvent() async { + @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)) @@ -199,10 +196,10 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await manager.cancelBatch(id) let batch = await consumer.value - XCTAssertTrue(batch.isCancelled) + #expect(batch.isCancelled) } - func test_cancelAll_cancelsEveryBatch() async { + @Test func cancelAllCancelsEveryBatch() async { let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/A", .init(dependencies: [("/A1", "/A1")])) engine.program(path: "/A1", .init()) @@ -213,14 +210,14 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { maxConcurrency: 1, reason: .manual) let idB = await manager.startBatch(rootImagePath: "/B", depth: 1, maxConcurrency: 1, reason: .manual) - XCTAssertNotEqual(idA, idB) + #expect(idA != idB) await manager.cancelAllBatches() try? await Task.sleep(nanoseconds: 50_000_000) let remaining = await manager.currentBatches() - XCTAssertTrue(remaining.isEmpty) + #expect(remaining.isEmpty) } - func test_prioritize_emitsTaskPrioritizedEvent() async { + @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 @@ -250,10 +247,10 @@ final class RuntimeBackgroundIndexingManagerTests: XCTestCase { await manager.prioritize(imagePath: "/D2") let boosted = await consumer.value - XCTAssertEqual(boosted, ["/D2"]) + #expect(boosted == ["/D2"]) } - func test_prioritize_isNoOpForUnknownPath() async { + @Test func prioritizeIsNoOpForUnknownPath() async { let engine = keep(MockBackgroundIndexingEngine()) engine.program(path: "/App", .init()) let manager = RuntimeBackgroundIndexingManager(engine: engine) diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift index fd19f310..cde7da5c 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -1,24 +1,29 @@ -import XCTest import Combine +import Foundation +import Testing @testable import RuntimeViewerCore -final class RuntimeEngineIndexStateTests: XCTestCase { - func test_isImageIndexed_falseForUnvisitedPath() async throws { +@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") - XCTAssertFalse(indexed) + #expect(!indexed) } - func test_isImageIndexed_trueAfterLoadImage() async throws { - let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" - try XCTSkipUnless( - FileManager.default.fileExists(atPath: foundation), + @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: foundation) - let indexed = try await engine.isImageIndexed(path: foundation) - XCTAssertTrue(indexed) + 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 @@ -31,59 +36,71 @@ final class RuntimeEngineIndexStateTests: XCTestCase { /// 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. - func test_isImageIndexed_normalizesPath() async throws { - let raw = "/System/Library/Frameworks/Foundation.framework/Foundation" - try XCTSkipUnless( - FileManager.default.fileExists(atPath: raw), + @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: raw) + try await engine.loadImage(at: Self.foundation) // After load, both raw and patched forms should report indexed. - let patched = DyldUtilities.patchImagePathForDyld(raw) - let indexedRaw = try await engine.isImageIndexed(path: raw) + let patched = DyldUtilities.patchImagePathForDyld(Self.foundation) + let indexedRaw = try await engine.isImageIndexed(path: Self.foundation) let indexedPatched = try await engine.isImageIndexed(path: patched) - XCTAssertTrue(indexedRaw, "isImageIndexed must return true for the unpatched path") - XCTAssertTrue(indexedPatched, "isImageIndexed must return true for the patched path too") + #expect(indexedRaw, "isImageIndexed must return true for the unpatched path") + #expect(indexedPatched, "isImageIndexed must return true for the patched path too") } - 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 + @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() - XCTAssertFalse(path.isEmpty) - XCTAssertTrue(FileManager.default.fileExists(atPath: path)) + #expect(!path.isEmpty) + #expect(FileManager.default.fileExists(atPath: path)) } - func test_loadImageForBackgroundIndexing_doesNotTriggerReloadData() async throws { - // CoreText is reliable across macOS versions; if it's absent, skip. - let path = "/System/Library/Frameworks/CoreText.framework/CoreText" - try XCTSkipUnless(FileManager.default.fileExists(atPath: path), - "Requires macOS with CoreText.framework present") + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: coreText), + "Requires macOS with CoreText.framework present" + ) + ) + func loadImageForBackgroundIndexingDoesNotTriggerReloadData() async throws { + // CoreText is reliable across macOS versions. let engine = RuntimeEngine(source: .local) - try await engine.loadImageForBackgroundIndexing(at: path) - let indexed = try await engine.isImageIndexed(path: path) - XCTAssertTrue(indexed) + try await engine.loadImageForBackgroundIndexing(at: Self.coreText) + let indexed = try await engine.isImageIndexed(path: Self.coreText) + #expect(indexed) } - func test_imageDidLoadPublisher_firesAfterLoadImage() async throws { - let foundation = "/System/Library/Frameworks/Foundation.framework/Foundation" - try XCTSkipUnless(FileManager.default.fileExists(atPath: foundation), - "Requires macOS with Foundation.framework present") + @Test( + .enabled( + if: FileManager.default.fileExists(atPath: foundation), + "Requires macOS with Foundation.framework present" + ) + ) + func imageDidLoadPublisherFiresAfterLoadImage() async throws { let engine = RuntimeEngine(source: .local) - let expectation = expectation(description: "imageDidLoad") - var received: String? - // imageDidLoadPublisher is `nonisolated` — no await needed. - let cancellable = engine.imageDidLoadPublisher.sink { path in - received = path - expectation.fulfill() + + // 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: foundation) - await fulfillment(of: [expectation], timeout: 5) - cancellable.cancel() - XCTAssertEqual(received, foundation) + + 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 index 5c3d9472..89de5b81 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift @@ -1,33 +1,33 @@ -import XCTest +import Testing @testable import RuntimeViewerCore -final class RuntimeIndexingValueTypesTests: XCTestCase { - func test_batchID_isUnique() { +@Suite struct RuntimeIndexingValueTypesTests { + @Test func batchIDIsUnique() { let a = RuntimeIndexingBatchID() let b = RuntimeIndexingBatchID() - XCTAssertNotEqual(a, b) + #expect(a != b) } - func test_taskItem_isNotCompletedWhenPending() { + @Test func taskItemIsNotCompletedWhenPending() { let item = RuntimeIndexingTaskItem(id: "/foo", resolvedPath: "/foo", state: .pending, hasPriorityBoost: false) - XCTAssertFalse(item.state.isTerminal) + #expect(!item.state.isTerminal) } - func test_taskState_failedIsTerminal() { + @Test func taskStateFailedIsTerminal() { let state = RuntimeIndexingTaskState.failed(message: "boom") - XCTAssertTrue(state.isTerminal) + #expect(state.isTerminal) } - func test_taskState_cancelledIsTerminal() { - XCTAssertTrue(RuntimeIndexingTaskState.cancelled.isTerminal) + @Test func taskStateCancelledIsTerminal() { + #expect(RuntimeIndexingTaskState.cancelled.isTerminal) } - func test_taskState_completedIsTerminal() { - XCTAssertTrue(RuntimeIndexingTaskState.completed.isTerminal) + @Test func taskStateCompletedIsTerminal() { + #expect(RuntimeIndexingTaskState.completed.isTerminal) } - func test_batch_progress_reportsCompletedFraction() { + @Test func batchProgressReportsCompletedFraction() { let items: [RuntimeIndexingTaskItem] = [ .init(id: "/a", resolvedPath: "/a", state: .completed, hasPriorityBoost: false), .init(id: "/b", resolvedPath: "/b", state: .completed, hasPriorityBoost: false), @@ -43,7 +43,7 @@ final class RuntimeIndexingValueTypesTests: XCTestCase { isCancelled: false, isFinished: false ) - XCTAssertEqual(batch.completedCount, 3) // completed + failed both count toward "done" - XCTAssertEqual(batch.totalCount, 4) + #expect(batch.completedCount == 3) // completed + failed both count toward "done" + #expect(batch.totalCount == 4) } } From 05d6586276bdbde52c3ff339d88c64069c6382ce Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 15:40:50 +0800 Subject: [PATCH 41/78] fix(core): make patchImagePathForDyld idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeated patching used to produce a doubled prefix like /sim_root/sim_root/usr/lib/libobjc.A.dylib because the function only checked starts(with: "/"). Add a guard that returns input unchanged when it already equals rootPath or has rootPath + "/" as a prefix. Splits the function into a default env-reading wrapper and a pure overload taking rootPath explicitly so tests can drive the logic without touching ProcessInfo. Covers the iOS Simulator scenario where dyld already reports patched paths to callers — re-patching is now a no-op rather than a corruption, removing a trap that would surface once cache writers also adopt patching to align with readers. --- .../Utils/DyldUtilities.swift | 18 ++++- .../DyldUtilitiesTests.swift | 70 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift index d4908e1b..41444bbc 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift @@ -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) } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift new file mode 100644 index 00000000..f5c02393 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift @@ -0,0 +1,70 @@ +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") + } +} From 60454e38371cc36c3e66833d55ac0600a9bcb98b Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 16:25:19 +0800 Subject: [PATCH 42/78] docs(review): record Pre-1 partial fix (idempotency) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark Pre-1 as 🟡 partial fix with commit a033d3d. Document why Step 1 (idempotency guard) precedes Step 2 (writer-side patching): without the guard, Step 2 would double-patch in iOS Simulator where dyld already returns patched image names. --- ...026-04-26-background-indexing-ultrareview.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md index 6451dc3f..07544751 100644 --- a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md +++ b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md @@ -15,7 +15,7 @@ - ✅ **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),Nit-1 (per-batch cancel 按钮),Nit-2 (.settingsEnabled reason),Nit-3 (documentBatchIDs 失败保留泄漏)** — 未处理,follow-up -- ⏳ **Pre-1 (path normalization)** — 仅 iOS Simulator 激活,绑 iOS Simulator 支持工作,本轮不修 +- 🟡 **Pre-1 (path normalization)** — 部分修复。`DyldUtilities.patchImagePathForDyld` 增加幂等性保护(commit `a033d3d`),避免 iOS Simulator 下 `dyld` 已经返回 patched path 时再次 patch 产生 `/sim_root/sim_root/...` 双前缀。Reader/writer 不对称的根本对齐留给 iOS Simulator 支持工作(Step 2) --- @@ -208,7 +208,7 @@ case .batchFinished(let finished): ## Pre-existing(P2 跟进) -### Pre-1. `isImageIndexed` 与 `loadImageForBackgroundIndexing` 路径规范化不对称 +### Pre-1. `isImageIndexed` 与 `loadImageForBackgroundIndexing` 路径规范化不对称 🟡 PARTIAL FIX 2026-04-28 **文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift:6-15` @@ -220,11 +220,20 @@ case .batchFinished(let finished): 测试 `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。 -绑 iOS Simulator 支持工作,本 PR 不阻塞。 +**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(待办,绑 iOS Simulator)**:走"彻底方案" —— `loadImage(at:)` / `loadImageForBackgroundIndexing(at:)` 入口 patch,内部存储统一 patched 形式,wire 上仍是 raw。Step 1 已保证多次 patch 安全,Step 2 可以放心铺开。本 PR 不阻塞。 --- From a60155afeb9eaeea021e45e8ab59471667d93e66 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 16:33:23 +0800 Subject: [PATCH 43/78] fix(core): canonicalize paths in loadImage writers Pre-1 Step 2. `loadImage(at:)` and `loadImageForBackgroundIndexing(at:)` now patch the path on entry via `DyldUtilities.patchImagePathForDyld` before touching internal storage (loadedImagePaths, section factory caches) and notification subjects (imageDidLoadSubject, sendRemoteImageDidLoadIfNeeded). This brings them in line with `_objects(in:)` and `_localObjectsWithProgress`, which already patched at entry, and with all reader-side lookups (`isImageLoaded`, `isImageIndexed`), which all patch first. Reader/writer is now symmetric on the canonical form. The wire still carries the raw path (`request: path` on the remote branch) so receivers patch with their own DYLD_ROOT_PATH, not the sender's. Step 1 (commit a033d3d) made `patchImagePathForDyld` idempotent, so re-patching an already-patched path produced by dyld in iOS Simulator runners is now a safe no-op. Adds two contract tests pinning the writer-side normalization. They pass trivially on macOS (where patch is identity) but catch any regression that drops the writer-side patch. --- ...6-04-26-background-indexing-ultrareview.md | 14 ++++-- .../RuntimeEngine+BackgroundIndexing.swift | 12 +++-- .../RuntimeViewerCore/RuntimeEngine.swift | 20 +++++--- .../RuntimeEngineIndexStateTests.swift | 48 +++++++++++++++++++ 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md index 07544751..c5c03b55 100644 --- a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md +++ b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md @@ -15,7 +15,7 @@ - ✅ **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),Nit-1 (per-batch cancel 按钮),Nit-2 (.settingsEnabled reason),Nit-3 (documentBatchIDs 失败保留泄漏)** — 未处理,follow-up -- 🟡 **Pre-1 (path normalization)** — 部分修复。`DyldUtilities.patchImagePathForDyld` 增加幂等性保护(commit `a033d3d`),避免 iOS Simulator 下 `dyld` 已经返回 patched path 时再次 patch 产生 `/sim_root/sim_root/...` 双前缀。Reader/writer 不对称的根本对齐留给 iOS Simulator 支持工作(Step 2) +- ✅ **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 --- @@ -208,7 +208,7 @@ case .batchFinished(let finished): ## Pre-existing(P2 跟进) -### Pre-1. `isImageIndexed` 与 `loadImageForBackgroundIndexing` 路径规范化不对称 🟡 PARTIAL FIX 2026-04-28 +### Pre-1. `isImageIndexed` 与 `loadImageForBackgroundIndexing` 路径规范化不对称 ✅ FIXED 2026-04-28 **文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift:6-15` @@ -233,7 +233,15 @@ case .batchFinished(let finished): 这一步并未改变 reader/writer 的对称性 —— `isImageIndexed` 仍 patch、writer 仍 raw,iOS Simulator 上语义层 bug 依旧。但它**清掉了 Step 2 在 writer 入口加 patch 时会撞上的双前缀地雷**。 -**Step 2(待办,绑 iOS Simulator)**:走"彻底方案" —— `loadImage(at:)` / `loadImageForBackgroundIndexing(at:)` 入口 patch,内部存储统一 patched 形式,wire 上仍是 raw。Step 1 已保证多次 patch 安全,Step 2 可以放心铺开。本 PR 不阻塞。 +**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 单元测试通过。 --- diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index edc6761a..a39303b5 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -28,11 +28,13 @@ extension RuntimeEngine { /// Used by the background indexing manager to avoid UI refresh storms. public func loadImageForBackgroundIndexing(at path: String) async throws { try await request { - // Mirror loadImage(at:) byte-for-byte sans reloadData(isReloadImageNodes:). - try DyldUtilities.loadImage(at: path) - _ = try await objcSectionFactory.section(for: path) - _ = try await swiftSectionFactory.section(for: path) - loadedImagePaths.insert(path) + // 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) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 1501625a..189f219a 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -529,13 +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) - imageDidLoadSubject.send(path) - sendRemoteImageDidLoadIfNeeded(path: path) + loadedImagePaths.insert(canonical) + imageDidLoadSubject.send(canonical) + sendRemoteImageDidLoadIfNeeded(path: canonical) } remote: { try await $0.sendMessage(name: .loadImage, request: path) } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift index cde7da5c..74207429 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -78,6 +78,54 @@ import Testing #expect(indexed) } + /// 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), From c24934b77831c250089980110c8fcb76aa438c38 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 16:37:17 +0800 Subject: [PATCH 44/78] refactor(background-indexing): adopt RxAppKit bindings in popover Replace the hand-rolled NSOutlineViewDataSource/Delegate, @objc target- action plumbing, and imperative isHidden updates with RxAppKit reactive bindings: - outlineView.rx.nodes drives the outline; BackgroundIndexingNode now conforms to OutlineNodeType + Differentiable, and the .batch case carries its child items inline so the tree structure is self-contained - button.rx.click.asSignal() feeds the ViewModel's Input directly, removing the cancelAll/clearFailed/openSettings PublishRelays and @objc action methods - output.isEnabled / hasAnyFailure drive .rx.isHidden via Driver combinators instead of subscribeOnNext side effects - Cell rendering is extracted into private BatchCellView and ItemCellView NSTableCellView subclasses for clearer ownership --- .../BackgroundIndexingNode.swift | 28 +- ...kgroundIndexingPopoverViewController.swift | 245 +++++++----------- .../BackgroundIndexingPopoverViewModel.swift | 10 +- 3 files changed, 130 insertions(+), 153 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift index 4d67ca87..70ef777c 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift @@ -1,6 +1,32 @@ import RuntimeViewerCore +import RxAppKit enum BackgroundIndexingNode: Hashable { - case batch(RuntimeIndexingBatch) + case batch(RuntimeIndexingBatch, items: [BackgroundIndexingNode]) case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) } + +extension BackgroundIndexingNode: OutlineNodeType { + var children: [BackgroundIndexingNode] { + switch self { + case .batch(_, let items): return items + case .item: return [] + } + } +} + +extension BackgroundIndexingNode: Differentiable { + enum Identifier: Hashable { + case batch(RuntimeIndexingBatchID) + case item(batchID: RuntimeIndexingBatchID, itemID: String) + } + + var differenceIdentifier: Identifier { + switch self { + 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 index 06b8dc8b..7427d03a 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -7,16 +7,7 @@ import RxCocoa import RxSwift import SnapKit -final class BackgroundIndexingPopoverViewController: - UXKitViewController -{ - // MARK: - Relays - - private let cancelBatchRelay = PublishRelay() - private let cancelAllRelay = PublishRelay() - private let clearFailedRelay = PublishRelay() - private let openSettingsRelay = PublishRelay() - +final class BackgroundIndexingPopoverViewController: UXKitViewController { // MARK: - Views private let titleLabel = Label("Background Indexing").then { @@ -68,17 +59,12 @@ final class BackgroundIndexingPopoverViewController: $0.title = "Close" } - // MARK: - Outline data - - private var renderedNodes: [BackgroundIndexingNode] = [] - // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setupLayout() setupOutlineView() - setupActions() preferredContentSize = NSSize(width: 380, height: 300) } @@ -139,37 +125,6 @@ final class BackgroundIndexingPopoverViewController: column.resizingMask = .autoresizingMask outlineView.addTableColumn(column) outlineView.outlineTableColumn = column - outlineView.dataSource = self - outlineView.delegate = self - } - - 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 - openSettingsButton.action = #selector(openSettingsClicked) - } - - // MARK: - Actions - - @objc private func cancelAllClicked() { - cancelAllRelay.accept(()) - } - - @objc private func clearFailedClicked() { - clearFailedRelay.accept(()) - } - - @objc private func closeClicked() { - dismiss(nil) - } - - @objc private func openSettingsClicked() { - openSettingsRelay.accept(()) } // MARK: - Bindings @@ -178,30 +133,34 @@ final class BackgroundIndexingPopoverViewController: super.setupBindings(for: viewModel) let input = BackgroundIndexingPopoverViewModel.Input( - cancelBatch: cancelBatchRelay.asSignal(), - cancelAll: cancelAllRelay.asSignal(), - clearFailed: clearFailedRelay.asSignal(), - openSettings: openSettingsRelay.asSignal() + cancelBatch: .never(), + cancelAll: cancelAllButton.rx.click.asSignal(), + clearFailed: clearFailedButton.rx.click.asSignal(), + openSettings: openSettingsButton.rx.click.asSignal() ) let output = viewModel.transform(input) + closeButton.rx.click.asSignal() + .emitOnNext { [weak self] in + guard let self else { return } + dismiss(nil) + } + .disposed(by: rx.disposeBag) + output.subtitle .drive(subtitleLabel.rx.stringValue) .disposed(by: rx.disposeBag) output.isEnabled - .driveOnNext { [weak self] enabled in - guard let self else { return } - emptyDisabledView.isHidden = enabled - openSettingsButton.isHidden = enabled - } + .drive(emptyDisabledView.rx.isHidden) .disposed(by: rx.disposeBag) - output.hasAnyFailure - .driveOnNext { [weak self] hasFailure in - guard let self else { return } - clearFailedButton.isHidden = !hasFailure - } + output.isEnabled + .drive(openSettingsButton.rx.isHidden) + .disposed(by: rx.disposeBag) + + output.hasAnyFailure.not() + .drive(clearFailedButton.rx.isHidden) .disposed(by: rx.disposeBag) // Direct-call into the Settings window. There is no `MainRoute.openSettings` @@ -212,91 +171,100 @@ final class BackgroundIndexingPopoverViewController: } .disposed(by: rx.disposeBag) - Observable - .combineLatest( - output.isEnabled.asObservable(), - output.hasAnyBatch.asObservable() - ) - .subscribeOnNext { [weak self] enabled, hasBatches in - guard let self else { return } - emptyIdleView.isHidden = !enabled || hasBatches - scrollView.isHidden = !enabled || !hasBatches - } - .disposed(by: rx.disposeBag) + Driver.combineLatest(output.isEnabled, output.hasAnyBatch) { enabled, hasBatches in + !enabled || hasBatches + } + .drive(emptyIdleView.rx.isHidden) + .disposed(by: rx.disposeBag) - output.nodes - .driveOnNext { [weak self] nodes in - guard let self else { return } - renderedNodes = nodes - outlineView.reloadData() - outlineView.expandItem(nil, expandChildren: true) + Driver.combineLatest(output.isEnabled, output.hasAnyBatch) { enabled, hasBatches in + !enabled || !hasBatches + } + .drive(scrollView.rx.isHidden) + .disposed(by: rx.disposeBag) + + output.nodes.drive(outlineView.rx.nodes) { (outlineView: NSOutlineView, _: NSTableColumn?, node: BackgroundIndexingNode) -> NSView? in + switch node { + case .batch(let batch, _): + let cell = outlineView.box.makeView(ofClass: BatchCellView.self) + cell.configure( + reason: batch.reason, + completedCount: batch.completedCount, + totalCount: batch.totalCount + ) + return cell + case .item(_, let item): + let cell = outlineView.box.makeView(ofClass: ItemCellView.self) + cell.configure(item: item) + return cell } - .disposed(by: rx.disposeBag) + } + .disposed(by: rx.disposeBag) + + output.nodes.driveOnNext { [weak self] _ in + guard let self else { return } + outlineView.expandItem(nil, expandChildren: true) + } + .disposed(by: rx.disposeBag) } } -// MARK: - NSOutlineViewDataSource & Delegate +extension BackgroundIndexingPopoverViewController { + private final class BatchCellView: NSTableCellView { + let titleLabel = Label("") -extension BackgroundIndexingPopoverViewController: NSOutlineViewDataSource, NSOutlineViewDelegate { - func outlineView(_ outlineView: NSOutlineView, - numberOfChildrenOfItem item: Any?) -> Int - { - if item == nil { - return renderedNodes.filter { - if case .batch = $0 { return true } else { return false } - }.count + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.centerY.equalToSuperview() + } } - guard let node = item as? BackgroundIndexingNode, - case .batch(let batch) = node - else { return 0 } - return batch.items.count - } - func outlineView(_ outlineView: NSOutlineView, - child index: Int, - ofItem item: Any?) -> Any - { - if item == nil { - let batches = renderedNodes.compactMap { node -> RuntimeIndexingBatch? in - if case .batch(let batch) = node { return batch } else { return nil } - } - return BackgroundIndexingNode.batch(batches[index]) + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - guard let node = item as? BackgroundIndexingNode, - case .batch(let batch) = node - else { - preconditionFailure("unexpected outline item type: \(type(of: item))") + + func configure( + reason: RuntimeIndexingBatchReason, + completedCount: Int, + totalCount: Int + ) { + titleLabel.stringValue = "\(Self.title(for: reason)) \(completedCount)/\(totalCount)" } - return BackgroundIndexingNode.item(batchID: batch.id, - item: batch.items[index]) - } - func outlineView(_ outlineView: NSOutlineView, - isItemExpandable item: Any) -> Bool - { - if let node = item as? BackgroundIndexingNode, - case .batch = node { return true } - return false + 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" + } + } } - func outlineView(_ outlineView: NSOutlineView, - viewFor tableColumn: NSTableColumn?, - item: Any) -> NSView? - { - guard let node = item as? BackgroundIndexingNode else { return nil } - - let cell = NSTableCellView() - let label = Label("") - cell.hierarchy { label } - label.snp.makeConstraints { make in - make.leading.trailing.centerY.equalToSuperview() + private final class ItemCellView: NSTableCellView { + let titleLabel = Label("") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - switch node { - case .batch(let batch): - let title = Self.title(for: batch.reason) - label.stringValue = "\(title) \(batch.completedCount)/\(batch.totalCount)" - case .item(_, let item): + func configure(item: RuntimeIndexingTaskItem) { let nameSource = item.resolvedPath ?? item.id let name = (nameSource as NSString).lastPathComponent let prefix: String = { @@ -315,22 +283,7 @@ extension BackgroundIndexingPopoverViewController: NSOutlineViewDataSource, NSOu if item.hasPriorityBoost, case .pending = item.state { text += " (priority)" } - label.stringValue = text - } - - return cell - } - - 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" + titleLabel.stringValue = text } } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index c7fcc9c9..fc6c82db 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -126,14 +126,12 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { private static func renderNodes(from batches: [RuntimeIndexingBatch]) -> [BackgroundIndexingNode] { - var out: [BackgroundIndexingNode] = [] - for batch in batches { - out.append(.batch(batch)) - for item in batch.items { - out.append(.item(batchID: batch.id, item: item)) + batches.map { batch in + let itemNodes = batch.items.map { item in + BackgroundIndexingNode.item(batchID: batch.id, item: item) } + return .batch(batch, items: itemNodes) } - return out } private static func subtitleFor( From f99226c65c8b22bc0208f682574f31ca09480299 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 16:48:46 +0800 Subject: [PATCH 45/78] fix(background-indexing): manager dedup + correct reason + leak cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the ultrareview's N3 / Nit-2 / Nit-3: N3 — Manager batch dedup. `RuntimeBackgroundIndexingManager.startBatch` now returns the existing batch's id when another non-finished batch shares the same `rootImagePath`. Reason discriminant is intentionally ignored so the realistic `.appLaunch` ↔ `.imageLoaded(path:)` pair collapses. Two checks: an early one before `expandDependencyGraph` to short-circuit work, and a re-check after the suspension to handle actor reentrancy races. Coordinator's `handleImageLoaded` comment updated to match the broader rule. Nit-2 — `.settingsEnabled` reason is no longer dead. Off→on transition in `handleSettingsChange` previously routed through `documentDidOpen()` which hardcoded `.appLaunch`, so the popover always showed "App launch indexing". Extracted `startMainExecutableBatch(reason:)` helper; both entry points now pass their own reason. No behavior change beyond the title string. Nit-3 — `documentBatchIDs` no longer leaks failed-finalized batch ids. The `.batchFinished` failure-retain branch and `clearFailedBatches()` both updated to drop their cleared ids. Without this, the set grew monotonically over a Document's lifetime and `documentWillClose` fired no-op cancel Tasks for ghost ids that had already finalized on the manager. Adds two manager tests: dedup hits across reasons, and dedup releases after finalize so a fresh batch on the same root works. --- .../RuntimeBackgroundIndexingManager.swift | 33 +++++++++++++ ...untimeBackgroundIndexingManagerTests.swift | 49 +++++++++++++++++++ ...RuntimeBackgroundIndexingCoordinator.swift | 41 +++++++++++++--- 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index 04c04562..a3808d5c 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -58,8 +58,33 @@ public actor RuntimeBackgroundIndexingManager { 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. + if let existingId = findActiveBatchID(forRootImagePath: rootImagePath) { + return existingId + } + let batch = RuntimeIndexingBatch( id: id, rootImagePath: rootImagePath, depth: depth, reason: reason, items: items, @@ -76,6 +101,14 @@ public actor RuntimeBackgroundIndexingManager { 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] { diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index 0bdbac56..e634b925 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -260,6 +260,55 @@ import Testing // 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, diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 438648e3..4279fb86 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -91,11 +91,18 @@ public final class RuntimeBackgroundIndexingCoordinator { 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 + 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) } @@ -148,8 +155,13 @@ public final class RuntimeBackgroundIndexingCoordinator { } } else { batches.removeAll { $0.id == finished.id } - documentBatchIDs.remove(finished.id) } + // The manager finalized this batch regardless of failure status — + // it's already removed from `activeBatches`. Drop it from + // `documentBatchIDs` too so `documentWillClose` doesn't fire + // no-op cancel Tasks for ghost ids. The UI side decision to keep + // failed batches visible is independent of this bookkeeping. + documentBatchIDs.remove(finished.id) Task { [engine] in await engine.reloadData(isReloadImageNodes: false) } @@ -255,6 +267,15 @@ public final class RuntimeBackgroundIndexingCoordinator { #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. Task { [weak self] in @@ -270,7 +291,7 @@ extension RuntimeBackgroundIndexingCoordinator { rootImagePath: root, depth: settings.depth, maxConcurrency: settings.maxConcurrency, - reason: .appLaunch) + reason: reason) self.documentBatchIDs.insert(id) } } @@ -302,10 +323,11 @@ extension RuntimeBackgroundIndexingCoordinator { private func handleImageLoaded(path: String) async { 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. Manager - // dedups batches that share rootImagePath + reason discriminant, so a - // second call here is a no-op rather than a wasted batch. + // 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, @@ -346,7 +368,10 @@ extension RuntimeBackgroundIndexingCoordinator { let wasEnabled = lastKnownIsEnabled lastKnownIsEnabled = latest.isEnabled if !wasEnabled && latest.isEnabled { - documentDidOpen() // Scenario E: off→on + // 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() From e3b18bac259e16ea1f3b75c50590603aaf0870bf Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 16:53:41 +0800 Subject: [PATCH 46/78] fix(background-indexing): wire per-batch cancel button in popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nit-1 from the ultrareview. The popover ViewModel's `cancelBatch` Input slot was already plumbed end-to-end (relay → coordinator → manager) but no UI element ever fired it — the ViewController passed `.never()` for the slot and the cell only rendered a label. `BatchCellView` now hosts an inline `xmark.circle` button next to the title. The button is hidden when the batch has already been finalized on the manager (failed-retain rows pending user dismiss) so users don't try to cancel something that's no longer running. Click routes through a closure on the cell into `cancelBatchRelay`, which feeds the existing Input.cancelBatch signal. --- ...kgroundIndexingPopoverViewController.swift | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index 7427d03a..0e3051ed 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -8,6 +8,10 @@ import RxSwift import SnapKit final class BackgroundIndexingPopoverViewController: UXKitViewController { + // MARK: - Relays + + private let cancelBatchRelay = PublishRelay() + // MARK: - Views private let titleLabel = Label("Background Indexing").then { @@ -133,7 +137,7 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController NSView? in + 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.configure( reason: batch.reason, completedCount: batch.completedCount, - totalCount: batch.totalCount + totalCount: batch.totalCount, + // Hide cancel for batches the manager has already finalized + // (kept around as failed-retain rows pending user dismiss). + isCancellable: !batch.isFinished, + onCancel: { [weak self] in + guard let self else { return } + cancelBatchRelay.accept(batch.id) + } ) return cell case .item(_, let item): @@ -212,13 +223,38 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController Void)? override init(frame frameRect: NSRect) { super.init(frame: frameRect) - addSubview(titleLabel) - titleLabel.snp.makeConstraints { make in - make.leading.trailing.centerY.equalToSuperview() + cancelButton.target = self + cancelButton.action = #selector(cancelButtonClicked) + + let stack = HStackView(spacing: 6) { + titleLabel + cancelButton + } + stack.alignment = .centerY + + addSubview(stack) + stack.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.centerY.equalToSuperview() } + // Title takes remaining space; button hugs its intrinsic size. + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + cancelButton.setContentHuggingPriority(.required, for: .horizontal) + cancelButton.setContentCompressionResistancePriority(.required, for: .horizontal) } @available(*, unavailable) @@ -229,11 +265,19 @@ extension BackgroundIndexingPopoverViewController { func configure( reason: RuntimeIndexingBatchReason, completedCount: Int, - totalCount: Int + totalCount: Int, + isCancellable: Bool, + onCancel: @escaping () -> Void ) { + self.onCancel = onCancel + cancelButton.isHidden = !isCancellable titleLabel.stringValue = "\(Self.title(for: reason)) \(completedCount)/\(totalCount)" } + @objc private func cancelButtonClicked() { + onCancel?() + } + private static func title(for reason: RuntimeIndexingBatchReason) -> String { switch reason { case .appLaunch: From 7fda3b93e3d8cc84ebadc97e1dfd000e191cf566 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 16:54:51 +0800 Subject: [PATCH 47/78] docs(review): mark N3 / Nit-1 / Nit-2 / Nit-3 fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four follow-up review items now have a dated 2026-04-28 fix block explaining the chosen approach and any new tests / contracts pinned. The summary at the top of the doc is updated accordingly: every item the ultrareview surfaced is now ✅ except Pre-existing Pre-1 (closed in earlier commits with Step 1 + Step 2). --- ...6-04-26-background-indexing-ultrareview.md | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md index c5c03b55..1058c546 100644 --- a/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md +++ b/Documentations/Reviews/2026-04-26-background-indexing-ultrareview.md @@ -14,7 +14,10 @@ - ✅ **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),Nit-1 (per-batch cancel 按钮),Nit-2 (.settingsEnabled reason),Nit-3 (documentBatchIDs 失败保留泄漏)** — 未处理,follow-up +- ✅ **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 --- @@ -70,7 +73,7 @@ Evolution 0002 决议 N4 主动把协议从 `AnyObject, Sendable` 改成纯 `Sen **修复 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 都说有,代码中没实现 +### N3. Manager batch dedup 注释/spec 都说有,代码中没实现 ✅ FIXED 2026-04-28 **文件**: `RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift:51-73` @@ -95,6 +98,12 @@ Evolution 0002 第 626 行:*"manager 去重:如果某活动批次的 `rootImageP - 把规则放宽为"任意匹配 `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` @@ -140,7 +149,7 @@ Task 24 后 batch 含 `.failed` 即被保留,toolbar 永久 `hasFailures` 红徽 ## Nit -### Nit-1. 每批次 Cancel 按钮缺失,`cancelBatchRelay` 是死代码 +### Nit-1. 每批次 Cancel 按钮缺失,`cancelBatchRelay` 是死代码 ✅ FIXED 2026-04-28 **文件**: `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift:282-311` @@ -156,7 +165,11 @@ Evolution 0002 第 521 行:*"Batch 行:标题由 reason 派生、`{completed}/{t (A) 是正确选择 —— 基础设施已经全部就位,只缺一个按钮。 -### Nit-2. Settings off→on 触发用错误 `reason` +**修复 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` @@ -175,7 +188,9 @@ if !wasEnabled && latest.isEnabled { **修法**:抽 `private func startMainExecutableBatch(reason: RuntimeIndexingBatchReason)` helper,`documentDidOpen()` 传 `.appLaunch`,`handleSettingsChange` off→on 分支传 `.settingsEnabled`。 -### Nit-3. `documentBatchIDs` 泄漏失败完成批次的 ID +**修复 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` @@ -204,6 +219,8 @@ case .batchFinished(let finished): - 失败保留分支补 `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 跟进) From 42246dbfa9ebe2917e27e5aa4209cd9d9d60c730 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 17:31:07 +0800 Subject: [PATCH 48/78] fix(background-indexing): make toolbar item clickable + visually live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs the user surfaced as "toolbar item is always disabled and won't click": 1. Click was a no-op. `BackgroundIndexingToolbarItem` set `target/action` on the NSToolbarItem itself, but its `view` was a plain NSView wrapping NSImageView + NSProgressIndicator + a small dot — none of them are NSControls, and NSToolbarItem only routes clicks to its action via NSControl subviews (or via the overflow menu). The button never fired. 2. Looked greyed-out. Idle state tinted the icon with `.secondaryLabelColor`, which renders as a muted gray next to peer toolbar buttons that use the standard tint — easy to read as "disabled". `BackgroundIndexingToolbarItemView` now hosts a `ToolbarButton` (the same `bezelStyle = .toolbar` NSButton subclass that `IconButtonToolbarItem` uses for MCP/Save/etc.) as its bottom layer. The spinner and failure dot are layered on top as click-through overlays — small private NSProgressIndicator / NSView subclasses override `hitTest(_:)` to return nil so clicks fall through to the button beneath. Idle tint is `nil` (default toolbar color) so the icon looks like its peers. `BackgroundIndexingToolbarItem` wires `itemView.button.target/action` to its existing `clicked()` selector. The item-level target/action are kept so overflow-menu clicks still work. Removed the dead `bindState` API — nothing called it. --- .../BackgroundIndexingToolbarItem.swift | 14 ++--- .../BackgroundIndexingToolbarItemView.swift | 59 +++++++++++++------ 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift index cd1f094f..65e116af 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift @@ -15,18 +15,16 @@ final class BackgroundIndexingToolbarItem: NSToolbarItem { paletteLabel = "Background Indexing" toolTip = "Background indexing status" view = itemView + + // The actual click receiver is the button inside `itemView`. The + // toolbar item's own target/action is also wired so the item works + // when it appears in the overflow menu (where there is no view). + itemView.button.target = self + itemView.button.action = #selector(clicked) target = self action = #selector(clicked) } - func bindState(_ driver: Driver) { - driver.driveOnNext { [weak self] state in - guard let self else { return } - itemView.state = state - } - .disposed(by: disposeBag) - } - @objc private func clicked() { tapRelay.accept(itemView) } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift index 4cd6f22c..31046b1e 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift @@ -1,4 +1,5 @@ import AppKit +import RuntimeViewerUI import SnapKit enum BackgroundIndexingToolbarState: Equatable { @@ -9,19 +10,26 @@ enum BackgroundIndexingToolbarState: Equatable { } final class BackgroundIndexingToolbarItemView: NSView { - private let iconView = NSImageView().then { - $0.image = NSImage(systemSymbolName: "square.stack.3d.down.right", - accessibilityDescription: nil) + /// Click-receiving control. `NSToolbarItem` with a non-control custom view + /// does NOT route clicks to the item's target/action — only NSControl + /// subclasses inside the view do. Wrapping the icon in a `ToolbarButton` + /// gives both the standard toolbar bezel + click handling. The spinner + /// and failure dot are click-through overlays on top. + let button = ToolbarButton().then { + $0.image = NSImage( + systemSymbolName: "square.stack.3d.down.right", + accessibilityDescription: nil) $0.symbolConfiguration = .init(pointSize: 15, weight: .regular) - $0.contentTintColor = .secondaryLabelColor + $0.imagePosition = .imageOnly + $0.title = "" } - private let spinner = NSProgressIndicator().then { + private let spinner = ClickThroughProgressIndicator().then { $0.style = .spinning $0.controlSize = .small $0.isIndeterminate = true $0.isDisplayedWhenStopped = false } - private let failureDot = NSView() + private let failureDot = ClickThroughView() var state: BackgroundIndexingToolbarState = .idle { didSet { applyState() } @@ -36,14 +44,13 @@ final class BackgroundIndexingToolbarItemView: NSView { required init?(coder: NSCoder) { fatalError() } private func setupLayout() { - hierarchy { - iconView - spinner - failureDot - } - iconView.snp.makeConstraints { make in - make.center.equalToSuperview() - make.width.height.equalTo(18) + // Button fills the view, then overlays paint on top. + addSubview(button) + addSubview(spinner) + addSubview(failureDot) + + button.snp.makeConstraints { make in + make.edges.equalToSuperview() } spinner.snp.makeConstraints { make in make.center.equalToSuperview() @@ -51,7 +58,7 @@ final class BackgroundIndexingToolbarItemView: NSView { } failureDot.snp.makeConstraints { make in make.width.height.equalTo(6) - make.trailing.bottom.equalTo(iconView) + make.trailing.bottom.equalTo(button).inset(2) } failureDot.wantsLayer = true failureDot.layer?.cornerRadius = 3 @@ -61,19 +68,22 @@ final class BackgroundIndexingToolbarItemView: NSView { private func applyState() { switch state { case .idle: - iconView.contentTintColor = .secondaryLabelColor + // Default toolbar tint — looks "live" like other toolbar buttons. + // Using `.secondaryLabelColor` here previously made the icon look + // disabled compared to its peers. + button.contentTintColor = nil spinner.stopAnimation(nil) failureDot.isHidden = true case .disabled: - iconView.contentTintColor = .tertiaryLabelColor + button.contentTintColor = .tertiaryLabelColor spinner.stopAnimation(nil) failureDot.isHidden = true case .indexing: - iconView.contentTintColor = .controlAccentColor + button.contentTintColor = .controlAccentColor spinner.startAnimation(nil) failureDot.isHidden = true case .hasFailures: - iconView.contentTintColor = .controlAccentColor + button.contentTintColor = .controlAccentColor spinner.startAnimation(nil) failureDot.isHidden = false } @@ -81,3 +91,14 @@ final class BackgroundIndexingToolbarItemView: NSView { override var intrinsicContentSize: NSSize { NSSize(width: 28, height: 28) } } + +/// AppKit lacks UIView's `isUserInteractionEnabled`. Overlays that must let +/// clicks fall through to the button beneath need a `hitTest(_:)` returning +/// `nil` for every point. +private final class ClickThroughProgressIndicator: NSProgressIndicator { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +private final class ClickThroughView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} From d9818d92e2e3b76a3cd16a7ca38b476ffa08cf87 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 17:48:47 +0800 Subject: [PATCH 49/78] refactor(background-indexing): simplify toolbar item to plain icon button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the indexing toolbar widget was unresponsive and looked disabled. Even after wiring the click handler in the previous commit (dbeea06), the icon-with-overlay design felt out of place next to the peer toolbar items. Strip the state visualization for now and revisit later — the popover itself already shows progress, failures, and batches in detail. `BackgroundIndexingToolbarItem` now subclasses `MainToolbarController.IconButtonToolbarItem` (the same base used by MCP / Save / Attach / etc.) with the `square.stack.3d.down.right` SF Symbol. No spinner, no failure dot, no tinting. Click flows via the inherited `button` and `MainWindowController` switches its Input wire from the now-removed `tapRelay` to the standard `.button.rx.clickWithSelf` pattern that mirrors `mcpStatusClick`. Drops `BackgroundIndexingToolbarItemView.swift` — its NSView wrapper, overlay layout, and the `BackgroundIndexingToolbarState` enum are no longer needed. Removes the `aggregateStateObservable → toolbar item view` binding in `MainWindowController`. --- .../project.pbxproj | 4 - .../BackgroundIndexingToolbarItem.swift | 26 +---- .../BackgroundIndexingToolbarItemView.swift | 104 ------------------ .../Main/MainWindowController.swift | 21 +--- 4 files changed, 5 insertions(+), 150 deletions(-) delete mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index f8942d93..b668bc5d 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -38,7 +38,6 @@ 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 */; }; - E9BD1A192FA000080000ABCD /* BackgroundIndexingToolbarItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A182FA000070000ABCD /* BackgroundIndexingToolbarItemView.swift */; }; E9BD1A1B2FA0000A0000ABCD /* BackgroundIndexingToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */; }; E96CF5332EC7A4A600CBC159 /* RuntimeSource+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */; }; E96DE1E32F0ACE8D00F9BAB2 /* CheckboxButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */; }; @@ -258,7 +257,6 @@ 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 = ""; }; - E9BD1A182FA000070000ABCD /* BackgroundIndexingToolbarItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingToolbarItemView.swift; sourceTree = ""; }; E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingToolbarItem.swift; sourceTree = ""; }; E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RuntimeSource+.swift"; sourceTree = ""; }; E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CheckboxButton+.swift"; sourceTree = ""; }; @@ -531,7 +529,6 @@ E9BD1A102FA000010000ABCD /* BackgroundIndexingNode.swift */, E9BD1A122FA000030000ABCD /* BackgroundIndexingPopoverViewModel.swift */, E9BD1A162FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift */, - E9BD1A182FA000070000ABCD /* BackgroundIndexingToolbarItemView.swift */, E9BD1A1A2FA000090000ABCD /* BackgroundIndexingToolbarItem.swift */, ); path = BackgroundIndexing; @@ -976,7 +973,6 @@ E9BD1A112FA000020000ABCD /* BackgroundIndexingNode.swift in Sources */, E9BD1A132FA000040000ABCD /* BackgroundIndexingPopoverViewModel.swift in Sources */, E9BD1A172FA000060000ABCD /* BackgroundIndexingPopoverViewController.swift in Sources */, - E9BD1A192FA000080000ABCD /* BackgroundIndexingToolbarItemView.swift in Sources */, E9BD1A1B2FA0000A0000ABCD /* BackgroundIndexingToolbarItem.swift in Sources */, E9F11E0B2F123EEC0052B0A3 /* SidebarRootCoordinator.swift in Sources */, E921246B2F447BA1007481E4 /* ExportingConfigurationViewModel.swift in Sources */, diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift index 65e116af..3829ab16 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItem.swift @@ -1,31 +1,13 @@ import AppKit -import RxCocoa -import RxSwift +import RuntimeViewerUI -final class BackgroundIndexingToolbarItem: NSToolbarItem { +final class BackgroundIndexingToolbarItem: MainToolbarController.IconButtonToolbarItem { static let identifier = NSToolbarItem.Identifier("backgroundIndexing") - let itemView = BackgroundIndexingToolbarItemView() - let tapRelay = PublishRelay() - private let disposeBag = DisposeBag() - init() { - super.init(itemIdentifier: Self.identifier) + super.init(itemIdentifier: Self.identifier, icon: .squareStack3dDownRight) label = "Indexing" paletteLabel = "Background Indexing" - toolTip = "Background indexing status" - view = itemView - - // The actual click receiver is the button inside `itemView`. The - // toolbar item's own target/action is also wired so the item works - // when it appears in the overflow menu (where there is no view). - itemView.button.target = self - itemView.button.action = #selector(clicked) - target = self - action = #selector(clicked) - } - - @objc private func clicked() { - tapRelay.accept(itemView) + toolTip = "Background indexing" } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift deleted file mode 100644 index 31046b1e..00000000 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingToolbarItemView.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AppKit -import RuntimeViewerUI -import SnapKit - -enum BackgroundIndexingToolbarState: Equatable { - case idle - case disabled - case indexing - case hasFailures -} - -final class BackgroundIndexingToolbarItemView: NSView { - /// Click-receiving control. `NSToolbarItem` with a non-control custom view - /// does NOT route clicks to the item's target/action — only NSControl - /// subclasses inside the view do. Wrapping the icon in a `ToolbarButton` - /// gives both the standard toolbar bezel + click handling. The spinner - /// and failure dot are click-through overlays on top. - let button = ToolbarButton().then { - $0.image = NSImage( - systemSymbolName: "square.stack.3d.down.right", - accessibilityDescription: nil) - $0.symbolConfiguration = .init(pointSize: 15, weight: .regular) - $0.imagePosition = .imageOnly - $0.title = "" - } - private let spinner = ClickThroughProgressIndicator().then { - $0.style = .spinning - $0.controlSize = .small - $0.isIndeterminate = true - $0.isDisplayedWhenStopped = false - } - private let failureDot = ClickThroughView() - - var state: BackgroundIndexingToolbarState = .idle { - didSet { applyState() } - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - setupLayout() - applyState() - } - required init?(coder: NSCoder) { fatalError() } - - private func setupLayout() { - // Button fills the view, then overlays paint on top. - addSubview(button) - addSubview(spinner) - addSubview(failureDot) - - button.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - spinner.snp.makeConstraints { make in - make.center.equalToSuperview() - make.width.height.equalTo(14) - } - failureDot.snp.makeConstraints { make in - make.width.height.equalTo(6) - make.trailing.bottom.equalTo(button).inset(2) - } - failureDot.wantsLayer = true - failureDot.layer?.cornerRadius = 3 - failureDot.layer?.backgroundColor = NSColor.systemRed.cgColor - } - - private func applyState() { - switch state { - case .idle: - // Default toolbar tint — looks "live" like other toolbar buttons. - // Using `.secondaryLabelColor` here previously made the icon look - // disabled compared to its peers. - button.contentTintColor = nil - spinner.stopAnimation(nil) - failureDot.isHidden = true - case .disabled: - button.contentTintColor = .tertiaryLabelColor - spinner.stopAnimation(nil) - failureDot.isHidden = true - case .indexing: - button.contentTintColor = .controlAccentColor - spinner.startAnimation(nil) - failureDot.isHidden = true - case .hasFailures: - button.contentTintColor = .controlAccentColor - spinner.startAnimation(nil) - failureDot.isHidden = false - } - } - - override var intrinsicContentSize: NSSize { NSSize(width: 28, height: 28) } -} - -/// AppKit lacks UIView's `isUserInteractionEnabled`. Overlays that must let -/// clicks fall through to the button beneath need a `hitTest(_:)` returning -/// `nil` for every point. -private final class ClickThroughProgressIndicator: NSProgressIndicator { - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -private final class ClickThroughView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift index eee73dd1..9337ebdc 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift @@ -108,7 +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.tapRelay.asSignal(), + backgroundIndexingClick: toolbarController.backgroundIndexingItem.button.rx.clickWithSelf.asSignal().map { $0 }, frameworksSelected: frameworksSelectedRelay.asSignal(), saveLocationSelected: saveLocationSelectedRelay.asSignal() ) @@ -151,25 +151,6 @@ final class MainWindowController: XiblessWindowController { output.isContentBackHidden.drive(toolbarController.contentBackItem.rx.isHidden).disposed(by: rx.disposeBag) - // Bind background indexing toolbar item state to the per-Document - // coordinator's aggregate observable. The popover route case - // (`MainRoute.backgroundIndexing`) reuses the same coordinator. - // Drive directly into the item view via `rx.disposeBag` (which is - // recreated each `setupBindings` call) so a source switch does not - // accumulate stale subscriptions on the toolbar item itself. - documentState.backgroundIndexingCoordinator - .aggregateStateObservable - .map { state -> BackgroundIndexingToolbarState in - if !state.hasActiveBatch { return .idle } - return state.hasAnyFailure ? .hasFailures : .indexing - } - .asDriver(onErrorJustReturn: .idle) - .driveOnNext { [weak self] state in - guard let self else { return } - toolbarController.backgroundIndexingItem.itemView.state = state - } - .disposed(by: rx.disposeBag) - // Bind menu content + selection from sections and switchSourceState Driver.combineLatest(output.runtimeEngineSections, output.switchSourceState) .driveOnNext { [weak self] sections, state in From 8839139a9cf1326cfe80b893687d97a308dffd80 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 19:18:35 +0800 Subject: [PATCH 50/78] fix(core): resolve main image via MachOImage.current() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 picked the stub (loaded first at dyld index 0) and the caller never saw the actual dependency graph or sections — background indexing expanded only 3 trivial items (root + libSystem + an unresolved @rpath/.debug.dylib) and finished in ~25 ms with nothing useful indexed. Centralize main-binary MachOImage retrieval in DyldUtilities and use MachOImage.current() (resolves via #dsohandle of the calling code) for the main executable's path. In Debug this returns the .debug.dylib that contains all 60+ real deps; in statically linked Release it returns the main exe, identical to before. Apply the helper in RuntimeEngine canOpen/rpaths/dependencies and in RuntimeObjCSection / RuntimeSwiftSection inits so cached sections also reflect the actual code-bearing image. --- .../Core/RuntimeObjCSection.swift | 5 ++--- .../Core/RuntimeSwiftSection.swift | 5 ++--- .../RuntimeEngine+BackgroundIndexing.swift | 21 +++--------------- .../Utils/DyldUtilities.swift | 22 ++++++++++++++++++- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift index 160dd76a..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 diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index e31fd091..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 diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index a39303b5..1463019d 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -45,27 +45,12 @@ extension RuntimeEngine { // MARK: - BackgroundIndexingEngineRepresenting extension RuntimeEngine: BackgroundIndexingEngineRepresenting { - /// `MachOImage(name:)` matches the basename of a loaded image (without the - /// dylib / framework extension). Mirrors the conversion done in - /// `RuntimeObjCSection` / `RuntimeSwiftSection` so the protocol callers can - /// pass a full filesystem path. - /// - /// Examples: - /// - `Foundation.framework/Foundation` → `Foundation` (single extension) - /// - `libobjc.A.dylib` → `libobjc.A` → `libobjc` (versioned dylib needs both strips) - /// - /// TODO: Consolidate with the identical conversion in `RuntimeObjCSection` - /// and `RuntimeSwiftSection` once we have a stable home in `DyldUtilities`. - private static func machOImageName(forPath path: String) -> String { - path.lastPathComponent.deletingPathExtension.deletingPathExtension - } - func canOpenImage(at path: String) -> Bool { - MachOImage(name: Self.machOImageName(forPath: path)) != nil + DyldUtilities.machOImage(forPath: path) != nil } func rpaths(for path: String) -> [String] { - guard let image = MachOImage(name: Self.machOImageName(forPath: path)) else { + guard let image = DyldUtilities.machOImage(forPath: path) else { return [] } return image.rpaths @@ -74,7 +59,7 @@ extension RuntimeEngine: BackgroundIndexingEngineRepresenting { func dependencies(for path: String) async throws -> [(installName: String, resolvedPath: String?)] { - guard let image = MachOImage(name: Self.machOImageName(forPath: path)) else { + guard let image = DyldUtilities.machOImage(forPath: path) else { return [] } let resolver = DylibPathResolver() diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift index 41444bbc..a78a4bd8 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? @@ -59,6 +59,26 @@ package enum DyldUtilities { return names } + /// 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). + package static func machOImage(forPath path: String) -> MachOImage? { + if path == imageNames().first { + 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) From e94ecc80c3620ac6c06f4f9de6ee9e237b5cd895 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 28 Apr 2026 19:18:38 +0800 Subject: [PATCH 51/78] style(core): log RuntimeEngine init after manager is set up Move the source-describing #log call to after backgroundIndexingManager is assigned so the initialization-complete log line follows the last stored property assignment. --- RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 189f219a..3a2bfaa2 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -202,8 +202,8 @@ public actor RuntimeEngine { self.pushesRuntimeData = pushesRuntimeData self.objcSectionFactory = .init() self.swiftSectionFactory = .init() - #log(.info, "Initializing RuntimeEngine with source: \(String(describing: source), privacy: .public)") self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) + #log(.info, "Initializing RuntimeEngine with source: \(String(describing: source), privacy: .public)") } public func connect(bonjourEndpoint: RuntimeNetworkEndpoint? = nil, xpcServerEndpoint: (any Sendable)? = nil) async throws { From 4cc8f65b370b8fe59131894cb50e2eb88e3b8f8c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 00:53:07 +0800 Subject: [PATCH 52/78] fix(background-indexing): resolve more dylib deps under realistic dyld scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent root causes for spurious "path unresolved" rows in the indexing popover: - `imageNames().first` returns the wrong path under `DYLD_INSERT_LIBRARIES` (Xcode injects `libLogRedirect.dylib` at dyld index 0 during debug runs). Add `DyldUtilities.mainExecutablePath()` via `_NSGetExecutablePath` and use it for both the host main exe path and the `MachOImage.current()` short-circuit. - BFS only passed each image's own LC_RPATH to `dependencies(for:)`, so a framework with no LC_RPATH but loaded via the host's rpath always failed. Walk the loader chain in the BFS and pass accumulated ancestor rpaths to the engine; merge them with the image's own rpaths in loader-first order, matching dyld's lookup. - LC_LOAD_WEAK_DYLIB entries that miss on disk are tolerated by dyld at runtime (e.g. Xcode-embedded `libswiftCompatibilitySpan.dylib`). Silently drop them from the BFS instead of surfacing red ✗ rows. Also adds resolution-failure logs in `DylibPathResolver` so the cause of any remaining miss is visible in the unified log. --- ...BackgroundIndexingEngineRepresenting.swift | 11 ++++- .../RuntimeBackgroundIndexingManager.swift | 26 ++++++++--- .../RuntimeEngine+BackgroundIndexing.swift | 29 +++++++++--- .../Utils/DyldUtilities.swift | 37 +++++++++++++++- .../Utils/DylibPathResolver.swift | 34 +++++++++++--- .../MockBackgroundIndexingEngine.swift | 20 ++++++++- ...untimeBackgroundIndexingManagerTests.swift | 44 ++++++++++++++++++- .../DyldUtilitiesTests.swift | 19 ++++++++ 8 files changed, 193 insertions(+), 27 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift index 9bf027b8..bfe6ce02 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift @@ -27,6 +27,15 @@ protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable { /// 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. - func dependencies(for path: String) + /// + /// `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. + func dependencies(for path: String, ancestorRpaths: [String]) async throws -> [(installName: String, resolvedPath: String?)] } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index a3808d5c..df1402f2 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -14,9 +14,7 @@ public actor RuntimeBackgroundIndexingManager { init(engine: any BackgroundIndexingEngineRepresenting) { self.engine = engine - var cont: AsyncStream.Continuation! - self.stream = AsyncStream { cont = $0 } - self.continuation = cont + (self.stream, self.continuation) = AsyncStream.makeStream() } deinit { continuation.finish() } @@ -114,10 +112,18 @@ public actor RuntimeBackgroundIndexingManager { { var visited: Set = [] var items: [RuntimeIndexingTaskItem] = [] - var frontier: [(path: String, level: Int)] = [(rootPath, 0)] + // `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. + var frontier: [(path: String, level: Int, ancestorRpaths: [String])] = + [(rootPath, 0, [])] while !frontier.isEmpty { - let (path, level) = frontier.removeFirst() + let (path, level, ancestorRpaths) = frontier.removeFirst() guard visited.insert(path).inserted else { continue } // `try?` — if the engine errors out (e.g. remote XPC drops mid-batch), @@ -145,11 +151,17 @@ public actor RuntimeBackgroundIndexingManager { // `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)) ?? [] + let deps = (try? await engine.dependencies( + for: path, ancestorRpaths: ancestorRpaths)) ?? [] + // 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)) + frontier.append((resolved, level + 1, descendantAncestors)) } } else { if visited.insert(dep.installName).inserted { diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 1463019d..a6bde660 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -14,11 +14,13 @@ extension RuntimeEngine { } } - /// Path of the target process's main executable (dyld image at index 0). + /// Path of the target process's main executable. public func mainExecutablePath() async throws -> String { try await request { - // dyld guarantees image index 0 is the main executable. - DyldUtilities.imageNames().first ?? "" + // `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) } @@ -56,7 +58,7 @@ extension RuntimeEngine: BackgroundIndexingEngineRepresenting { return image.rpaths } - func dependencies(for path: String) async throws + func dependencies(for path: String, ancestorRpaths: [String]) async throws -> [(installName: String, resolvedPath: String?)] { guard let image = DyldUtilities.machOImage(forPath: path) else { @@ -64,17 +66,30 @@ extension RuntimeEngine: BackgroundIndexingEngineRepresenting { } let resolver = DylibPathResolver() let main = try await mainExecutablePath() - let rpathList = image.rpaths + // 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 } - .map { dependency in + .compactMap { dependency in let installName = dependency.dylib.name let resolvedPath = resolver.resolve( installName: installName, imagePath: path, - rpaths: rpathList, + rpaths: mergedRpaths, mainExecutablePath: main ) + // 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/Utils/DyldUtilities.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift index a78a4bd8..876eee55 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift @@ -59,6 +59,35 @@ 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 @@ -71,8 +100,14 @@ package enum DyldUtilities { /// `#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 == imageNames().first { + if path == mainExecutablePath() { return MachOImage.current() } let imageName = path.lastPathComponent.deletingPathExtension.deletingPathExtension diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift index d7ddc476..d67b1ae5 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DylibPathResolver.swift @@ -1,5 +1,7 @@ import Foundation +import FoundationToolbox +@Loggable struct DylibPathResolver { private let fileManager: FileManager @@ -15,29 +17,47 @@ struct DylibPathResolver { mainExecutablePath: String) -> String? { if installName.hasPrefix("@rpath/") { let tail = String(installName.dropFirst("@rpath/".count)) + var attempts: [String] = [] for rpath in rpaths { - let candidate = expand(rpath, imagePath: imagePath, - mainExecutablePath: mainExecutablePath) - + "/" + tail - if pathExists(candidate) { + 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 - return pathExists(candidate) ? candidate : nil + 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 - return pathExists(candidate) ? candidate : nil + 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 pathExists(installName) ? installName : nil + return exists ? installName : nil } /// True when `path` is either an on-disk file OR an image baked into the diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift index 9bae5c13..6eb45f00 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift @@ -10,11 +10,18 @@ final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, 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] } 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) { @@ -27,6 +34,11 @@ final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, 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 @@ -48,11 +60,15 @@ final class MockBackgroundIndexingEngine: BackgroundIndexingEngineRepresenting, lock.lock(); defer { lock.unlock() } return paths[path] != nil } - func rpaths(for path: String) async -> [String] { [] } - func dependencies(for path: String) + func rpaths(for path: String) async -> [String] { + lock.lock(); defer { lock.unlock() } + return paths[path]?.rpaths ?? [] + } + func dependencies(for path: String, ancestorRpaths: [String]) async -> [(installName: String, resolvedPath: String?)] { lock.lock(); defer { lock.unlock() } + dependenciesCallLog.append(.init(path: path, ancestorRpaths: ancestorRpaths)) return paths[path]?.dependencies ?? [] } } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index e634b925..ebbbb8ab 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -125,6 +125,46 @@ import Testing #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", @@ -365,10 +405,10 @@ import Testing func rpaths(for path: String) async throws -> [String] { try await base.rpaths(for: path) } - func dependencies(for path: String) + func dependencies(for path: String, ancestorRpaths: [String]) async throws -> [(installName: String, resolvedPath: String?)] { - try await base.dependencies(for: path) + try await base.dependencies(for: path, ancestorRpaths: ancestorRpaths) } } } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift index f5c02393..df733ab4 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/DyldUtilitiesTests.swift @@ -68,3 +68,22 @@ struct DyldUtilitiesTests { #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)") + } +} From 2971ad9078590d660ec30de48b07a411107c5d26 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 00:53:37 +0800 Subject: [PATCH 53/78] refactor(settings): nest indexing options under Settings.Indexing.BackgroundMode Rename `Settings.BackgroundIndexing` to `Settings.Indexing.BackgroundMode` to make room for non-background indexing modes (e.g. on-demand) without having to rename callers again later. The nested struct keeps the existing `isEnabled` / `depth` / `maxConcurrency` keys intact, so persisted defaults migrate transparently. Also clamps `maxConcurrency`'s upper bound to the host's `processorCount` (was a hardcoded 8) and renames the Settings sidebar entry from "Background Indexing" to "Indexing". --- ...RuntimeBackgroundIndexingCoordinator.swift | 4 +-- .../Settings+Types.swift | 29 ++++++++++++------- .../RuntimeViewerSettings/Settings.swift | 6 ++-- ...sView.swift => IndexingSettingsView.swift} | 26 +++++++++-------- .../SettingsRootView.swift | 6 ++-- .../BackgroundIndexingPopoverViewModel.swift | 6 ++-- 6 files changed, 44 insertions(+), 33 deletions(-) rename RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/{BackgroundIndexingSettingsView.swift => IndexingSettingsView.swift} (51%) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 4279fb86..1e7aeb2f 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -336,9 +336,9 @@ extension RuntimeBackgroundIndexingCoordinator { self.documentBatchIDs.insert(id) } - private func currentBackgroundIndexingSettings() -> Settings.BackgroundIndexing { + private func currentBackgroundIndexingSettings() -> Settings.Indexing.BackgroundMode { @Dependency(\.settings) var settings - return settings.backgroundIndexing + return settings.indexing.backgroundMode } private func bootstrapSettingsObservation() { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift index fd9496c5..4c9274f3 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -65,18 +65,27 @@ extension Settings { @Codable @MemberInit - public struct BackgroundIndexing { - /// Whether background indexing is enabled - @Default(false) - public var isEnabled: Bool + 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 - /// Indexing depth (valid range enforced by the Settings UI: 1...5) - @Default(1) - public var depth: Int + public static let `default` = Self() + } - /// Maximum concurrent indexing tasks (valid range enforced by the Settings UI: 1...8) - @Default(4) - public var maxConcurrency: Int + @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 7a4b5e7c..b50f4116 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift @@ -31,8 +31,8 @@ public final class Settings { didSet { scheduleAutoSave() } } - @Default(BackgroundIndexing.default) - public var backgroundIndexing: BackgroundIndexing = .init() { + @Default(Indexing.default) + public var indexing: Indexing = .init() { didSet { scheduleAutoSave() } } @@ -79,7 +79,7 @@ public final class Settings { notifications = decoded.notifications transformer = decoded.transformer mcp = decoded.mcp - backgroundIndexing = decoded.backgroundIndexing + indexing = decoded.indexing update = decoded.update #log(.debug, "Settings loaded successfully.") } catch { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift similarity index 51% rename from RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift rename to RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift index f20ee0e3..5f28d7ec 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/BackgroundIndexingSettingsView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift @@ -4,30 +4,32 @@ import SwiftUI import Dependencies import RuntimeViewerSettings -struct BackgroundIndexingSettingsView: View { - @AppSettings(\.backgroundIndexing) - var settings +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: $settings.isEnabled) + 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: $settings.depth, in: 1...5) { - LabeledContent("Depth", value: "\(settings.depth)") + Stepper(value: $indexing.backgroundMode.depth, in: 1...5) { + LabeledContent("Depth", value: "\(indexing.backgroundMode.depth)") } - .disabled(!settings.isEnabled) + .disabled(!indexing.backgroundMode.isEnabled) - Stepper(value: $settings.maxConcurrency, in: 1...8) { - LabeledContent("Max Concurrent Tasks", value: "\(settings.maxConcurrency)") + Stepper(value: $indexing.backgroundMode.maxConcurrency, in: 1...Self.maxConcurrencyUpperBound) { + LabeledContent("Max Concurrent Tasks", value: "\(indexing.backgroundMode.maxConcurrency)") } - .disabled(!settings.isEnabled) - } header: { - Text("Indexing") + .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.") } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift index 561893c2..cca0549c 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/SettingsRootView.swift @@ -20,7 +20,7 @@ private enum SettingsPage: String, CaseIterable, Identifiable { case general = "General" case notifications = "Notifications" case transformer = "Transformer" - case backgroundIndexing = "Background Indexing" + case indexing = "Indexing" case mcp = "MCP" case updates = "Updates" case helper = "Helper" @@ -32,7 +32,7 @@ private enum SettingsPage: String, CaseIterable, Identifiable { case .general: "gearshape" case .notifications: "bell.badge" case .transformer: "arrow.triangle.2.circlepath" - case .backgroundIndexing: "square.stack.3d.down.right" + case .indexing: "square.stack.3d.down.right" case .mcp: "network" case .updates: "arrow.down.circle" case .helper: "wrench.and.screwdriver" @@ -45,7 +45,7 @@ private enum SettingsPage: String, CaseIterable, Identifiable { case .general: GeneralSettingsView() case .notifications: NotificationSettingsView() case .transformer: TransformerSettingsView() - case .backgroundIndexing: BackgroundIndexingSettingsView() + case .indexing: IndexingSettingsView() case .mcp: MCPSettingsView() case .updates: UpdateSettingsView() case .helper: HelperServiceSettingsView() diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index fc6c82db..f8a680a2 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -108,19 +108,19 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { private func subscribeToIsEnabled() { withObservationTracking { - _ = settings.backgroundIndexing.isEnabled + _ = 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.backgroundIndexing.isEnabled + self.isEnabled = self.settings.indexing.backgroundMode.isEnabled self.subscribeToIsEnabled() } } // Seed the current value synchronously on initial subscribe. - isEnabled = settings.backgroundIndexing.isEnabled + isEnabled = settings.indexing.backgroundMode.isEnabled } private static func renderNodes(from batches: [RuntimeIndexingBatch]) From 1ec4e2f53b2e5beab5563b97261dd94ace3f14ac Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 00:54:10 +0800 Subject: [PATCH 54/78] feat(background-indexing): live-bind popover cells with progress + SF Symbol icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover used to call `cell.configure(...)` once per `viewFor:item:`, so cell content went stale after the first render — RxAppKit's `elementUpdated` path goes through `NSOutlineView.reloadItem(_:)` which only marks the row for redisplay, it does not re-invoke `viewFor:item:`. Switch each cell to `bind(...)` against a per-cell driver (`viewModel.batch(for:)` / `viewModel.item(for:itemID:)`) so the row updates whenever the batch's progress or the item's state changes without scroll/click forcing a relayout. Visual rework that comes with the live binding: - Batch row gains a horizontal progress bar driven by completed/total. - Item row swaps the `↻ ✓ ✗` text glyphs for SF Symbols with the state-appropriate tint, and `arrow.triangle.2.circlepath` rotates while running. Uses raw `NSImageView` instead of the project's `ImageView` wrapper because the wrapper sets `wantsUpdateLayer=true` which flattens the image into `layer.contents` and disables symbol effects. - Header/footer separators and a slightly taller content size to give the new progress bar room to breathe. `RuntimeEngineManager` and its `Reactive` extension are marked `@MainActor` so the popover VM can read them synchronously without hopping actors. --- ...kgroundIndexingPopoverViewController.swift | 233 +++++++++++++----- .../BackgroundIndexingPopoverViewModel.swift | 25 ++ 2 files changed, 193 insertions(+), 65 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index 0e3051ed..04e6661c 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -12,6 +12,8 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController() + private let (scrollView, outlineView): (ScrollView, OutlineView) = OutlineView.scrollableSingleColumnOutlineView() + // MARK: - Views private let titleLabel = Label("Background Indexing").then { @@ -23,6 +25,14 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController NSView? in switch node { case .batch(let batch, _): let cell = outlineView.box.makeView(ofClass: BatchCellView.self) - cell.configure( - reason: batch.reason, - completedCount: batch.completedCount, - totalCount: batch.totalCount, - // Hide cancel for batches the manager has already finalized - // (kept around as failed-retain rows pending user dismiss). - isCancellable: !batch.isFinished, + 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 item): + case .item(let batchID, let item): let cell = outlineView.box.makeView(ofClass: ItemCellView.self) - cell.configure(item: item) + cell.bind(item: viewModel.item(for: batchID, itemID: item.id)) return cell } } @@ -222,7 +236,19 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController Void)? override init(frame frameRect: NSRect) { @@ -240,21 +267,30 @@ extension BackgroundIndexingPopoverViewController { cancelButton.target = self cancelButton.action = #selector(cancelButtonClicked) - let stack = HStackView(spacing: 6) { + // 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 } - stack.alignment = .centerY + + 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() - make.centerY.equalToSuperview() } - // Title takes remaining space; button hugs its intrinsic size. - titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - cancelButton.setContentHuggingPriority(.required, for: .horizontal) - cancelButton.setContentCompressionResistancePriority(.required, for: .horizontal) } @available(*, unavailable) @@ -262,16 +298,30 @@ extension BackgroundIndexingPopoverViewController { fatalError("init(coder:) has not been implemented") } - func configure( - reason: RuntimeIndexingBatchReason, - completedCount: Int, - totalCount: Int, - isCancellable: Bool, - onCancel: @escaping () -> Void - ) { + func bind(batch: Driver, + onCancel: @escaping () -> Void) + { + // Reset on every bind so cell reuse drops the prior subscription. + disposeBag = DisposeBag() self.onCancel = onCancel - cancelButton.isHidden = !isCancellable - titleLabel.stringValue = "\(Self.title(for: reason)) \(completedCount)/\(totalCount)" + + batch.driveOnNext { [weak self] batch in + guard let self else { return } + update(with: batch) + } + .disposed(by: disposeBag) + } + + private func update(with batch: RuntimeIndexingBatch) { + cancelButton.isHidden = batch.isFinished + titleLabel.stringValue = Self.title(for: batch.reason) + countLabel.stringValue = "\(batch.completedCount)/\(batch.totalCount)" + + progressIndicator.maxValue = max(Double(batch.totalCount), 1) + progressIndicator.doubleValue = Double(batch.completedCount) + // 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 } @objc private func cancelButtonClicked() { @@ -293,13 +343,34 @@ extension BackgroundIndexingPopoverViewController { } private final class ItemCellView: NSTableCellView { - let titleLabel = Label("") + // 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("") + private var disposeBag = DisposeBag() override init(frame frameRect: NSRect) { super.init(frame: frameRect) - addSubview(titleLabel) - titleLabel.snp.makeConstraints { make in - make.leading.trailing.centerY.equalToSuperview() + + iconImageView.setContentHuggingPriority(.required, for: .horizontal) + iconImageView.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentHuggingPriority(.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) } } @@ -308,26 +379,58 @@ extension BackgroundIndexingPopoverViewController { fatalError("init(coder:) has not been implemented") } - func configure(item: RuntimeIndexingTaskItem) { + func bind(item: Driver) { + disposeBag = DisposeBag() + item.driveOnNext { [weak self] item in + guard let self else { return } + update(with: item) + } + .disposed(by: 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 - let prefix: String = { - switch item.state { - case .pending: return "·" - case .running: return "↻" - case .completed: return "✓" - case .failed: return "✗" - case .cancelled: return "⊘" - } - }() - var text = "\(prefix) \(name)" + var text = name if case .failed(let message) = item.state { - text = "\(prefix) \(item.id) — \(message)" + 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 index f8a680a2..a83966f9 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -106,6 +106,31 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { ) } + /// 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. + func batch(for id: RuntimeIndexingBatchID) -> Driver { + coordinator.batchesObservable + .compactMap { $0.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 + { + coordinator.batchesObservable + .compactMap { batches in + batches.first(where: { $0.id == batchID })? + .items.first(where: { $0.id == itemID }) + } + .distinctUntilChanged() + .asDriver(onErrorDriveWith: .empty()) + } + private func subscribeToIsEnabled() { withObservationTracking { _ = settings.indexing.backgroundMode.isEnabled From 8ae6addd9082441753e472a3bed182c5f554bc6f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 10:54:15 +0800 Subject: [PATCH 55/78] refactor(core): adopt Runtime prefix for background indexing types Rename BackgroundIndexingEngineRepresenting and ResolvedDependency to RuntimeBackgroundIndexingEngineRepresenting / RuntimeResolvedDependency so the internal types match the module-wide Runtime* naming convention, making them unambiguous when surfaced through public APIs and search. --- ...ackgroundIndexingEngineRepresenting.swift} | 2 +- .../RuntimeBackgroundIndexingManager.swift | 49 +++++++++---------- ....swift => RuntimeResolvedDependency.swift} | 2 +- .../RuntimeEngine+BackgroundIndexing.swift | 2 +- .../MockBackgroundIndexingEngine.swift | 2 +- ...untimeBackgroundIndexingManagerTests.swift | 6 +-- 6 files changed, 31 insertions(+), 32 deletions(-) rename RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/{BackgroundIndexingEngineRepresenting.swift => RuntimeBackgroundIndexingEngineRepresenting.swift} (97%) rename RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/{ResolvedDependency.swift => RuntimeResolvedDependency.swift} (78%) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift similarity index 97% rename from RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift rename to RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift index bfe6ce02..ab5961fe 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/BackgroundIndexingEngineRepresenting.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift @@ -15,7 +15,7 @@ /// (`RuntimeEngine.backgroundIndexingManager`); making the back-reference /// non-retaining breaks the cycle that would otherwise leak engine + manager /// + section caches on every source switch. -protocol BackgroundIndexingEngineRepresenting: AnyObject, Sendable { +protocol RuntimeBackgroundIndexingEngineRepresenting: AnyObject, Sendable { func isImageIndexed(path: String) async throws -> Bool func loadImageForBackgroundIndexing(at path: String) async throws func mainExecutablePath() async throws -> String diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index df1402f2..b3784bc0 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -2,25 +2,32 @@ import Foundation import Semaphore 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 BackgroundIndexingEngineRepresenting + private unowned let engine: any RuntimeBackgroundIndexingEngineRepresenting private let stream: AsyncStream private let continuation: AsyncStream.Continuation private var activeBatches: [RuntimeIndexingBatchID: BatchState] = [:] - init(engine: any BackgroundIndexingEngineRepresenting) { + public nonisolated var events: AsyncStream { stream } + + init(engine: any RuntimeBackgroundIndexingEngineRepresenting) { self.engine = engine (self.stream, self.continuation) = AsyncStream.makeStream() } deinit { continuation.finish() } - public nonisolated var events: AsyncStream { stream } - public func currentBatches() -> [RuntimeIndexingBatch] { activeBatches.values.map(\.batch) } @@ -34,7 +41,9 @@ public actor RuntimeBackgroundIndexingManager { public func cancelAllBatches() { let ids = Array(activeBatches.keys) - for id in ids { cancelBatch(id) } + for id in ids { + cancelBatch(id) + } } public func prioritize(imagePath: String) { @@ -86,7 +95,8 @@ public actor RuntimeBackgroundIndexingManager { let batch = RuntimeIndexingBatch( id: id, rootImagePath: rootImagePath, depth: depth, reason: reason, items: items, - isCancelled: false, isFinished: false) + isCancelled: false, isFinished: false + ) let state = BatchState(batch: batch, maxConcurrency: max(1, maxConcurrency)) activeBatches[id] = state continuation.yield(.batchStarted(batch)) @@ -100,16 +110,14 @@ public actor RuntimeBackgroundIndexingManager { } private func findActiveBatchID(forRootImagePath rootImagePath: String) - -> RuntimeIndexingBatchID? - { + -> RuntimeIndexingBatchID? { activeBatches.first { _, state in !state.batch.isFinished && state.batch.rootImagePath == rootImagePath }?.key } func expandDependencyGraph(rootPath: String, depth: Int) - async -> [RuntimeIndexingTaskItem] - { + async -> [RuntimeIndexingTaskItem] { var visited: Set = [] var items: [RuntimeIndexingTaskItem] = [] // `ancestorRpaths` carries the LC_RPATH entries collected from every @@ -152,7 +160,8 @@ public actor RuntimeBackgroundIndexingManager { // `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)) ?? [] + for: path, ancestorRpaths: ancestorRpaths + )) ?? [] // 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`. @@ -217,8 +226,7 @@ public actor RuntimeBackgroundIndexingManager { batchID: RuntimeIndexingBatchID, pending: inout [String] ) -> String { if let state = activeBatches[batchID], - let boostedPendingIndex = pending.firstIndex(where: { state.priorityBoostPaths.contains($0) }) - { + let boostedPendingIndex = pending.firstIndex(where: { state.priorityBoostPaths.contains($0) }) { return pending.remove(at: boostedPendingIndex) } return pending.removeFirst() @@ -246,8 +254,7 @@ public actor RuntimeBackgroundIndexingManager { private func updateItemState(batchID: RuntimeIndexingBatchID, path: String, - state: RuntimeIndexingTaskState) - { + 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 @@ -263,9 +270,8 @@ public actor RuntimeBackgroundIndexingManager { // 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 - { + where state.batch.items[itemIndex].state == .pending + || state.batch.items[itemIndex].state == .running { state.batch.items[itemIndex].state = .cancelled } } @@ -277,11 +283,4 @@ public actor RuntimeBackgroundIndexingManager { } activeBatches[id] = nil } - - struct BatchState { - var batch: RuntimeIndexingBatch - var maxConcurrency: Int - var drivingTask: Task? - var priorityBoostPaths: Set = [] - } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeResolvedDependency.swift similarity index 78% rename from RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift rename to RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeResolvedDependency.swift index 3f9135a5..dff19985 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/ResolvedDependency.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeResolvedDependency.swift @@ -1,4 +1,4 @@ -public struct ResolvedDependency: Sendable, Hashable { +public struct RuntimeResolvedDependency: Sendable, Hashable { public let installName: String public let resolvedPath: String? diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index a6bde660..5fa46682 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -46,7 +46,7 @@ extension RuntimeEngine { // MARK: - BackgroundIndexingEngineRepresenting -extension RuntimeEngine: BackgroundIndexingEngineRepresenting { +extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { func canOpenImage(at path: String) -> Bool { DyldUtilities.machOImage(forPath: path) != nil } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift index 6eb45f00..7940bcab 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift @@ -3,7 +3,7 @@ import Foundation // `@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, +final class MockBackgroundIndexingEngine: RuntimeBackgroundIndexingEngineRepresenting, @unchecked Sendable { struct ProgrammedPath: Sendable { diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index ebbbb8ab..2340fb75 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -379,12 +379,12 @@ import Testing func exit() { lock.lock(); current -= 1; lock.unlock() } } - private final class InstrumentedEngine: BackgroundIndexingEngineRepresenting, + private final class InstrumentedEngine: RuntimeBackgroundIndexingEngineRepresenting, @unchecked Sendable { - let base: any BackgroundIndexingEngineRepresenting + let base: any RuntimeBackgroundIndexingEngineRepresenting let counter: ConcurrencyCounter - init(base: any BackgroundIndexingEngineRepresenting, counter: ConcurrencyCounter) { + init(base: any RuntimeBackgroundIndexingEngineRepresenting, counter: ConcurrencyCounter) { self.base = base; self.counter = counter } func isImageIndexed(path: String) async throws -> Bool { From ab7ca5a6db8e191be1a91852c62896cd3bbf0dda Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 10:54:25 +0800 Subject: [PATCH 56/78] docs(background-indexing): plan history section + revise spec for it Replace Alternative E's failure-retention-in-batchesRelay with a parallel historyRelay holding all finalized batches (success / failure / cancelled). Active and historical concerns split into two outline sections, Clear Failed becomes Clear History, and AggregateState.hasAnyFailure goes unused now that the toolbar is a static IconButton. Plan doc captures the task-by-task migration ahead of implementation. --- .../Evolution/0002-background-indexing.md | 48 +- ...-04-29-background-indexing-history-plan.md | 937 ++++++++++++++++++ 2 files changed, 968 insertions(+), 17 deletions(-) create mode 100644 Documentations/Plans/2026-04-29-background-indexing-history-plan.md diff --git a/Documentations/Evolution/0002-background-indexing.md b/Documentations/Evolution/0002-background-indexing.md index 8788bfff..92e56925 100644 --- a/Documentations/Evolution/0002-background-indexing.md +++ b/Documentations/Evolution/0002-background-indexing.md @@ -3,7 +3,7 @@ - **状态**: Accepted - **作者**: JH - **日期**: 2026-04-24 -- **最后更新**: 2026-04-28 +- **最后更新**: 2026-04-29 ## 摘要 @@ -264,10 +264,12 @@ public enum RuntimeIndexingEvent: Sendable { 2. 监听引擎的 `imageDidLoadPublisher` → 为该镜像启动一次依赖批次。 3. 监听 Sidebar 的镜像选中信号 → 调用 `manager.prioritize(path:)`。 4. 将 `manager.events`(AsyncStream)桥接到 `eventRelay: PublishRelay`(RxSwift)。 -5. 维护从事件归约而来的 `batchesRelay: BehaviorRelay<[RuntimeIndexingBatch]>`。**包含任意失败项的已完成批次会被保留**在 `batchesRelay` 中,直到用户在弹出框中通过"Clear Failed"显式清除;干净完成与取消会立即移除。 -6. 暴露 `aggregateStateDriver: Driver`。`hasFailures` 由保留下来的失败批次推导。 +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` / `aggregateRelay`、把 `self.engine` 切到新 engine、重启 pumps、若 isEnabled 则重新触发 `documentDidOpen()`。`engine` 字段为 `var`,每次 swap 时被覆盖。参见决策日志 2026-04-28(I3 / N2)。 +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)。 ### 数据流场景 @@ -375,9 +377,10 @@ handleEngineSwap(to: newEngine): await oldEngine.backgroundIndexingManager.cancelBatch(id) } } - // 3) 清 UI relays —— 旧 batches 不再适用 + // 3) 清 UI relays —— 旧 batches / 历史均不再适用 documentBatchIDs.removeAll() batchesRelay.accept([]) + historyRelay.accept([]) refreshAggregate(batches: []) // 4) 切到新 engine @@ -593,23 +596,31 @@ case backgroundIndexing(sender: NSView) - 头部:`Label("Background Indexing")` 加一个读取聚合进度的副标题 `Label`。 - 空状态 A(已禁用):图标 + "Background indexing is disabled" + `"Open Settings"` 按钮。 -- 空状态 B(已启用、无批次):图标 + "No active indexing tasks"。 -- 主体:渲染 `BackgroundIndexingNode` 的 `StatefulOutlineView`。 -- 页脚:`HStackView`,包含 `Cancel All` 按钮(无活动批次时禁用)、`Clear Failed` 按钮(仅当存在保留的失败批次时可见)以及 `Close` 按钮。 +- 空状态 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 batch(RuntimeIndexingBatch) + 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 的展开 / 折叠状态**。 + 大纲单元格: -- Batch 行:标题由 `reason` 派生、`"{completed}/{total}"`,以及一个 cancel 按钮。点击 cancel 会触发 `cancelBatchRelay.accept(batchID)`。 -- Item 行:状态图标(pending 灰点 / running 旋转 / completed 绿色 ✓ / failed 红色 ✗ / cancelled 灰色 ⊘)+ 显示名 + 副标签。失败行展示完整 install name 与错误信息。`hasPriorityBoost == true` 的行展示一个 `"priority"` 标签。 +- 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,这样错误绑定的调用方会立即暴露。 @@ -619,21 +630,21 @@ enum BackgroundIndexingNode: Hashable { 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 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 clearFailed: Signal + let clearHistory: Signal let openSettings: Signal } struct Output { let nodes: Driver<[BackgroundIndexingNode]> let isEnabled: Driver let hasAnyBatch: Driver - let hasAnyFailure: Driver + let hasAnyHistory: Driver let subtitle: Driver // Forwarded to the ViewController, which calls // `SettingsWindowController.shared.showWindow(nil)` directly. @@ -644,7 +655,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { } ``` -`isEnabled` 通过与 coordinator **相同**的 `withObservationTracking` 重新注册循环与 `Settings.shared.backgroundIndexing.isEnabled` 保持同步 —— 不是在 `transform` 中读一次后遗忘。这样弹出框打开时它的空状态会随 Settings 切换而响应。`hasAnyFailure` 由 coordinator 的 `aggregateState` 派生,驱动 `Clear Failed` 按钮的可见性。 +`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。 @@ -815,7 +826,9 @@ RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift ### E. UI 立即丢弃已完成 / 已取消的批次 -更简单的归约逻辑:`.batchFinished` / `.batchCancelled` 到达时从 coordinator relay 中移除批次,弹出框就忘掉它存在过。被否决,因为失败的批次承载着可操作信息;静默丢失它们意味着 toolbar 的 `hasFailures` 指示器永远不会浮现。改为:包含任何 `.failed` 项的已完成批次会被保留,直到用户点击弹出框中的 `Clear Failed`。 +更简单的归约逻辑:`.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。 ## 影响 @@ -842,3 +855,4 @@ RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/Document.swift | 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-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. From a661f0623d869ffd086dcfe12e393687b7e03791 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 11:00:45 +0800 Subject: [PATCH 57/78] feat(background-indexing): add coordinator-level history relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...RuntimeBackgroundIndexingCoordinator.swift | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 1e7aeb2f..3c4de362 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -33,6 +33,7 @@ public final class RuntimeBackgroundIndexingCoordinator { 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) ) @@ -68,6 +69,15 @@ public final class RuntimeBackgroundIndexingCoordinator { aggregateRelay.asObservable() } + 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 } + // MARK: - Public command surface public func cancelBatch(_ id: RuntimeIndexingBatchID) { @@ -107,6 +117,10 @@ public final class RuntimeBackgroundIndexingCoordinator { refreshAggregate(batches: remaining) } + public func clearHistory() { + historyRelay.accept([]) + } + // MARK: - Event pump (AsyncStream → Relay) private func startEventPump() { @@ -146,10 +160,14 @@ public final class RuntimeBackgroundIndexingCoordinator { batch.items[itemIndex].hasPriorityBoost = true }} 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 } @@ -167,7 +185,11 @@ public final class RuntimeBackgroundIndexingCoordinator { } case .batchCancelled(let cancelled): - // Cancellation always removes — user already acknowledged the outcome. + // 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 @@ -245,9 +267,10 @@ public final class RuntimeBackgroundIndexingCoordinator { } } - // 3) Drop UI state — the old engine's batches no longer apply. + // 3) Drop UI state — the old engine's batches and history no longer apply. documentBatchIDs.removeAll() batchesRelay.accept([]) + historyRelay.accept([]) refreshAggregate(batches: []) // 4) Switch the captured engine reference. From 8de230fa47cfaf19081eb38d618c08a29e7f3225 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 11:08:01 +0800 Subject: [PATCH 58/78] feat(background-indexing): add section node case + header cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../BackgroundIndexingNode.swift | 14 ++++++ ...kgroundIndexingPopoverViewController.swift | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift index 70ef777c..e4a2a29b 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift @@ -2,13 +2,20 @@ 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 [] } @@ -17,12 +24,19 @@ extension BackgroundIndexingNode: OutlineNodeType { 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): diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index 04e6661c..c9b99c3d 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -208,6 +208,10 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController 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( @@ -235,6 +239,50 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController Date: Wed, 29 Apr 2026 11:17:56 +0800 Subject: [PATCH 59/78] 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. --- ...RuntimeBackgroundIndexingCoordinator.swift | 50 +++------------ ...kgroundIndexingPopoverViewController.swift | 36 +++++++---- .../BackgroundIndexingPopoverViewModel.swift | 63 ++++++++++++------- 3 files changed, 74 insertions(+), 75 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 3c4de362..b047c33e 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -98,25 +98,6 @@ public final class RuntimeBackgroundIndexingCoordinator { } } - 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) - } - public func clearHistory() { historyRelay.accept([]) } @@ -160,25 +141,8 @@ public final class RuntimeBackgroundIndexingCoordinator { batch.items[itemIndex].hasPriorityBoost = true }} 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 } - } - // The manager finalized this batch regardless of failure status — - // it's already removed from `activeBatches`. Drop it from - // `documentBatchIDs` too so `documentWillClose` doesn't fire - // no-op cancel Tasks for ghost ids. The UI side decision to keep - // failed batches visible is independent of this bookkeeping. + appendToHistory(finished) + batches.removeAll { $0.id == finished.id } documentBatchIDs.remove(finished.id) Task { [engine] in await engine.reloadData(isReloadImageNodes: false) @@ -187,9 +151,7 @@ public final class RuntimeBackgroundIndexingCoordinator { 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) + appendToHistory(cancelled) batches.removeAll { $0.id == cancelled.id } documentBatchIDs.remove(cancelled.id) Task { [engine] in @@ -206,6 +168,12 @@ public final class RuntimeBackgroundIndexingCoordinator { return copy } + private func appendToHistory(_ batch: RuntimeIndexingBatch) { + var updatedHistory = historyRelay.value + updatedHistory.insert(batch, at: 0) + historyRelay.accept(updatedHistory) + } + private func refreshAggregate(batches: [RuntimeIndexingBatch]) { let hasActive = !batches.isEmpty let hasFailure = batches.contains { batch in diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index c9b99c3d..99112b84 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -53,9 +53,9 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController { @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 hasAnyHistory: Bool = false @Observed private(set) var subtitle: String = "" private let coordinator: RuntimeBackgroundIndexingCoordinator @@ -28,7 +28,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { struct Input { let cancelBatch: Signal let cancelAll: Signal - let clearFailed: Signal + let clearHistory: Signal let openSettings: Signal } @@ -36,7 +36,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { let nodes: Driver<[BackgroundIndexingNode]> let isEnabled: Driver let hasAnyBatch: Driver - let hasAnyFailure: Driver + let hasAnyHistory: Driver let subtitle: Driver // Forwarded to the ViewController so it can call // `SettingsWindowController.shared.showWindow(nil)` directly — mirrors @@ -46,22 +46,27 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { } func transform(_ input: Input) -> Output { - coordinator.batchesObservable - .map(Self.renderNodes) - .asDriver(onErrorJustReturn: []) - .driveOnNext { [weak self] newNodes in - guard let self else { return } - nodes = newNodes - hasAnyBatch = !newNodes.isEmpty - } - .disposed(by: rx.disposeBag) + 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) 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) @@ -82,9 +87,9 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { } .disposed(by: rx.disposeBag) - input.clearFailed.emitOnNext { [weak self] in + input.clearHistory.emitOnNext { [weak self] in guard let self else { return } - coordinator.clearFailedBatches() + coordinator.clearHistory() } .disposed(by: rx.disposeBag) @@ -100,7 +105,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { nodes: $nodes.asDriver(), isEnabled: $isEnabled.asDriver(), hasAnyBatch: $hasAnyBatch.asDriver(), - hasAnyFailure: $hasAnyFailure.asDriver(), + hasAnyHistory: $hasAnyHistory.asDriver(), subtitle: $subtitle.asDriver(), openSettings: openSettingsRelay.asSignal() ) @@ -148,15 +153,29 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { isEnabled = settings.indexing.backgroundMode.isEnabled } - private static func renderNodes(from batches: [RuntimeIndexingBatch]) + private static func renderNodes(active: [RuntimeIndexingBatch], + history: [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) + 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( From c5fd25e86e259b9dca307659cefc773cdada86f9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 11:30:31 +0800 Subject: [PATCH 60/78] fix(background-indexing): order relay updates to avoid transient duplicate batch nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply(event:)` previously appended a finished/cancelled batch to historyRelay before settling batchesRelay, so combineLatest in the ViewModel briefly emitted nodes where the same batch appeared in both ACTIVE and HISTORY sections — producing a duplicate `differenceIdentifier` in the staged-changeset dataset. Defer history additions to after the batches accept, and derive hasAnyBatch / hasAnyHistory from the closure params instead of reaching back into the coordinator's sync accessors. The unused sync accessors are removed. --- ...RuntimeBackgroundIndexingCoordinator.swift | 23 +++++++++++++------ .../BackgroundIndexingPopoverViewModel.swift | 10 ++++---- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index b047c33e..b40a8017 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -73,11 +73,6 @@ public final class RuntimeBackgroundIndexingCoordinator { 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 } - // MARK: - Public command surface public func cancelBatch(_ id: RuntimeIndexingBatchID) { @@ -119,6 +114,13 @@ public final class RuntimeBackgroundIndexingCoordinator { private func apply(event: RuntimeIndexingEvent) { var batches = batchesRelay.value + // Collect batches that need to be appended to history. We defer the + // actual `appendToHistory` calls until after `batchesRelay.accept` so + // that `combineLatest(batchesObservable, historyObservable)` never emits + // a transient state where the same batch appears in both ACTIVE and + // HISTORY sections (which would produce duplicate `differenceIdentifier` + // values and undefined DifferenceKit behavior). + var historyAdditions: [RuntimeIndexingBatch] = [] switch event { case .batchStarted(let batch): batches.append(batch) @@ -141,9 +143,9 @@ public final class RuntimeBackgroundIndexingCoordinator { batch.items[itemIndex].hasPriorityBoost = true }} case .batchFinished(let finished): - appendToHistory(finished) batches.removeAll { $0.id == finished.id } documentBatchIDs.remove(finished.id) + historyAdditions.append(finished) Task { [engine] in await engine.reloadData(isReloadImageNodes: false) } @@ -151,15 +153,22 @@ public final class RuntimeBackgroundIndexingCoordinator { case .batchCancelled(let cancelled): // Cancellation always removes from active. Now also lands in history // so the user can review what got cancelled. - appendToHistory(cancelled) batches.removeAll { $0.id == cancelled.id } documentBatchIDs.remove(cancelled.id) + historyAdditions.append(cancelled) Task { [engine] in await engine.reloadData(isReloadImageNodes: false) } } + // Settle the active-batches relay first so it no longer contains the + // finished/cancelled batch before history is updated. batchesRelay.accept(batches) refreshAggregate(batches: batches) + // Now safe to push history: combineLatest will emit (new batches without + // finished, new history with finished) — a fully consistent state. + for batchToArchive in historyAdditions { + appendToHistory(batchToArchive) + } } private func mutating(_ value: Value, _ mutate: (inout Value) -> Void) -> Value { diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index ce97dbc2..3edd4136 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -51,14 +51,14 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { coordinator.historyObservable ) .map { active, history in - Self.renderNodes(active: active, history: history) + (Self.renderNodes(active: active, history: history), active, history) } - .asDriver(onErrorJustReturn: []) - .driveOnNext { [weak self] newNodes in + .asDriver(onErrorJustReturn: ([], [], [])) + .driveOnNext { [weak self] newNodes, active, history in guard let self else { return } nodes = newNodes - hasAnyBatch = !coordinator.batchesValue.isEmpty - hasAnyHistory = !coordinator.historyValue.isEmpty + hasAnyBatch = !active.isEmpty + hasAnyHistory = !history.isEmpty } .disposed(by: rx.disposeBag) From 30c00d2edba268cd2360cf6b6cf20fc583dc9e9a Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 12:03:32 +0800 Subject: [PATCH 61/78] fix(background-indexing): bind history batch/item drivers to the history relay too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-cell drivers `batch(for:)` and `item(for:itemID:)` only searched `coordinator.batchesObservable`. Once a batch moved to the history relay the compactMap returned nil and BatchCellView/ItemCellView never received a value — the HISTORY row rendered its initial empty state (no title, visible progress bar, visible cancel button). Combine both relays and prefer active over history when an id appears in both during the short-lived hand-off between the two accept calls. --- .../BackgroundIndexingPopoverViewModel.swift | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index 3edd4136..f10d0d14 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -116,24 +116,36 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { /// `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 { - coordinator.batchesObservable - .compactMap { $0.first(where: { $0.id == id }) } - .distinctUntilChanged() - .asDriver(onErrorDriveWith: .empty()) + 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 { - coordinator.batchesObservable - .compactMap { batches in - batches.first(where: { $0.id == batchID })? - .items.first(where: { $0.id == itemID }) - } - .distinctUntilChanged() - .asDriver(onErrorDriveWith: .empty()) + 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()) } private func subscribeToIsEnabled() { From 1c769ebcad29feb280a0aeb682ae0d5b8c831207 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 14:09:57 +0800 Subject: [PATCH 62/78] fix(background-indexing): pin BatchCellView rows to stack width VStackView's AppKit default alignment is .centerX, so children sit at their horizontal intrinsicContentSize. NSProgressIndicator (.bar style) returns noIntrinsicMetric horizontally and HStackView's intrinsic width isn't enough to anchor the cell either, so View Debugger flagged an ambiguous width / horizontal position on every batch row. Pin both the top row and the progress bar to the stack's leading/trailing. --- .../BackgroundIndexingPopoverViewController.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index 99112b84..fae27685 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -351,6 +351,18 @@ extension BackgroundIndexingPopoverViewController { 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) + } } @available(*, unavailable) From d191dea8b1ca14d3bcd950c92652eba820eb98c5 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 29 Apr 2026 18:01:56 +0800 Subject: [PATCH 63/78] refactor(background-indexing): adopt TableCellView base + restrict row selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cell views switch from NSTableCellView with manual init/coder to the project's TableCellView base class via setup(), and use rx.disposeBag instead of locally-held DisposeBags — keeps cell lifetimes consistent with the rest of the AppKit codebase. Also wire NSOutlineViewDelegate so selection is allowed only on .item rows; users can no longer place focus on a section header or batch row by clicking. --- ...kgroundIndexingPopoverViewController.swift | 88 ++++++++++--------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index fae27685..19140566 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -13,7 +13,7 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController() private let (scrollView, outlineView): (ScrollView, OutlineView) = OutlineView.scrollableSingleColumnOutlineView() - + // MARK: - Views private let titleLabel = Label("Background Indexing").then { @@ -142,8 +142,8 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController Bool { + guard let node = item as? BackgroundIndexingNode, case .item = node else { return false } + return true + } +} + extension BackgroundIndexingPopoverViewController { - private final class SectionHeaderCellView: NSTableCellView { + 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 init(frame frameRect: NSRect) { - super.init(frame: frameRect) - + + override func setup() { + super.setup() + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) countLabel.setContentHuggingPriority(.required, for: .horizontal) countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) @@ -281,49 +291,49 @@ extension BackgroundIndexingPopoverViewController { } } - @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 .active: titleLabel.stringValue = "ACTIVE" case .history: titleLabel.stringValue = "HISTORY" } countLabel.stringValue = "\(count)" } } - private final class BatchCellView: NSTableCellView { + 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") + accessibilityDescription: "Cancel batch" + ) $0.imagePosition = .imageOnly $0.toolTip = "Cancel this batch" $0.contentTintColor = .secondaryLabelColor } - private var disposeBag = DisposeBag() + private var onCancel: (() -> Void)? - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) + override func setup() { + super.setup() + cancelButton.target = self cancelButton.action = #selector(cancelButtonClicked) @@ -365,23 +375,17 @@ extension BackgroundIndexingPopoverViewController { } } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - func bind(batch: Driver, - onCancel: @escaping () -> Void) - { + onCancel: @escaping () -> Void) { // Reset on every bind so cell reuse drops the prior subscription. - disposeBag = DisposeBag() + rx.disposeBag = DisposeBag() self.onCancel = onCancel batch.driveOnNext { [weak self] batch in guard let self else { return } update(with: batch) } - .disposed(by: disposeBag) + .disposed(by: rx.disposeBag) } private func update(with batch: RuntimeIndexingBatch) { @@ -414,23 +418,24 @@ extension BackgroundIndexingPopoverViewController { } } - private final class ItemCellView: NSTableCellView { - // 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 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("") - private var disposeBag = DisposeBag() - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) + 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 @@ -438,26 +443,25 @@ extension BackgroundIndexingPopoverViewController { } addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview() } + iconImageView.snp.makeConstraints { make in make.size.equalTo(12) } - } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + titleLabel.maximumNumberOfLines = 1 } func bind(item: Driver) { - disposeBag = DisposeBag() + rx.disposeBag = DisposeBag() item.driveOnNext { [weak self] item in guard let self else { return } update(with: item) } - .disposed(by: disposeBag) + .disposed(by: rx.disposeBag) } private func update(with item: RuntimeIndexingTaskItem) { From d6cc2b249ccd0f59504282150a66bf900cd8a104 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 1 May 2026 21:38:50 +0800 Subject: [PATCH 64/78] fix(core): forward background-indexing commands via engine proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy server only registered handlers for the pre-existing engine commands. Requests for the new `mainExecutablePath`, `isImageIndexed`, and `loadImageForBackgroundIndexing` commands therefore arrived at the proxy with no matching handler and were silently dropped. `RuntimeMessageChannel.sendRequest` holds `sendSemaphore` for the entire duration of the await, so a single dropped request blocked every later request on the same channel — including `isImageLoaded` issued from `SidebarRuntimeObjectViewModel.reloadData()`. The view model's `loadState` stayed at `.unknown` until the TCP connection eventually died and `finishReceiving` drained the pending continuations. Register the three missing handlers so the proxy delegates background indexing requests to the wrapped engine, mirroring the engine's own `setupMessageHandlerForServer` registration. --- .../RuntimeEngineProxyServer.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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) From f48ed2075bab8e6313af08e724015058de43cbd7 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 5 May 2026 17:22:44 +0800 Subject: [PATCH 65/78] chore(precompiled-libs): bump swift-syntax to 603.0.1 Tracks the published SwiftSyntax 603 binary release: refreshed all xcframework URLs / checksums, added the new SwiftWarningControl product, and seeded SwiftSyntax602 / SwiftSyntax603 aggregation targets so downstream macro plugins can opt into the newer toolchain. --- .../swift-syntax/Package.swift | 208 +++++++++++------- .../SwiftBasicFormat_Aggregation.swift | 0 ...lerPluginMessageHandling_Aggregation.swift | 0 .../SwiftCompilerPlugin_Aggregation.swift | 0 .../SwiftDiagnostics_Aggregation.swift | 0 .../SwiftIDEUtils_Aggregation.swift | 0 .../SwiftIfConfig_Aggregation.swift | 0 .../SwiftLexicalLookup_Aggregation.swift | 0 ...iftLibraryPluginProvider_Aggregation.swift | 0 .../SwiftOperators_Aggregation.swift | 0 .../SwiftParserDiagnostics_Aggregation.swift | 0 .../SwiftParser_Aggregation.swift | 0 .../SwiftRefactor_Aggregation.swift | 0 .../SwiftSyntax509_Aggregation.swift | 0 .../SwiftSyntax510_Aggregation.swift | 0 .../SwiftSyntax600_Aggregation.swift | 0 .../SwiftSyntax601_Aggregation.swift | 0 .../SwiftSyntax602_Aggregation.swift | 3 + .../SwiftSyntax603_Aggregation.swift | 3 + .../SwiftSyntaxBuilder_Aggregation.swift | 0 ...wiftSyntaxMacroExpansion_Aggregation.swift | 0 ...MacrosGenericTestSupport_Aggregation.swift | 0 ...tSyntaxMacrosTestSupport_Aggregation.swift | 0 .../SwiftSyntaxMacros_Aggregation.swift | 0 .../SwiftSyntax_Aggregation.swift | 0 .../SwiftWarningControl_Aggregation.swift | 3 + ...lerPluginMessageHandling_Aggregation.swift | 0 ...raryPluginProviderCShims_Aggregation.swift | 0 ...iftLibraryPluginProvider_Aggregation.swift | 0 .../_SwiftSyntaxCShims_Aggregation.swift | 0 ...SyntaxGenericTestSupport_Aggregation.swift | 0 31 files changed, 135 insertions(+), 82 deletions(-) mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Package.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftBasicFormat_Aggregation/SwiftBasicFormat_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftCompilerPluginMessageHandling_Aggregation/SwiftCompilerPluginMessageHandling_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftCompilerPlugin_Aggregation/SwiftCompilerPlugin_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftDiagnostics_Aggregation/SwiftDiagnostics_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftIDEUtils_Aggregation/SwiftIDEUtils_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftIfConfig_Aggregation/SwiftIfConfig_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftLexicalLookup_Aggregation/SwiftLexicalLookup_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftLibraryPluginProvider_Aggregation/SwiftLibraryPluginProvider_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftOperators_Aggregation/SwiftOperators_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftParserDiagnostics_Aggregation/SwiftParserDiagnostics_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftParser_Aggregation/SwiftParser_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftRefactor_Aggregation/SwiftRefactor_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax509_Aggregation/SwiftSyntax509_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax510_Aggregation/SwiftSyntax510_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax600_Aggregation/SwiftSyntax600_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax601_Aggregation/SwiftSyntax601_Aggregation.swift create mode 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax602_Aggregation/SwiftSyntax602_Aggregation.swift create mode 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax603_Aggregation/SwiftSyntax603_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxBuilder_Aggregation/SwiftSyntaxBuilder_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacroExpansion_Aggregation/SwiftSyntaxMacroExpansion_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacrosGenericTestSupport_Aggregation/SwiftSyntaxMacrosGenericTestSupport_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacrosTestSupport_Aggregation/SwiftSyntaxMacrosTestSupport_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntaxMacros_Aggregation/SwiftSyntaxMacros_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftSyntax_Aggregation/SwiftSyntax_Aggregation.swift create mode 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/SwiftWarningControl_Aggregation/SwiftWarningControl_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftCompilerPluginMessageHandling_Aggregation/_SwiftCompilerPluginMessageHandling_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftLibraryPluginProviderCShims_Aggregation/_SwiftLibraryPluginProviderCShims_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftLibraryPluginProvider_Aggregation/_SwiftLibraryPluginProvider_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftSyntaxCShims_Aggregation/_SwiftSyntaxCShims_Aggregation.swift mode change 100644 => 100755 RuntimeViewerPrecompiledLibraries/swift-syntax/Sources/_SwiftSyntaxGenericTestSupport_Aggregation/_SwiftSyntaxGenericTestSupport_Aggregation.swift 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 From 7c63dfd57707c36bad43c7490c2fc23686291ef8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 5 May 2026 17:23:10 +0800 Subject: [PATCH 66/78] refactor(application): route Settings window through AppCoordinator Adds an `AppCoordinator` (with `AppRoute.settings`) and an `appRouter` dependency on `ViewModel`. Call sites that previously reached into `SettingsWindowController.shared` directly (AppDelegate menu action, MCPStatusPopoverViewModel disabled-tap, BackgroundIndexingPopoverViewModel "Open Settings" button) now trigger `.settings` through the router so the Settings UI module can stay `package`-scoped instead of leaking a public window controller. Bumps swift-memberwise-init-macro to gohanlon/0.6.0 to satisfy the upstream macro toolchain. --- RuntimeViewerPackages/Package.resolved | 53 ++++++++++++++----- RuntimeViewerPackages/Package.swift | 4 +- .../AppCoordinator.swift | 41 ++++++++++++++ .../RuntimeViewerApplication/ViewModel.swift | 7 +++ .../SettingsWindowController.swift | 17 +++++- .../project.pbxproj | 32 +++++------ .../App/AppDelegate.swift | 8 ++- ...kgroundIndexingPopoverViewController.swift | 8 --- .../BackgroundIndexingPopoverViewModel.swift | 34 ++++++------ .../MCP/MCPStatusPopoverViewController.swift | 5 -- .../MCP/MCPStatusPopoverViewModel.swift | 6 +-- 11 files changed, 144 insertions(+), 71 deletions(-) create mode 100644 RuntimeViewerPackages/Sources/RuntimeViewerApplication/AppCoordinator.swift 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/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/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/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index b668bc5d..166b9f54 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -35,10 +35,6 @@ E9530A3C2D9D5898008FBC7F /* SIPChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9530A3B2D9D5898008FBC7F /* SIPChecker.swift */; }; E961FEEC2F54513E00ED3419 /* RuntimeViewerServer.framework in Copy RuntimeViewerServer Framework */ = {isa = PBXBuildFile; fileRef = E98BF6372F1D4ABB0041DB20 /* RuntimeViewerServer.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E96BF91D2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96BF91C2F60541F009C40D2 /* MCPStatusPopoverViewModel.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 */; }; E96CF5332EC7A4A600CBC159 /* RuntimeSource+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */; }; E96DE1E32F0ACE8D00F9BAB2 /* CheckboxButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */; }; E975449A2C42BA5B00CC9DDD /* LoadFrameworksViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E97544992C42BA5B00CC9DDD /* LoadFrameworksViewController.xib */; }; @@ -78,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 */; }; @@ -254,10 +254,6 @@ E9668FFB2CEF7140007B344A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E9668FFC2CEF7140007B344A /* launchd.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchd.plist; sourceTree = ""; }; E96BF91C2F60541F009C40D2 /* MCPStatusPopoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPStatusPopoverViewModel.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 = ""; }; E96CF5322EC7A4A600CBC159 /* RuntimeSource+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RuntimeSource+.swift"; sourceTree = ""; }; E96DE1E22F0ACE8D00F9BAB2 /* CheckboxButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CheckboxButton+.swift"; sourceTree = ""; }; E97544982C42BA5B00CC9DDD /* LoadFrameworksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFrameworksViewController.swift; sourceTree = ""; }; @@ -291,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 = ""; }; @@ -523,6 +523,14 @@ path = MCP; sourceTree = ""; }; + E9B4C6552F35E9C800823FE0 /* com.mxiris.runtimeviewer.service */ = { + isa = PBXGroup; + children = ( + E9B4C6542F35E9C800823FE0 /* main.swift */, + ); + path = com.mxiris.runtimeviewer.service; + sourceTree = ""; + }; E9BD1A142FA000050000ABCD /* BackgroundIndexing */ = { isa = PBXGroup; children = ( @@ -534,14 +542,6 @@ path = BackgroundIndexing; sourceTree = ""; }; - E9B4C6552F35E9C800823FE0 /* com.mxiris.runtimeviewer.service */ = { - isa = PBXGroup; - children = ( - E9B4C6542F35E9C800823FE0 /* main.swift */, - ); - path = com.mxiris.runtimeviewer.service; - sourceTree = ""; - }; E9CE07BA2C1497EA0070A6E8 /* Base */ = { isa = PBXGroup; children = ( 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/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index 19140566..2921f5af 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -184,14 +184,6 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController { private let coordinator: RuntimeBackgroundIndexingCoordinator private let openSettingsRelay = PublishRelay() - init(documentState: DocumentState, - router: any Router, - coordinator: RuntimeBackgroundIndexingCoordinator) - { + init( + documentState: DocumentState, + router: any Router, + coordinator: RuntimeBackgroundIndexingCoordinator + ) { self.coordinator = coordinator super.init(documentState: documentState, router: router) } @@ -38,11 +39,6 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { 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 } func transform(_ input: Input) -> Output { @@ -75,6 +71,8 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { // value below. subscribeToIsEnabled() + input.openSettings.emit(to: appRouter.rx.trigger(.settings)).disposed(by: rx.disposeBag) + input.cancelBatch.emitOnNext { [weak self] id in guard let self else { return } coordinator.cancelBatch(id) @@ -106,8 +104,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { isEnabled: $isEnabled.asDriver(), hasAnyBatch: $hasAnyBatch.asDriver(), hasAnyHistory: $hasAnyHistory.asDriver(), - subtitle: $subtitle.asDriver(), - openSettings: openSettingsRelay.asSignal() + subtitle: $subtitle.asDriver() ) } @@ -133,8 +130,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { /// Same rationale as `batch(for:)`, scoped to one item inside a batch. func item(for batchID: RuntimeIndexingBatchID, itemID: String) - -> Driver - { + -> Driver { Observable.combineLatest( coordinator.batchesObservable, coordinator.historyObservable @@ -165,10 +161,11 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { isEnabled = settings.indexing.backgroundMode.isEnabled } - private static func renderNodes(active: [RuntimeIndexingBatch], - history: [RuntimeIndexingBatch]) - -> [BackgroundIndexingNode] - { + 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 @@ -182,8 +179,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { } private static func makeBatchNode(_ batch: RuntimeIndexingBatch) - -> BackgroundIndexingNode - { + -> BackgroundIndexingNode { let itemNodes = batch.items.map { item in BackgroundIndexingNode.item(batchID: batch.id, item: item) } 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 { struct Output { let state: Driver - let openSettings: Signal } func transform(_ input: Input) -> Output { @@ -77,7 +76,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 +102,7 @@ final class MCPStatusPopoverViewModel: ViewModel { .disposed(by: rx.disposeBag) return Output( - state: $state.asDriver(), - openSettings: openSettingsRelay.asSignal() + state: $state.asDriver() ) } } From bb3726cc604a7b04cef2a22845cd824fd7306e77 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 5 May 2026 17:23:36 +0800 Subject: [PATCH 67/78] refactor(background-indexing): address PR #44 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four independent improvements driven by the PR review: 1. Cache `mainExecutablePath` once at BFS entry and thread it through `dependencies(for:ancestorRpaths:mainExecutablePath:)` so a deep graph no longer pays one main-path lookup per node — the wins compound on remote (XPC / TCP) sources where each call is a network round-trip. 2. Bound `RuntimeBackgroundIndexingCoordinator.historyRelay` at 100 entries so a long-running session that triggers many `imageLoaded` batches no longer grows history without limit; users can still clear manually via `clearHistory()`. 3. Switch the BFS frontier from `Array.removeFirst()` (O(n)) to `Deque.popFirst()` (O(1)) via `swift-collections` — explicit dependency added so we no longer rely on a transitive product. 4. Make `RuntimeEngine.backgroundIndexingManager` a `lazy var` instead of an IUO; the actor's isolation guarantees thread-safe lazy init and removes the force-unwrap. --- RuntimeViewerCore/Package.resolved | 12 +++++------ RuntimeViewerCore/Package.swift | 9 +++++++-- ...BackgroundIndexingEngineRepresenting.swift | 9 ++++++++- .../RuntimeBackgroundIndexingManager.swift | 20 +++++++++++++++---- .../RuntimeEngine+BackgroundIndexing.swift | 7 ++++--- .../RuntimeViewerCore/RuntimeEngine.swift | 10 +++++----- .../MockBackgroundIndexingEngine.swift | 9 +++++++-- ...untimeBackgroundIndexingManagerTests.swift | 8 ++++++-- ...RuntimeBackgroundIndexingCoordinator.swift | 10 ++++++++++ 9 files changed, 69 insertions(+), 25 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index 6ce8246a..f75cfae9 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a3dd8fcc311882d02a960d48c289a156d4e6905b90262ad225bc4c3c8ced6b64", + "originHash" : "014e9411eb99836d920593b5f5322f6b24f15a8dd48eb3888fa15549267f312e", "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 71733b45..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", @@ -166,6 +170,7 @@ let package = Package( .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 index ab5961fe..a3cbbbe4 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingEngineRepresenting.swift @@ -36,6 +36,13 @@ protocol RuntimeBackgroundIndexingEngineRepresenting: AnyObject, Sendable { /// 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. - func dependencies(for path: String, ancestorRpaths: [String]) + /// + /// `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 index b3784bc0..8dff5b7a 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -1,5 +1,6 @@ import Foundation import Semaphore +import DequeModule public actor RuntimeBackgroundIndexingManager { struct BatchState { @@ -120,6 +121,13 @@ public actor RuntimeBackgroundIndexingManager { 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 @@ -127,11 +135,13 @@ public actor RuntimeBackgroundIndexingManager { // 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. - var frontier: [(path: String, level: Int, ancestorRpaths: [String])] = + // + // `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 !frontier.isEmpty { - let (path, level, ancestorRpaths) = frontier.removeFirst() + 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), @@ -160,7 +170,9 @@ public actor RuntimeBackgroundIndexingManager { // `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 + 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", diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 5fa46682..e0c9f92b 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -58,14 +58,15 @@ extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { return image.rpaths } - func dependencies(for path: String, ancestorRpaths: [String]) async throws + 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() - let main = try await mainExecutablePath() // 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 @@ -79,7 +80,7 @@ extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { installName: installName, imagePath: path, rpaths: mergedRpaths, - mainExecutablePath: main + mainExecutablePath: mainExecutablePath ) // LC_LOAD_WEAK_DYLIB: dyld silently skips at runtime when the // target isn't on disk (e.g. Xcode embeds diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 3a2bfaa2..9001ac20 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -180,10 +180,11 @@ public actor RuntimeEngine { public private(set) var xpcListenerEndpoint: (any Sendable)? /// Coordinator for background indexing batches that load and index images - /// without blocking the main runtime data flow. Created at the end of - /// `init` so it can capture `self` after all other stored properties are - /// initialized. - public private(set) var backgroundIndexingManager: RuntimeBackgroundIndexingManager! + /// 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, @@ -202,7 +203,6 @@ public actor RuntimeEngine { self.pushesRuntimeData = pushesRuntimeData self.objcSectionFactory = .init() self.swiftSectionFactory = .init() - self.backgroundIndexingManager = RuntimeBackgroundIndexingManager(engine: self) #log(.info, "Initializing RuntimeEngine with source: \(String(describing: source), privacy: .public)") } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift index 7940bcab..20f6f01c 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/MockBackgroundIndexingEngine.swift @@ -16,6 +16,7 @@ final class MockBackgroundIndexingEngine: RuntimeBackgroundIndexingEngineReprese struct DependenciesCall: Sendable, Equatable { var path: String var ancestorRpaths: [String] + var mainExecutablePath: String } private let lock = NSLock() @@ -64,11 +65,15 @@ final class MockBackgroundIndexingEngine: RuntimeBackgroundIndexingEngineReprese lock.lock(); defer { lock.unlock() } return paths[path]?.rpaths ?? [] } - func dependencies(for path: String, ancestorRpaths: [String]) + 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)) + 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 index 2340fb75..a221a090 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -405,10 +405,14 @@ import Testing func rpaths(for path: String) async throws -> [String] { try await base.rpaths(for: path) } - func dependencies(for path: String, ancestorRpaths: [String]) + func dependencies(for path: String, + ancestorRpaths: [String], + mainExecutablePath: String) async throws -> [(installName: String, resolvedPath: String?)] { - try await base.dependencies(for: path, ancestorRpaths: ancestorRpaths) + try await base.dependencies(for: path, + ancestorRpaths: ancestorRpaths, + mainExecutablePath: mainExecutablePath) } } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index b40a8017..3bc0919b 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -11,6 +11,13 @@ import RuntimeViewerSettings @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 @@ -180,6 +187,9 @@ public final class RuntimeBackgroundIndexingCoordinator { 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) } From c6c7f6dd6f3e943d36562fa99ab2558c9f43bb51 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 6 May 2026 17:01:05 +0800 Subject: [PATCH 68/78] fix(background-indexing): capture engine across source-switch awaits `startMainExecutableBatch` and `handleImageLoaded` re-read `self.engine` across `await` suspensions. Because `handleEngineSwap` runs on the same @MainActor and reassigns `self.engine`, a swap that lands between awaits would submit the old engine's root path to the new engine's manager and leak the resulting batch id into `documentBatchIDs`. Capture `engine` in each Task's capture list so every await targets the engine that started the work, mirroring the existing `cancelBatch` / `documentWillClose` pattern. Add a `self.engine === engine` guard before mutating `documentBatchIDs` so a stray id from the now-old manager is dropped instead of polluting the new engine's tracking set. --- ...RuntimeBackgroundIndexingCoordinator.swift | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 3bc0919b..1c5431e1 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -288,7 +288,12 @@ extension RuntimeBackgroundIndexingCoordinator { private func startMainExecutableBatch(reason: RuntimeIndexingBatchReason) { // The class is `@MainActor`, so this Task inherits main-actor isolation // and can mutate `documentBatchIDs` synchronously after the awaits. - Task { [weak self] in + // 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 } @@ -302,6 +307,11 @@ extension RuntimeBackgroundIndexingCoordinator { 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) } } @@ -319,18 +329,21 @@ extension RuntimeBackgroundIndexingCoordinator { 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 + // 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 self.engine.imageDidLoadPublisher.values { - await self.handleImageLoaded(path: path) + for await path in engine.imageDidLoadPublisher.values { + await self.handleImageLoaded(path: path, on: engine) } } } - private func handleImageLoaded(path: String) async { + 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 @@ -343,6 +356,10 @@ extension RuntimeBackgroundIndexingCoordinator { 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) } From 8656055fe4211f284f7dfe4d266c5174f675c659 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 6 May 2026 17:01:12 +0800 Subject: [PATCH 69/78] chore(background-indexing): drop dead openSettingsRelay subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay was declared and accepted but never exposed in `Output` and never subscribed; the open-settings action is already routed earlier via `input.openSettings.emit(to: appRouter.rx.trigger(.settings))`. The accompanying comment claimed the relay was forwarded to Output — which it wasn't — making the dead code actively misleading. --- .../BackgroundIndexingPopoverViewModel.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index b72c36ad..1a7ac075 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -15,7 +15,6 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { @Observed private(set) var subtitle: String = "" private let coordinator: RuntimeBackgroundIndexingCoordinator - private let openSettingsRelay = PublishRelay() init( documentState: DocumentState, @@ -91,14 +90,6 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { } .disposed(by: rx.disposeBag) - // Forward openSettings to output so the ViewController can call - // `SettingsWindowController.shared.showWindow(nil)` directly. - input.openSettings.emitOnNext { [weak self] in - guard let self else { return } - openSettingsRelay.accept(()) - } - .disposed(by: rx.disposeBag) - return Output( nodes: $nodes.asDriver(), isEnabled: $isEnabled.asDriver(), From f3e5df07d532e2cd921ecc0e3d179c462ea28e94 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 6 May 2026 18:30:25 +0800 Subject: [PATCH 70/78] docs(background-indexing): tighten doc comments per PR #44 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadImageForBackgroundIndexing: explicitly state it suppresses both reloadData() and imageDidLoadPublisher; emitting either would feed the coordinator's pump and recursively spawn batches during a BFS. - prioritize(imagePath:): document best-effort semantics — only .pending items get boosted, no preemption of running tasks. - startBatch second dedup check: note the deliberate trade-off of running full BFS in both racers rather than holding the actor across BFS, keeping cancel/prioritize responsive. - DocumentState.backgroundIndexingCoordinator: lazy is init-deferral, not gating-by-enablement — coordinator stays alive for off→on toggles. --- .../RuntimeBackgroundIndexingManager.swift | 24 +++++++++++++++++++ .../RuntimeEngine+BackgroundIndexing.swift | 10 ++++++-- .../DocumentState.swift | 17 +++++++++---- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index 8dff5b7a..bfb6a8d1 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -47,6 +47,23 @@ public actor RuntimeBackgroundIndexingManager { } } + /// 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: { @@ -89,6 +106,13 @@ public actor RuntimeBackgroundIndexingManager { // 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 } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index e0c9f92b..cf3cfa94 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -26,8 +26,14 @@ extension RuntimeEngine { } } - /// Like `loadImage(at:)` but does **not** call `reloadData()`. - /// Used by the background indexing manager to avoid UI refresh storms. + /// 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 diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index facee12e..e683d2a0 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -26,10 +26,19 @@ public final class DocumentState { @Observed public var currentSubtitle: String = "" - /// Per-Document background indexing coordinator. Created lazily on first - /// access so that opening a Document does not pay the cost when the - /// feature is disabled. The coordinator captures `runtimeEngine` at - /// init — see the doc comment on that property. + /// 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) } From 979a4177b69341d080cd74cedca6f187b7ba91ec Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 6 May 2026 18:30:35 +0800 Subject: [PATCH 71/78] fix(background-indexing): cancel all old-engine batches on source swap handleEngineSwap iterated only documentBatchIDs, which misses any startBatch Task that suspended after the swap began but before its id was inserted. The self.engine === engine guard in startMainExecutableBatch / handleImageLoaded correctly drops the stray id, but the batch itself remained active on the orphaned old manager and ran to completion uninterrupted, occupying CPU and section-cache slots until the old engine deinit'd. Switch to oldEngine.backgroundIndexingManager.cancelAllBatches() so both already-tracked batches and any swap-window arrivals are cancelled. --- ...RuntimeBackgroundIndexingCoordinator.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 1c5431e1..f72a38b0 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -232,9 +232,9 @@ public final class RuntimeBackgroundIndexingCoordinator { private func handleEngineSwap(to newEngine: RuntimeEngine) { // Capture the old engine before we overwrite, so we can dispatch a - // best-effort cancel to its manager for any document batches we own. + // cancel to its manager covering both already-tracked batches and + // any swap-window arrivals. let oldEngine = engine - let oldBatchIDs = documentBatchIDs // 1) Stop pumps tied to the old engine. The Tasks were `for await` // looping over an AsyncStream owned by the old manager; cancelling @@ -244,14 +244,20 @@ public final class RuntimeBackgroundIndexingCoordinator { eventPumpTask = nil imageLoadedPumpTask = nil - // 2) Best-effort cancel of in-flight batches on the old manager. + // 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. - if !oldBatchIDs.isEmpty { - Task { - for id in oldBatchIDs { - await oldEngine.backgroundIndexingManager.cancelBatch(id) - } - } + Task { + await oldEngine.backgroundIndexingManager.cancelAllBatches() } // 3) Drop UI state — the old engine's batches and history no longer apply. From bb8dc96993709c266ea680acae54df9afe4d6e3d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 6 May 2026 18:30:44 +0800 Subject: [PATCH 72/78] refactor(background-indexing): split popover isEnabled bootstrap The previous subscribeToIsEnabled both seeded `isEnabled` from settings and registered the Observation tracker, with the seeding line running on every recursive call from onChange. Idempotent (both paths converge on the same value) but the trailing comment claiming "initial subscribe" was misleading. Mirror RuntimeBackgroundIndexingCoordinator's bootstrap/register pair: bootstrapIsEnabledObservation seeds once, registerIsEnabledObservation only registers and is the recursion target. --- .../BackgroundIndexingPopoverViewModel.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index 1a7ac075..c4e2ed58 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -68,7 +68,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { // ViewModel base class is `@MainActor`, so `transform` runs on the // main actor; we can subscribe synchronously and seed the initial // value below. - subscribeToIsEnabled() + bootstrapIsEnabledObservation() input.openSettings.emit(to: appRouter.rx.trigger(.settings)).disposed(by: rx.disposeBag) @@ -135,7 +135,18 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { .asDriver(onErrorDriveWith: .empty()) } - private func subscribeToIsEnabled() { + /// 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 @@ -145,11 +156,9 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { Task { @MainActor [weak self] in guard let self else { return } self.isEnabled = self.settings.indexing.backgroundMode.isEnabled - self.subscribeToIsEnabled() + self.registerIsEnabledObservation() } } - // Seed the current value synchronously on initial subscribe. - isEnabled = settings.indexing.backgroundMode.isEnabled } private static func renderNodes( From 6762a5bfc9e1c290b23c89a87c0d29170c0cf6bf Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 6 May 2026 18:31:05 +0800 Subject: [PATCH 73/78] test(background-indexing): pin publisher silence for background loads The prior test was named ...DoesNotTriggerReloadData but only asserted the image became indexed, leaving the publisher-silence contract unverified. Rename to MarksIndexedAndDoesNotEmitPublishers and add NSLock-backed counters that sink reloadDataPublisher and imageDidLoadPublisher, asserting both stay at zero across the call. Regression coverage if someone wires loadImageForBackgroundIndexing into the canonical loadImage path (which would feed the coordinator's image-loaded pump and recursively spawn batches). --- .../RuntimeEngineIndexStateTests.swift | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift index 74207429..d92db9a7 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeEngineIndexStateTests.swift @@ -64,18 +64,73 @@ import Testing #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 loadImageForBackgroundIndexingDoesNotTriggerReloadData() async throws { - // CoreText is reliable across macOS versions. + 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) + #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 From daec90d2264a3b33bb392e05cc57b6d85058c9b1 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 6 May 2026 22:16:16 +0800 Subject: [PATCH 74/78] chore: bump swift-memberwise-init-macro to upstream 0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches Distribution workspace from MxIris-Library-Forks fork (0.5.3-fork.1) to gohanlon/swift-memberwise-init-macro 0.6.0 — upstream now has the changes the fork was carrying. --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" } }, { From 408f99ccf57674395566405b40685dcff817b51d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 7 May 2026 12:06:20 +0800 Subject: [PATCH 75/78] refactor(background-indexing): address PR #44 review (close routing + finishedCount split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two PR #44 review items in the same popover surface, easier to review together than apart: * Route the popover's Close button through the ViewModel via `Input.close → router.trigger(.dismiss)` instead of calling `dismiss(nil)` from inside `setupBindings(for:)`. The direct call was the only point in the new BackgroundIndexing UI where navigation bypassed MVVM-C. * `RuntimeIndexingBatch.completedCount` previously summed every terminal item (`.completed | .failed | .cancelled`) and the popover cell rendered it as "X/Y" next to a green checkmark, so a fully failed batch read as fully successful. Split that into `finishedCount` (drives `progress` + the bar — every stopped item counts) and `succeededCount` / `failedCount` / `cancelledCount`. The cell now shows "succeeded/total" with a tinted "N failed" / "N cancelled" tail when present. Aggregate-state progress (`RuntimeBackgroundIndexingCoordinator`) keeps using `finishedCount` since it should reach 100% once work has stopped. Tests: rename `batchProgressReportsCompletedFraction` → `batchProgressReportsFinishedFraction` and assert the new counters including a `.cancelled` item, so all four properties are exercised. --- RuntimeViewerCore/Package.resolved | 2 +- .../RuntimeIndexingBatch.swift | 35 ++++++++++++- .../RuntimeIndexingValueTypesTests.swift | 13 +++-- ...RuntimeBackgroundIndexingCoordinator.swift | 2 +- ...kgroundIndexingPopoverViewController.swift | 51 ++++++++++++++----- .../BackgroundIndexingPopoverViewModel.swift | 3 ++ 6 files changed, 87 insertions(+), 19 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index f75cfae9..52728517 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "014e9411eb99836d920593b5f5322f6b24f15a8dd48eb3888fa15549267f312e", + "originHash" : "559e3422e65f895209b7bc59f592f199a4ce355371a1a367f971bf06b56fc6fd", "pins" : [ { "identity" : "associatedobject", diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift index e29103db..476afe0b 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatch.swift @@ -21,9 +21,40 @@ public struct RuntimeIndexingBatch: Sendable, Identifiable, Hashable { } public var totalCount: Int { items.count } - public var completedCount: Int { items.lazy.filter { $0.state.isTerminal }.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(completedCount) / Double(totalCount) + return Double(finishedCount) / Double(totalCount) } } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift index 89de5b81..b213eb11 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeIndexingValueTypesTests.swift @@ -27,12 +27,13 @@ import Testing #expect(RuntimeIndexingTaskState.completed.isTerminal) } - @Test func batchProgressReportsCompletedFraction() { + @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(), @@ -43,7 +44,13 @@ import Testing isCancelled: false, isFinished: false ) - #expect(batch.completedCount == 3) // completed + failed both count toward "done" - #expect(batch.totalCount == 4) + #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/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index f72a38b0..c2d0e474 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -202,7 +202,7 @@ public final class RuntimeBackgroundIndexingCoordinator { } } let totalItems = batches.reduce(0) { $0 + $1.totalCount } - let doneItems = batches.reduce(0) { $0 + $1.completedCount } + let doneItems = batches.reduce(0) { $0 + $1.finishedCount } let progress: Double? = totalItems > 0 ? Double(doneItems) / Double(totalItems) : nil diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index 2921f5af..324e472d 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -155,18 +155,12 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController Bool { - guard let node = item as? BackgroundIndexingNode, case .item = node else { return false } - return true + return false } } @@ -383,15 +376,49 @@ extension BackgroundIndexingPopoverViewController { private func update(with batch: RuntimeIndexingBatch) { cancelButton.isHidden = batch.isFinished titleLabel.stringValue = Self.title(for: batch.reason) - countLabel.stringValue = "\(batch.completedCount)/\(batch.totalCount)" + countLabel.attributedStringValue = Self.countText(for: batch) progressIndicator.maxValue = max(Double(batch.totalCount), 1) - progressIndicator.doubleValue = Double(batch.completedCount) + 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?() } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index c4e2ed58..499f81d4 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -30,6 +30,7 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { let cancelAll: Signal let clearHistory: Signal let openSettings: Signal + let close: Signal } struct Output { @@ -72,6 +73,8 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { 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) From a792144bed43fb964fb028351b9afef50dfd1154 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 7 May 2026 12:06:26 +0800 Subject: [PATCH 76/78] chore(mcp): drop dead openSettingsRelay declaration `Output.openSettings` and the matching ViewController binding were removed earlier; the relay declaration was the only remaining residue. Same cleanup that 8656055 applied to the BackgroundIndexing twin. --- .../MCP/MCPStatusPopoverViewModel.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift index 411b0ed4..0e6fce8a 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift @@ -54,8 +54,6 @@ enum MCPConfigType { final class MCPStatusPopoverViewModel: ViewModel { @Observed private(set) var state: MCPServerState = MCPService.shared.serverState - private let openSettingsRelay = PublishRelay() - struct Input { let actionButtonClick: Signal let copyPortClick: Signal From 8dba2c6dcbaa91d3f0642663bd439b9e16ff47a9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 7 May 2026 17:52:53 +0800 Subject: [PATCH 77/78] fix(background-indexing): coalesce coordinator relay updates High-frequency taskStarted/taskFinished events were each firing batchesRelay synchronously on the main actor, saturating the main thread enough to trigger AppKit's "deferral block timed out after 500ms" log and visible UI lag during a busy batch. Stage event mutations into stagedBatches synchronously and flush to batchesRelay/aggregateRelay/historyRelay at most once per ~16ms (one frame at 60Hz). Lifecycle events (batchStarted/Finished/Cancelled) still flush immediately so the popover reacts instantly. Also replaces the O(B*N) batches.map { mutating(...) } per task event with O(B+N) index-based mutation in mutateTaskItem; drops the now- unused mutating helper. clearHistory and handleEngineSwap reset the new staging state alongside the existing relay clears. --- ...RuntimeBackgroundIndexingCoordinator.swift | 180 ++++++++++++++---- 1 file changed, 139 insertions(+), 41 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index c2d0e474..4ad484fd 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -45,6 +45,29 @@ public final class RuntimeBackgroundIndexingCoordinator { 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? @@ -101,6 +124,10 @@ public final class RuntimeBackgroundIndexingCoordinator { } 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([]) } @@ -120,68 +147,133 @@ public final class RuntimeBackgroundIndexingCoordinator { } private func apply(event: RuntimeIndexingEvent) { - var batches = batchesRelay.value - // Collect batches that need to be appended to history. We defer the - // actual `appendToHistory` calls until after `batchesRelay.accept` so - // that `combineLatest(batchesObservable, historyObservable)` never emits - // a transient state where the same batch appears in both ACTIVE and - // HISTORY sections (which would produce duplicate `differenceIdentifier` - // values and undefined DifferenceKit behavior). - var historyAdditions: [RuntimeIndexingBatch] = [] + // 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): - batches.append(batch) + stagedBatches.append(batch) + hasPendingActiveChange = true + pendingAggregateRefresh = true + requiresImmediateFlush = true case .taskStarted(let id, let path): - batches = batches.map { mutating($0) { batch in - guard batch.id == id, let itemIndex = batch.items.firstIndex(where: { $0.id == path }) - else { return } - batch.items[itemIndex].state = .running - }} + if mutateTaskItem(batchID: id, path: path, { item in + item.state = .running + }) { + hasPendingActiveChange = true + pendingAggregateRefresh = true + } case .taskFinished(let id, let path, let result): - batches = batches.map { mutating($0) { batch in - guard batch.id == id, let itemIndex = batch.items.firstIndex(where: { $0.id == path }) - else { return } - batch.items[itemIndex].state = result - }} + if mutateTaskItem(batchID: id, path: path, { item in + item.state = result + }) { + hasPendingActiveChange = true + pendingAggregateRefresh = true + } case .taskPrioritized(let id, let path): - batches = batches.map { mutating($0) { batch in - guard batch.id == id, let itemIndex = batch.items.firstIndex(where: { $0.id == path }) - else { return } - batch.items[itemIndex].hasPriorityBoost = true - }} + // 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): - batches.removeAll { $0.id == finished.id } + stagedBatches.removeAll { $0.id == finished.id } documentBatchIDs.remove(finished.id) - historyAdditions.append(finished) + 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. - batches.removeAll { $0.id == cancelled.id } + stagedBatches.removeAll { $0.id == cancelled.id } documentBatchIDs.remove(cancelled.id) - historyAdditions.append(cancelled) + pendingHistoryAdditions.append(cancelled) + hasPendingActiveChange = true + pendingAggregateRefresh = true + requiresImmediateFlush = true Task { [engine] in await engine.reloadData(isReloadImageNodes: false) } } - // Settle the active-batches relay first so it no longer contains the - // finished/cancelled batch before history is updated. - batchesRelay.accept(batches) - refreshAggregate(batches: batches) - // Now safe to push history: combineLatest will emit (new batches without - // finished, new history with finished) — a fully consistent state. - for batchToArchive in historyAdditions { - appendToHistory(batchToArchive) + + 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() } } - private func mutating(_ value: Value, _ mutate: (inout Value) -> Void) -> Value { - var copy = value - mutate(©) - return copy + /// 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) { @@ -261,7 +353,13 @@ public final class RuntimeBackgroundIndexingCoordinator { } // 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: []) From a353db0f2d3da6ce3d3b09f938fa8775f8f723c3 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 7 May 2026 17:53:02 +0800 Subject: [PATCH 78/78] fix(sidebar): keep root node indexing off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RxConcurrency adds a flatMapLatest(_:) overload that takes an async closure and wraps the body in Observable.async { Task { ... } }. The sync closure in indexedNodes was binding to that overload, and the Task inherited @MainActor isolation from the enclosing ViewModel (which is @MainActor through its base). That hop overrode .observe(on: ConcurrentDispatchQueueScheduler(qos: .userInteractive)) and dropped ~386ms of cell construction — SidebarRootCellViewModel init, NSAttributedString building, generic metadata instantiation, dynamic casts — onto the main thread per Time Profile sample. Switching to .map binds the synchronous RxSwift overload, so the prior .observe(on:) hop actually delivers the work onto the user- interactive concurrent queue. --- .../Sidebar/SidebarRootViewModel.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift index b06bb723..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 {