diff --git a/ArchiveScript.sh b/ArchiveScript.sh index 0390f5a5..59e41106 100755 --- a/ArchiveScript.sh +++ b/ArchiveScript.sh @@ -422,7 +422,12 @@ if $COMMIT_PUSH; then cp docs/appcast.xml "$tmp_appcast" run git fetch origin main APPCAST_BRANCH="appcast-for-$VERSION_TAG" - run git checkout -B "$APPCAST_BRANCH" origin/main + # Archive runs `xcodebuild -resolvePackageDependencies`, which mutates + # the workspace's Package.resolved. Those changes do NOT belong in the + # appcast PR and would otherwise block `git checkout` with a "local + # changes would be overwritten" abort. docs/appcast.xml is safely in + # $tmp_appcast and gets re-applied below, so a forced checkout is fine. + run git checkout -f -B "$APPCAST_BRANCH" origin/main cp "$tmp_appcast" docs/appcast.xml rm -f "$tmp_appcast" if git diff --quiet docs/appcast.xml; then diff --git a/CLAUDE.md b/CLAUDE.md index ea7aa145..03c6e42d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,6 +268,20 @@ override func setupBindings(for viewModel: MyViewModel) { | `VStackView(alignment:spacing:) { ... }` | `NSStackView(orientation: .vertical)` | | `HStackView(spacing:) { ... }` | `NSStackView(orientation: .horizontal)` | | `ScrollView()` | `NSScrollView()` | +| `final class XxxView: LayerBackedView` | `final class XxxView: NSView` (when it needs `cornerRadius` / border / `backgroundColor` / shadow) | + +**Layer-backed views** — any custom AppKit view that needs layer-level visuals (rounded corners, border, background color, shadow) MUST inherit `UIFoundationAppKit.LayerBackedView`, never raw `NSView` with hand-rolled `wantsLayer = true` + `layer?.cornerRadius / layer?.borderColor / layer?.backgroundColor = ....cgColor`. The base class already sets `wantsLayer + layerContentsRedrawPolicy = .onSetNeedsDisplay` and centralizes everything in `updateLayer()`, so: + +- Assign the exposed `NSColor?` properties directly — `backgroundColor = NSColor(light:dark:)`, `borderColor = ...` — **without** `.cgColor`. Dynamic colors re-resolve on appearance change automatically; you do NOT need to override `viewDidChangeEffectiveAppearance`. +- One-time setup goes in `override func setup()` (called by `commonInit`); first-layout work goes in `override func firstLayout()`. Don't repeat `wantsLayer = true` in `init`. +- Available properties: `cornerRadius`, `borderWidth`, `borderColor`, `borderPositions`, `borderLocation`, `borderInsets`, `backgroundColor`, `shadowColor`, `shadowOpacity`, `shadowOffset`, `shadowRadius`, `shadowPath`. +- **Gotcha — `borderPositions` defaults to `[]`, so the border won't render** even with non-zero `borderWidth` + non-nil `borderColor`. To draw a full rounded border you MUST set `borderPositions = .all`: + ```swift + cornerRadius = 8 + borderWidth = 1 + borderColor = NSColor(light: ..., dark: ...) + borderPositions = .all // ← otherwise the previous 3 lines are ignored + ``` **View initialization** — `.then {}` returns the configured object (for assignment): ```swift diff --git a/Changelogs/v2.1.0-beta.2.md b/Changelogs/v2.1.0-beta.2.md new file mode 100644 index 00000000..0cbb7644 --- /dev/null +++ b/Changelogs/v2.1.0-beta.2.md @@ -0,0 +1,112 @@ +# v2.1.0-beta.2 + +Second public preview of the **2.1** line. Headline additions: a new +**Relationships** tab in the Inspector, a **Batch Export** flow from the File +menu, and **selection history** in the toolbar. Plus a stack of performance +and stability fixes carried over from beta.1. + +This is a `beta` build — only clients that opted in via +**Settings → Updates → Include pre-release versions** receive it. + +--- + +## New Features + +### Inspector → Relationships +A new **Relationships** tab sits between *Hierarchy* and *Specialization* and +shows where a type is referenced across the binaries you have loaded. + +- Surfaces inbound references (who uses this type) and outbound references + (what this type depends on), drawn from a dedicated relationships engine. +- Works across binaries: if the indexed images include the consumers of a + type, they show up regardless of which dylib defines it. +- The list lays out cleanly even when a type has no relationships — an empty + state explains why instead of showing a blank tab. + +### Batch Export +Export the interfaces of multiple images in one pass without opening each +document individually. + +- New **File → Export Multiple Images…** entry walks you through image + selection, format selection, and destination directory in a single sheet. +- Per-language format choices (Objective-C `.h`, Swift surface, etc.) are + remembered across runs, so the next export starts where you left off. +- The completion summary now uses stat cards to show how many files + succeeded, failed, or were skipped, and lists each failure individually so + you can re-run only what broke. +- Export file names are clamped below the APFS `NAME_MAX` limit, so long + Swift-mangled names no longer produce errors mid-batch. + +### Selection History in the Toolbar +The toolbar now drives back / forward navigation off a true selection +history cursor, the way Xcode and Finder do. + +- Picking a sidebar item, drilling into a type, and switching between + Hierarchy / Relationships / Specialization tabs all push onto the same + stack. +- The buttons enable / disable based on what's actually in the history, so + you never end up clicking back into nothing. +- The active inspector tab is preserved when you move between selections, so + jumping back into a type returns you to the panel you were last reading. + +### Sidebar: smarter expansion +- Single-clicking a non-leaf row now expands it (matching how the rest of + macOS treats outline rows). +- A new setting caps how deep a double-click expands an entire subtree, so + you can preview a framework without exploding hundreds of children. + +--- + +## Performance + +- **Type-picker prep moved off the main thread.** When you open the + Specialization sheet for a generic with a wide constraint, the candidate + list (10k+ entries in common cases) is now built on a background queue + with a `LoadingButton` placeholder — the UI no longer hangs while the + list is being assembled. +- **Lazy cell view models for the type picker.** A new + `DifferentiableBox` wrapper defers per-row view-model construction until + the row is actually rendered, cutting time-to-first-frame on large + candidate lists by roughly an order of magnitude. +- **macOS 26 navbar push cost cut.** Pushing the inspector when the user + selects a new sidebar row reuses the existing view controllers instead of + rebuilding them, removing a visible flash that appeared on macOS 26. + +--- + +## Bug Fixes + +- The Inspector no longer races against `reloadData()` when you click + through the sidebar rapidly — in-flight reloads are now cancelled before + the next one starts. +- Indexing skips weak-load dylibs that are shadowed by the dyld shared + cache, so they no longer show up as "missing" entries. +- Injected servers now resolve their main binary path through the dyld + registry, which fixes the inspector failing to load for some injection + targets. +- XPC peer activation runs after the modifier installs its handlers, + closing a small race window that could drop the first request on a fresh + connection. +- Inspector specialization rows can no longer be selected by accident — the + parameter table now refuses row selection the way the rest of the + inspector does. +- Decoding the `runtimeObjectsInImage` response now uses the right request + type, so the inspector populates correctly for some injection scenarios + that previously returned an empty list. + +--- + +## Under the Hood + +- The RuntimeViewer ↔ helper transport was rebuilt on top of the upstream + `swift-helper-service` library — XPC connection, peer lifecycle, and + daemon entry all delegate to the library now, leaving this repo with a + thin set of Runtime-specific adapters. +- `RuntimeViewerCore` was split: parsing lives in dedicated indexers, the + relationships engine has its own resolver, and `RuntimeSwiftSection` was + carved into focused extensions instead of one growing file. +- Document navigation is now state-driven from `DocumentState.selectionStack` + rather than ad-hoc coordinator calls, which is what unlocks the toolbar + history behaviour above. +- The communication layer was tightened (sealed `VoidResponse`, prefixed + shared transport types with `Runtime*`, dropped `@unchecked Sendable`). diff --git a/Changelogs/v2.1.0-beta.3.md b/Changelogs/v2.1.0-beta.3.md new file mode 100644 index 00000000..d3f8824d --- /dev/null +++ b/Changelogs/v2.1.0-beta.3.md @@ -0,0 +1,79 @@ +# v2.1.0-beta.3 + +Third public preview of the **2.1** line. Stability focus on top of +[beta.2](v2.1.0-beta.2.md): one critical crash fix in the Bonjour-based +network connection, plus a MachOSwiftSection bump that picks up an +SwiftUI-related opaque-type parsing fix. Everything else carries over +from beta.2 unchanged. + +This is a `beta` build — only clients that opted in via +**Settings → Updates → Include pre-release versions** receive it. + +--- + +## Bug Fixes + +### Crash on Bonjour-routed peers under batched receives +Inspecting an iOS / visionOS / Apple TV target over Bonjour, or pushing a +batch-export through a Bonjour connection, could crash with +`EXC_BAD_ACCESS` once a single `NWConnection.receive` carried more than a +few dozen small frames (heartbeats, acks, or export chunks). The crash hit +the connection's dispatch-queue worker thread with a 13_000+ frame stack +overflow. + +Two compounding factors fed the overflow on this code path: + +1. `RuntimeMessageChannel`'s message-dispatch loop accessed two + `@Mutex`-decorated properties (`receivedDataContinuation`, + `onMessageReceived`) on every iteration. The macro-generated `_modify` + coroutine accessor pinned ~280 bytes of coroutine context on the caller's + frame per access and the frames did not unwind between iterations. + +2. `RuntimeNetworkConnection.observeIncomingMessages` spawned a fresh + `Task { await handleReceivedMessage(data) }` for each message inside that + same loop, adding another ~10 frames per iteration on top of (1). + +The fix: + +- The dispatch loop now snapshots both properties into locals before the hot + loop, so each iteration only touches local variables and the `_modify` + coroutine frames no longer accumulate. +- `RuntimeNetworkConnection` now matches `RuntimeLocalSocketConnection` and + `RuntimeDirectTCPConnection`: the callback only does a non-blocking + `yield` into an unbounded `AsyncStream`, and one long-lived consumer + task drains it. Early messages yielded before the consumer task reaches + `for await` are buffered, preserving FIFO across the entire receive burst. + +Regression tests cover a 10k-message burst delivered from a dispatch queue +plus a disabled reproducer that replays the pre-fix per-message-Task shape +for manual verification. + +### Cross-image opaque-type parsing crash (via MachOSwiftSection 0.12.0-beta.3) +Bumps MachOSwiftSection to **0.12.0-beta.3**, which fixes a crash when an +indexed image references a Swift opaque-return type whose underlying +descriptor lives in a sibling loaded image — e.g. SwiftUI body code that +resolves `View.searchFieldStyle(_:)` against `DVTUserInterfaceKit`. Before +the bump, the `MachOContext` decoder treated the symbolic +reference offset as in-image and walked off into `__PAGEZERO`. The decoder +now mirrors the Swift runtime and resolves the descriptor through the +in-process pointer scheme, making cross-image references transparent. + +The same MachOSwiftSection bump also brings: + +- **SymbolIndexStore self-cleanup** — short-lived indexers now release their + cache entry on `deinit` instead of leaking it for the full process + lifetime. +- **`SharedCache` management API** — `contains` / `remove` / `removeAll` + let holders explicitly tear down cache entries on disposal. +- Internal refactors that fold `GenericContext` initialisers onto the + unified `ReadingContext` path, fixing a latent negative-size bug in the + legacy in-process decoder. + +--- + +## Carried Over From beta.2 + +All features from [v2.1.0-beta.2](v2.1.0-beta.2.md) ship unchanged: the +Inspector **Relationships** tab, the **Batch Export** flow under *File → +Export Multiple Images…*, selection history in the toolbar, and the +underlying communication-layer hardening. diff --git a/Changelogs/v2.1.0-beta.4.md b/Changelogs/v2.1.0-beta.4.md new file mode 100644 index 00000000..9ba1c9c9 --- /dev/null +++ b/Changelogs/v2.1.0-beta.4.md @@ -0,0 +1,90 @@ +# v2.1.0-beta.4 + +Fourth public preview of the **2.1** line. Re-rolls the Bonjour crash fix from +[beta.3](v2.1.0-beta.3.md): beta.3's release binary still overflowed the +dispatch-queue worker stack inside `AsyncStream.yield`. Everything else +carries over from beta.3 unchanged. + +This is a `beta` build — only clients that opted in via +**Settings → Updates → Include pre-release versions** receive it. + +--- + +## Bug Fixes + +### Bonjour-connection crash regression vs. beta.3 +beta.3 shipped two coordinated fixes for the original v2.1.0-beta.2 +`EXC_BAD_ACCESS` on a Bonjour batched receive: + +- snapshot `RuntimeMessageChannel`'s `@Mutex`-backed properties into + locals before the hot loop, and +- bridge `RuntimeNetworkConnection.onMessageReceived` through an + `AsyncStream` drained by one long-lived consumer task. + +In debug builds the combination was enough. In release, the binary still +crashed in `AsyncStream._Storage.yield` with a stack-guard fault — the +crash backtrace showed two `RuntimeViewer` offsets (`0xe88a0` / `0x6c33c`) +recursing tightly inside a single `connection.receive` callback on the +`com.RuntimeViewer.RuntimeViewerCommunication.RuntimeNetworkConnection` +queue. The `@Mutex` macro's `_modify` coroutine accessor still pinned +coroutine context on the caller's frame across iterations even with the +snapshot in place, just less of it. + +This build drops the `@Mutex` macro from `RuntimeMessageChannel` entirely +and goes back to the hand-rolled `Mutex` + `withLock` form that shipped +in v2.1.0-beta.1 (which never crashed on this path). `withLock` is a plain +synchronous function — no coroutine, no caller-frame pinning, the stack +unwinds cleanly between iterations. + +The buffered-stream bridge from beta.3 stays in place: callback only does +a non-blocking `yield` and the long-lived consumer task drains +`handleReceivedMessage` off the dispatch-queue stack. + +--- + +## Known Issues + +### Remote-Server background indexing stalls image loading + +When the remote engine acting as the Server side (iOS Simulator, iPad / +iPhone over Bonjour, etc.) has **background indexing** enabled, image +loading on the connecting client stalls — the sidebar shows neither image +contents nor a loading progress indicator. + +`LoadImageForBackgroundIndexingRequest` on the Server side ends up calling +`dlopen` on the peer's own main-executable path. On the iOS Simulator dyld +rewrites that through the simruntime root prefix and the call fails with +`(no such file)`; on any platform `dlopen` of an already-loaded main exec +fails by definition. The resulting `DyldOpenError` is echoed back as a +`RuntimeNetworkRequestError`, but that payload carries no `identifier` +field, so the peer can't decode it as a `RuntimeRequestData` envelope — +its own catch arm echoes yet another error, and the two sides ping-pong +on the shared `sendSemaphore`, starving real traffic (image-list pushes, +indexing progress, on-demand object requests). + +**Workaround**: turn off background indexing on the remote-Server side +until the next build. + +**Status**: two-part fix planned for the next build — guard +`_loadImageForBackgroundIndexing` against already-loaded images on the +indexer side, and harden each connection's envelope-decode-failure arm to +swallow rather than echo, so a single peer-handler failure can never +amplify into a ping-pong again. + +--- + +## Carried Over From beta.3 + +All changes from [v2.1.0-beta.3](v2.1.0-beta.3.md) ship unchanged: + +- MachOSwiftSection 0.12.0-beta.3 bump (opaque-type symbolic-ref fix + + SymbolIndexStore self-cleanup + SharedCache management API). +- Remote-pin refresh across `RuntimeViewerCore` / `RuntimeViewerPackages` + to the current latest tag of each non-pinned dependency. +- ArchiveScript: force-checkout in the detached-HEAD branch so the + appcast PR step no longer aborts on archive-time `Package.resolved` + drift. + +And from [v2.1.0-beta.2](v2.1.0-beta.2.md) the Inspector **Relationships** +tab, **Batch Export**, selection history, and the underlying +communication-layer hardening. diff --git a/Changelogs/v2.1.0-beta.5.md b/Changelogs/v2.1.0-beta.5.md new file mode 100644 index 00000000..1b8d4c2a --- /dev/null +++ b/Changelogs/v2.1.0-beta.5.md @@ -0,0 +1,167 @@ +# v2.1.0-beta.5 + +Fifth public preview of the **2.1** line. Clears the +[v2.1.0-beta.4](v2.1.0-beta.4.md) known-issue on remote-Server background +indexing, ships the new **Always Index** list (re-rolled from the build +that beta.4 had to revert), and reorganizes the **Background Indexing** +settings into a master switch plus two independently toggleable sub-modes. + +This is a `beta` build — only clients that opted in via +**Settings → Updates → Include pre-release versions** receive it. + +--- + +## Bug Fixes + +### Remote-Server background indexing no longer stalls image loading + +beta.4 documented the ping-pong: when the Server side of a remote engine +(iOS Simulator, iPad / iPhone over Bonjour, etc.) had background indexing +on, `LoadImageForBackgroundIndexingRequest` `dlopen`'d the peer's own +main executable and returned `DyldOpenError`. The error payload carried +no `identifier`, the peer envelope-decoded it as `keyNotFound`, and both +sides ping-ponged on the shared `sendSemaphore` — image-list pushes, +indexing progress, and on-demand object requests all starved. + +Two coordinated fixes ship in this build: + +1. **`_loadImageForBackgroundIndexing` short-circuits already-loaded + images.** The BFS routinely visits the peer's own main executable, + which `dlopen` refuses to re-open by definition; on iOS Simulator it + also hits system images whose canonical path differs from dyld's + runtime form due to `DYLD_ROOT_PATH` rewriting. Checking `imageList` + first avoids the spurious `DyldOpenError` at source. + +2. **`canOpenImage` / `rpaths` / `dependencies` now dispatch instead of + reading the client process's dyld directly.** The protocol's "pure + local check" assumption only ever worked on the local engine; remote + engines were silently returning empty dependency lists from the + client-side `DyldUtilities.machOImage` lookup, so BFS got stuck at + the root and batches only ever indexed the main executable instead + of its full dependency closure. (Wire-format addition: + `RuntimeDependencyEntry` replaces the tuple-typed dependencies + return value at the dispatch seam — tuples aren't `Codable`. The + manager-facing API is unchanged.) + +### Communication-layer hardening: per-round-trip nonce + swallow decode failures + +Adjacent to the indexing fix, the request/response channel itself got +two structural fixes that prevent any future peer-handler failure from +amplifying into a ping-pong: + +- **Per-round-trip nonce.** `sendRequest` used to hold `sendSemaphore` + from wait-to-resume, so a slow peer handler (e.g. 20 s of section + parsing during background indexing) monopolized the channel and + blocked every other in-flight send at the local outbox. Each round + trip now stamps a UUID nonce, `pendingRequests` keys by nonce, and + the semaphore releases as soon as the write returns; concurrent + sends wait on responses in parallel and identifier-keyed collisions + on the routing table are impossible. Wire format is backward + compatible — `nonce` is optional and absent values fall back to + identifier-keyed routing. +- **Decode-failure arm swallows silently.** The receive catch arm + previously echoed a bare `RuntimeNetworkRequestError` with no + `identifier` when envelope decode failed, which is what fed the + beta.4 ping-pong. It now splits into two arms: decode failures + swallow silently; handler failures echo via the same nonce-keyed + envelope used by successful responses, so the peer routes the error + back to its matching pending entry instead of re-echoing. + +--- + +## New Features + +### Always Index list + +The user-configurable **Always Index** list shipping in beta.5 is the +re-rolled version of the feature that beta.4 had to back out at archive +time. Each row pins a single image — either a full path (starting with +`/`) or a file-name shorthand matched against the loaded image list by +last-path-component — and the background indexer runs that image +through a batch every time a document opens, the runtime engine +changes, or the list itself changes. + +Toggle **Follow Dependencies** on a row to also walk the image's +dependency closure using the same `depth` as Heuristic Discovery; +otherwise only the image itself is indexed. + +Entries that don't match any loaded image are silently skipped and +re-tried on the next `fullReload` — useful for remote engines whose +`imageList` populates asynchronously after `documentDidOpen`. + +### Background Indexing: master switch + two sub-modes + +The Background Indexing settings have been reorganized from a single +toggle into a master switch plus two independently toggleable sub-modes: + +- **Master switch** (`Enable Background Indexing`) — overall on/off. + When off, neither sub-mode runs. +- **Heuristic Discovery** — main-executable BFS at document open and + engine swap. Configurable `depth` (1...5) lives under this sub-mode. +- **Always Index** — the new list described above. + +`Max Concurrent Tasks` is shared between sub-modes. Each toggle reacts +independently: turning **Heuristic Discovery** off mid-run cancels only +heuristic batches; **Always Index** batches keep running, and vice +versa. Flipping the master switch off cancels everything. + +### Background Indexing popover: grouped by reason + +Each section (ACTIVE / HISTORY) now groups its batches by reason +category — `Heuristic Discovery` / `Always Index` / `Manual` — under a +collapsible header instead of rendering one flat list of batches. +Always-index groups flatten one level (entry → item) so single-image +rows show the user-supplied identifier directly. + +--- + +## Improvements + +### `RuntimeMessageChannel` stack-overflow guards rolled back + +The snapshot-before-loop and `AsyncStream` consumer fixes from +beta.3's `bec9f4b` were a workaround for the `@Mutex` `_modify` +coroutine pinning that already got removed in beta.4 +(`0fdca62 fix(communication): revert @Mutex macro back to manual Mutex`). +With the real trigger gone, the guards were dead weight and the +strictly serial drain was incidentally compounding latency when a peer +echoed handler errors at high rate. Reverted in `aa8ba43`. + +### `RuntimeEngine.imageNodes` exposed as `nonisolated` + +`imageNodes` is now a `nonisolated` getter backed by the underlying +`CurrentValueSubject`; mutations still go through the actor-isolated +`setImageNodes(_:)`. Synchronous call sites no longer need an `await` +hop, which is what gates the **Export Multiple Images** menu item on +`imageNodes.count >= 2` inside `validateMenuItem` (which runs on the +main thread and can't suspend). + +--- + +## Dependencies + +- `UIFoundation` 0.10.0 → 0.10.2 (visual-effect opt-out hook, + safe-area anchoring on macOS 11+). +- `RxAppKit` 0.4.0 → 0.5.0 (drives `rx.itemSelected` off + `selectionDidChangeNotification` so keyboard / type-select + selection events surface alongside clicks — already relied on by + the type-select navigation debounce shipped in beta.4). +- `swift-dependencies` 1.12.0 → 1.13.0 (declares the trait set the + resolved graph enables; required by release-channel resolves under + `USING_LOCAL_DEPENDENCIES=0`). + +--- + +## Carried Over From beta.4 + +All changes from [v2.1.0-beta.4](v2.1.0-beta.4.md) ship unchanged: + +- `@Mutex` macro dropped from `RuntimeMessageChannel`; back to the + hand-rolled `Mutex` + `withLock` form from beta.1. + +And from [v2.1.0-beta.3](v2.1.0-beta.3.md) the MachOSwiftSection +0.12.0-beta.3 bump and the wider remote-pin refresh. + +And from [v2.1.0-beta.2](v2.1.0-beta.2.md) the Inspector +**Relationships** tab, **Batch Export**, selection history, and the +underlying communication-layer hardening. diff --git a/RuntimeViewer-Debug.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RuntimeViewer-Debug.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1be82bf4..cbd0e6db 100644 --- a/RuntimeViewer-Debug.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RuntimeViewer-Debug.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachInjector", "state" : { - "revision" : "9badd94674dbcb9291458421e25a70971733a08f", - "version" : "0.2.0" + "revision" : "138ed60c926868968bdb965ae2709f8dced2ee2d", + "version" : "0.3.0" } }, { @@ -144,15 +144,6 @@ "version" : "1.6.0" } }, - { - "identity" : "nsattributedstringbuilder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/NSAttributedStringBuilder", - "state" : { - "revision" : "d0d35f858a556b7f7f6ad2da7d7a8b469a72fb5c", - "version" : "0.4.2" - } - }, { "identity" : "objectarchivekit", "kind" : "remoteSourceControl", @@ -194,8 +185,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/RunningApplicationKit", "state" : { - "revision" : "5ff991e2b32445cebce2514fbc428594dfa092cd", - "version" : "0.3.2" + "revision" : "8c64a39bbcbe96b7758afd0e2e0a9f3d82f7305a", + "version" : "0.3.3" } }, { @@ -477,6 +468,24 @@ "version" : "0.2.2" } }, + { + "identity" : "swift-helper-service", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/swift-helper-service", + "state" : { + "revision" : "d15e26aa1e96e03a72a913511e5cd19b96c2a3bf", + "version" : "0.1.3" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, { "identity" : "swift-literal-type-inference", "kind" : "remoteSourceControl", @@ -507,19 +516,19 @@ { "identity" : "swift-mobile-gestalt", "kind" : "remoteSourceControl", - "location" : "https://github.com/p-x9/swift-mobile-gestalt", + "location" : "https://github.com/MxIris-Library-Forks/swift-mobile-gestalt", "state" : { - "revision" : "aa9e0a9dde0be80f395a77888851e4afdd6f4252", - "version" : "0.4.0" + "revision" : "c17c533c080e30b9797922861025c4c20b1b7e74", + "version" : "0.5.0" } }, { "identity" : "swift-navigation", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", + "location" : "https://github.com/MxIris-Library-Forks/swift-navigation", "state" : { - "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", - "version" : "2.8.0" + "revision" : "4f27f7cd5cf12caeeeae25e05a23f82d8f0d5813", + "version" : "2.8.100" } }, { @@ -599,8 +608,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", "state" : { - "revision" : "d56672e7939ae929e94d4ea66da3fc527a69724e", - "version" : "0.5.100" + "revision" : "456b212834f2032312af1ea5f98d214cbaca5a2c", + "version" : "0.5.102" } }, { diff --git a/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved index 670b40c8..2daa21a2 100644 --- a/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,15 +27,6 @@ "version" : "3.2.0" } }, - { - "identity" : "cocoacoordinator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/CocoaCoordinator", - "state" : { - "revision" : "8756b37cfc91918a6d92ba92125dd6f45e5a8aae", - "version" : "0.4.1" - } - }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", @@ -68,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "4ab712e98990bb3a44a5ef160bd0b0c3f03ace08", - "version" : "0.5.5" + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" } }, { @@ -86,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ukushu/Ifrit", "state" : { - "revision" : "9b9556e14cee24ad16b19d0eb099283cf79a7d94", - "version" : "3.0.0" + "revision" : "7c889a67bad90c5efefa56889b2d61bfbb831473", + "version" : "4.0.0" } }, { @@ -104,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", - "version" : "8.8.1" + "revision" : "cf8be20d07654570554c8a8a4952bc8a5766a8b0", + "version" : "8.9.0" } }, { @@ -131,35 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachInjector", "state" : { - "revision" : "9badd94674dbcb9291458421e25a70971733a08f", - "version" : "0.2.0" - } - }, - { - "identity" : "machokit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit.git", - "state" : { - "revision" : "d6d8fc4fe355c31a25eeda4b87d9e8a959d6784a", - "version" : "0.49.102" - } - }, - { - "identity" : "machoobjcsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", - "state" : { - "revision" : "38661a32c597281ece378fd8edde3dc6bdb39a7a", - "version" : "0.7.100" - } - }, - { - "identity" : "machoswiftsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - "state" : { - "revision" : "8b34efb02340298e3a2cee69541f99a8e701a719", - "version" : "0.12.0-beta.1" + "revision" : "138ed60c926868968bdb965ae2709f8dced2ee2d", + "version" : "0.3.0" } }, { @@ -171,15 +135,6 @@ "version" : "1.6.0" } }, - { - "identity" : "nsattributedstringbuilder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/NSAttributedStringBuilder", - "state" : { - "revision" : "d0d35f858a556b7f7f6ad2da7d7a8b469a72fb5c", - "version" : "0.4.2" - } - }, { "identity" : "objectarchivekit", "kind" : "remoteSourceControl", @@ -195,7 +150,7 @@ "location" : "https://github.com/OpenUXKit/OpenUXKit", "state" : { "branch" : "main", - "revision" : "a8d89523afd0aa9e06e81ee9b7748eb288ff2630" + "revision" : "21b944e638ff66d45ba1f550483c875be3c1f93f" } }, { @@ -210,30 +165,12 @@ { "identity" : "rearrange", "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/Rearrange.git", + "location" : "https://github.com/ChimeHQ/Rearrange", "state" : { "revision" : "4de8be41dba304192e87dc0a11e0aa39e72aa2e8", "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", @@ -270,31 +207,13 @@ "version" : "6.10.2" } }, - { - "identity" : "rxswiftplus", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/RxSwiftPlus", - "state" : { - "revision" : "4f3ab85a5ce982d430265004e588e4c7da748d06", - "version" : "0.2.2" - } - }, - { - "identity" : "rxuikit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/RxUIKit", - "state" : { - "revision" : "f4397315d83edf4f9390dbe96e212c892577c7eb", - "version" : "0.1.1" - } - }, { "identity" : "semaphore", "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Library-Forks/Semaphore", "state" : { - "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", - "version" : "0.1.0" + "revision" : "e6a244dec033ed1033878d5da5911a3ba2489701", + "version" : "0.1.1" } }, { @@ -302,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/SFSymbols", "state" : { - "revision" : "98c7f4a22419d8034a058a8bfaec7cba4e53dcca", - "version" : "0.2.0" + "revision" : "373b919278adc197759ebcc2018956cacff1cc57", + "version" : "0.3.0" } }, { @@ -311,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SnapKit/SnapKit", "state" : { - "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", - "version" : "5.7.1" + "revision" : "e27a338a03a5f388de759da63f9baf7988ed9e00", + "version" : "6.0.0" } }, { @@ -329,8 +248,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", - "version" : "2.9.1" + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" } }, { @@ -338,17 +257,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-apinotes", "state" : { - "revision" : "2fe208c1824f053c04778c5dad2a6fe93d8ab3bf", - "version" : "0.1.0" + "revision" : "e762ac739f71adf83ed2e05f40211c96cd91f6c5", + "version" : "0.2.0" } }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", + "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" + "revision" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", + "version" : "1.8.1" } }, { @@ -365,8 +284,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { - "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", - "version" : "1.1.3" + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" } }, { @@ -410,8 +329,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-clang", "state" : { - "revision" : "f92a834ed33249612d9ef30a41d8254d32a7602a", - "version" : "0.2.0" + "revision" : "89195b5191f6678da7e782cec24f2cbc8d75cf79", + "version" : "0.3.0" } }, { @@ -428,8 +347,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", - "version" : "1.4.1" + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" } }, { @@ -437,8 +356,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" } }, { @@ -464,17 +383,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-demangling", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", - "state" : { - "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", - "version" : "0.4.0" + "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", + "version" : "1.6.0" } }, { @@ -482,8 +392,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", - "version" : "1.12.0" + "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", + "version" : "1.13.0" } }, { @@ -491,8 +401,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/swift-dyld-private", "state" : { - "revision" : "2f5ce94df9b1356d3c75793a659bf22f35bc8699", - "version" : "1.2.0" + "revision" : "e9b255ed123e0ff5bb998d11639b993196159214", + "version" : "1.2.1" } }, { @@ -513,6 +423,15 @@ "version" : "0.2.2" } }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, { "identity" : "swift-literal-type-inference", "kind" : "remoteSourceControl", @@ -527,8 +446,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" + "revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", + "version" : "1.13.1" } }, { @@ -543,19 +462,19 @@ { "identity" : "swift-mobile-gestalt", "kind" : "remoteSourceControl", - "location" : "https://github.com/p-x9/swift-mobile-gestalt", + "location" : "https://github.com/MxIris-Library-Forks/swift-mobile-gestalt", "state" : { - "revision" : "aa9e0a9dde0be80f395a77888851e4afdd6f4252", - "version" : "0.4.0" + "revision" : "c17c533c080e30b9797922861025c4c20b1b7e74", + "version" : "0.5.0" } }, { "identity" : "swift-navigation", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", + "location" : "https://github.com/MxIris-Library-Forks/swift-navigation", "state" : { - "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", - "version" : "2.8.0" + "revision" : "4f27f7cd5cf12caeeeae25e05a23f82d8f0d5813", + "version" : "2.8.100" } }, { @@ -563,17 +482,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", - "version" : "2.99.0" - } - }, - { - "identity" : "swift-objc-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump.git", - "state" : { - "revision" : "4206040acd64db453c5c28c1539b98fa5befa8fc", - "version" : "0.8.100" + "revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", + "version" : "2.100.0" } }, { @@ -595,21 +505,21 @@ } }, { - "identity" : "swift-semantic-string", + "identity" : "swift-system", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", + "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", - "version" : "0.1.1" + "revision" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985", + "version" : "1.6.5" } }, { - "identity" : "swift-system", + "identity" : "swiftcross", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", + "location" : "https://github.com/Cocoanetics/SwiftCross.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "cc164b31ede7d2ee72af713b59c5d1f9be1556e0", + "version" : "1.2.0" } }, { @@ -617,8 +527,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Cocoanetics/SwiftMCP", "state" : { - "revision" : "c96b2412b16fca49156d6914b18568dc07fe978e", - "version" : "1.4.4" + "revision" : "df3ef01ab09bcdf775f4669be242065e05d61feb", + "version" : "1.5.1" } }, { @@ -635,26 +545,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", "state" : { - "revision" : "d56672e7939ae929e94d4ea66da3fc527a69724e", - "version" : "0.5.100" - } - }, - { - "identity" : "systemhud", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/SystemHUD", - "state" : { - "revision" : "42d259b5d2b3d5cb4ce14281ce86f010034ff36c", - "version" : "0.1.0" - } - }, - { - "identity" : "uifoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/UIFoundation", - "state" : { - "revision" : "40f1b90194e66e8613397f5ce1d601afc230dfd0", - "version" : "0.8.2" + "revision" : "456b212834f2032312af1ea5f98d214cbaca5a2c", + "version" : "0.5.102" } }, { @@ -663,7 +555,7 @@ "location" : "https://github.com/OpenUXKit/UXKitCoordinator", "state" : { "branch" : "main", - "revision" : "6e103a7628d8c8108a3b5d6dabafb61ee6fffb09" + "revision" : "481806584ed23911fbe0449fdda57085f8615779" } }, { @@ -675,15 +567,6 @@ "version" : "2.2.1" } }, - { - "identity" : "xcoordinator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/XCoordinator", - "state" : { - "revision" : "1d35f8bf10b9cf0ab49736493d2d8b6c27fc63c0", - "version" : "3.0.0-beta.1" - } - }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", @@ -698,8 +581,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", - "version" : "6.2.1" + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" } } ], diff --git a/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved index 726c1910..cdc9e4a4 100644 --- a/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -54,24 +54,6 @@ "version" : "1.3.0" } }, - { - "identity" : "dsfappearancemanager", - "kind" : "remoteSourceControl", - "location" : "https://github.com/dagronf/DSFAppearanceManager", - "state" : { - "revision" : "e9add5fdf05fe50950d105c77963b7373ddb5ad4", - "version" : "3.5.1" - } - }, - { - "identity" : "dsfquickactionbar", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-macOS-Library-Forks/DSFQuickActionBar", - "state" : { - "revision" : "ae07ffcd50bedd418b46d096f4785f7c3bc96e3e", - "version" : "6.2.102" - } - }, { "identity" : "enumkit", "kind" : "remoteSourceControl", @@ -81,22 +63,13 @@ "version" : "1.1.3" } }, - { - "identity" : "filter-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-macOS-Library-Forks/filter-ui", - "state" : { - "revision" : "0d7d6d0fe5e478d7a70f6b1b014998b96e2b171e", - "version" : "0.1.2" - } - }, { "identity" : "frameworktoolbox", "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "d011291f5e8d6430fb91b52296dda50e85dc5c11", - "version" : "0.5.2" + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" } }, { @@ -108,15 +81,6 @@ "version" : "0.1.0" } }, - { - "identity" : "ide-icons", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/ide-icons", - "state" : { - "revision" : "d262688aecaf1cdab961b2a504566ae5f71c29d1", - "version" : "0.1.3" - } - }, { "identity" : "ifrit", "kind" : "remoteSourceControl", @@ -140,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", - "version" : "8.8.1" + "revision" : "cf8be20d07654570554c8a8a4952bc8a5766a8b0", + "version" : "8.9.0" } }, { @@ -167,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachInjector", "state" : { - "revision" : "9badd94674dbcb9291458421e25a70971733a08f", - "version" : "0.2.0" + "revision" : "138ed60c926868968bdb965ae2709f8dced2ee2d", + "version" : "0.3.0" } }, { @@ -180,15 +144,6 @@ "version" : "1.6.0" } }, - { - "identity" : "nsattributedstringbuilder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Library-Forks/NSAttributedStringBuilder", - "state" : { - "revision" : "d0d35f858a556b7f7f6ad2da7d7a8b469a72fb5c", - "version" : "0.4.2" - } - }, { "identity" : "objectarchivekit", "kind" : "remoteSourceControl", @@ -204,7 +159,7 @@ "location" : "https://github.com/OpenUXKit/OpenUXKit", "state" : { "branch" : "main", - "revision" : "a8d89523afd0aa9e06e81ee9b7748eb288ff2630" + "revision" : "21b944e638ff66d45ba1f550483c875be3c1f93f" } }, { @@ -219,7 +174,7 @@ { "identity" : "rearrange", "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/Rearrange.git", + "location" : "https://github.com/ChimeHQ/Rearrange", "state" : { "revision" : "4de8be41dba304192e87dc0a11e0aa39e72aa2e8", "version" : "2.1.1" @@ -230,8 +185,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/RunningApplicationKit", "state" : { - "revision" : "5ff991e2b32445cebce2514fbc428594dfa092cd", - "version" : "0.3.2" + "revision" : "8c64a39bbcbe96b7758afd0e2e0a9f3d82f7305a", + "version" : "0.3.3" } }, { @@ -239,8 +194,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/RxAppKit", "state" : { - "revision" : "e4ea5c272acdc4f1c220bcc0a44d79cebf1ed98b", - "version" : "0.3.1" + "revision" : "8194bca7c33b10f40d63894cf827890850390f04", + "version" : "0.4.0" } }, { @@ -284,8 +239,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/RxSwiftPlus", "state" : { - "revision" : "4f3ab85a5ce982d430265004e588e4c7da748d06", - "version" : "0.2.2" + "revision" : "d40a7d551b58ce3006eaabcaa3721b99db72ea78", + "version" : "0.2.3" } }, { @@ -302,8 +257,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Library-Forks/Semaphore", "state" : { - "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", - "version" : "0.1.0" + "revision" : "e6a244dec033ed1033878d5da5911a3ba2489701", + "version" : "0.1.1" } }, { @@ -311,8 +266,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/SFSymbols", "state" : { - "revision" : "98c7f4a22419d8034a058a8bfaec7cba4e53dcca", - "version" : "0.2.0" + "revision" : "373b919278adc197759ebcc2018956cacff1cc57", + "version" : "0.3.0" } }, { @@ -338,8 +293,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", - "version" : "2.9.1" + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" } }, { @@ -347,8 +302,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-apinotes", "state" : { - "revision" : "2fe208c1824f053c04778c5dad2a6fe93d8ab3bf", - "version" : "0.1.0" + "revision" : "e762ac739f71adf83ed2e05f40211c96cd91f6c5", + "version" : "0.2.0" } }, { @@ -356,8 +311,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" + "revision" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", + "version" : "1.8.1" } }, { @@ -374,8 +329,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { - "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", - "version" : "1.1.3" + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" } }, { @@ -419,8 +374,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-clang", "state" : { - "revision" : "f92a834ed33249612d9ef30a41d8254d32a7602a", - "version" : "0.2.0" + "revision" : "89195b5191f6678da7e782cec24f2cbc8d75cf79", + "version" : "0.3.0" } }, { @@ -437,8 +392,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", - "version" : "1.4.1" + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" } }, { @@ -446,8 +401,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" } }, { @@ -473,8 +428,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", - "version" : "1.5.0" + "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", + "version" : "1.6.0" } }, { @@ -491,8 +446,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/swift-dyld-private", "state" : { - "revision" : "2f5ce94df9b1356d3c75793a659bf22f35bc8699", - "version" : "1.2.0" + "revision" : "e9b255ed123e0ff5bb998d11639b993196159214", + "version" : "1.2.1" } }, { @@ -513,6 +468,15 @@ "version" : "0.2.2" } }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, { "identity" : "swift-literal-type-inference", "kind" : "remoteSourceControl", @@ -527,35 +491,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" + "revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", + "version" : "1.13.1" } }, { "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" } }, { "identity" : "swift-mobile-gestalt", "kind" : "remoteSourceControl", - "location" : "https://github.com/p-x9/swift-mobile-gestalt", + "location" : "https://github.com/MxIris-Library-Forks/swift-mobile-gestalt", "state" : { - "revision" : "aa9e0a9dde0be80f395a77888851e4afdd6f4252", - "version" : "0.4.0" + "revision" : "c17c533c080e30b9797922861025c4c20b1b7e74", + "version" : "0.5.0" } }, { "identity" : "swift-navigation", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", + "location" : "https://github.com/MxIris-Library-Forks/swift-navigation", "state" : { - "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", - "version" : "2.8.0" + "revision" : "4f27f7cd5cf12caeeeae25e05a23f82d8f0d5813", + "version" : "2.8.100" } }, { @@ -563,17 +527,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", - "version" : "2.99.0" - } - }, - { - "identity" : "swift-objc-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump.git", - "state" : { - "revision" : "4206040acd64db453c5c28c1539b98fa5befa8fc", - "version" : "0.8.100" + "revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", + "version" : "2.100.0" } }, { @@ -617,8 +572,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Cocoanetics/SwiftMCP", "state" : { - "revision" : "c96b2412b16fca49156d6914b18568dc07fe978e", - "version" : "1.4.4" + "revision" : "5e8961f73834abb6f05fede365831a019a68be91", + "version" : "1.4.7" } }, { @@ -635,8 +590,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", "state" : { - "revision" : "d56672e7939ae929e94d4ea66da3fc527a69724e", - "version" : "0.5.100" + "revision" : "456b212834f2032312af1ea5f98d214cbaca5a2c", + "version" : "0.5.102" } }, { @@ -653,8 +608,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/UIFoundation", "state" : { - "revision" : "d7c490fd668e26ccf82e1776ce77c32e4ca8f3e3", - "version" : "0.5.1" + "revision" : "d821c32ff9efa9aa13506c12457ea0d430110a20", + "version" : "0.10.0" } }, { @@ -663,7 +618,7 @@ "location" : "https://github.com/OpenUXKit/UXKitCoordinator", "state" : { "branch" : "main", - "revision" : "6e103a7628d8c8108a3b5d6dabafb61ee6fffb09" + "revision" : "481806584ed23911fbe0449fdda57085f8615779" } }, { @@ -698,8 +653,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", - "version" : "6.2.1" + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" } } ], diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index d6750096..5d3f389d 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ba6c41a4750f97271f03e2502227aa324dbbbbebf505309fc479b44d05101a4e", + "originHash" : "52454f2d1779089b9c6ae08b6c6b64023e8264f9809a2ba047215a945805be4f", "pins" : [ { "identity" : "associatedobject", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "4ab712e98990bb3a44a5ef160bd0b0c3f03ace08", - "version" : "0.5.5" + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Library-Forks/Semaphore", "state" : { - "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", - "version" : "0.1.0" + "revision" : "e6a244dec033ed1033878d5da5911a3ba2489701", + "version" : "0.1.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-apinotes", "state" : { - "revision" : "2fe208c1824f053c04778c5dad2a6fe93d8ab3bf", - "version" : "0.1.0" + "revision" : "e762ac739f71adf83ed2e05f40211c96cd91f6c5", + "version" : "0.2.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "5e0406d68a4937f21ae5f670b8f89dad1d156a1c", - "version" : "1.8.0" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-clang", "state" : { - "revision" : "f92a834ed33249612d9ef30a41d8254d32a7602a", - "version" : "0.2.0" + "revision" : "89195b5191f6678da7e782cec24f2cbc8d75cf79", + "version" : "0.3.0" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" } }, { @@ -208,13 +208,22 @@ "version" : "3.15.1" } }, + { + "identity" : "swift-demangling", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", + "state" : { + "revision" : "f165282b354bd2fdeeb3b48a54cf173b25a3bb7b", + "version" : "0.4.1" + } + }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", - "version" : "1.12.0" + "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", + "version" : "1.13.0" } }, { @@ -222,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/swift-dyld-private", "state" : { - "revision" : "2f5ce94df9b1356d3c75793a659bf22f35bc8699", - "version" : "1.2.0" + "revision" : "e9b255ed123e0ff5bb998d11639b993196159214", + "version" : "1.2.1" } }, { @@ -265,28 +274,28 @@ { "identity" : "swift-mobile-gestalt", "kind" : "remoteSourceControl", - "location" : "https://github.com/p-x9/swift-mobile-gestalt", + "location" : "https://github.com/MxIris-Library-Forks/swift-mobile-gestalt", "state" : { - "revision" : "aa9e0a9dde0be80f395a77888851e4afdd6f4252", - "version" : "0.4.0" + "revision" : "c17c533c080e30b9797922861025c4c20b1b7e74", + "version" : "0.5.0" } }, { - "identity" : "swift-objc-dump", + "identity" : "swift-object-association", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump.git", + "location" : "https://github.com/p-x9/swift-object-association.git", "state" : { - "revision" : "4206040acd64db453c5c28c1539b98fa5befa8fc", - "version" : "0.8.100" + "revision" : "93806cfecae1f198c894ed5585b93aff0e2d1f5a", + "version" : "0.5.0" } }, { - "identity" : "swift-object-association", + "identity" : "swift-semantic-string", "kind" : "remoteSourceControl", - "location" : "https://github.com/p-x9/swift-object-association.git", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", "state" : { - "revision" : "93806cfecae1f198c894ed5585b93aff0e2d1f5a", - "version" : "0.5.0" + "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", + "version" : "0.1.1" } }, { @@ -330,8 +339,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", - "version" : "6.2.1" + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" } } ], diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 0b20160e..4126bb0b 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -43,9 +43,11 @@ func envEnable(_ key: String, default defaultValue: Bool = false) -> Bool { } } +let usingLocalDependencies = envEnable("USING_LOCAL_DEPENDENCIES") + extension Package.Dependency { enum LocalSearchPath { - case package(path: String, isRelative: Bool, isEnabled: Bool) + case package(path: String, isRelative: Bool, isEnabled: Bool = usingLocalDependencies, traits: Set = [.defaults]) } static func package(local localSearchPaths: LocalSearchPath..., remote: Package.Dependency) -> Package.Dependency { @@ -59,7 +61,7 @@ extension Package.Dependency { } for local in localSearchPaths { switch local { - case .package(let path, let isRelative, let isEnabled): + case .package(let path, let isRelative, let isEnabled, let traits): guard isEnabled else { continue } let url = if isRelative { URL(fileURLWithPath: path, relativeTo: URL(fileURLWithPath: #filePath)) @@ -68,7 +70,7 @@ extension Package.Dependency { } if FileManager.default.fileExists(atPath: url.path) { - return .package(path: url.path) + return .package(path: url.path, traits: traits) } } } @@ -80,8 +82,6 @@ let appkitPlatforms: [Platform] = [.macOS] let uikitPlatforms: [Platform] = [.iOS, .tvOS, .visionOS, .macCatalyst, .watchOS] -let usingLocalDependencies = envEnable("USING_LOCAL_DEPENDENCIES") - let package = Package( name: "RuntimeViewerCore", platforms: [ @@ -106,7 +106,6 @@ let package = Package( local: .package( path: "../../MachOKit", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOKit", @@ -117,54 +116,50 @@ let package = Package( local: .package( path: "../../MachOObjCSection", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", - from: "0.6.101", + from: "0.7.103", ), ), .package( local: .package( path: "../../MachOSwiftSection", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - exact: "0.12.0-beta.1", + exact: "0.12.0-beta.3", ), ), .package( url: "https://github.com/MxIris-Library-Forks/Asynchrone", - from: "0.23.0-fork", + from: "0.23.0-fork.1", ), .package( url: "https://github.com/MxIris-Library-Forks/Semaphore", - from: "0.1.0", + from: "0.1.1", ), .package( url: "https://github.com/apple/swift-collections", - from: "1.1.0", + from: "1.5.1", ), .package( url: "https://github.com/Mx-Iris/FrameworkToolbox", - from: "0.5.5", + from: "0.7.1", ), .package( local: .package( path: "../../../../Personal/Library/macOS/swift-helper-service", isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../swift-helper-service", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/swift-helper-service", - from: "0.1.2", + from: "0.1.3", ), ), .package( @@ -173,7 +168,7 @@ let package = Package( ), .package( url: "https://github.com/mxcl/Version", - from: "2.2.0", + from: "2.2.1", ), .package( url: "https://github.com/SwiftyLab/MetaCodable", diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeDirectTCPConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeDirectTCPConnection.swift index c80c63a4..6014f551 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeDirectTCPConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeDirectTCPConnection.swift @@ -215,36 +215,61 @@ final class RuntimeDirectTCPConnection: RuntimeUnderlyingConnection, @unchecked do { guard let stream = messageChannel.receivedMessages() else { return } for try await data in stream { - do { - let requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) + await dispatchReceivedMessage(data) + } + } catch { + #log(.error, "Message observation error: \(error, privacy: .public)") + } + } + } - // Check if this is a response to a pending request - if messageChannel.deliverToPendingRequest(identifier: requestData.identifier, data: data) { - continue - } + /// Dispatch one received envelope. Splits the failure surface into two + /// arms so a peer's bare error never feeds back into our own bare error, + /// which would otherwise ping-pong on the shared `sendSemaphore` and + /// starve real traffic. See Changelogs/v2.1.0-beta.4.md. + private func dispatchReceivedMessage(_ data: Data) async { + let requestData: RuntimeRequestData + do { + requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) + } catch { + // Envelope decode failure → no identifier, no safe way to + // route an error response. Swallow rather than echo, otherwise + // both peers loop on each other's malformed errors. + #log(.error, "Envelope decode failed, swallowing to avoid ping-pong: \(error, privacy: .public)") + return + } - guard let handler = messageChannel.handler(for: requestData.identifier) else { - #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") - continue - } + // Route by nonce when present (new wire form), fall back to + // identifier for legacy peers. + let routingKey = requestData.nonce ?? requestData.identifier + if messageChannel.deliverToPendingRequest(routingKey: routingKey, data: data) { + return + } - #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") - let responseData = try await handler.closure(requestData.data) + guard let handler = messageChannel.handler(for: requestData.identifier) else { + #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") + return + } - if handler.responseType != RuntimeMessageNull.self { - let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData) - try await send(requestData: response) - } - } catch { - #log(.error, "Handler error: \(error, privacy: .public)") - let errorResponse = RuntimeNetworkRequestError(message: "\(error)") - if let errorData = try? JSONEncoder().encode(errorResponse) { - try? await sendRaw(data: errorData + RuntimeMessageChannel.endMarkerData) - } - } - } - } catch { - #log(.error, "Message observation error: \(error, privacy: .public)") + #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") + do { + let responseData = try await handler.closure(requestData.data) + if handler.responseType != RuntimeMessageNull.self { + // Echo the request's nonce so concurrent same-`identifier` + // round trips route their responses without collision. + let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData, nonce: requestData.nonce) + try await send(requestData: response) + } + } catch { + // Handler-execution failure → wrap in + // `RuntimeNetworkRequestError` and emit via the same + // nonce-stamped envelope used by successful responses so + // the peer's `sendRequest` finds the matching pending entry. + #log(.error, "Handler \(requestData.identifier, privacy: .public) failed: \(error, privacy: .public)") + let errorPayload = RuntimeNetworkRequestError(message: "\(error)") + if let errorData = try? JSONEncoder().encode(errorPayload) { + let errorEnvelope = RuntimeRequestData(identifier: requestData.identifier, data: errorData, nonce: requestData.nonce) + try? await send(requestData: errorEnvelope) } } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeLocalSocketConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeLocalSocketConnection.swift index 6d8e70d6..02c6bb6a 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeLocalSocketConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeLocalSocketConnection.swift @@ -249,36 +249,61 @@ final class RuntimeLocalSocketConnection: RuntimeUnderlyingConnection, @unchecke do { guard let stream = messageChannel.receivedMessages() else { return } for try await data in stream { - do { - let requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) + await dispatchReceivedMessage(data) + } + } catch { + #log(.error, "Message observation error: \(error, privacy: .public)") + } + } + } - // Check if this is a response to a pending request - if messageChannel.deliverToPendingRequest(identifier: requestData.identifier, data: data) { - continue - } + /// Dispatch one received envelope. Splits the failure surface into two + /// arms so a peer's bare error never feeds back into our own bare error, + /// which would otherwise ping-pong on the shared `sendSemaphore` and + /// starve real traffic. See Changelogs/v2.1.0-beta.4.md. + private func dispatchReceivedMessage(_ data: Data) async { + let requestData: RuntimeRequestData + do { + requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) + } catch { + // Envelope decode failure → no identifier, no safe way to + // route an error response. Swallow rather than echo, otherwise + // both peers loop on each other's malformed errors. + #log(.error, "Envelope decode failed, swallowing to avoid ping-pong: \(error, privacy: .public)") + return + } - guard let handler = messageChannel.handler(for: requestData.identifier) else { - #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") - continue - } + // Route by nonce when present (new wire form), fall back to + // identifier for legacy peers. + let routingKey = requestData.nonce ?? requestData.identifier + if messageChannel.deliverToPendingRequest(routingKey: routingKey, data: data) { + return + } - #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") - let responseData = try await handler.closure(requestData.data) + guard let handler = messageChannel.handler(for: requestData.identifier) else { + #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") + return + } - if handler.responseType != RuntimeMessageNull.self { - let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData) - try await send(requestData: response) - } - } catch { - #log(.error, "Handler error: \(error, privacy: .public)") - let errorResponse = RuntimeNetworkRequestError(message: "\(error)") - if let errorData = try? JSONEncoder().encode(errorResponse) { - try? await sendRaw(data: errorData + RuntimeMessageChannel.endMarkerData) - } - } - } - } catch { - #log(.error, "Message observation error: \(error, privacy: .public)") + #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") + do { + let responseData = try await handler.closure(requestData.data) + if handler.responseType != RuntimeMessageNull.self { + // Echo the request's nonce so concurrent same-`identifier` + // round trips route their responses without collision. + let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData, nonce: requestData.nonce) + try await send(requestData: response) + } + } catch { + // Handler-execution failure → wrap in + // `RuntimeNetworkRequestError` and emit via the same + // nonce-stamped envelope used by successful responses so + // the peer's `sendRequest` finds the matching pending entry. + #log(.error, "Handler \(requestData.identifier, privacy: .public) failed: \(error, privacy: .public)") + let errorPayload = RuntimeNetworkRequestError(message: "\(error)") + if let errorData = try? JSONEncoder().encode(errorPayload) { + let errorEnvelope = RuntimeRequestData(identifier: requestData.identifier, data: errorData, nonce: requestData.nonce) + try? await send(requestData: errorEnvelope) } } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift index 8c010fc2..15a5909b 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift @@ -217,31 +217,63 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se } private func handleReceivedMessage(_ data: Data) async { + let requestData: RuntimeRequestData do { - let requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) + requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) + } catch { + // Envelope decode failure: the payload carries no `identifier`, + // so any error response we emit cannot be routed back to a + // pending `sendRequest` on the peer. Echoing a bare + // `RuntimeNetworkRequestError` (the pre-fix behavior) causes + // the peer to also fail envelope decode and echo its own bare + // error, producing an unbounded ping-pong that starves real + // traffic on the shared `sendSemaphore`. Swallow silently — + // `sendRequest` carries a configurable timeout so a peer that + // drops a single bad message will not hang indefinitely. + // See Changelogs/v2.1.0-beta.4.md for the full failure mode. + #log(.error, "Envelope decode failed, swallowing to avoid ping-pong: \(error, privacy: .public)") + return + } - // Check if this is a response to a pending request - if messageChannel.deliverToPendingRequest(identifier: requestData.identifier, data: data) { - return - } + // Check if this is a response to a pending request. Route by + // `nonce` when the envelope carries one (the new wire form); + // legacy envelopes without a nonce fall back to `identifier`, + // matching the pre-nonce single-in-flight model. + let routingKey = requestData.nonce ?? requestData.identifier + if messageChannel.deliverToPendingRequest(routingKey: routingKey, data: data) { + return + } - guard let handler = messageChannel.handler(for: requestData.identifier) else { - #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") - return - } + guard let handler = messageChannel.handler(for: requestData.identifier) else { + #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") + return + } - #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") + #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") + do { let responseData = try await handler.closure(requestData.data) - if handler.responseType != RuntimeMessageNull.self { - let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData) + // Echo the request's nonce so the peer's `sendRequest` can + // route this response back to the correct pending entry + // even when multiple round trips share the same command + // name (e.g. concurrent `isImageLoaded` lookups). + let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData, nonce: requestData.nonce) try await send(requestData: response) } } catch { - #log(.error, "Handler error: \(error, privacy: .public)") - let errorResponse = RuntimeNetworkRequestError(message: "\(error)") - if let errorData = try? JSONEncoder().encode(errorResponse) { - try? await sendRaw(data: errorData + RuntimeMessageChannel.endMarkerData) + // Handler execution failure: wrap in `RuntimeNetworkRequestError` + // and emit via the same nonce-stamped envelope used by + // successful responses. The peer's `deliverToPendingRequest` + // routes it back to the matching `sendRequest`, which surfaces + // the failure (envelope.data fails to decode as the expected + // `Response` → DecodingError). Nonce scoping is what prevents + // the envelope-decode → bare-echo loop the pre-fix arm + // produced on every handler throw. + #log(.error, "Handler \(requestData.identifier, privacy: .public) failed: \(error, privacy: .public)") + let errorPayload = RuntimeNetworkRequestError(message: "\(error)") + if let errorData = try? JSONEncoder().encode(errorPayload) { + let errorEnvelope = RuntimeRequestData(identifier: requestData.identifier, data: errorData, nonce: requestData.nonce) + try? await send(requestData: errorEnvelope) } } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeStdioConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeStdioConnection.swift index 64cc5993..eaa7e87c 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeStdioConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeStdioConnection.swift @@ -225,33 +225,7 @@ final class RuntimeStdioConnection: RuntimeUnderlyingConnection, @unchecked Send do { guard let stream = messageChannel.receivedMessages() else { return } for try await data in stream { - do { - let requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) - - // Check if this is a response to a pending request - if messageChannel.deliverToPendingRequest(identifier: requestData.identifier, data: data) { - continue - } - - guard let handler = messageChannel.handler(for: requestData.identifier) else { - #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") - continue - } - - #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") - let responseData = try await handler.closure(requestData.data) - - if handler.responseType != RuntimeMessageNull.self { - let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData) - try await send(requestData: response) - } - } catch { - #log(.error, "Handler error: \(error, privacy: .public)") - let errorResponse = RuntimeNetworkRequestError(message: "\(error)") - if let errorData = try? JSONEncoder().encode(errorResponse) { - try? await sendRaw(data: errorData + RuntimeMessageChannel.endMarkerData) - } - } + await dispatchReceivedMessage(data) } } catch { #log(.error, "Message observation error: \(error, privacy: .public)") @@ -259,6 +233,57 @@ final class RuntimeStdioConnection: RuntimeUnderlyingConnection, @unchecked Send } } + /// Dispatch one received envelope. Splits the failure surface into two + /// arms so a peer's bare error never feeds back into our own bare error, + /// which would otherwise ping-pong on the shared `sendSemaphore` and + /// starve real traffic. See Changelogs/v2.1.0-beta.4.md. + private func dispatchReceivedMessage(_ data: Data) async { + let requestData: RuntimeRequestData + do { + requestData = try JSONDecoder().decode(RuntimeRequestData.self, from: data) + } catch { + // Envelope decode failure → no identifier, no safe way to + // route an error response. Swallow rather than echo, otherwise + // both peers loop on each other's malformed errors. + #log(.error, "Envelope decode failed, swallowing to avoid ping-pong: \(error, privacy: .public)") + return + } + + // Route by nonce when present (new wire form), fall back to + // identifier for legacy peers. + let routingKey = requestData.nonce ?? requestData.identifier + if messageChannel.deliverToPendingRequest(routingKey: routingKey, data: data) { + return + } + + guard let handler = messageChannel.handler(for: requestData.identifier) else { + #log(.default, "No handler for: \(requestData.identifier, privacy: .public)") + return + } + + #log(.debug, "Handling request: \(requestData.identifier, privacy: .public)") + do { + let responseData = try await handler.closure(requestData.data) + if handler.responseType != RuntimeMessageNull.self { + // Echo the request's nonce so concurrent same-`identifier` + // round trips route their responses without collision. + let response = RuntimeRequestData(identifier: requestData.identifier, data: responseData, nonce: requestData.nonce) + try await send(requestData: response) + } + } catch { + // Handler-execution failure → wrap in + // `RuntimeNetworkRequestError` and emit via the same + // nonce-stamped envelope used by successful responses so + // the peer's `sendRequest` finds the matching pending entry. + #log(.error, "Handler \(requestData.identifier, privacy: .public) failed: \(error, privacy: .public)") + let errorPayload = RuntimeNetworkRequestError(message: "\(error)") + if let errorData = try? JSONEncoder().encode(errorPayload) { + let errorEnvelope = RuntimeRequestData(identifier: requestData.identifier, data: errorData, nonce: requestData.nonce) + try? await send(requestData: errorEnvelope) + } + } + } + // MARK: - RuntimeUnderlyingConnection func send(requestData: RuntimeRequestData) async throws { diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeXPCConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeXPCConnection.swift index e10dce8d..a7961902 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeXPCConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeXPCConnection.swift @@ -5,16 +5,8 @@ import FoundationToolbox import Combine public import HelperCommunication import HelperPeer - -// MARK: - XPCListenerEndpointProviding - -/// Protocol for connections that expose their XPC listener endpoint. -/// -/// Used by `RuntimeEngine` to retrieve the server's listener endpoint -/// for registration with the Mach Service injected endpoint registry. -public protocol XPCListenerEndpointProviding: AnyObject { - var xpcListenerEndpoint: HelperPeerEndpoint { get } -} +import HelperClient +import InjectedEndpointRegistryServiceInterface // MARK: - RuntimeXPCConnection @@ -33,8 +25,8 @@ public protocol XPCListenerEndpointProviding: AnyObject { /// - This adapter bridges the peer's `AsyncStream` to a /// Combine `CurrentValueSubject` for /// compatibility with the rest of `RuntimeEngine`. -/// - `XPCListenerEndpointProviding.xpcListenerEndpoint` is cached at init time -/// so callers retain synchronous access (matches the previous semantics). +/// - The peer's listener endpoint is cached at init time so server-side +/// self-registration (see `RuntimeXPCServerConnection`) is synchronous. /// /// ## Use Cases /// @@ -152,10 +144,6 @@ class RuntimeXPCConnection: RuntimeConnection, @unchecked Sendable { } } -extension RuntimeXPCConnection: XPCListenerEndpointProviding { - public var xpcListenerEndpoint: HelperPeerEndpoint { cachedListenerEndpoint } -} - // MARK: - RuntimeXPCClientConnection /// XPC client connection for the main application side. @@ -234,6 +222,41 @@ final class RuntimeXPCServerConnection: RuntimeXPCConnection, @unchecked Sendabl await super.init(identifier: identifier, peer: peer) try await modifier?(self) try await peer.activate() + await announceListenerEndpoint() + } + + /// Announce this server's listener endpoint to the Mach Service injected-endpoint + /// registry so the host can reconnect directly after restart, bypassing the broker + /// handshake. Failures are logged but never propagated: a successful peer activation + /// must not be torn down just because the registry is unreachable — the host can + /// still rediscover this process via the broker. + private func announceListenerEndpoint() async { + do { + let helperClient = HelperClient() + try await helperClient.connectToTool( + machServiceName: RuntimeViewerMachServiceName, + isPrivilegedHelperTool: true + ) + try await helperClient.sendToTool(request: RegisterInjectedEndpointRequest( + pid: ProcessInfo.processInfo.processIdentifier, + appName: Self.injectedAppName, + bundleIdentifier: Bundle.main.bundleIdentifier ?? "", + endpoint: cachedListenerEndpoint + )) + #log(.info, "Registered injected endpoint with Mach Service (PID: \(ProcessInfo.processInfo.processIdentifier))") + } catch { + #log(.error, "Failed to register injected endpoint: \(error, privacy: .public)") + } + } + + private static var injectedAppName: String { + if let displayName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String { + return displayName + } + if let bundleName = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String { + return bundleName + } + return ProcessInfo.processInfo.processName } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeCommunicator.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeCommunicator.swift index efe2da5c..2a22c121 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeCommunicator.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeCommunicator.swift @@ -54,11 +54,21 @@ public final class RuntimeCommunicator { /// /// - Parameters: /// - source: The runtime source to connect to. - /// - bonjourEndpoint: The Bonjour endpoint to connect to (required for `.bonjour` with `.client` role). + /// - credential: Session-scoped credential resolved at connect time. Required for + /// `.bonjour` + `.client` (the discovered `NWEndpoint`); optional for `.remote` + `.client` + /// (a previously-handshaked XPC peer endpoint enables direct reconnect). See + /// `RuntimeConnectionCredential` for the full matrix. + /// - waitForConnection: For `.directTCP` server only — whether to block until the first + /// client connects. /// - modifier: Optional closure to configure the connection before use. /// - Returns: A configured `RuntimeConnection` ready for communication. /// - Throws: An error if the connection cannot be established. - public func connect(to source: RuntimeSource, bonjourEndpoint: RuntimeNetworkEndpoint? = nil, xpcServerEndpoint: (any Sendable)? = nil, waitForConnection: Bool = true, modifier: ((RuntimeConnection) async throws -> Void)? = nil) async throws -> RuntimeConnection { + public func connect( + to source: RuntimeSource, + credential: RuntimeConnectionCredential? = nil, + waitForConnection: Bool = true, + modifier: ((RuntimeConnection) async throws -> Void)? = nil + ) async throws -> RuntimeConnection { #log(.info, "Connecting to source: \(String(describing: source), privacy: .public)") switch source { case .local: @@ -73,9 +83,9 @@ public final class RuntimeCommunicator { #log(.info, "XPC server connection established") return connection } else { - if let xpcServerEndpoint = xpcServerEndpoint as? HelperPeerEndpoint { + if case .xpcServer(let serverEndpoint) = credential { #log(.debug, "Creating XPC client connection (direct reconnect) with identifier: \(String(describing: identifier), privacy: .public)") - let connection = try await RuntimeXPCClientConnection(identifier: identifier, serverEndpoint: xpcServerEndpoint, modifier: modifier) + let connection = try await RuntimeXPCClientConnection(identifier: identifier, serverEndpoint: serverEndpoint, modifier: modifier) #log(.info, "XPC client direct reconnection established") return connection } else { @@ -92,7 +102,7 @@ public final class RuntimeCommunicator { case .bonjour(let name, _, let role): if role.isClient { - guard let bonjourEndpoint else { + guard case .bonjour(let bonjourEndpoint) = credential else { #log(.error, "Bonjour client connection requires an endpoint") throw RuntimeCommunicatorError.bonjourClientRequiresEndpoint } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeConnectionCredential.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeConnectionCredential.swift new file mode 100644 index 00000000..1ea3a41b --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeConnectionCredential.swift @@ -0,0 +1,39 @@ +import Foundation + +#if os(macOS) +public import HelperCommunication +#endif + +/// Session-scoped credential required by some `RuntimeSource` cases at connect time. +/// +/// `RuntimeSource` describes the **identity** of a connection target (stable, `Codable`, used for +/// equality / hashing / persistence). A credential is the orthogonal piece of information that is +/// resolved per session — typically by service discovery or a prior handshake — and therefore must +/// not participate in the source's identity. +/// +/// The cases are mutually exclusive: a single `connect(to:credential:)` call needs at most one of +/// them, so they collapse into a single optional parameter instead of separate slots. +/// +/// ## When to provide a credential +/// +/// | Source | Credential | Required? | +/// |---------------------------------|---------------------------|-----------| +/// | `.bonjour` + `.client` | `.bonjour(endpoint)` | Required | +/// | `.remote` + `.client` (reconnect) | `.xpcServer(endpoint)` | Optional, enables direct reconnect | +/// | All other cases | `nil` | — | +public enum RuntimeConnectionCredential: Sendable { + /// Bonjour endpoint resolved by service discovery. + /// + /// Required for `RuntimeSource.bonjour` with `Role.client` — the endpoint cannot be + /// derived from the source alone because it is produced at runtime by `NWBrowser`. + case bonjour(RuntimeNetworkEndpoint) + + #if os(macOS) + /// XPC server endpoint captured from a prior handshake. + /// + /// Optional for `RuntimeSource.remote` with `Role.client`. When supplied, the communicator + /// reconnects directly to the existing peer instead of going through XPC service lookup — + /// used for reattaching to previously-injected processes. + case xpcServer(HelperPeerEndpoint) + #endif +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift index 0030cf5c..da7ec54e 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift @@ -12,23 +12,23 @@ import Asynchrone /// /// - Note: Uses `@unchecked Sendable` because the stored metatypes (`requestType`, `responseType`) /// are immutable and inherently thread-safe, but `any Codable.Type` doesn't conform to `Sendable`. -final class RuntimeMessageHandler: Sendable { +final class RuntimeMessageHandler: @unchecked Sendable { typealias RawHandler = @Sendable (Data) async throws -> Data /// The wrapped handler that processes raw Data. let closure: RawHandler /// The type of the request this handler expects. - let requestType: (Codable & Sendable).Type + let requestType: any Codable.Type /// The type of the response this handler returns. - let responseType: (Codable & Sendable).Type + let responseType: any Codable.Type /// Creates a message handler with typed request and response. /// /// - Parameter closure: The handler closure that receives a typed request /// and returns a typed response. - init(closure: @escaping @Sendable (Request) async throws -> Response) { + init(closure: @escaping @Sendable (Request) async throws -> Response) { self.requestType = Request.self self.responseType = Response.self @@ -95,38 +95,32 @@ extension RuntimeMessageProtocol { /// }) /// ``` @Loggable -final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { +final class RuntimeMessageChannel: @unchecked Sendable, RuntimeMessageProtocol { /// Unique identifier for this channel. let id = UUID() /// Called when a complete message is received. /// - Note: This callback is called from a locked context; avoid long-running operations. - @Mutex var onMessageReceived: (@Sendable (Data) -> Void)? /// Message handlers keyed by message identifier. - @Mutex - private var messageHandlers: [String: RuntimeMessageHandler] = [:] + private let messageHandlers = Mutex<[String: RuntimeMessageHandler]>([:]) /// In-flight request bookkeeping keyed by request identifier. The entry holds both /// the awaited continuation and the optional timeout `Task` so the success and /// writer-error paths can cancel the timer before it fires — without that, an /// orphaned timer from a finished request can wake later and incorrectly time out /// a *different* request that happened to be registered under the same identifier. - @Mutex - private var pendingRequests: [String: PendingRequest] = [:] + private let pendingRequests = Mutex<[String: PendingRequest]>([:]) /// Buffer for incoming data. - @Mutex - private var receivingData: Data = .init() + private let receivingData = Mutex(Data()) /// Stream for received messages. - @Mutex private var receivedDataStream: SharedAsyncSequence>? /// Continuation for yielding received messages. - @Mutex - private var receivedDataContinuation: AsyncThrowingStream.Continuation? + private let receivedDataContinuation = Mutex.Continuation?>(nil) /// Semaphore for serializing send operations. private let sendSemaphore = AsyncSemaphore(value: 1) @@ -141,7 +135,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { private func setupStreams() { let (stream, continuation) = AsyncThrowingStream.makeStream() self.receivedDataStream = stream.shared() - self.receivedDataContinuation = continuation + self.receivedDataContinuation.withLock { $0 = continuation } } // MARK: - Handler Registration @@ -171,7 +165,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { /// Registers a handler for messages with both request payload and response. func setMessageHandler(name: String, handler: @escaping @Sendable (Request) async throws -> Response) { - _messageHandlers.withLock { $0[name] = RuntimeMessageHandler(closure: handler) } + messageHandlers.withLock { $0[name] = RuntimeMessageHandler(closure: handler) } #log(.debug, "Registered message handler for: \(name, privacy: .public)") } @@ -182,20 +176,26 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { /// Returns the handler for the given message identifier. func handler(for identifier: String) -> RuntimeMessageHandler? { - _messageHandlers.withLock { $0[identifier] } + messageHandlers.withLock { $0[identifier] } } - /// Checks if there's a pending request waiting for a response with the given identifier. - /// If found, delivers the data to the pending request and returns true. + /// Looks up a pending request by routing key and delivers the response. + /// + /// `routingKey` is the per-round-trip `nonce` when the envelope carries + /// one, otherwise the legacy `identifier` (command name). The new + /// `sendRequest` always stamps + registers under `nonce` so + /// concurrent in-flight requests sharing the same command name route + /// correctly; envelope-decode paths fall back to `identifier` only when + /// a peer doesn't echo a nonce (e.g. legacy wire interop). /// - Parameters: - /// - identifier: The request identifier to check. + /// - routingKey: Lookup key — typically `envelope.nonce ?? envelope.identifier`. /// - data: The response data to deliver. /// - Returns: `true` if the data was delivered to a pending request, `false` otherwise. - func deliverToPendingRequest(identifier: String, data: Data) -> Bool { - guard let pending = _pendingRequests.withLock({ $0.removeValue(forKey: identifier) }) else { + func deliverToPendingRequest(routingKey: String, data: Data) -> Bool { + guard let pending = pendingRequests.withLock({ $0.removeValue(forKey: routingKey) }) else { return false } - #log(.debug, "Delivered response to pending request: \(identifier, privacy: .public)") + #log(.debug, "Delivered response to pending request: \(routingKey, privacy: .public)") pending.cancelTimeoutTask() pending.continuation.resume(returning: data) return true @@ -205,17 +205,16 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { /// Appends data to the receiving buffer and processes complete messages. func appendReceivedData(_ data: Data) { - _receivingData.withLock { $0.append(data) } + receivingData.withLock { $0.append(data) } processReceivedData() } /// Processes the receiving buffer and extracts complete messages. private func processReceivedData() { - let hasContinuation = receivedDataContinuation != nil var extractedMessages: [Data] = [] var remainingBufferSize = 0 - _receivingData.withLock { buffer in + receivingData.withLock { buffer in while true { guard let endRange = buffer.range(of: Self.endMarkerData) else { break @@ -234,12 +233,13 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { remainingBufferSize = buffer.count } + let hasContinuation = receivedDataContinuation.withLock { $0 != nil } if extractedMessages.isEmpty { #log(.debug, "[MessageChannel] processReceivedData: no end marker found (buffer=\(remainingBufferSize, privacy: .public) bytes, continuation=\(hasContinuation, privacy: .public))") } for messageData in extractedMessages { #log(.debug, "[MessageChannel] processReceivedData: yielding message (\(messageData.count, privacy: .public) bytes, continuation=\(hasContinuation, privacy: .public))") - receivedDataContinuation?.yield(messageData) + _ = receivedDataContinuation.withLock { $0?.yield(messageData) } onMessageReceived?(messageData) } } @@ -256,7 +256,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { } else { #log(.info, "finishReceiving: stream closed normally") } - _receivedDataContinuation.withLock { continuation in + receivedDataContinuation.withLock { continuation in if let error { continuation?.finish(throwing: error) } else { @@ -267,7 +267,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { // Drain any pending requests that were waiting for a response on the now-dead channel // and resume each with an error so the `await` in `sendRequest` unblocks. - let drainedRequests: [PendingRequest] = _pendingRequests.withLock { pending in + let drainedRequests: [PendingRequest] = pendingRequests.withLock { pending in let values = Array(pending.values) pending.removeAll() return values @@ -284,7 +284,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { /// Returns the current size of the receiving buffer. var receivingBufferSize: Int { - _receivingData.withLock { $0.count } + receivingData.withLock { $0.count } } // MARK: - Sending Data @@ -304,8 +304,20 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { /// Sends a request and waits for a response. /// + /// Concurrency model: `sendSemaphore` now guards only the on-wire write, + /// **not** the wait for the response. A slow peer handler (e.g. 20 s of + /// section parsing on the background indexer) no longer monopolizes the + /// semaphore for its entire round trip, so other `sendRequest`s can + /// leave the local outbox immediately. Each in-flight request is keyed + /// in `pendingRequests` by a freshly minted per-round-trip nonce + /// (`RuntimeRequestData.nonce`) so concurrent requests sharing the same + /// command name (e.g. multiple `isImageLoaded`) route their responses + /// without collision; the peer must echo the nonce verbatim in its + /// response envelope. + /// /// - Parameters: /// - requestData: The request payload framed by `RuntimeRequestData`. + /// If `nonce` is `nil` a fresh `UUID` is stamped before sending. /// - timeout: Optional deadline (seconds). When non-nil, if no response arrives within /// the deadline the call throws `RuntimeMessageChannelError.requestTimeout` and the /// pending entry is removed, so a late response will be ignored. When `nil` the call @@ -317,45 +329,57 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { timeout: TimeInterval? = nil, writer: @escaping @Sendable (Data) async throws -> Void ) async throws -> Response { - await sendSemaphore.wait() - // Use defer so the semaphore is released on every exit path — including when the - // continuation throws. The previous implementation released only on the success path - // and leaked the slot whenever sendRequest threw, blocking every subsequent caller. - defer { sendSemaphore.signal() } - - #log(.debug, "Sending request: \(requestData.identifier, privacy: .public)") - let data = try JSONEncoder().encode(requestData) + // Stamp a nonce so concurrent same-`identifier` requests don't collide + // in `pendingRequests`. Honor any caller-supplied nonce (internal + // echo paths) but allocate one otherwise. + let nonce = requestData.nonce ?? UUID().uuidString + let stamped: RuntimeRequestData = (requestData.nonce == nil) + ? RuntimeRequestData(identifier: requestData.identifier, data: requestData.data, nonce: nonce) + : requestData + + #log(.debug, "Sending request: \(stamped.identifier, privacy: .public) [nonce \(nonce, privacy: .public)]") + let data = try JSONEncoder().encode(stamped) let dataToSend = data + Self.endMarkerData // Register pending request before sending let responseData: Data = try await withCheckedThrowingContinuation { continuation in let pending = PendingRequest(continuation: continuation) - _pendingRequests.withLock { $0[requestData.identifier] = pending } + pendingRequests.withLock { $0[nonce] = pending } // Spawn the timeout task before the writer task so the entry is fully wired // up — including its cancel handle — before any code path that resolves the // continuation can run. The success/writer-error paths cancel the timer so // it cannot wake later and incorrectly time out a re-used identifier. if let timeout { - let identifier = requestData.identifier - let timeoutTask = Task { + let identifier = stamped.identifier + let timeoutTask = Task { [nonce] in try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) if Task.isCancelled { return } - if let pending = self._pendingRequests.withLock({ $0.removeValue(forKey: identifier) }) { - #log(.error, "Request \(identifier, privacy: .public) timed out after \(timeout, privacy: .public)s") + if let pending = self.pendingRequests.withLock({ $0.removeValue(forKey: nonce) }) { + #log(.error, "Request \(identifier, privacy: .public) [nonce \(nonce, privacy: .public)] timed out after \(timeout, privacy: .public)s") pending.continuation.resume(throwing: RuntimeMessageChannelError.requestTimeout) } } pending.setTimeoutTask(timeoutTask) } - Task { + Task { [identifier = stamped.identifier, nonce] in + // Acquire the semaphore here, inside the write Task, so the + // outer await (`withCheckedThrowingContinuation`) doesn't + // hold it for the full round trip. The semaphore still + // serializes adjacent writes so length-prefixed envelopes + // don't interleave on the wire; once `writer` returns the + // slot frees immediately even though we keep waiting on + // `continuation` for the response. + await self.sendSemaphore.wait() do { try await writer(dataToSend) + self.sendSemaphore.signal() } catch { + self.sendSemaphore.signal() // Remove pending request and resume with error - if let pending = self._pendingRequests.withLock({ $0.removeValue(forKey: requestData.identifier) }) { - #log(.error, "Failed to send request \(requestData.identifier, privacy: .public): \(String(describing: error), privacy: .public)") + if let pending = self.pendingRequests.withLock({ $0.removeValue(forKey: nonce) }) { + #log(.error, "Failed to send request \(identifier, privacy: .public) [nonce \(nonce, privacy: .public)]: \(String(describing: error), privacy: .public)") pending.cancelTimeoutTask() pending.continuation.resume(throwing: error) } @@ -363,7 +387,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { } } - #log(.debug, "Received response for: \(requestData.identifier, privacy: .public)") + #log(.debug, "Received response for: \(stamped.identifier, privacy: .public) [nonce \(nonce, privacy: .public)]") let response = try JSONDecoder().decode(RuntimeRequestData.self, from: responseData) return try JSONDecoder().decode(Response.self, from: response.data) } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift index 60105ded..517f1225 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift @@ -27,19 +27,34 @@ struct RuntimeRequestData: Codable { let data: Data - init(identifier: String, data: Data) { + /// Per-round-trip routing key. `nil` for fire-and-forget messages and + /// for messages that originated on a peer that doesn't stamp one + /// (wire-level backward compat). When non-nil, `sendRequest` + /// uses it to key the `pendingRequests` entry — multiple concurrent + /// in-flight requests can therefore share the same `identifier` + /// (command name) without colliding on the pending-routing table, so + /// the channel's `sendSemaphore` no longer has to serialize round + /// trips end-to-end. Peer-side handlers must echo the value verbatim + /// in the response envelope; without it the client falls back to + /// `identifier` (legacy behavior, single in-flight per command). + let nonce: String? + + init(identifier: String, data: Data, nonce: String? = nil) { self.identifier = identifier self.data = data + self.nonce = nonce } - init(identifier: String, value: Value) throws { + init(identifier: String, value: Value, nonce: String? = nil) throws { self.identifier = identifier self.data = try JSONEncoder().encode(value) + self.nonce = nonce } - init(request: Request) throws { + init(request: Request, nonce: String? = nil) throws { self.identifier = Request.identifier self.data = try JSONEncoder().encode(request) + self.nonce = nonce } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift index f8e429c2..8f0afd1e 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeBackgroundIndexingManager.swift @@ -47,6 +47,19 @@ public actor RuntimeBackgroundIndexingManager { } } + /// Cancels every active batch whose `RuntimeIndexingBatch` matches the + /// supplied predicate. Used by the coordinator when the user disables a + /// single sub-mode (Heuristic / Custom) so that the other sub-mode's + /// in-flight batches keep running. + public func cancelBatches(matching predicate: @Sendable (RuntimeIndexingBatch) -> Bool) { + let ids = activeBatches.compactMap { id, state in + predicate(state.batch) ? id : nil + } + for id in ids { + cancelBatch(id) + } + } + /// Best-effort priority boost for `imagePath` inside any active batch. /// /// Items currently in `.pending` state are marked with `hasPriorityBoost` @@ -85,15 +98,16 @@ public actor RuntimeBackgroundIndexingManager { ) 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. + // and the user simultaneously toggles the master switch off / on, + // causing `handleSettingsChange` to dispatch a second batch on the + // same root with `.settingsEnabled`. 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. + // ignored so `.appLaunch` ↔ `.settingsEnabled` (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 } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift index 5982fb78..5bf02724 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift @@ -1,6 +1,43 @@ public enum RuntimeIndexingBatchReason: Sendable, Hashable { case appLaunch - case imageLoaded(path: String) case settingsEnabled case manual + /// Triggered by an entry in `Settings.Indexing.custom.entries`. + /// `identifier` is the raw user-supplied string (full imagePath or just + /// the image's last path component); displayed verbatim in the popover. + case alwaysIndex(identifier: String) + + /// Batches generated by the Heuristic sub-mode (main-executable BFS). + /// Used by `cancelBatches(matching:)` to scope cancellation when the + /// user flips the heuristic toggle off while batches are running. + public var isHeuristic: Bool { + switch self { + case .appLaunch, .settingsEnabled: return true + case .alwaysIndex, .manual: return false + } + } + + /// Batches generated by the Custom sub-mode (always-index list). + public var isCustom: Bool { + if case .alwaysIndex = self { return true } + return false + } + + /// Coarse-grained grouping used by the Background Indexing popover. + /// Batches sharing a category are displayed under a single collapsible + /// header (e.g. several `.alwaysIndex(identifier:)` rows collapse into + /// one "Always Index" group). + public enum Category: Sendable, Hashable { + case heuristic + case alwaysIndex + case manual + } + + public var category: Category { + switch self { + case .appLaunch, .settingsEnabled: return .heuristic + case .alwaysIndex: return .alwaysIndex + case .manual: return .manual + } + } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject+Export.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject+Export.swift index cd649494..f350d02d 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject+Export.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject+Export.swift @@ -1,28 +1,95 @@ import Foundation extension RuntimeObject { + /// Maximum length, in UTF-8 bytes, of a single path component on the + /// filesystems RuntimeViewer exports to (APFS/HFS+ `NAME_MAX`). Writing a + /// longer component throws `NSFileWriteInvalidFileNameError` (Cocoa 642). + /// C++ template type names (e.g. a fully-spelled `std::unordered_map<…>`) + /// routinely blow past this, so every export file name is clamped below. + private static let maxFileNameByteLength = 255 + public var exportFileName: String { let invalidCharacters = CharacterSet(charactersIn: "/:<>\"\\|?*") let sanitized = displayName .components(separatedBy: invalidCharacters) .joined(separator: "_") + + let base: String + let suffix: String switch kind { case .swift(.type(_)): - return "\(sanitized).swiftinterface" + base = sanitized + suffix = ".swiftinterface" case .swift(.extension(_)): - return "\(sanitized)+Extension.swiftinterface" + base = sanitized + suffix = "+Extension.swiftinterface" case .swift(.conformance(_)): - return "\(sanitized)+Conformance.swiftinterface" + base = sanitized + suffix = "+Conformance.swiftinterface" case .objc(.type(.class)), .c: - return "\(sanitized).h" + base = sanitized + suffix = ".h" case .objc(.type(.protocol)): - return "\(sanitized)-Protocol.h" + base = sanitized + suffix = "-Protocol.h" case .objc(.category(_)): if let categoryName = sanitized.contentInParentheses { - return "\(sanitized.replacingOccurrences(of: "(\(categoryName))", with: ""))+\(categoryName).h" + base = sanitized.replacingOccurrences(of: "(\(categoryName))", with: "") + suffix = "+\(categoryName).h" } else { - return "\(sanitized).h" + base = sanitized + suffix = ".h" } } + + return Self.clampFileName(base: base, suffix: suffix) + } + + /// Joins `base + suffix`, clamping the result to `maxFileNameByteLength`. + /// When the natural name fits, it is returned verbatim. When it doesn't, + /// `base` is truncated and a short, deterministic hash of the *full* base is + /// appended so two distinct long names that share a truncated prefix don't + /// collide on disk (and the same object always maps to the same file name + /// across runs). The `suffix` (extension / category tag) is never dropped. + private static func clampFileName(base: String, suffix: String) -> String { + let fullName = base + suffix + if fullName.utf8.count <= maxFileNameByteLength { + return fullName + } + + let disambiguator = String(format: "~%08x", stableHash(of: base)) + let budget = maxFileNameByteLength - suffix.utf8.count - disambiguator.utf8.count + let truncatedBase = base.truncatedToUTF8ByteLength(max(0, budget)) + return truncatedBase + disambiguator + suffix + } + + /// FNV-1a 64-bit folded to 32 bits. Deterministic across processes (unlike + /// `Hashable.hashValue`, which is per-run seeded), so the disambiguating + /// suffix is stable for a given type name. + private static func stableHash(of string: String) -> UInt32 { + var hash: UInt64 = 14695981039346656037 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 1099511628211 + } + return UInt32(truncatingIfNeeded: hash) ^ UInt32(truncatingIfNeeded: hash >> 32) + } +} + +private extension String { + /// Truncates to at most `maxBytes` UTF-8 bytes without splitting a + /// `Character`, so the result is always valid UTF-8 even when the cut would + /// otherwise land in the middle of a multi-byte scalar. + func truncatedToUTF8ByteLength(_ maxBytes: Int) -> String { + if utf8.count <= maxBytes { return self } + var result = "" + var byteCount = 0 + for character in self { + let characterByteCount = String(character).utf8.count + if byteCount + characterByteCount > maxBytes { break } + result.append(character) + byteCount += characterByteCount + } + return result } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift index c38d8948..12b9f150 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift @@ -41,7 +41,7 @@ public struct RuntimeObject: Hashable, Identifiable, Sendable { public var imageName: String { imagePath.lastPathComponent.deletingPathExtension } public func withImagePath(_ imagePath: String) -> RuntimeObject { - .init(name: name, displayName: displayName, kind: kind, secondaryKind: secondaryKind, imagePath: imagePath, children: children) + .init(name: name, displayName: displayName, kind: kind, secondaryKind: secondaryKind, imagePath: imagePath, children: children, properties: properties) } /// Returns a copy of this object with `child` appended to its `children`. diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index 507f9e79..4ad27522 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -85,6 +85,8 @@ actor RuntimeSwiftSection { // both maps and throw `invalidRuntimeObject`. private var interfaceByObject: OrderedDictionary = [:] + private var specializedDefinitionByObject: [RuntimeObjectKey: TypeDefinition] = [:] + private var lastTransformerConfiguration: Transformer.SwiftConfiguration = .init() private var interfaceDefinitionNameByObject: [RuntimeObjectKey: InterfaceDefinitionName] = [:] @@ -107,6 +109,17 @@ actor RuntimeSwiftSection { /// specialized definitions live on the parent rather than on the /// indexer). case specializedType(unspecialized: SwiftInterface.TypeName, specialized: SwiftInterface.TypeName) + /// Nested specialized types derived by upstream's + /// `derivingNestedSpecializationsWith` — e.g. when specializing + /// `Phase`, upstream walks the parent's `typeChildren` and + /// produces a `Phase.Value` that is itself specialized but + /// has no generic parent the indexer knows about (the unspecialized + /// `Phase.Value` lives under the unspecialized `Phase`, not under + /// this derived `Phase`). Its `TypeDefinition` is only + /// reachable through `specializedDefinitionByObject`; consumers + /// MUST route through that cache rather than trying to look up the + /// typeName in `indexer.allTypeDefinitions`. + case derivedSpecializedType(SwiftInterface.TypeName) case rootProtocol(SwiftInterface.ProtocolName) case childProtocol(SwiftInterface.ProtocolName) case typeExtension(SwiftInterface.ExtensionName) @@ -188,15 +201,25 @@ actor RuntimeSwiftSection { return runtimeObjectName } - private func makeRuntimeObject(for typeDefinition: TypeDefinition, isChild: Bool, unspecializedTypeName: SwiftInterface.TypeName? = nil) throws -> RuntimeObject { - let typeChildren = try typeDefinition.typeChildren.map { try makeRuntimeObject(for: $0, isChild: true) } + private func makeRuntimeObject( + for typeDefinition: TypeDefinition, + isChild: Bool, + unspecializedTypeName: SwiftInterface.TypeName? = nil + ) throws -> RuntimeObject { + let mangledName = try mangleAsString(typeDefinition.typeName.node) + let typeChildren = try typeDefinition.typeChildren.map { + try makeRuntimeObject(for: $0, isChild: true) + } let protocolChildren = try typeDefinition.protocolChildren.map { try makeRuntimeObject(for: $0, isChild: true) } let specializedChildren = try typeDefinition.specializedChildren.map { - try makeRuntimeObject(for: $0, isChild: true, unspecializedTypeName: typeDefinition.typeName) + try makeRuntimeObject( + for: $0, + isChild: true, + unspecializedTypeName: typeDefinition.typeName + ) } let allChildren = typeChildren + protocolChildren + specializedChildren - let mangledName = try mangleAsString(typeDefinition.typeName.node) var properties: RuntimeObject.Properties = [] if typeDefinition.type.contextDescriptorWrapper.contextDescriptor.layout.flags.isGeneric { properties.insert(.isGeneric) @@ -207,9 +230,28 @@ actor RuntimeSwiftSection { } let displayName = isSpecialized ? typeDefinition.typeName.name(using: .interfaceTypeBuilderOnly.subtracting(.removeBoundGeneric)) : typeDefinition.typeName.name - let runtimeObject = RuntimeObject(name: mangledName, displayName: displayName, kind: typeDefinition.typeName.runtimeObjectKind, secondaryKind: nil, imagePath: imagePath, children: allChildren, properties: properties) + let runtimeObject = RuntimeObject( + name: mangledName, + displayName: displayName, + kind: typeDefinition.typeName.runtimeObjectKind, + secondaryKind: nil, + imagePath: imagePath, + children: allChildren, + properties: properties + ) if isSpecialized, let unspecializedTypeName { interfaceDefinitionNameByObject[runtimeObject.key] = .specializedType(unspecialized: unspecializedTypeName, specialized: typeDefinition.typeName) + specializedDefinitionByObject[runtimeObject.key] = typeDefinition + } else if isSpecialized { + // Derived nested specialization: upstream produced this object + // by walking a freshly specialized parent's `typeChildren` (see + // `derivingNestedSpecializationsWith`) — its generic ancestor + // lives elsewhere in the indexer and is not addressable from + // here. The `TypeDefinition` is still authoritative, so cache + // it directly and let consumers look it up through + // `specializedDefinitionByObject` instead of the indexer. + interfaceDefinitionNameByObject[runtimeObject.key] = .derivedSpecializedType(typeDefinition.typeName) + specializedDefinitionByObject[runtimeObject.key] = typeDefinition } else if isChild { interfaceDefinitionNameByObject[runtimeObject.key] = .childType(typeDefinition.typeName) } else { @@ -238,6 +280,10 @@ extension RuntimeSwiftSection { var newInterfaceString: SemanticString = "" switch interfaceDefinitionName { case .specializedType(let unspecializedTypeName, let specializedTypeName): + if let specializedDefinition = specializedDefinitionByObject[object.key] { + try await newInterfaceString.append(printer.printTypeDefinition(specializedDefinition)) + break + } // The indexer keeps `allTypeDefinitions` keyed by the // unspecialized typeName; specialized children live on the // parent's `specializedChildren` array (per the upstream's @@ -248,6 +294,11 @@ extension RuntimeSwiftSection { let specializedDefinition = parentDefinition.specializedChildren.first(where: { $0.typeName == specializedTypeName }) else { throw Error.invalidRuntimeObject } try await newInterfaceString.append(printer.printTypeDefinition(specializedDefinition)) + case .derivedSpecializedType: + guard let specializedDefinition = specializedDefinitionByObject[object.key] else { + throw Error.invalidRuntimeObject + } + try await newInterfaceString.append(printer.printTypeDefinition(specializedDefinition)) case .rootType(let rootTypeName): guard let typeDefinition = indexer.rootTypeDefinitions[rootTypeName] else { throw Error.invalidRuntimeObject } try await newInterfaceString.append(printer.printTypeDefinition(typeDefinition)) @@ -446,10 +497,16 @@ extension RuntimeSwiftSection { collect(from: typeDefinition) } case .specializedType(let unspecializedTypeName, let specializedTypeName): - if let parentDefinition = indexer.allTypeDefinitions[unspecializedTypeName], + if let specializedDefinition = specializedDefinitionByObject[object.key] { + collect(from: specializedDefinition) + } else if let parentDefinition = indexer.allTypeDefinitions[unspecializedTypeName], let specializedDefinition = parentDefinition.specializedChildren.first(where: { $0.typeName == specializedTypeName }) { collect(from: specializedDefinition) } + case .derivedSpecializedType: + if let specializedDefinition = specializedDefinitionByObject[object.key] { + collect(from: specializedDefinition) + } case .rootProtocol(let protocolName), .childProtocol(let protocolName): if let protocolDefinition = indexer.allProtocolDefinitions[protocolName] { @@ -600,6 +657,9 @@ extension RuntimeSwiftSection { let specializedDefinition = try await baseTypeDefinition.specialize( with: result, typeArgumentNodes: typeArgumentNodes.count == upstreamRequest.parameters.count ? typeArgumentNodes : nil, + derivingNestedSpecializationsWith: specializer, + selection: upstreamSelection, + typeArgumentNodesByParameter: resolved.nodesByParameter, in: machO ) // After the lone suspension point — bail out before mutating section @@ -1001,6 +1061,7 @@ extension RuntimeSwiftSection { if newIndexConfiguration.showCImportedTypes != oldIndexConfiguration.showCImportedTypes { #log(.debug, "Index configuration changed, re-preparing builder") interfaceDefinitionNameByObject.removeAll() + specializedDefinitionByObject.removeAll() } if newPrintConfiguration != oldPrintConfiguration { diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportConfiguration.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportConfiguration.swift index 94d3cb5b..4789b659 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportConfiguration.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportConfiguration.swift @@ -1,7 +1,7 @@ public import Foundation public struct RuntimeInterfaceExportConfiguration: Sendable { - public enum Format: Int, Sendable { + public enum Format: Int, Sendable, Codable { case singleFile = 0 case directory = 1 } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportMetadata.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportMetadata.swift index 4a911041..f3bc9ef2 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportMetadata.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Export/RuntimeInterfaceExportMetadata.swift @@ -61,6 +61,7 @@ struct RuntimeInterfaceExportMetadata: Codable, Sendable { static func make( configuration: RuntimeInterfaceExportConfiguration, + module: ModuleInfo? = nil, objcInterfaceCount: Int, swiftInterfaceCount: Int, succeeded: Int, @@ -69,7 +70,7 @@ struct RuntimeInterfaceExportMetadata: Codable, Sendable { ) -> RuntimeInterfaceExportMetadata { RuntimeInterfaceExportMetadata( runtimeViewer: .current, - module: .resolve(imagePath: configuration.imagePath, imageName: configuration.imageName), + module: module ?? .resolve(imagePath: configuration.imagePath, imageName: configuration.imageName), export: .init( generatedAt: generatedAt, objcFormat: configuration.objcFormat.displayName, @@ -408,15 +409,7 @@ private extension RuntimeInterfaceExportMetadata.RuntimeViewerInfo { } } -private extension RuntimeInterfaceExportMetadata.ModuleInfo { - struct MachOVersionInfo { - var installName: String? - var currentVersion: String? - var compatibilityVersion: String? - var sourceVersion: String? - var uuid: String? - } - +extension RuntimeInterfaceExportMetadata.ModuleInfo { static func resolve(imagePath: String, imageName: String) -> Self { let resolvedPath = DyldUtilities.patchImagePathForDyld(imagePath) let displayedResolvedPath = resolvedPath == imagePath ? nil : resolvedPath @@ -437,6 +430,16 @@ private extension RuntimeInterfaceExportMetadata.ModuleInfo { uuid: machOInfo.uuid ) } +} + +private extension RuntimeInterfaceExportMetadata.ModuleInfo { + struct MachOVersionInfo { + var installName: String? + var currentVersion: String? + var compatibilityVersion: String? + var sourceVersion: String? + var uuid: String? + } static func bundleInfo(forPath path: String) -> [String: String]? { let url = URL(fileURLWithPath: path) @@ -495,7 +498,7 @@ private extension RuntimeInterfaceExportMetadata.ModuleInfo { } static func machOVersionInfo(forPath imagePath: String, resolvedPath: String) -> MachOVersionInfo { - if let image = DyldUtilities.machOImage(forPath: imagePath) ?? DyldUtilities.machOImage(forPath: resolvedPath) { + if let image = DyldUtilities.exactMachOImage(forPath: imagePath) ?? DyldUtilities.exactMachOImage(forPath: resolvedPath) { return machOVersionInfo(for: image) } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 14df362c..b7be3aaf 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -4,26 +4,24 @@ import MachOKit extension RuntimeEngine { public func isImageIndexed(path: String) async throws -> Bool { - try await request { - let normalized = DyldUtilities.patchImagePathForDyld(path) - let hasObjC = await objcSectionFactory.hasCachedSection(for: normalized) - let hasSwift = await swiftSectionFactory.hasCachedSection(for: normalized) - return hasObjC && hasSwift - } remote: { senderConnection in - try await senderConnection.sendMessage(name: .isImageIndexed, request: path) - } + try await dispatch(IsImageIndexedRequest(path: path)) + } + + func _isImageIndexed(path: String) async -> Bool { + let normalized = DyldUtilities.patchImagePathForDyld(path) + let hasObjC = await objcSectionFactory.hasCachedSection(for: normalized) + let hasSwift = await swiftSectionFactory.hasCachedSection(for: normalized) + return hasObjC && hasSwift } /// Path of the target process's main executable. + /// + /// `imageNames().first` is unreliable under `DYLD_INSERT_LIBRARIES` + /// (Xcode injects `libLogRedirect.dylib` at index 0 during debug runs). + /// `_NSGetExecutablePath` (wrapped by `DyldUtilities.mainExecutablePath`) + /// always returns the host binary. public func mainExecutablePath() async throws -> String { - try await request { - // `imageNames().first` is unreliable under `DYLD_INSERT_LIBRARIES` - // (Xcode injects `libLogRedirect.dylib` at index 0 during debug - // runs). `_NSGetExecutablePath` always returns the host binary. - DyldUtilities.mainExecutablePath() - } remote: { senderConnection in - try await senderConnection.sendMessage(name: .mainExecutablePath) - } + try await dispatch(MainExecutablePathRequest()) } /// Like `loadImage(at:)` but does **not** call `reloadData()` and does @@ -35,40 +33,98 @@ extension RuntimeEngine { /// `RuntimeBackgroundIndexingCoordinator`'s image-loaded pump and /// recursively spawn a fresh batch for every image we just indexed. public func loadImageForBackgroundIndexing(at path: String) async throws { - try await request { - // Mirror loadImage(at:) byte-for-byte sans reloadData. See loadImage - // for the canonicalization rationale. - let canonical = DyldUtilities.patchImagePathForDyld(path) + _ = try await dispatch(LoadImageForBackgroundIndexingRequest(path: path)) + } + + /// Local implementation of `loadImageForBackgroundIndexing(at:)`. Mirrors + /// `_loadImage(at:)` byte-for-byte sans `reloadData` / `imageDidLoad` + /// emission. See `_loadImage(at:)` for the canonicalization rationale. + /// + /// Skips `dlopen` when dyld already has the image mapped. The indexer's + /// dependency-graph BFS can reach the host process's own main executable + /// (which `dlopen` refuses to re-open by definition) and, on iOS + /// Simulator, system images whose canonical path differs from dyld's + /// runtime form due to `DYLD_ROOT_PATH` rewriting (the rewritten path + /// resolves to `(no such file)` on disk). Either failure mode raises + /// `DyldOpenError`, which the request-handler catch arm echoes back as + /// a bare `RuntimeNetworkRequestError` carrying no `identifier` field — + /// the peer fails envelope decode, echoes its own bare error, and both + /// sides ping-pong on the shared `sendSemaphore`, starving every other + /// request (image-list pushes, sidebar `isImageLoaded`, indexing + /// progress). See Changelogs/v2.1.0-beta.4.md for the full failure + /// description. Section caching below is idempotent on already-loaded + /// images so the rest of the indexing pipeline still runs. + func _loadImageForBackgroundIndexing(at path: String) async throws { + let canonical = DyldUtilities.patchImagePathForDyld(path) + if !imageList.contains(canonical) { 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) } + _ = try await objcSectionFactory.section(for: canonical) + _ = try await swiftSectionFactory.section(for: canonical) + loadedImagePaths.insert(canonical) } } // MARK: - BackgroundIndexingEngineRepresenting extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { - func canOpenImage(at path: String) -> Bool { + /// All three metadata methods below `dispatch` so they query the engine + /// hosting the actual binary (`DyldUtilities.machOImage(forPath:)` reads + /// the current process's dyld, which is only correct for the local + /// arm). Without dispatch, remote sources (XPC / Bonjour / iOS Simulator) + /// would resolve every dependency path against the client process's + /// dyld map — which doesn't know about the remote app's images — and + /// `dependencies(for: rootPath)` would always return `[]`, leaving the + /// BFS stuck at the root image. That's why pre-fix remote engines only + /// ever indexed their own main executable instead of the full + /// dependency closure. + func canOpenImage(at path: String) async -> Bool { + // Errors map to `false` (treat as "can't open"), matching the + // pre-dispatch behaviour where a missing image silently returned + // `false` rather than throwing. + await (try? dispatch(CanOpenImageRequest(path: path))) ?? false + } + + func rpaths(for path: String) async throws -> [String] { + try await dispatch(RpathsRequest(path: path)) + } + + func dependencies(for path: String, + ancestorRpaths: [String], + mainExecutablePath: String) async throws + -> [(installName: String, resolvedPath: String?)] { + let entries: [RuntimeDependencyEntry] = try await dispatch( + DependenciesRequest( + path: path, + ancestorRpaths: ancestorRpaths, + mainExecutablePath: mainExecutablePath, + ), + ) + // Manager-facing API still uses the tuple shape it was written + // against; only the wire form needs a named struct (tuples aren't + // Codable). Repack here. + return entries.map { ($0.installName, $0.resolvedPath) } + } +} + +// MARK: - Local implementations of the BFS metadata methods + +extension RuntimeEngine { + func _canOpenImage(at path: String) -> Bool { DyldUtilities.machOImage(forPath: path) != nil } - func rpaths(for path: String) -> [String] { + func _rpaths(for path: String) -> [String] { guard let image = DyldUtilities.machOImage(forPath: path) else { return [] } return image.rpaths } - func dependencies(for path: String, - ancestorRpaths: [String], - mainExecutablePath: String) async throws - -> [(installName: String, resolvedPath: String?)] - { + func _dependencies(for path: String, + ancestorRpaths: [String], + mainExecutablePath: String) + -> [RuntimeDependencyEntry] { guard let image = DyldUtilities.machOImage(forPath: path) else { return [] } @@ -86,7 +142,7 @@ extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { installName: installName, imagePath: path, rpaths: mergedRpaths, - mainExecutablePath: mainExecutablePath + mainExecutablePath: mainExecutablePath, ) // Two link modes where dyld is allowed to never produce a // loaded image at BFS time: @@ -124,7 +180,56 @@ extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { return nil } } - return (installName, resolvedPath) + return RuntimeDependencyEntry(installName: installName, resolvedPath: resolvedPath) } } } + +// MARK: - Wire types + +/// Codable equivalent of the BFS metadata tuple. Tuples can't conform to +/// `Codable`, so the on-wire form uses this struct and the public +/// `dependencies(...)` API repacks to tuples at the dispatch seam. +public struct RuntimeDependencyEntry: Codable, Sendable { + public let installName: String + public let resolvedPath: String? + + public init(installName: String, resolvedPath: String?) { + self.installName = installName + self.resolvedPath = resolvedPath + } +} + +// MARK: - Request types + +extension RuntimeEngine { + struct CanOpenImageRequest: RuntimeEngineRequest { + let path: String + static var commandName: String { CommandNames.canOpenImage.commandName } + func perform(on engine: RuntimeEngine) async throws -> Bool { + await engine._canOpenImage(at: path) + } + } + + struct RpathsRequest: RuntimeEngineRequest { + let path: String + static var commandName: String { CommandNames.rpathsForImage.commandName } + func perform(on engine: RuntimeEngine) async throws -> [String] { + await engine._rpaths(for: path) + } + } + + struct DependenciesRequest: RuntimeEngineRequest { + let path: String + let ancestorRpaths: [String] + let mainExecutablePath: String + static var commandName: String { CommandNames.dependenciesForImage.commandName } + func perform(on engine: RuntimeEngine) async throws -> [RuntimeDependencyEntry] { + await engine._dependencies( + for: path, + ancestorRpaths: ancestorRpaths, + mainExecutablePath: mainExecutablePath, + ) + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+GenericSpecialization.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+GenericSpecialization.swift index af7c8ac3..d84ae5be 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+GenericSpecialization.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+GenericSpecialization.swift @@ -13,14 +13,14 @@ extension RuntimeEngine { /// (`RuntimeSpecializationRequest`) so the client does not need /// `@_spi(Support) SwiftInterface` to deserialize the response. public func specializationRequest(for object: RuntimeObject) async throws -> RuntimeSpecializationRequest { - try await request { - guard let swiftSection = await swiftSectionFactory.existingSection(for: object.imagePath) else { - throw EngineError.imageNotIndexed(imagePath: object.imagePath) - } - return try await swiftSection.specializationRequest(for: object) - } remote: { senderConnection in - try await senderConnection.sendMessage(name: .specializationRequest, request: object) + try await dispatch(SpecializationRequestForObjectRequest(object: object)) + } + + func _specializationRequest(for object: RuntimeObject) async throws -> RuntimeSpecializationRequest { + guard let swiftSection = await swiftSectionFactory.existingSection(for: object.imagePath) else { + throw EngineError.imageNotIndexed(imagePath: object.imagePath) } + return try await swiftSection.specializationRequest(for: object) } /// Run runtime-aware preflight on the user's selection before invoking @@ -37,22 +37,17 @@ extension RuntimeEngine { for object: RuntimeObject, with selection: RuntimeSpecializationSelection ) async throws -> RuntimeSpecializationValidation { - try await runtimePreflight(for: .init(object: object, selection: selection)) + try await dispatch(RuntimePreflightRequest(object: object, selection: selection)) } - /// Internal (rather than `private`) so that - /// `RuntimeEngine.setMessageHandlerBinding(forName:of:to:)` in `RuntimeEngine.swift` - /// can reference `$0.runtimePreflight(for:)` across files. `private` extension - /// members are only visible within the file declaring the extension. - func runtimePreflight(for request: SpecializeRequest) async throws -> RuntimeSpecializationValidation { - try await self.request { - guard let swiftSection = await swiftSectionFactory.existingSection(for: request.object.imagePath) else { - throw EngineError.imageNotIndexed(imagePath: request.object.imagePath) - } - return try await swiftSection.runtimePreflight(for: request.object, with: request.selection) - } remote: { senderConnection in - try await senderConnection.sendMessage(name: .runtimePreflight, request: request) + func _runtimePreflight( + for object: RuntimeObject, + with selection: RuntimeSpecializationSelection + ) async throws -> RuntimeSpecializationValidation { + guard let swiftSection = await swiftSectionFactory.existingSection(for: object.imagePath) else { + throw EngineError.imageNotIndexed(imagePath: object.imagePath) } + return try await swiftSection.runtimePreflight(for: object, with: selection) } /// Specialize the given generic Swift type and register the resulting @@ -72,35 +67,20 @@ extension RuntimeEngine { _ object: RuntimeObject, with selection: RuntimeSpecializationSelection ) async throws -> RuntimeObject { - try await specialize(for: .init(object: object, selection: selection)) + try await dispatch(SpecializeRequest(object: object, selection: selection)) } - /// Internal (rather than `private`) so that - /// `RuntimeEngine.setMessageHandlerBinding(forName:of:to:)` in `RuntimeEngine.swift` - /// can reference `$0.specialize(for:)` across files. `private` extension - /// members are only visible within the file declaring the extension. @discardableResult - func specialize(for request: SpecializeRequest) async throws -> RuntimeObject { - try await self.request { - guard let swiftSection = await swiftSectionFactory.existingSection(for: request.object.imagePath) else { - throw EngineError.imageNotIndexed(imagePath: request.object.imagePath) - } - let runtimeObject = try await swiftSection.specialize(for: request.object, with: request.selection) - broadcast(.specializationAdded(parent: request.object, child: runtimeObject)) - return runtimeObject - } remote: { senderConnection in - try await senderConnection.sendMessage(name: .specialize, request: request) + func _specialize( + _ object: RuntimeObject, + with selection: RuntimeSpecializationSelection + ) async throws -> RuntimeObject { + guard let swiftSection = await swiftSectionFactory.existingSection(for: object.imagePath) else { + throw EngineError.imageNotIndexed(imagePath: object.imagePath) } - } - - struct SpecializeRequest: Codable, Sendable { - let object: RuntimeObject - let selection: RuntimeSpecializationSelection - } - - struct SpecializationRequestForCandidateRequest: Codable, Sendable { - let candidateID: String - let imagePath: String + let runtimeObject = try await swiftSection.specialize(for: object, with: selection) + broadcast(.specializationAdded(parent: object, child: runtimeObject)) + return runtimeObject } /// Build an inner `RuntimeSpecializationRequest` for a generic candidate @@ -113,22 +93,19 @@ extension RuntimeEngine { forCandidateID candidateID: String, in imagePath: String ) async throws -> RuntimeSpecializationRequest { - try await specializationRequest(for: .init(candidateID: candidateID, imagePath: imagePath)) + try await dispatch(SpecializationRequestForCandidateRequest(candidateID: candidateID, imagePath: imagePath)) } - func specializationRequest( - for request: SpecializationRequestForCandidateRequest + func _specializationRequest( + forCandidateID candidateID: String, + in imagePath: String ) async throws -> RuntimeSpecializationRequest { - try await self.request { - guard let swiftSection = await swiftSectionFactory.existingSection(for: request.imagePath) else { - throw EngineError.imageNotIndexed(imagePath: request.imagePath) - } - return try await swiftSection.specializationRequest( - forCandidateID: request.candidateID, - in: request.imagePath - ) - } remote: { senderConnection in - try await senderConnection.sendMessage(name: .specializationRequestForCandidate, request: request) + guard let swiftSection = await swiftSectionFactory.existingSection(for: imagePath) else { + throw EngineError.imageNotIndexed(imagePath: imagePath) } + return try await swiftSection.specializationRequest( + forCandidateID: candidateID, + in: imagePath + ) } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift new file mode 100644 index 00000000..cb6f117f --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift @@ -0,0 +1,160 @@ +import Foundation +import RuntimeViewerCommunication + +// MARK: - Image queries + +extension RuntimeEngine { + struct IsImageLoadedRequest: RuntimeEngineRequest { + let path: String + static var commandName: String { CommandNames.isImageLoaded.commandName } + func perform(on engine: RuntimeEngine) async throws -> Bool { + await engine._isImageLoaded(path: path) + } + } + + struct IsImageIndexedRequest: RuntimeEngineRequest { + let path: String + static var commandName: String { CommandNames.isImageIndexed.commandName } + func perform(on engine: RuntimeEngine) async throws -> Bool { + await engine._isImageIndexed(path: path) + } + } + + struct MainExecutablePathRequest: RuntimeEngineRequest { + static var commandName: String { CommandNames.mainExecutablePath.commandName } + func perform(on engine: RuntimeEngine) async throws -> String { + DyldUtilities.mainExecutablePath() + } + } + + struct LoadImageRequest: RuntimeEngineRequest { + let path: String + static var commandName: String { CommandNames.loadImage.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeEngineEmpty { + try await engine._loadImage(at: path) + return RuntimeEngineEmpty() + } + } + + struct LoadImageForBackgroundIndexingRequest: RuntimeEngineRequest { + let path: String + static var commandName: String { CommandNames.loadImageForBackgroundIndexing.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeEngineEmpty { + try await engine._loadImageForBackgroundIndexing(at: path) + return RuntimeEngineEmpty() + } + } + + /// Server-side answer to `imageName(ofObjectName:)`. Symmetric with the + /// pre-refactor behavior where the local arm always returned `nil` and + /// only the remote arm answered meaningfully — so a proxy / server engine + /// keeps that empty answer when no upstream lookup is available. + struct ImageNameOfObjectRequest: RuntimeEngineRequest { + let object: RuntimeObject + static var commandName: String { CommandNames.imageNameOfClassName.commandName } + func perform(on engine: RuntimeEngine) async throws -> String? { + nil + } + } + + /// Resolves Mach-O / bundle metadata for the inspected image on the engine + /// that actually owns the image, so export README metadata is not polluted + /// by the macOS host process picking up a same-named framework via + /// `DyldUtilities`' basename fallback. + struct ExportModuleInfoRequest: RuntimeEngineRequest { + let imagePath: String + let imageName: String + static var commandName: String { CommandNames.runtimeInterfaceExportModuleInfo.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeInterfaceExportMetadata.ModuleInfo { + RuntimeInterfaceExportMetadata.ModuleInfo.resolve(imagePath: imagePath, imageName: imageName) + } + } +} + +// MARK: - Objects & interfaces + +extension RuntimeEngine { + struct ObjectsInImageRequest: RuntimeEngineRequest { + let image: String + static var commandName: String { CommandNames.runtimeObjectsInImage.commandName } + func perform(on engine: RuntimeEngine) async throws -> [RuntimeObject] { + try await engine._objects(in: image) + } + } + + struct InterfaceRequest: RuntimeEngineRequest { + let object: RuntimeObject + let options: RuntimeObjectInterface.GenerationOptions + static var commandName: String { CommandNames.runtimeInterfaceForRuntimeObjectInImageWithOptions.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeObjectInterface? { + try await engine._interface(for: object, options: options) + } + } + + struct HierarchyRequest: RuntimeEngineRequest { + let object: RuntimeObject + static var commandName: String { CommandNames.runtimeObjectHierarchy.commandName } + func perform(on engine: RuntimeEngine) async throws -> [String] { + try await engine._hierarchy(for: object) + } + } + + struct RelationshipsRequest: RuntimeEngineRequest { + let object: RuntimeObject + static var commandName: String { CommandNames.runtimeRelationshipsForObject.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeRelationships { + await engine._relationships(for: object) + } + } + + struct MemberAddressesRequest: RuntimeEngineRequest { + let object: RuntimeObject + let memberName: String? + static var commandName: String { CommandNames.memberAddresses.commandName } + func perform(on engine: RuntimeEngine) async throws -> [RuntimeMemberAddress] { + try await engine._memberAddresses(for: object, memberName: memberName) + } + } +} + +// MARK: - Generic specialization + +extension RuntimeEngine { + /// Wire form of `specializationRequest(for:)`. The `for object:` half is in + /// `RuntimeEngine+GenericSpecialization.swift` so the public API and its + /// `RuntimeEngineRequest` shim stay co-located. + struct SpecializationRequestForObjectRequest: RuntimeEngineRequest { + let object: RuntimeObject + static var commandName: String { CommandNames.specializationRequest.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeSpecializationRequest { + try await engine._specializationRequest(for: object) + } + } + + struct SpecializationRequestForCandidateRequest: RuntimeEngineRequest { + let candidateID: String + let imagePath: String + static var commandName: String { CommandNames.specializationRequestForCandidate.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeSpecializationRequest { + try await engine._specializationRequest(forCandidateID: candidateID, in: imagePath) + } + } + + struct RuntimePreflightRequest: RuntimeEngineRequest { + let object: RuntimeObject + let selection: RuntimeSpecializationSelection + static var commandName: String { CommandNames.runtimePreflight.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeSpecializationValidation { + try await engine._runtimePreflight(for: object, with: selection) + } + } + + struct SpecializeRequest: RuntimeEngineRequest { + let object: RuntimeObject + let selection: RuntimeSpecializationSelection + static var commandName: String { CommandNames.specialize.commandName } + func perform(on engine: RuntimeEngine) async throws -> RuntimeObject { + try await engine._specialize(object, with: selection) + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index a5845606..9a907782 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -56,12 +56,16 @@ public actor RuntimeEngine { case isImageIndexed case mainExecutablePath case loadImageForBackgroundIndexing + case canOpenImage + case rpathsForImage + case dependenciesForImage case patchImagePathForDyld case runtimeObjectHierarchy case runtimeRelationshipsForObject case runtimeObjectInfo case imageNameOfClassName case observeRuntime + case runtimeInterfaceExportModuleInfo case runtimeInterfaceForRuntimeObjectInImageWithOptions case runtimeObjectsOfKindInImage case runtimeObjectsInImage @@ -118,7 +122,7 @@ public actor RuntimeEngine { private var needsReregistrationOnConnect = false private nonisolated let stateSubject = CurrentValueSubject(.initializing) - + /// Publisher that emits engine state changes. public nonisolated var statePublisher: some Publisher { stateSubject.eraseToAnyPublisher() @@ -137,9 +141,8 @@ public actor RuntimeEngine { private nonisolated let imageNodesSubject = CurrentValueSubject<[RuntimeImageNode], Never>([]) - public var imageNodes: [RuntimeImageNode] { + public nonisolated var imageNodes: [RuntimeImageNode] { get { imageNodesSubject.value } - set { imageNodesSubject.send(newValue) } } /// Publisher that emits image node changes. Accessible from any isolation context. @@ -195,32 +198,25 @@ public actor RuntimeEngine { private let communicator = RuntimeCommunicator() - /// The connection to the sender or receiver + /// The connection to the sender or receiver, established by `connect()`. private var connection: RuntimeConnection? - /// The XPC listener endpoint of this engine's connection, if applicable. - /// Set after `connect()` succeeds for XPC-based connections (macOS only). - /// Used by injected apps to register their endpoint with the Mach Service - /// for Host reconnection. Stored as `any Sendable` to avoid platform-specific - /// types in the actor interface; cast to `HelperPeerEndpoint` on macOS. - public private(set) var xpcListenerEndpoint: (any Sendable)? - /// Coordinator for background indexing batches that load and index images /// without blocking the main runtime data flow. `lazy` so it captures /// `self` only after all other stored properties are initialized; the /// actor's isolation guarantees the lazy initialization is single-threaded. public private(set) lazy var backgroundIndexingManager: RuntimeBackgroundIndexingManager = - RuntimeBackgroundIndexingManager(engine: self) + .init(engine: self) public init( source: RuntimeSource, engineID: String = UUID().uuidString, hostInfo: RuntimeHostInfo = RuntimeHostInfo( hostID: RuntimeNetworkBonjour.localInstanceID, - hostName: RuntimeNetworkBonjour.localHostName + hostName: RuntimeNetworkBonjour.localHostName, ), originChain: [String] = [RuntimeNetworkBonjour.localInstanceID], - pushesRuntimeData: Bool = true + pushesRuntimeData: Bool = true, ) { self.engineID = engineID self.source = source @@ -233,31 +229,26 @@ public actor RuntimeEngine { #log(.info, "Initializing RuntimeEngine with source: \(String(describing: source), privacy: .public)") } - public func connect(bonjourEndpoint: RuntimeNetworkEndpoint? = nil, xpcServerEndpoint: (any Sendable)? = nil) async throws { + public func connect(credential: RuntimeConnectionCredential? = nil) async throws { if let role = source.remoteRole { stateSubject.send(.connecting) switch role { case .server: #log(.info, "Starting as server") - connection = try await communicator.connect(to: source, bonjourEndpoint: bonjourEndpoint) { connection in + connection = try await communicator.connect(to: source, credential: credential) { connection in self.connection = connection self.setupMessageHandlerForServer() self.observeConnectionState(connection) } #log(.info, "Server connection established") - #if os(macOS) - if let xpcEndpointProvider = connection as? XPCListenerEndpointProviding { - xpcListenerEndpoint = xpcEndpointProvider.xpcListenerEndpoint - } - #endif if pushesRuntimeData { await observeRuntime() } stateSubject.send(.connected) case .client: #log(.info, "Starting as client for source: \(String(describing: self.source), privacy: .public)") - connection = try await communicator.connect(to: source, bonjourEndpoint: bonjourEndpoint, xpcServerEndpoint: xpcServerEndpoint) { connection in + connection = try await communicator.connect(to: source, credential: credential) { connection in #log(.debug, "[EngineMirroring] client connection modifier called for \(String(describing: self.source), privacy: .public), connection state: \(String(describing: connection.state), privacy: .public)") self.connection = connection self.setupMessageHandlerForClient() @@ -326,33 +317,31 @@ public actor RuntimeEngine { private func setupMessageHandlerForServer() { #log(.debug, "Setting up server message handlers") - setMessageHandlerBinding(forName: .isImageLoaded, of: self) { $0.isImageLoaded(path:) } - setMessageHandlerBinding(forName: .isImageIndexed, of: self) { $0.isImageIndexed(path:) } - setMessageHandlerBinding(forName: .mainExecutablePath) { engine -> String in - try await engine.mainExecutablePath() - } - setMessageHandlerBinding(forName: .loadImage, of: self) { $0.loadImage(at:) } - setMessageHandlerBinding(forName: .loadImageForBackgroundIndexing, of: self) { $0.loadImageForBackgroundIndexing(at:) } - setMessageHandlerBinding(forName: .imageNameOfClassName, of: self) { $0.imageName(ofObjectName:) } - - connection?.setMessageHandler(name: CommandNames.runtimeObjectsInImage.commandName) { [weak self] (imagePath: String) -> [RuntimeObject] in - guard let self else { throw RequestError.senderConnectionIsLose } - return try await self._serverObjectsWithProgress(in: imagePath) - } - setMessageHandlerBinding(forName: .runtimeInterfaceForRuntimeObjectInImageWithOptions, of: self) { $0.interface(for:) } - setMessageHandlerBinding(forName: .runtimeObjectHierarchy, of: self) { $0.hierarchy(for:) } - setMessageHandlerBinding(forName: .runtimeRelationshipsForObject, of: self) { $0.relationships(for:) } - setMessageHandlerBinding(forName: .memberAddresses, of: self) { $0.memberAddresses(for:) } - connection?.setMessageHandler(name: CommandNames.specializationRequest.commandName) { [weak self] (object: RuntimeObject) -> RuntimeSpecializationRequest in - guard let self else { throw RequestError.senderConnectionIsLose } - return try await self.specializationRequest(for: object) + guard let connection else { + #log(.default, "Connection is nil when setting up server message handlers") + return } - connection?.setMessageHandler(name: CommandNames.specializationRequestForCandidate.commandName) { [weak self] (request: SpecializationRequestForCandidateRequest) -> RuntimeSpecializationRequest in + // Shared registry — same set of commands that + // `RuntimeEngineProxyServer.setupRequestHandlers()` installs. + Self.registerSharedHandlers(on: connection, engine: self) + + // Server-only override: progress-bearing variant of + // `runtimeObjectsInImage`. Re-registering after the shared handler is + // intentional — the last `setMessageHandler` wins. + // + // The payload MUST stay `ObjectsInImageRequest` to match the wire form + // the client's `dispatch(ObjectsInImageRequest:)` and + // `_remoteObjectsWithProgress` both send, as well as the shared handler + // this overrides. Decoding it as a bare `String` here would fail every + // structured `objects(in:)` request with NSCocoaErrorDomain 4864. + connection.setMessageHandler(name: CommandNames.runtimeObjectsInImage.commandName) { [weak self] (request: ObjectsInImageRequest) -> [RuntimeObject] in guard let self else { throw RequestError.senderConnectionIsLose } - return try await self.specializationRequest(for: request) + return try await _serverObjectsWithProgress(in: request.image) } - setMessageHandlerBinding(forName: .runtimePreflight, of: self) { $0.runtimePreflight(for:) } - setMessageHandlerBinding(forName: .specialize, of: self) { $0.specialize(for:) } + + // Server-only: manager-layer engine list lookup. Not part of the + // shared registry because `RuntimeEngineProxyServer` runs below the + // manager and has no engine list of its own. setMessageHandlerBinding(forName: .engineList) { _ -> [RuntimeRemoteEngineDescriptor] in #log(.debug, "[EngineMirroring] engineList handler called, provider set: \(RuntimeEngine.engineListProvider != nil, privacy: .public)") let result = await RuntimeEngine.engineListProvider?() ?? [] @@ -365,7 +354,7 @@ public actor RuntimeEngine { private func setupMessageHandlerForClient() { #log(.debug, "Setting up client message handlers for source: \(String(describing: self.source), privacy: .public)") setMessageHandlerBinding(forName: .imageList) { $0.imageList = $1 } - setMessageHandlerBinding(forName: .imageNodes) { $0.imageNodes = $1 } + setMessageHandlerBinding(forName: .imageNodes) { $0.setImageNodes($1) } setMessageHandlerBinding(forName: .dataDidChange) { (engine: RuntimeEngine, change: RuntimeDataChange) in engine.dataChangeSubject.send(change) } @@ -426,7 +415,7 @@ public actor RuntimeEngine { /// Overload for commands with no request body but a response. private func setMessageHandlerBinding( forName name: CommandNames, - respond: @escaping (isolated RuntimeEngine) async throws -> Response + respond: @escaping (isolated RuntimeEngine) async throws -> Response, ) { guard let connection else { #log(.default, "Connection is nil when setting message handler for \(name.commandName, privacy: .public)") @@ -443,7 +432,7 @@ public actor RuntimeEngine { imageList = DyldUtilities.imageNames() #log(.debug, "Loaded \(self.imageList.count, privacy: .public) images") if isReloadImageNodes { - imageNodes = [DyldUtilities.dyldSharedCacheImageRootNode, DyldUtilities.otherImageRootNode] + setImageNodes([DyldUtilities.dyldSharedCacheImageRootNode, DyldUtilities.otherImageRootNode]) #log(.debug, "Reloaded image nodes") } broadcast(.fullReload(isReloadImageNodes: isReloadImageNodes)) @@ -489,7 +478,7 @@ public actor RuntimeEngine { } private func setImageNodes(_ imageNodes: [RuntimeImageNode]) { - self.imageNodes = imageNodes + self.imageNodesSubject.value = imageNodes } /// Forwards an `imageDidLoad` event to the connected client when this @@ -503,7 +492,7 @@ public actor RuntimeEngine { } } - private func _objects(in image: String) async throws -> [RuntimeObject] { + func _objects(in image: String) async throws -> [RuntimeObject] { #log(.debug, "Getting objects in image: \(image, privacy: .public)") let image = DyldUtilities.patchImagePathForDyld(image) let (isObjCSectionExisted, objcSection) = try await objcSectionFactory.section(for: image) @@ -517,7 +506,7 @@ public actor RuntimeEngine { return objcObjects + swiftObjects } - private func _interface(for name: RuntimeObject, options: RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? { + func _interface(for name: RuntimeObject, options: RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? { let rawInterface: RuntimeObjectInterface? switch name.kind { @@ -594,76 +583,60 @@ extension RuntimeEngine { case senderConnectionIsLose } - func request(local: () async throws -> T, remote: (_ senderConnection: RuntimeConnection) async throws -> T) async throws -> T { + /// Dispatch the request against this engine. On a client engine the call + /// is serialized and forwarded to the connected server; otherwise the + /// local `RuntimeEngineRequest.perform(on:)` implementation runs. + /// + /// Lives in this file rather than `RuntimeEngineRequest.swift` so it can read + /// the file-private `connection` directly without widening visibility. + func dispatch(_ request: R) async throws -> R.Response { if let remoteRole = source.remoteRole, remoteRole.isClient { guard let connection else { throw RequestError.senderConnectionIsLose } - return try await remote(connection) - } else { - return try await local() + return try await connection.sendMessage(name: R.commandName, request: request) } + return try await request.perform(on: self) } public func isImageLoaded(path: String) async throws -> Bool { - try await request { - imageList.contains(DyldUtilities.patchImagePathForDyld(path)) - } remote: { - return try await $0.sendMessage(name: .isImageLoaded, request: path) - } + try await dispatch(IsImageLoadedRequest(path: path)) } - public func loadImage(at path: String) async throws { - try await request { - // 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(canonical) - imageDidLoadSubject.send(canonical) - sendRemoteImageDidLoadIfNeeded(path: canonical) - } remote: { - try await $0.sendMessage(name: .loadImage, request: path) - } + func _isImageLoaded(path: String) -> Bool { + imageList.contains(DyldUtilities.patchImagePathForDyld(path)) } - public func imageName(ofObjectName name: RuntimeObject) async throws -> String? { - try await request { - nil - } remote: { - return try await $0.sendMessage(name: .imageNameOfClassName, request: name) - } + public func loadImage(at path: String) async throws { + _ = try await dispatch(LoadImageRequest(path: path)) + } + + /// Local implementation of `loadImage(at:)`. Canonicalizes 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. + func _loadImage(at path: String) async throws { + 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(canonical) + imageDidLoadSubject.send(canonical) + sendRemoteImageDidLoadIfNeeded(path: canonical) } - struct InterfaceRequest: Codable { - let object: RuntimeObject - let options: RuntimeObjectInterface.GenerationOptions + public func imageName(ofObjectName name: RuntimeObject) async throws -> String? { + try await dispatch(ImageNameOfObjectRequest(object: name)) } public func interface(for object: RuntimeObject, options: RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? { - return try await interface(for: .init(object: object, options: options)) - } - - private func interface(for request: InterfaceRequest) async throws -> RuntimeObjectInterface? { - try await self.request { - try await _interface(for: request.object, options: request.options) - } remote: { senderConnection in - return try await senderConnection.sendMessage(name: .runtimeInterfaceForRuntimeObjectInImageWithOptions, request: InterfaceRequest(object: request.object, options: request.options)) - } + try await dispatch(InterfaceRequest(object: object, options: options)) } public func objects(in image: String) async throws -> [RuntimeObject] { - try await request { - try await _objects(in: image) - } remote: { - return try await $0.sendMessage(name: .runtimeObjectsInImage, request: image) - } + try await dispatch(ObjectsInImageRequest(image: image)) } public func objectsWithProgress(in image: String) -> AsyncThrowingStream { @@ -674,11 +647,10 @@ extension RuntimeEngine { return } do { - let objects: [RuntimeObject] - if let remoteRole = self.source.remoteRole, remoteRole.isClient { - objects = try await self._remoteObjectsWithProgress(in: image, continuation: continuation) + let objects: [RuntimeObject] = if let remoteRole = source.remoteRole, remoteRole.isClient { + try await _remoteObjectsWithProgress(in: image, continuation: continuation) } else { - objects = try await self._localObjectsWithProgress(in: image, continuation: continuation) + try await _localObjectsWithProgress(in: image, continuation: continuation) } continuation.yield(.completed(objects)) continuation.finish() @@ -691,7 +663,7 @@ extension RuntimeEngine { private func _localObjectsWithProgress( in image: String, - continuation: AsyncThrowingStream.Continuation + continuation: AsyncThrowingStream.Continuation, ) async throws -> [RuntimeObject] { #log(.debug, "Getting objects with progress in image: \(image, privacy: .public)") let image = DyldUtilities.patchImagePathForDyld(image) @@ -721,28 +693,32 @@ extension RuntimeEngine { private func _remoteObjectsWithProgress( in image: String, - continuation: AsyncThrowingStream.Continuation + continuation: AsyncThrowingStream.Continuation, ) async throws -> [RuntimeObject] { guard let connection else { throw RequestError.senderConnectionIsLose } let cancellable = objectsLoadingProgressSubject.sink { progress in continuation.yield(RuntimeObjectsLoadingEvent.progress(progress)) } defer { cancellable.cancel() } - return try await connection.sendMessage(name: .runtimeObjectsInImage, request: image) + // Send the structured `ObjectsInImageRequest` (not a bare `String`) so the + // wire form matches the server's `runtimeObjectsInImage` handler, which + // decodes `ObjectsInImageRequest`. Keeping these symmetric is what lets + // both `objects(in:)` and `objectsWithProgress(in:)` hit the same server arm. + return try await connection.sendMessage(name: .runtimeObjectsInImage, request: ObjectsInImageRequest(image: image)) } public func hierarchy(for object: RuntimeObject) async throws -> [String] { - try await request { () -> [String] in - switch object.kind { - case .c: - return [] - case .objc: - return try await objcSectionFactory.existingSection(for: object.imagePath)?.classHierarchy(for: object) ?? [] - case .swift: - return try await swiftSectionFactory.existingSection(for: object.imagePath)?.classHierarchy(for: object) ?? [] - } - } remote: { - return try await $0.sendMessage(name: .runtimeObjectHierarchy, request: object) + try await dispatch(HierarchyRequest(object: object)) + } + + func _hierarchy(for object: RuntimeObject) async throws -> [String] { + switch object.kind { + case .c: + return [] + case .objc: + return try await objcSectionFactory.existingSection(for: object.imagePath)?.classHierarchy(for: object) ?? [] + case .swift: + return try await swiftSectionFactory.existingSection(for: object.imagePath)?.classHierarchy(for: object) ?? [] } } @@ -755,36 +731,26 @@ extension RuntimeEngine { /// factories; this method keeps only the thin local/remote dispatch. /// The remote arm forwards the query to the connected server. public func relationships(for object: RuntimeObject) async throws -> RuntimeRelationships { - try await request { - await relationshipsResolver.relationships(for: object) - } remote: { - return try await $0.sendMessage(name: .runtimeRelationshipsForObject, request: object) - } + try await dispatch(RelationshipsRequest(object: object)) } - struct MemberAddressesRequest: Codable { - let object: RuntimeObject - let memberName: String? + func _relationships(for object: RuntimeObject) async -> RuntimeRelationships { + await relationshipsResolver.relationships(for: object) } - + public func memberAddresses(for object: RuntimeObject, memberName: String?) async throws -> [RuntimeMemberAddress] { - try await memberAddresses(for: .init(object: object, memberName: memberName)) - } - - private func memberAddresses(for request: MemberAddressesRequest) async throws -> [RuntimeMemberAddress] { - try await self.request { - switch request.object.kind { - case .swift: - return try await swiftSectionFactory.existingSection(for: request.object.imagePath)?.memberAddresses(for: request.object, memberName: request.memberName) ?? [] - case .objc: - return try await objcSectionFactory.existingSection(for: request.object.imagePath)?.memberAddresses(for: request.object, memberName: request.memberName) ?? [] - default: - return [] - } - } remote: { senderConnection in - return try await senderConnection.sendMessage(name: .memberAddresses, request: request) - } + try await dispatch(MemberAddressesRequest(object: object, memberName: memberName)) + } + func _memberAddresses(for object: RuntimeObject, memberName: String?) async throws -> [RuntimeMemberAddress] { + switch object.kind { + case .swift: + return try await swiftSectionFactory.existingSection(for: object.imagePath)?.memberAddresses(for: object, memberName: memberName) ?? [] + case .objc: + return try await objcSectionFactory.existingSection(for: object.imagePath)?.memberAddresses(for: object, memberName: memberName) ?? [] + default: + return [] + } } /// Asks the connected peer for its shared engine list. @@ -795,16 +761,17 @@ extension RuntimeEngine { /// "treat as direct engine" branch instead of hanging forever on a flaky link /// (e.g. AWDL between iPhone and Mac). public func requestEngineList(timeout: TimeInterval = 5) async throws -> [RuntimeRemoteEngineDescriptor] { - try await request { - [] - } remote: { - try await $0.sendMessage(name: .engineList, timeout: timeout) - } + // Bypasses the standard `dispatch` because the client arm needs a + // configurable per-call timeout, and the local arm always returns an + // empty list (no engine-list provider runs on a non-server engine). + guard let remoteRole = source.remoteRole, remoteRole.isClient else { return [] } + guard let connection else { throw RequestError.senderConnectionIsLose } + return try await connection.sendMessage(name: .engineList, timeout: timeout) } public func pushEngineListChanged(_ descriptors: [RuntimeRemoteEngineDescriptor]) async throws { - let hasConnection = self.connection != nil - let isServer = self.source.remoteRole?.isServer == true + let hasConnection = connection != nil + let isServer = source.remoteRole?.isServer == true guard let connection, isServer else { #log(.debug, "[EngineMirroring] pushEngineListChanged skipped: connection=\(hasConnection, privacy: .public), isServer=\(isServer, privacy: .public)") return @@ -846,7 +813,7 @@ extension RuntimeEngine { public func exportInterfaces( with configuration: RuntimeInterfaceExportConfiguration, - reporter: RuntimeInterfaceExportReporter + reporter: RuntimeInterfaceExportReporter, ) async throws { defer { reporter.finish() } let startTime = CFAbsoluteTimeGetCurrent() @@ -873,7 +840,7 @@ extension RuntimeEngine { let item = RuntimeInterfaceExportItem( object: object, plainText: runtimeInterface.interfaceString.string, - suggestedFileName: object.exportFileName + suggestedFileName: object.exportFileName, ) results.append(item) succeeded += 1 @@ -900,12 +867,12 @@ extension RuntimeEngine { try RuntimeInterfaceExportWriter.writeSingleFile( items: objcItems, to: configuration.directory, - imageName: configuration.imageName + imageName: configuration.imageName, ) case .directory: let writeResult = try RuntimeInterfaceExportWriter.writeDirectory( items: objcItems, - to: configuration.directory + to: configuration.directory, ) for (failedItem, writeError) in writeResult.failedItems { reporter.send(.objectFailed(failedItem.object, writeError)) @@ -920,12 +887,12 @@ extension RuntimeEngine { try RuntimeInterfaceExportWriter.writeSingleFile( items: swiftItems, to: configuration.directory, - imageName: configuration.imageName + imageName: configuration.imageName, ) case .directory: let writeResult = try RuntimeInterfaceExportWriter.writeDirectory( items: swiftItems, - to: configuration.directory + to: configuration.directory, ) for (failedItem, writeError) in writeResult.failedItems { reporter.send(.objectFailed(failedItem.object, writeError)) @@ -935,12 +902,19 @@ extension RuntimeEngine { } if configuration.includeMetadata { + let module = try await dispatch( + ExportModuleInfoRequest( + imagePath: configuration.imagePath, + imageName: configuration.imageName + ) + ) let metadata = RuntimeInterfaceExportMetadata.make( configuration: configuration, + module: module, objcInterfaceCount: objcCount, swiftInterfaceCount: swiftCount, succeeded: succeeded, - failed: failed + writeFailed + failed: failed + writeFailed, ) try RuntimeInterfaceExportWriter.writeMetadata(metadata, to: configuration.directory) } @@ -956,7 +930,7 @@ extension RuntimeEngine { failed: failed + writeFailed, totalDuration: duration, objcCount: objcCount, - swiftCount: swiftCount + swiftCount: swiftCount, ) reporter.send(.completed(result)) } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift index dfaf6baf..e1fe085c 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift @@ -76,7 +76,7 @@ public actor RuntimeEngineProxyServer { return } let imageList = await engine.imageList - let imageNodes = await engine.imageNodes + let imageNodes = engine.imageNodes #log(.info, "[PROXY \(self.identifier, privacy: .public)] sendInitialData: imageList=\(imageList.count, privacy: .public), imageNodes=\(imageNodes.count, privacy: .public)") do { try await connection.sendMessage( @@ -121,82 +121,15 @@ public actor RuntimeEngineProxyServer { return } - connection.setMessageHandler(name: RuntimeEngine.CommandNames.isImageLoaded.commandName) { - [engine] (path: String) -> Bool in - 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) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.runtimeInterfaceForRuntimeObjectInImageWithOptions.commandName) { - [engine] (request: RuntimeEngine.InterfaceRequest) -> RuntimeObjectInterface? in - try await engine.interface(for: request.object, options: request.options) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.runtimeObjectHierarchy.commandName) { - [engine] (object: RuntimeObject) -> [String] in - try await engine.hierarchy(for: object) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.runtimeRelationshipsForObject.commandName) { - [engine] (object: RuntimeObject) -> RuntimeRelationships in - try await engine.relationships(for: object) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.loadImage.commandName) { - [engine] (path: String) in - 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) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.memberAddresses.commandName) { - [engine] (request: RuntimeEngine.MemberAddressesRequest) -> [RuntimeMemberAddress] in - try await engine.memberAddresses(for: request.object, memberName: request.memberName) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.specializationRequest.commandName) { - [engine] (object: RuntimeObject) -> RuntimeSpecializationRequest in - try await engine.specializationRequest(for: object) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.specializationRequestForCandidate.commandName) { - [engine] (request: RuntimeEngine.SpecializationRequestForCandidateRequest) -> RuntimeSpecializationRequest in - try await engine.specializationRequest(forCandidateID: request.candidateID, in: request.imagePath) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.runtimePreflight.commandName) { - [engine] (request: RuntimeEngine.SpecializeRequest) -> RuntimeSpecializationValidation in - try await engine.runtimePreflight(for: request.object, with: request.selection) - } - - connection.setMessageHandler(name: RuntimeEngine.CommandNames.specialize.commandName) { - [engine] (request: RuntimeEngine.SpecializeRequest) -> RuntimeObject in - try await engine.specialize(request.object, with: request.selection) - } + // Shared registry — same set of commands `RuntimeEngine`'s own server + // arm installs. Adding a new shared command in + // `RuntimeEngine.registerSharedHandlers(on:engine:)` automatically + // takes effect here too, eliminating the parallel-edit hazard that + // used to bite us every time a new command landed. + RuntimeEngine.registerSharedHandlers(on: connection, engine: engine) #if canImport(AppKit) + // Proxy-only: serve the running app icon to whichever client connects. let engineSource = engine.source connection.setMessageHandler(name: Self.iconRequestCommand) { () -> Data? in diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift new file mode 100644 index 00000000..2f6cebf5 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift @@ -0,0 +1,66 @@ +import Foundation +import RuntimeViewerCommunication + +/// A typed RuntimeEngine command. Each conforming type carries its own wire +/// name plus the local `perform(on:)` implementation, so a single declaration +/// drives all three call sites: the client-side dispatch, the server-side +/// handler registration, and `RuntimeEngineProxyServer`'s handler registration. +/// +/// Adding a new command therefore reduces to (1) declaring a new conformer and +/// (2) appending it to `RuntimeEngine.registerSharedHandlers(on:engine:)`. Both +/// server entry points pick the new handler up automatically — no more parallel +/// edits between `RuntimeEngine` and `RuntimeEngineProxyServer`. +public protocol RuntimeEngineRequest: Codable & Sendable { + associatedtype Response: Codable & Sendable + + static var commandName: String { get } + + func perform(on engine: RuntimeEngine) async throws -> Response +} + +/// Fire-and-forget marker so that `Void`-returning commands can ride the same +/// `RuntimeEngineRequest` machinery as response-bearing ones. Encodes as `{}`. +public struct RuntimeEngineEmpty: Codable, Sendable { + public init() {} +} + +extension RuntimeEngine { + /// Register a single request type on `connection`, routing inbound + /// commands of that type to `perform(on: engine)`. + static func register( + _ requestType: R.Type, + on connection: RuntimeConnection, + engine: RuntimeEngine + ) { + connection.setMessageHandler(name: R.commandName) { (request: R) -> R.Response in + try await request.perform(on: engine) + } + } + + /// All request types that both `setupMessageHandlerForServer` and + /// `RuntimeEngineProxyServer.setupRequestHandlers` need to expose. + /// Adding a command requires only appending one line here — see the + /// matching Request struct in `RuntimeEngine+Requests.swift` / + /// `RuntimeEngine+GenericSpecialization.swift`. + static func registerSharedHandlers(on connection: RuntimeConnection, engine: RuntimeEngine) { + register(IsImageLoadedRequest.self, on: connection, engine: engine) + register(IsImageIndexedRequest.self, on: connection, engine: engine) + register(MainExecutablePathRequest.self, on: connection, engine: engine) + register(LoadImageRequest.self, on: connection, engine: engine) + register(LoadImageForBackgroundIndexingRequest.self, on: connection, engine: engine) + register(CanOpenImageRequest.self, on: connection, engine: engine) + register(RpathsRequest.self, on: connection, engine: engine) + register(DependenciesRequest.self, on: connection, engine: engine) + register(ImageNameOfObjectRequest.self, on: connection, engine: engine) + register(ExportModuleInfoRequest.self, on: connection, engine: engine) + register(ObjectsInImageRequest.self, on: connection, engine: engine) + register(InterfaceRequest.self, on: connection, engine: engine) + register(HierarchyRequest.self, on: connection, engine: engine) + register(RelationshipsRequest.self, on: connection, engine: engine) + register(MemberAddressesRequest.self, on: connection, engine: engine) + register(SpecializationRequestForObjectRequest.self, on: connection, engine: engine) + register(SpecializationRequestForCandidateRequest.self, on: connection, engine: engine) + register(RuntimePreflightRequest.self, on: connection, engine: engine) + register(SpecializeRequest.self, on: connection, engine: engine) + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift index d8bff3f7..fa6b24a5 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/DyldUtilities.swift @@ -121,17 +121,27 @@ package enum DyldUtilities { /// a path whose exact spelling doesn't match dyld's registered form /// (e.g. symlink-resolved vs. raw, or a bare framework name). package static func machOImage(forPath path: String) -> MachOImage? { - if path == mainExecutablePath(), - let debugDylib = loadedImage(forExactPath: path + ".debug.dylib") { - return debugDylib - } - if let exact = loadedImage(forExactPath: path) { + if let exact = exactMachOImage(forPath: path) { return exact } let imageName = path.lastPathComponent.deletingPathExtension.deletingPathExtension return MachOImage(name: imageName) } + /// Resolves a filesystem path to a loaded `MachOImage` without basename + /// fallback. + /// + /// Export metadata must use exact matching only: when inspecting a remote + /// iOS simulator image from the macOS app process, a fuzzy basename match + /// can accidentally pick the host's same-named framework. + package static func exactMachOImage(forPath path: String) -> MachOImage? { + if path == mainExecutablePath(), + let debugDylib = loadedImage(forExactPath: path + ".debug.dylib") { + return debugDylib + } + return loadedImage(forExactPath: path) + } + /// Returns the loaded `MachOImage` whose dyld-registered path equals /// `path` byte-for-byte, or `nil` if no such image is loaded. private static func loadedImage(forExactPath path: String) -> MachOImage? { diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift index ce0316c5..ec5e84aa 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift @@ -255,7 +255,7 @@ struct RuntimeMessageChannelPendingRequestTests { func testNoPendingRequest() { let channel = RuntimeMessageChannel() - let delivered = channel.deliverToPendingRequest(identifier: "nonexistent", data: Data()) + let delivered = channel.deliverToPendingRequest(routingKey: "nonexistent", data: Data()) #expect(delivered == false) } } @@ -350,7 +350,11 @@ struct RuntimeMessageChannelTimeoutTests { func testTimeoutFiresWhenResponseMissing() async throws { let channel = RuntimeMessageChannel() let identifier = "timeout-test-1" - let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null) + // Caller-supplied nonce makes the test's `deliverToPendingRequest` + // lookup match the routing key `sendRequest` registers under; + // without it the channel would mint a fresh UUID and the test + // couldn't reach the pending entry by name. + let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null, nonce: identifier) let start = Date() do { @@ -369,8 +373,8 @@ struct RuntimeMessageChannelTimeoutTests { #expect(elapsed < 1.0) // Once the deadline fired, the pending entry must be gone — a late response - // arriving with the same identifier should not match anything. - let lateDelivery = channel.deliverToPendingRequest(identifier: identifier, data: Data()) + // arriving with the same routing key should not match anything. + let lateDelivery = channel.deliverToPendingRequest(routingKey: identifier, data: Data()) #expect(lateDelivery == false) } @@ -378,7 +382,9 @@ struct RuntimeMessageChannelTimeoutTests { func testTimeoutDoesNotFireWhenResponseFastEnough() async throws { let channel = RuntimeMessageChannel() let identifier = "timeout-test-2" - let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null) + // Caller-supplied nonce so the response envelope below can be + // routed back to this exact pending entry by name. + let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null, nonce: identifier) async let response: String = channel.sendRequest( requestData: payload, @@ -391,9 +397,9 @@ struct RuntimeMessageChannelTimeoutTests { // response synthetically. We deliver the same wire shape sendRequest expects: // an outer RuntimeRequestData whose `data` field decodes into Response. try await Task.sleep(nanoseconds: 50_000_000) - let responseEnvelope = try RuntimeRequestData(identifier: identifier, value: "ok") + let responseEnvelope = try RuntimeRequestData(identifier: identifier, value: "ok", nonce: identifier) let envelopeData = try JSONEncoder().encode(responseEnvelope) - let delivered = channel.deliverToPendingRequest(identifier: identifier, data: envelopeData) + let delivered = channel.deliverToPendingRequest(routingKey: identifier, data: envelopeData) #expect(delivered) let actual = try await response @@ -404,7 +410,7 @@ struct RuntimeMessageChannelTimeoutTests { func testNilTimeoutDoesNotFire() async throws { let channel = RuntimeMessageChannel() let identifier = "timeout-test-3" - let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null) + let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null, nonce: identifier) async let response: String = channel.sendRequest( requestData: payload, @@ -415,9 +421,9 @@ struct RuntimeMessageChannelTimeoutTests { // the call must still be waiting for an explicit response or disconnect. try await Task.sleep(nanoseconds: 300_000_000) - let responseEnvelope = try RuntimeRequestData(identifier: identifier, value: "delivered") + let responseEnvelope = try RuntimeRequestData(identifier: identifier, value: "delivered", nonce: identifier) let envelopeData = try JSONEncoder().encode(responseEnvelope) - let delivered = channel.deliverToPendingRequest(identifier: identifier, data: envelopeData) + let delivered = channel.deliverToPendingRequest(routingKey: identifier, data: envelopeData) #expect(delivered) let actual = try await response @@ -449,17 +455,22 @@ struct RuntimeMessageChannelTimeoutTests { #expect(elapsed < 1.0) } - @Test("a finished request's timeout task does not spuriously fail a later same-identifier request") + @Test("a finished request's timeout task does not spuriously fail a later same-routing-key request") func testTimeoutDoesNotPoisonLaterRequestUnderSameIdentifier() async throws { struct WriterFailure: Error, Equatable {} let channel = RuntimeMessageChannel() let identifier = "timeout-test-5" - let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null) + // Force both round trips to use the same routing key by supplying + // the nonce ourselves; this is the pessimistic scenario the + // timeout-cleanup guarantee has to hold under. (Real production + // traffic gives every round trip a fresh UUID nonce, so they end + // up in distinct dictionary slots and can't collide at all.) + let payload = try RuntimeRequestData(identifier: identifier, value: RuntimeMessageNull.null, nonce: identifier) // Step 1: first request fails fast in the writer, with a short timeout. If the // timeout task were left orphaned, it would wake at ~0.3 s and remove whatever - // entry happens to be registered under the same identifier — clobbering step 2. + // entry happens to be registered under the same routing key — clobbering step 2. do { let _: String = try await channel.sendRequest( requestData: payload, @@ -472,7 +483,7 @@ struct RuntimeMessageChannelTimeoutTests { // expected } - // Step 2: register a second request under the *same* identifier, with a longer + // Step 2: register a second request under the *same* routing key, with a longer // timeout. Sleep past the first request's would-be deadline (0.3 s) but well // under the second's (3 s), then deliver a synthetic response. async let secondResponse: String = channel.sendRequest( @@ -486,9 +497,9 @@ struct RuntimeMessageChannelTimeoutTests { // the first request's stale deadline. try await Task.sleep(nanoseconds: 600_000_000) - let envelope = try RuntimeRequestData(identifier: identifier, value: "ok") + let envelope = try RuntimeRequestData(identifier: identifier, value: "ok", nonce: identifier) let envelopeData = try JSONEncoder().encode(envelope) - let delivered = channel.deliverToPendingRequest(identifier: identifier, data: envelopeData) + let delivered = channel.deliverToPendingRequest(routingKey: identifier, data: envelopeData) #expect(delivered, "second request should still be pending — first request's timer must not have removed it") let actual = try await secondResponse @@ -496,6 +507,150 @@ struct RuntimeMessageChannelTimeoutTests { } } +// MARK: - Burst / Stack-Overflow Regression Tests +// +// These tests pin down the f2b6324 regression that the buffered-stream bridge +// in `RuntimeNetworkConnection.observeIncomingMessages` was added to fix. +// +// Background — `RuntimeMessageChannel.processReceivedData` extracts every +// complete frame in a single `_receivingData.withLock` pass and then fires +// `onMessageReceived` for each frame in a tight synchronous for-loop. When the +// transport (NWConnection here) is on a `DispatchQueue` and a single `receive` +// callback delivers a burst of small frames (heartbeats, acks, batch-export +// chunks), that loop runs on the dispatch-queue worker thread. The pre-fix +// `RuntimeNetworkConnection.observeIncomingMessages` spawned a fresh +// `Task { await handleReceivedMessage(data) }` *inside the callback body* — +// every iteration leaves the `swift_task_create_commonImpl` / `addStatusRecord` +// / `task_status_changed` / `os_signpost_*` frames pinned on the dispatch +// queue's stack, and the thread overflows once the burst clears a few thousand +// frames. The fix routes the callback through an unbounded `AsyncStream` and +// drains it from one long-lived consumer task, so the hot loop only does a +// non-blocking `yield`. + +@Suite("RuntimeMessageChannel Burst Regression", .serialized) +struct RuntimeMessageChannelBurstRegressionTests { + + @Test("Buffered stream bridge survives 10k-message burst from a dispatch queue with FIFO + no loss") + func testBufferedStreamBridgeSurvivesBurst() async throws { + let channel = RuntimeMessageChannel() + // Same bridge shape as RuntimeNetworkConnection: callback does an + // unbounded `yield`, a long-lived consumer drains the stream. + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + channel.onMessageReceived = { data in + continuation.yield(data) + } + + let endMarker = RuntimeMessageChannel.endMarkerData + let messageCount = 10_000 + var buffer = Data() + for messageIndex in 0.. (Bool, Bool) in + var localHeuristic = false + var localCustom = false + for await event in events { + switch event { + case .batchCancelled(let batch) where batch.id == heuristicId: + localHeuristic = true + case .batchFinished(let batch) where batch.id == customId: + localCustom = true + case .batchCancelled(let batch) where batch.id == customId: + // Should not happen — captured for assertion below. + localCustom = false + default: + break + } + if localHeuristic && localCustom { return (localHeuristic, localCustom) } + } + return (localHeuristic, localCustom) + } + (heuristicCancelled, customFinishedNaturally) = await observer.value + #expect(heuristicCancelled, "Heuristic batch must emit batchCancelled") + #expect(customFinishedNaturally, "Custom batch must run to completion (batchFinished)") + } + + /// `.alwaysIndex(identifier:)` carries the raw user-supplied string through + /// the manager untouched so the popover can render it verbatim. This guards + /// against accidental normalization (e.g. resolving the identifier to its + /// path and stuffing that into the reason). + @Test func alwaysIndexReasonRoundTripsThroughEvents() async { + let engine = keep(MockBackgroundIndexingEngine()) + engine.program(path: "/System/Library/Frameworks/Foundation.framework/Foundation", + .init(isIndexed: true)) // short-circuit immediately + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let events = manager.events + let consumer = Task { () -> [RuntimeIndexingBatchReason] in + var reasons: [RuntimeIndexingBatchReason] = [] + for await event in events { + switch event { + case .batchStarted(let batch): + reasons.append(batch.reason) + case .batchFinished(let batch): + reasons.append(batch.reason) + return reasons + case .batchCancelled(let batch): + reasons.append(batch.reason) + return reasons + default: + break + } + } + return reasons + } + + _ = await manager.startBatch( + rootImagePath: "/System/Library/Frameworks/Foundation.framework/Foundation", + depth: 0, maxConcurrency: 1, + reason: .alwaysIndex(identifier: "Foundation")) + let reasons = await consumer.value + #expect(reasons == [.alwaysIndex(identifier: "Foundation"), + .alwaysIndex(identifier: "Foundation")]) + } + /// 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 { diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/ExportTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/ExportTests.swift index e74f3560..c7968bdd 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/ExportTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/ExportTests.swift @@ -113,6 +113,45 @@ struct RuntimeInterfaceExportMetadataTests { #expect(readme.contains("- RuntimeViewer license: MIT License")) } + @Test("make uses engine-provided module metadata") + func makeUsesEngineProvidedModuleMetadata() { + let configuration = RuntimeInterfaceExportConfiguration( + imagePath: "/remote/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", + imageName: "SwiftUICore", + directory: FileManager.default.temporaryDirectory, + objcFormat: .singleFile, + swiftFormat: .singleFile, + generationOptions: .mcp + ) + let module = RuntimeInterfaceExportMetadata.ModuleInfo( + name: "SwiftUICore", + path: "/remote/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", + resolvedPath: nil, + bundleIdentifier: "com.apple.SwiftUICore", + bundleShortVersion: "8.0.66.1.103", + bundleVersion: "8.0.66.1.103", + installName: "/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", + currentVersion: "8.0.66", + compatibilityVersion: "1.0.0", + sourceVersion: "1165.1.103", + uuid: "A8FC6D2D-DFE9-3557-A734-7F2B231F8C97" + ) + + let metadata = RuntimeInterfaceExportMetadata.make( + configuration: configuration, + module: module, + objcInterfaceCount: 1, + swiftInterfaceCount: 2, + succeeded: 3, + failed: 0, + generatedAt: Date(timeIntervalSince1970: 0) + ) + + #expect(metadata.module.installName == "/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore") + #expect(metadata.module.currentVersion == "8.0.66") + #expect(metadata.module.sourceVersion == "1165.1.103") + } + @Test("writer emits README only") func writerEmitsReadmeOnly() throws { let directory = FileManager.default.temporaryDirectory diff --git a/RuntimeViewerMCP/Package.swift b/RuntimeViewerMCP/Package.swift index 8f86cd1e..18a2d987 100644 --- a/RuntimeViewerMCP/Package.swift +++ b/RuntimeViewerMCP/Package.swift @@ -1,6 +1,81 @@ // swift-tools-version: 6.2 import PackageDescription +import Foundation + +let localEnvironment: [String: String] = { + let localEnvironmentFilePath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent(".package.env") + .path + guard FileManager.default.fileExists(atPath: localEnvironmentFilePath), + let contents = try? String(contentsOfFile: localEnvironmentFilePath, encoding: .utf8) + else { + return [:] + } + var environment: [String: String] = [:] + for line in contents.components(separatedBy: .newlines) { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") { + continue + } + let parts = trimmedLine.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = parts[0].trimmingCharacters(in: .whitespaces) + let value = parts[1].trimmingCharacters(in: .whitespaces) + environment[key] = value + } + return environment +}() + +func envEnable(_ key: String, default defaultValue: Bool = false) -> Bool { + let value = localEnvironment[key] ?? Context.environment[key] + guard let value else { + return defaultValue + } + if value == "1" { + return true + } else if value == "0" { + return false + } else { + return defaultValue + } +} + +let usingLocalDependencies = envEnable("USING_LOCAL_DEPENDENCIES") + +extension Package.Dependency { + enum LocalSearchPath { + case package(path: String, isRelative: Bool, isEnabled: Bool = usingLocalDependencies, traits: Set = [.defaults]) + } + + static func package(local localSearchPaths: LocalSearchPath..., remote: Package.Dependency) -> Package.Dependency { + let currentFilePath = #filePath + let isClonedDependency = currentFilePath.contains("/checkouts/") || + currentFilePath.contains("/SourcePackages/") || + currentFilePath.contains("/.build/") + + if isClonedDependency { + return remote + } + for local in localSearchPaths { + switch local { + case .package(let path, let isRelative, let isEnabled, let traits): + guard isEnabled else { continue } + let url = if isRelative { + URL(fileURLWithPath: path, relativeTo: URL(fileURLWithPath: #filePath)) + } else { + URL(fileURLWithPath: path) + } + + if FileManager.default.fileExists(atPath: url.path) { + return .package(path: url.path, traits: traits) + } + } + } + return remote + } +} let package = Package( name: "RuntimeViewerMCP", diff --git a/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index c94dc61b..d1549771 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5d30de8440b0a6bd270a101d248638191711eb76bcab03663473d2a25d71ed2a", + "originHash" : "87c078de9798a5eb9f341245f98da58b0ac955198ef85190eb208c2ee84eb723", "pins" : [ { "identity" : "associatedobject", @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "4ab712e98990bb3a44a5ef160bd0b0c3f03ace08", - "version" : "0.5.5" + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ukushu/Ifrit", "state" : { - "revision" : "9b9556e14cee24ad16b19d0eb099283cf79a7d94", - "version" : "3.0.0" + "revision" : "7c889a67bad90c5efefa56889b2d61bfbb831473", + "version" : "4.0.0" } }, { @@ -145,6 +145,15 @@ "version" : "0.5.0" } }, + { + "identity" : "openuxkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenUXKit/OpenUXKit", + "state" : { + "branch" : "main", + "revision" : "21b944e638ff66d45ba1f550483c875be3c1f93f" + } + }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -157,7 +166,7 @@ { "identity" : "rearrange", "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/Rearrange.git", + "location" : "https://github.com/ChimeHQ/Rearrange", "state" : { "revision" : "4de8be41dba304192e87dc0a11e0aa39e72aa2e8", "version" : "2.1.1" @@ -199,22 +208,13 @@ "version" : "6.10.2" } }, - { - "identity" : "rxswiftplus", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/RxSwiftPlus", - "state" : { - "revision" : "d40a7d551b58ce3006eaabcaa3721b99db72ea78", - "version" : "0.2.3" - } - }, { "identity" : "semaphore", "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Library-Forks/Semaphore", "state" : { - "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", - "version" : "0.1.0" + "revision" : "e6a244dec033ed1033878d5da5911a3ba2489701", + "version" : "0.1.1" } }, { @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/SFSymbols", "state" : { - "revision" : "98c7f4a22419d8034a058a8bfaec7cba4e53dcca", - "version" : "0.2.0" + "revision" : "373b919278adc197759ebcc2018956cacff1cc57", + "version" : "0.3.0" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SnapKit/SnapKit", "state" : { - "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", - "version" : "5.7.1" + "revision" : "e27a338a03a5f388de759da63f9baf7988ed9e00", + "version" : "6.0.0" } }, { @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-apinotes", "state" : { - "revision" : "2fe208c1824f053c04778c5dad2a6fe93d8ab3bf", - "version" : "0.1.0" + "revision" : "e762ac739f71adf83ed2e05f40211c96cd91f6c5", + "version" : "0.2.0" } }, { @@ -258,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "5e0406d68a4937f21ae5f670b8f89dad1d156a1c", - "version" : "1.8.0" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -303,8 +303,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-clang", "state" : { - "revision" : "f92a834ed33249612d9ef30a41d8254d32a7602a", - "version" : "0.2.0" + "revision" : "89195b5191f6678da7e782cec24f2cbc8d75cf79", + "version" : "0.3.0" } }, { @@ -330,8 +330,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" } }, { @@ -357,8 +357,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", - "version" : "1.5.0" + "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", + "version" : "1.6.0" } }, { @@ -366,8 +366,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", - "version" : "1.12.0" + "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", + "version" : "1.13.0" } }, { @@ -375,8 +375,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/swift-dyld-private", "state" : { - "revision" : "2f5ce94df9b1356d3c75793a659bf22f35bc8699", - "version" : "1.2.0" + "revision" : "e9b255ed123e0ff5bb998d11639b993196159214", + "version" : "1.2.1" } }, { @@ -397,6 +397,15 @@ "version" : "0.2.2" } }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, { "identity" : "swift-literal-type-inference", "kind" : "remoteSourceControl", @@ -418,28 +427,19 @@ { "identity" : "swift-mobile-gestalt", "kind" : "remoteSourceControl", - "location" : "https://github.com/p-x9/swift-mobile-gestalt", + "location" : "https://github.com/MxIris-Library-Forks/swift-mobile-gestalt", "state" : { - "revision" : "aa9e0a9dde0be80f395a77888851e4afdd6f4252", - "version" : "0.4.0" + "revision" : "c17c533c080e30b9797922861025c4c20b1b7e74", + "version" : "0.5.0" } }, { "identity" : "swift-navigation", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", - "version" : "2.8.0" - } - }, - { - "identity" : "swift-objc-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump.git", + "location" : "https://github.com/MxIris-Library-Forks/swift-navigation", "state" : { - "revision" : "4206040acd64db453c5c28c1539b98fa5befa8fc", - "version" : "0.8.100" + "revision" : "4f27f7cd5cf12caeeeae25e05a23f82d8f0d5813", + "version" : "2.8.100" } }, { @@ -488,12 +488,12 @@ } }, { - "identity" : "systemhud", + "identity" : "uxkitcoordinator", "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/SystemHUD", + "location" : "https://github.com/OpenUXKit/UXKitCoordinator", "state" : { - "revision" : "42d259b5d2b3d5cb4ce14281ce86f010034ff36c", - "version" : "0.1.0" + "branch" : "main", + "revision" : "481806584ed23911fbe0449fdda57085f8615779" } }, { @@ -519,8 +519,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", - "version" : "6.2.1" + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" } } ], diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 5545b28f..ddc8b3ed 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -77,9 +77,11 @@ struct MxIrisStudioWorkspace: RawRepresentable, ExpressibleByStringLiteral, Cust } } +let usingLocalDependencies = envEnable("USING_LOCAL_DEPENDENCIES") + extension Package.Dependency { enum LocalSearchPath { - case package(path: String, isRelative: Bool, isEnabled: Bool, traits: Set = [.defaults]) + case package(path: String, isRelative: Bool, isEnabled: Bool = usingLocalDependencies, traits: Set = [.defaults]) } static func package(local localSearchPaths: LocalSearchPath..., remote: Package.Dependency) -> Package.Dependency { @@ -116,8 +118,6 @@ let uikitPlatforms: [Platform] = [.iOS, .tvOS, .visionOS] let usingSystemUXKit = envEnable("USING_SYSTEM_UXKIT", default: true) -let usingLocalDependencies = envEnable("USING_LOCAL_DEPENDENCIES") - var sharedSwiftSettings: [SwiftSetting] = [] let UIFoundationTraits: Set = ["AppleInternal", "FilterUI", "IDEIcons", "QuickActionBar", "NSAttributedStringBuilder"] @@ -179,18 +179,16 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("UIFoundation"), isRelative: true, - isEnabled: usingLocalDependencies, traits: UIFoundationTraits, ), .package( path: "../../UIFoundation", isRelative: true, - isEnabled: usingLocalDependencies, traits: UIFoundationTraits, ), remote: .package( url: "https://github.com/Mx-Iris/UIFoundation", - from: "0.8.2", + from: "0.10.2", traits: UIFoundationTraits, ), ), @@ -199,16 +197,14 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.forkLibraryDirectory.libraryPath("XCoordinator"), isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../XCoordinator", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/MxIris-Library-Forks/XCoordinator", - from: "3.0.0-beta", + from: "3.0.0-beta.1", ), ), @@ -216,12 +212,10 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("CocoaCoordinator"), isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../CocoaCoordinator", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/CocoaCoordinator", @@ -230,16 +224,14 @@ let package = Package( ), .package( - local: .package( - path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("UXKitCoordinator"), - isRelative: true, - isEnabled: usingLocalDependencies, - ), - .package( - path: "../../UXKitCoordinator", - isRelative: true, - isEnabled: usingLocalDependencies, - ), +// local: .package( +// path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("UXKitCoordinator"), +// isRelative: true, +// ), +// .package( +// path: "../../UXKitCoordinator", +// isRelative: true, +// ), remote: .package( url: "https://github.com/OpenUXKit/UXKitCoordinator", branch: "main", @@ -250,25 +242,22 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("RxSwiftPlus"), isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/RxSwiftPlus", - from: "0.2.2", + from: "0.2.3", ), ), .package( - local: .package( - path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("OpenUXKit"), - isRelative: true, - isEnabled: usingLocalDependencies, - ), - .package( - path: "../../OpenUXKit", - isRelative: true, - isEnabled: usingLocalDependencies, - ), +// local: .package( +// path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("OpenUXKit"), +// isRelative: true, +// ), +// .package( +// path: "../../OpenUXKit", +// isRelative: true, +// ), remote: .package( url: "https://github.com/OpenUXKit/OpenUXKit", branch: "main", @@ -279,16 +268,14 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("RxAppKit"), isRelative: true, - isEnabled: usingLocalDependencies, ), -// .package( -// path: "../../RxAppKit", -// isRelative: true, -// isEnabled: usingLocalDependencies, -// ), + .package( + path: "../../RxAppKit", + isRelative: true, + ), remote: .package( url: "https://github.com/Mx-Iris/RxAppKit", - from: "0.3.0", + from: "0.5.0", ), ), @@ -296,12 +283,10 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryIOSDirectory.libraryPath("RxUIKit"), isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../RxUIKit", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/RxUIKit", @@ -313,16 +298,14 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("swift-helper-service"), isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../swift-helper-service", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/swift-helper-service", - from: "0.1.2", + from: "0.1.3", ), ), @@ -330,12 +313,10 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("RunningApplicationKit"), isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../RunningApplicationKit", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/RunningApplicationKit", @@ -347,7 +328,6 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("SystemHUD"), isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/SystemHUD", @@ -357,17 +337,17 @@ let package = Package( .package( url: "https://github.com/SnapKit/SnapKit", - from: "5.0.0", + from: "6.0.0", ), .package( url: "https://github.com/ReactiveX/RxSwift", - from: "6.0.0", + from: "6.10.2", ), .package( url: "https://github.com/Mx-Iris/SFSymbols", - from: "0.2.0", + from: "0.3.0", ), .package( @@ -392,7 +372,7 @@ let package = Package( .package( url: "https://github.com/pointfreeco/swift-dependencies", - from: "1.9.4", + from: "1.13.0", ), .package( @@ -402,7 +382,7 @@ let package = Package( .package( url: "https://github.com/ukushu/Ifrit", - from: "3.0.0", + from: "4.0.0", ), .package( @@ -422,12 +402,12 @@ let package = Package( .package( url: "https://github.com/siteline/swiftui-introspect", - from: "26.0.0", + from: "26.0.1", ), .package( url: "https://github.com/ChimeHQ/Rearrange", - from: "2.0.0", + from: "2.1.1", ), ], @@ -499,7 +479,7 @@ let package = Package( dependencies: [ "RuntimeViewerUI", "RuntimeViewerArchitectures", - .target(name: "RuntimeViewerSettings", condition: .when(platforms: appkitPlatforms)), + "RuntimeViewerSettings", .target(name: "RuntimeViewerSettingsUI", condition: .when(platforms: appkitPlatforms)), .target(name: "RuntimeViewerHelperClient", condition: .when(platforms: appkitPlatforms)), .target(name: "RuntimeViewerCatalystExtensions", condition: .when(platforms: appkitPlatforms)), diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index feefe78d..5033e27f 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -12,10 +12,10 @@ 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()`. + /// many always-index retries 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 { @@ -59,15 +59,39 @@ public final class RuntimeBackgroundIndexingCoordinator { private static let coalesceWindowNanos: UInt64 = 16_000_000 private var eventPumpTask: Task? - private var imageLoadedPumpTask: Task? - private var lastKnownIsEnabled: Bool = false + /// Pump that re-runs `startAlwaysIndexBatches()` after every fullReload. + /// Required because remote engines (XPC / Bonjour) populate `imageList` + /// asynchronously: the first `documentDidOpen` may see an empty list and + /// resolve every imageName-only identifier to nil. Once the server's + /// fullReload broadcast lands and the client's `imageList` is set, this + /// pump retries. `dispatchedAlwaysIndexIdentifiers` gates re-entry so + /// the pump idles for identifiers that have already produced a batch + /// this engine session — otherwise every batch finish → `reloadData` → + /// pump → empty-batch-start → finish → loop would spin forever. + private var reloadDataPumpTask: Task? + /// Last observed values of each Settings.Indexing toggle, used by + /// `handleSettingsChange` to detect transitions and dispatch only the + /// minimal start / cancel work. Seeded by `bootstrapSettingsObservation`. + private var lastKnownMasterEnabled: Bool = false + private var lastKnownHeuristicEnabled: Bool = false + private var lastKnownCustomEnabled: Bool = false + #if canImport(RuntimeViewerSettings) + private var lastKnownCustomEntries: [Settings.Indexing.AlwaysIndexEntry] = [] + /// Identifiers from `custom.entries` that have successfully resolved + /// to a path and had `startBatch` dispatched at least once during the + /// current engine session. Used by `startAlwaysIndexBatches` to skip + /// no-op re-entry triggered by the reload pump. Reset on engine swap, + /// master off→on, custom off→on, and entry-list change so genuinely + /// new work re-runs. + private var dispatchedAlwaysIndexIdentifiers: Set = [] + #endif public init(documentState: DocumentState) { self.documentState = documentState self.engine = documentState.runtimeEngine startEventPump() #if canImport(RuntimeViewerSettings) - startImageLoadedPump() + startReloadDataPump() bootstrapSettingsObservation() #endif bootstrapEngineObservation() @@ -75,7 +99,7 @@ public final class RuntimeBackgroundIndexingCoordinator { deinit { eventPumpTask?.cancel() - imageLoadedPumpTask?.cancel() + reloadDataPumpTask?.cancel() } // MARK: - Public observables for UI @@ -249,20 +273,20 @@ public final class RuntimeBackgroundIndexingCoordinator { // looping over an AsyncStream owned by the old manager; cancelling // them ends the loops cleanly. eventPumpTask?.cancel() - imageLoadedPumpTask?.cancel() + reloadDataPumpTask?.cancel() eventPumpTask = nil - imageLoadedPumpTask = nil + reloadDataPumpTask = nil // 2) Cancel **all** in-flight batches on the old manager — not just // the ones in `documentBatchIDs`. A `startBatch` Task that // suspended before its id was inserted into `documentBatchIDs` // would otherwise leak: the `self.engine === engine` guard in - // `startMainExecutableBatch` / `handleImageLoaded` correctly drops - // its id, but the batch itself remains active on the old manager - // and runs to completion uninterrupted, occupying CPU and the - // section-cache slots until the old engine is finally deinit'd. - // `cancelAllBatches` covers both already-tracked batches and any - // swap-window arrivals. + // `startMainExecutableBatch` / `startAlwaysIndexBatches` correctly + // drops its id, but the batch itself remains active on the old + // manager and runs to completion uninterrupted, occupying CPU and + // the section-cache slots until the old engine is finally + // deinit'd. `cancelAllBatches` covers both already-tracked + // batches and any swap-window arrivals. // // Fire-and-forget — old engine's manager will deinit shortly. Task { @@ -283,9 +307,14 @@ public final class RuntimeBackgroundIndexingCoordinator { // 5) Restart pumps on the new engine's manager. startEventPump() #if canImport(RuntimeViewerSettings) - startImageLoadedPump() + startReloadDataPump() + // New engine session — clear the dispatched-identifiers gate so the + // always-index list re-dispatches against the new engine's image + // list (which starts empty for remote engines). + dispatchedAlwaysIndexIdentifiers.removeAll() // If the feature is enabled, treat the swap like a fresh document // open — the new engine's main executable should be indexed. + // `documentDidOpen()` also triggers the always-index list. documentDidOpen() #endif } @@ -294,14 +323,22 @@ public final class RuntimeBackgroundIndexingCoordinator { #if canImport(RuntimeViewerSettings) extension RuntimeBackgroundIndexingCoordinator { public func documentDidOpen() { - startMainExecutableBatch(reason: .appLaunch) + let indexing = currentIndexingSettings() + guard indexing.isEnabled else { return } + if indexing.heuristic.isEnabled { + startMainExecutableBatch(reason: .appLaunch) + } + if indexing.custom.isEnabled { + startAlwaysIndexBatches() + } } /// 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". + /// indexing". Callers are responsible for checking master / heuristic + /// enablement before invoking — this method does not re-check. private func startMainExecutableBatch(reason: RuntimeIndexingBatchReason) { // The class is `@MainActor`, so this Task inherits main-actor isolation // and can mutate `documentBatchIDs` synchronously after the awaits. @@ -312,8 +349,11 @@ extension RuntimeBackgroundIndexingCoordinator { // `documentBatchIDs`. Task { [weak self, engine] in guard let self else { return } - let settings = self.currentBackgroundIndexingSettings() - guard settings.isEnabled else { return } + let indexing = self.currentIndexingSettings() + // Re-check both gates after the Task hop because the settings + // observation may have flipped a toggle between the caller's check + // and this Task running. + guard indexing.isEnabled, indexing.heuristic.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. @@ -321,8 +361,8 @@ extension RuntimeBackgroundIndexingCoordinator { !root.isEmpty else { return } let id = await engine.backgroundIndexingManager.startBatch( rootImagePath: root, - depth: settings.depth, - maxConcurrency: settings.maxConcurrency, + depth: indexing.heuristic.depth, + maxConcurrency: indexing.maxConcurrency, reason: reason) // If the engine swapped while we were suspended, the batch landed // on the now-old manager which `handleEngineSwap` has already @@ -342,59 +382,131 @@ 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. - // 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 + // MARK: - Always-index list + + /// Reads `Settings.Indexing.custom.entries` and starts one batch per + /// resolvable entry. Entries that don't resolve to a path in the engine's + /// `imageList` are silently skipped — they remain in + /// `lastKnownCustomEntries` as still-pending so the next fullReload + /// retry can pick them up. + /// + /// `followDependencies` controls the per-entry depth: when false, the + /// batch is pinned to `depth: 0` so the BFS only emits the resolved + /// image itself; when true, the heuristic sub-mode's `depth` is reused + /// so the BFS walks the full dependency closure like the main-executable + /// batch. + /// + /// The Manager dedups by `rootImagePath`, so re-entry on the same path + /// is a cheap no-op that returns the existing batch id — making this + /// method safe to call from multiple triggers (documentDidOpen, fullReload, + /// settings change, engine swap). Callers are responsible for checking + /// master / custom enablement before invoking. + private func startAlwaysIndexBatches() { + let entries = currentIndexingSettings().custom.entries + guard !entries.isEmpty else { return } + // Gate: only process entries we haven't dispatched yet this session. + // The reload pump re-enters here after every fullReload, including + // ones our own batch finishes emit; without this filter we'd start + // a fresh zero-item batch for each already-dispatched identifier, + // which finishes immediately, fires reloadData, re-enters here, + // loops forever. + let pendingEntries = entries.filter { + !dispatchedAlwaysIndexIdentifiers.contains($0.identifier) + } + guard !pendingEntries.isEmpty else { return } + Task { [weak self, engine] in guard let self else { return } - // Combine.Publisher.values bridges to AsyncSequence on macOS 12+ / - // iOS 15+; the project's deployment targets satisfy this. Errors are - // Never on this publisher, so no try is needed. - for await path in engine.imageDidLoadPublisher.values { - await self.handleImageLoaded(path: path, on: engine) + let indexing = self.currentIndexingSettings() + // Re-check both gates after the Task hop (mirrors + // `startMainExecutableBatch`). + guard indexing.isEnabled, indexing.custom.isEnabled else { return } + // `engine.imageList` is `actor`-isolated; one hop fetches the + // snapshot we'll use to resolve every identifier this round. + // Remote engines populate `imageList` asynchronously via the + // `imageList` message handler, so an early call here may see + // `[]` — `startReloadDataPump` retries after fullReload, and + // the gate above leaves unresolved identifiers eligible for + // retry until they finally match a path in `imageList`. + let imageList = await engine.imageList + for entry in pendingEntries { + guard let resolvedPath = resolveAlwaysIndexIdentifier(entry.identifier, in: imageList) else { continue } + let effectiveDepth = entry.followDependencies ? indexing.heuristic.depth : 0 + let id = await engine.backgroundIndexingManager.startBatch( + rootImagePath: resolvedPath, + depth: effectiveDepth, + maxConcurrency: indexing.maxConcurrency, + reason: .alwaysIndex(identifier: entry.identifier)) + guard self.engine === engine else { return } + self.staging.insertDocumentBatchID(id) + self.dispatchedAlwaysIndexIdentifiers.insert(entry.identifier) } } } - private func handleImageLoaded(path: String, on engine: RuntimeEngine) async { - let settings = currentBackgroundIndexingSettings() - guard settings.isEnabled else { return } - // If `documentDidOpen` is currently indexing the same path (e.g. dyld - // fires this notification for the main executable right after launch), - // the manager dedups by `rootImagePath` and returns the existing - // batch's id. Inserting it into `documentBatchIDs` is a no-op on the - // Set when it's already tracked. - let id = await engine.backgroundIndexingManager.startBatch( - rootImagePath: path, - depth: settings.depth, - maxConcurrency: settings.maxConcurrency, - reason: .imageLoaded(path: path)) - // If the engine swapped while we were suspended on `startBatch`, the - // id belongs to the old manager and `handleEngineSwap` has already - // cleared `documentBatchIDs`; don't reintroduce a stale id. - guard self.engine === engine else { return } - self.staging.insertDocumentBatchID(id) + /// Maps a user-supplied identifier to a path that exists in `imageList`. + /// - Full imagePath (leading `/`): looked up verbatim against `imageList` + /// (which is already the patched form returned by `DyldUtilities.imageNames`). + /// - imageName (no leading `/`): matched against `lastPathComponent` of + /// each entry. Strict equality — `Foundation` won't match `CoreFoundation`. + /// Returns nil when no entry matches; caller should silent-skip. + private nonisolated func resolveAlwaysIndexIdentifier( + _ identifier: String, + in imageList: [String] + ) -> String? { + let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("/") { + return imageList.contains(trimmed) ? trimmed : nil + } else { + return imageList.first { ($0 as NSString).lastPathComponent == trimmed } + } } - private func currentBackgroundIndexingSettings() -> Settings.Indexing.BackgroundMode { + /// Pump that listens for `engine.reloadDataPublisher` and retries + /// `startAlwaysIndexBatches()` after each fullReload. Required so remote + /// engines whose `imageList` arrives asynchronously can still resolve + /// imageName identifiers once the list lands. Manager dedup keeps + /// already-running batches unique across retries. + private func startReloadDataPump() { + reloadDataPumpTask = Task { [weak self, engine] in + guard let self else { return } + for await _ in engine.reloadDataPublisher.values { + await MainActor.run { + guard self.engine === engine else { return } + let indexing = self.currentIndexingSettings() + guard indexing.isEnabled, indexing.custom.isEnabled else { return } + self.startAlwaysIndexBatches() + } + } + } + } + + private func currentIndexingSettings() -> Settings.Indexing { @Dependency(\.settings) var settings - return settings.indexing.backgroundMode + return settings.indexing } private func bootstrapSettingsObservation() { - self.lastKnownIsEnabled = currentBackgroundIndexingSettings().isEnabled + let indexing = currentIndexingSettings() + self.lastKnownMasterEnabled = indexing.isEnabled + self.lastKnownHeuristicEnabled = indexing.heuristic.isEnabled + self.lastKnownCustomEnabled = indexing.custom.isEnabled + self.lastKnownCustomEntries = indexing.custom.entries self.subscribeToSettings() } private func subscribeToSettings() { withObservationTracking { - let snapshot = currentBackgroundIndexingSettings() + let snapshot = currentIndexingSettings() _ = snapshot.isEnabled - _ = snapshot.depth _ = snapshot.maxConcurrency + _ = snapshot.heuristic.isEnabled + _ = snapshot.heuristic.depth + _ = snapshot.custom.isEnabled + // Track custom entries too so the observation re-fires when the + // user adds / edits / removes a row or flips the per-row + // followDependencies toggle in Settings UI. + _ = snapshot.custom.entries } 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. @@ -406,22 +518,126 @@ extension RuntimeBackgroundIndexingCoordinator { } } + /// Reconciles the three Indexing toggles (master / heuristic / custom) + /// against their last-known values. The matrix: + /// + /// - master off → on : honor whatever sub-toggles are on by dispatching + /// their batches with `.settingsEnabled` (heuristic) / always-index + /// refresh (custom). + /// - master on → off : cancel **every** active batch. + /// - heuristic off → on (master stays on) : dispatch a fresh main- + /// executable batch. + /// - heuristic on → off (master stays on) : cancel only batches whose + /// reason `isHeuristic`; custom batches keep running. + /// - custom off → on (master stays on) : reset the dispatched-identifiers + /// gate and dispatch always-index. + /// - custom on → off (master stays on) : cancel only batches whose + /// reason `isCustom`; heuristic batches keep running. + /// - custom.entries changed (both stay on) : drop removed identifiers + /// from the gate, reset edited ones, dispatch fresh entries. + /// + /// `depth` / `maxConcurrency` changes are intentional no-ops; next + /// `startBatch` picks up the new values. private func handleSettingsChange() { - let latest = currentBackgroundIndexingSettings() - let wasEnabled = lastKnownIsEnabled - lastKnownIsEnabled = latest.isEnabled - if !wasEnabled && latest.isEnabled { - // Scenario E: off→on. Use `.settingsEnabled` so the popover's - // title-by-reason mapping shows "Settings enabled" instead of - // the misleading "App launch indexing". - startMainExecutableBatch(reason: .settingsEnabled) - } else if wasEnabled && !latest.isEnabled { + let indexing = currentIndexingSettings() + let wasMaster = lastKnownMasterEnabled + let wasHeuristic = lastKnownHeuristicEnabled + let wasCustom = lastKnownCustomEnabled + let nowMaster = indexing.isEnabled + let nowHeuristic = indexing.heuristic.isEnabled + let nowCustom = indexing.custom.isEnabled + lastKnownMasterEnabled = nowMaster + lastKnownHeuristicEnabled = nowHeuristic + lastKnownCustomEnabled = nowCustom + + if !wasMaster && nowMaster { + // Master off→on: treat as a fresh start. Reset the dispatched gate + // so every custom entry re-runs. Each sub-mode dispatches only if + // its own toggle is on. + dispatchedAlwaysIndexIdentifiers.removeAll() + if nowHeuristic { + startMainExecutableBatch(reason: .settingsEnabled) + } + if nowCustom { + startAlwaysIndexBatches() + } + // Sync the entries baseline so the entries-changed branch below + // doesn't double-fire after this off→on sweep. + lastKnownCustomEntries = indexing.custom.entries + return + } + + if wasMaster && !nowMaster { + // Master on→off: stop everything; sub-toggles are now irrelevant. Task { [engine] in await engine.backgroundIndexingManager.cancelAllBatches() } + // Don't update the entries baseline here — if the user flips + // master back on later, we want the same baseline to compare + // against on the next change. + return + } + + // From here on, master stayed on. Reconcile each sub-toggle in turn. + + if nowMaster { + if !wasHeuristic && nowHeuristic { + startMainExecutableBatch(reason: .settingsEnabled) + } else if wasHeuristic && !nowHeuristic { + Task { [engine] in + await engine.backgroundIndexingManager.cancelBatches(matching: { $0.reason.isHeuristic }) + } + } + + if !wasCustom && nowCustom { + // Custom off→on while master stays on: reset the gate so + // every entry dispatches (same semantics as master off→on + // for custom). + dispatchedAlwaysIndexIdentifiers.removeAll() + startAlwaysIndexBatches() + } else if wasCustom && !nowCustom { + Task { [engine] in + await engine.backgroundIndexingManager.cancelBatches(matching: { $0.reason.isCustom }) + } + } + } + + // Entry list changes: trigger always-index when content actually + // changed and both master + custom are enabled. Adding / editing + // entries (or flipping a per-row followDependencies toggle) kicks + // off batches for the new content; removing entries is silent — + // already-running batches keep running unless the user cancels + // them from the popover. Toggling followDependencies on an existing + // entry also kicks off a new batch: Manager dedup is by + // `rootImagePath`, so the existing depth=0 batch stays the in-flight + // winner until it finishes. The depth change picks up on the next + // start (e.g. document reopen). + let previousEntries = lastKnownCustomEntries + let latestEntries = indexing.custom.entries + let entriesChanged = latestEntries != previousEntries + lastKnownCustomEntries = latestEntries + // Skip when custom off→on already fired startAlwaysIndexBatches + // above to avoid a duplicate (Manager dedup would no-op the second + // call, but skipping the redundant Task hop is cleaner). + if entriesChanged, nowMaster, nowCustom, wasCustom { + // Drop identifiers no longer in the list and reset + // followDependencies-flipped ones so they can re-dispatch with + // the new depth. Identifiers whose row was untouched stay in + // the set so we don't pointlessly re-run their batches. + // Duplicate identifiers in either list collapse to "last wins"; + // a per-identifier resolution can only produce one rootImagePath + // anyway, so equivalence under the last copy is good enough. + let latestByID = Dictionary(latestEntries.map { ($0.identifier, $0) }, + uniquingKeysWith: { _, latest in latest }) + let previousByID = Dictionary(previousEntries.map { ($0.identifier, $0) }, + uniquingKeysWith: { _, latest in latest }) + dispatchedAlwaysIndexIdentifiers = dispatchedAlwaysIndexIdentifiers.filter { identifier in + guard let latestEntry = latestByID[identifier] else { return false } + guard let previousEntry = previousByID[identifier] else { return true } + return latestEntry == previousEntry + } + startAlwaysIndexBatches() } - // depth / maxConcurrency changes: intentional no-op; next startBatch picks - // up the new values. } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 08d155f2..b85bc7b7 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -2,6 +2,9 @@ import Foundation import Observation import RuntimeViewerCore import RuntimeViewerArchitectures +#if canImport(UIKit) +import UIKit +#endif @MainActor public final class DocumentState { @@ -25,13 +28,21 @@ public final class DocumentState { @Observed public fileprivate(set) var currentImageNode: RuntimeImageNode? - /// Navigation stack of runtime objects currently under inspection. + /// Navigation history of runtime objects under inspection. Behaves like + /// a browser history list — `selectionIndex` is the cursor pointing at + /// the currently shown element; `.pop` / `.forward` only move the + /// cursor and leave the rest of the history in place so the user can + /// step back and forth. /// /// - Empty: nothing selected (content / inspector show placeholder). - /// - One element: root inspection of that object. - /// - Multiple elements: user drilled into related objects from the - /// inspector relationships tab. The last element is the active - /// selection; preceding elements are ancestors on the back stack. + /// - One element: cursor sits on the single entry (`.previous` / + /// `.forward` both no-ops). + /// - Multiple elements with cursor mid-stack: the user navigated back + /// into earlier history and can still go forward to a later entry. + /// + /// `.push` from the cursor mid-stack truncates the forward portion + /// first (browser-style) so a new branch overwrites the abandoned + /// future. /// /// Read-only externally: every mutation goes through `selectionRouter`, /// which dispatches a typed `SelectionRoute` and emits to @@ -39,14 +50,31 @@ public final class DocumentState { @Observed public fileprivate(set) var selectionStack: [RuntimeObject] = [] - /// Top of `selectionStack` — the object currently shown by content / - /// inspector. Mutations go through explicit selection routes - /// (`selectAtRoot`, `drillInto`, `pop`, `clear`). - public var selectedRuntimeObject: RuntimeObject? { selectionStack.last } + /// Cursor into `selectionStack`. `-1` when the stack is empty; + /// otherwise an index in `0..= 0, selectionIndex < selectionStack.count else { return nil } + return selectionStack[selectionIndex] + } + + /// True when `.pop` (previous) would move the cursor to an earlier + /// history entry. Drives toolbar previous-button enablement. + public var canGoPrevious: Bool { selectionIndex > 0 } + + /// True when `.forward` (next) would move the cursor to a later + /// history entry. Drives toolbar next-button enablement. + public var canGoNext: Bool { selectionIndex < selectionStack.count - 1 } /// Mutation surface for every observable state on this `DocumentState`. /// View models trigger routes on this router - /// (`documentState.selectionRouter.trigger(.drillInto(x))`). The router + /// (`documentState.selectionRouter.trigger(.push(x))`). The router /// applies the state mutation synchronously, then emits to /// `routeSignal` so scene-level subscribers (`MainCoordinator`) can /// fan out to their child coordinators. @@ -57,7 +85,7 @@ public final class DocumentState { /// observe the post-mutation snapshot when handling a route. Hot — /// new subscribers do not see past routes. public var routeSignal: Signal { _selectionRouter.routeRelay.asSignal() } - + private lazy var _selectionRouter = SelectionRouter(documentState: self) @Observed @@ -80,16 +108,28 @@ public final class DocumentState { } private final class SelectionRouter: Router { + #if canImport(AppKit) && !targetEnvironment(macCatalyst) typealias Route = SelectionRoute + #else + typealias RouteType = SelectionRoute + #endif unowned let documentState: DocumentState - + let routeRelay = PublishRelay() - + init(documentState: DocumentState) { self.documentState = documentState } + #if canImport(UIKit) && !targetEnvironment(macCatalyst) && !os(macOS) + // XCoordinator's `Router` extends `Presentable`. `SelectionRouter` does + // not own a view controller — it exists solely to mutate state and emit + // routes — so the Presentable surface is a deliberate no-op. + var viewController: UIViewController! { nil } + func router(for route: R) -> (any Router)? { nil } + #endif + func contextTrigger( _ route: SelectionRoute, with options: TransitionOptions, @@ -101,27 +141,68 @@ private final class SelectionRouter: Router { documentState.runtimeEngine = engine documentState.currentImageNode = nil documentState.selectionStack = [] + documentState.selectionIndex = -1 case .switchImage(let node): if documentState.currentImageNode == node, documentState.selectionStack.isEmpty { return } documentState.currentImageNode = node documentState.selectionStack = [] + documentState.selectionIndex = -1 case .selectAtRoot(let object): documentState.selectionStack = [object] + documentState.selectionIndex = 0 case .push(let object): + // Browser-style push: drop any forward history before + // branching, so `.forward` after this never replays an entry + // the user just abandoned. + if documentState.selectionIndex < documentState.selectionStack.count - 1 { + documentState.selectionStack = Array(documentState.selectionStack.prefix(documentState.selectionIndex + 1)) + } documentState.selectionStack.append(object) + documentState.selectionIndex = documentState.selectionStack.count - 1 case .pop: guard !documentState.selectionStack.isEmpty else { return } documentState.selectionStack.removeLast() + // Clamp cursor back into the new bounds — if it was sitting + // on the entry we just removed (or beyond), step it to the + // new last; otherwise leave it where it was. + if documentState.selectionStack.isEmpty { + documentState.selectionIndex = -1 + } else if documentState.selectionIndex >= documentState.selectionStack.count { + documentState.selectionIndex = documentState.selectionStack.count - 1 + } + case .backward: + guard documentState.selectionIndex > 0 else { return } + documentState.selectionIndex -= 1 + case .forward: + guard documentState.selectionIndex < documentState.selectionStack.count - 1 else { return } + documentState.selectionIndex += 1 case .clear: guard !documentState.selectionStack.isEmpty else { return } documentState.selectionStack = [] + documentState.selectionIndex = -1 } routeRelay.accept(route) completion?(EmptyRouteTransitionContext.shared) } } +#if canImport(AppKit) && !targetEnvironment(macCatalyst) private struct EmptyRouteTransitionContext: TransitionContext { static let shared = EmptyRouteTransitionContext() var presentables: [any Presentable] { [] } } +#elseif canImport(UIKit) +private struct EmptyRouteTransitionContext: TransitionProtocol { + typealias RootViewController = UIViewController + static let shared = EmptyRouteTransitionContext() + + var presentables: [Presentable] { [] } + var animation: TransitionAnimation? { nil } + + func perform(on rootViewController: UIViewController, with options: TransitionOptions, completion: PresentationHandler?) { + completion?() + } + + static func multiple(_ transitions: [EmptyRouteTransitionContext]) -> EmptyRouteTransitionContext { .shared } +} +#endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift index bbd2a836..2a349660 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift @@ -242,7 +242,7 @@ public final class RuntimeEngineManager { hostInfo: remoteHostInfo, originChain: [endpoint.instanceID ?? endpoint.name] ) - try await runtimeEngine.connect(bonjourEndpoint: endpoint) + try await runtimeEngine.connect(credential: .bonjour(endpoint)) appendBonjourRuntimeEngine(runtimeEngine) #log(.info,"Successfully connected to Bonjour endpoint: \(endpoint.name, privacy: .public)") @@ -415,7 +415,7 @@ public final class RuntimeEngineManager { role: .client ) ) - try await runtimeEngine.connect(xpcServerEndpoint: injectedEndpointInfo.endpoint) + try await runtimeEngine.connect(credential: .xpcServer(injectedEndpointInfo.endpoint)) #log(.info, "Reconnected to injected app: \(injectedEndpointInfo.appName, privacy: .public) (PID: \(injectedEndpointInfo.pid))") attachedRuntimeEngines.append(runtimeEngine) observeRuntimeEngineState(runtimeEngine) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRoute.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRoute.swift index e5800d79..84c1186f 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRoute.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRoute.swift @@ -17,7 +17,6 @@ public enum InspectorRoute: Routable { case back } -#if os(macOS) @AssociatedValue(.public) @CaseCheckable(.public) public enum InspectorRuntimeObjectRoute: Routable { @@ -30,6 +29,3 @@ public enum InspectorRuntimeObjectRoute: Routable { /// `MainCoordinator` already listens for. case requestSpecializationSheet(RuntimeObject) } -#else -public typealias InspectorRuntimeObjectRoute = InspectorRoute -#endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/SelectionRoute.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/SelectionRoute.swift index a4aa10e3..9e3b2281 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/SelectionRoute.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/SelectionRoute.swift @@ -19,12 +19,21 @@ import RuntimeViewerArchitectures /// - `switchImage`: change the currently inspected image, atomically /// clearing any in-flight drill-down stack (sidebar image click, sidebar /// back). -/// - `selectAtRoot`: replace the entire inspection stack with one object -/// (sidebar row click, specialization completion). -/// - `drillInto`: push one object onto the stack (inspector relationship / -/// specialization child click). -/// - `pop`: remove the topmost object (toolbar content back). -/// - `clear`: empty the stack but keep `currentImageNode`. +/// - `selectAtRoot`: replace the entire inspection history with one +/// object (specialization completion). Resets `selectionIndex` to 0. +/// - `push`: append a new object after the cursor, truncating any +/// forward history first, then advance the cursor to the new entry +/// (sidebar row click, inspector relationship / specialization child +/// click, content link click). +/// - `pop`: actually remove the topmost entry from the history array +/// and clamp the cursor back into the new bounds. Reserved for callers +/// that need to shrink the history (the toolbar previous button uses +/// `.backward` instead — it only moves the cursor). +/// - `backward`: step the cursor one entry back without mutating the +/// history array (toolbar previous). No-op at index 0. +/// - `forward`: step the cursor one entry forward without mutating the +/// history array (toolbar next). No-op at the latest entry. +/// - `clear`: empty the history but keep `currentImageNode`. @AssociatedValue(.public) @CaseCheckable(.public) public enum SelectionRoute: Routable { @@ -33,5 +42,7 @@ public enum SelectionRoute: Routable { case selectAtRoot(RuntimeObject) case push(RuntimeObject) case pop + case backward + case forward case clear } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index 982747a4..3bec28dd 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -16,10 +16,18 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, /// `rebuildChildren()`. Crucially does NOT depend on `RuntimeObject.children`, /// so a parent whose children change (e.g. via specialization) still /// matches its previous instance. + /// + /// `parentFingerprint` folds the entire ancestry chain into a single + /// `Int`, which is what lets two cells with the same `(imagePath, name, + /// kind)` but different sidebar positions stay distinct — e.g. the + /// `Phase.Value` produced by directly specializing the inner + /// generic vs. the `Phase.Value` derived when the outer generic + /// gets specialized. public struct StableID: Hashable { public let imagePath: String public let name: String public let kind: RuntimeObjectKind + public let parentFingerprint: Int } /// Mutable so the sidebar can splice in a new specialized child via @@ -37,8 +45,33 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, public let forOpenQuickly: Bool + /// Sidebar-tree parent. Weak to avoid retain cycles since the parent + /// strongly holds its children via `_children`. Only the cell's own + /// position in the sidebar uses this — `RuntimeObject` itself stays + /// position-agnostic. + public private(set) weak var parent: SidebarRuntimeObjectCellViewModel? + + /// Recursive identity hash folding `parent.fingerprint` into the cell's + /// own `(imagePath, name, kind)`. Not cached: `stableID` reads it on + /// demand so a late-binding `parent` change (or `parent` deallocation) + /// just shows up next access. `runtimeObject.children` is intentionally + /// excluded — splicing a child must not flip the parent's fingerprint. + public var fingerprint: Int { + var hasher = Hasher() + hasher.combine(parent?.fingerprint ?? 0) + hasher.combine(runtimeObject.imagePath) + hasher.combine(runtimeObject.name) + hasher.combine(runtimeObject.kind) + return hasher.finalize() + } + public var stableID: StableID { - StableID(imagePath: runtimeObject.imagePath, name: runtimeObject.name, kind: runtimeObject.kind) + StableID( + imagePath: runtimeObject.imagePath, + name: runtimeObject.name, + kind: runtimeObject.kind, + parentFingerprint: parent?.fingerprint ?? 0 + ) } public var children: [SidebarRuntimeObjectCellViewModel] { @@ -149,10 +182,11 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, .lineBreakeMode(.byTruncatingTail) } - public init(runtimeObject: RuntimeObject, forOpenQuickly: Bool) { + public init(runtimeObject: RuntimeObject, forOpenQuickly: Bool, parent: SidebarRuntimeObjectCellViewModel? = nil) { self.runtimeObject = runtimeObject self.forOpenQuickly = forOpenQuickly super.init() + self.parent = parent rebuildChildren() refreshAppearance() } @@ -162,6 +196,7 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, /// `Differentiable` consumers see stable identities and outlineView /// state (selection/expansion) is preserved. private func rebuildChildren() { + let parentFingerprint = fingerprint let recycledChildrenByStableID = Dictionary( _children.map { ($0.stableID, $0) }, uniquingKeysWith: { firstViewModel, _ in firstViewModel } @@ -170,13 +205,14 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, let childStableID = StableID( imagePath: childRuntimeObject.imagePath, name: childRuntimeObject.name, - kind: childRuntimeObject.kind + kind: childRuntimeObject.kind, + parentFingerprint: parentFingerprint ) if let recycledChild = recycledChildrenByStableID[childStableID] { recycledChild.runtimeObject = childRuntimeObject // recurses via didSet return recycledChild } - return Self(runtimeObject: childRuntimeObject, forOpenQuickly: forOpenQuickly) + return Self(runtimeObject: childRuntimeObject, forOpenQuickly: forOpenQuickly, parent: self) } .sorted { leftChild, rightChild in leftChild.runtimeObject.displayName < rightChild.runtimeObject.displayName @@ -185,6 +221,31 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, applyFilter() } + /// Returns a RuntimeObject tree that reflects the current child viewmodels, + /// including children that were spliced into descendants after this cell's + /// original RuntimeObject was created. + func materializedRuntimeObject() -> RuntimeObject { + RuntimeObject( + name: runtimeObject.name, + displayName: runtimeObject.displayName, + kind: runtimeObject.kind, + secondaryKind: runtimeObject.secondaryKind, + imagePath: runtimeObject.imagePath, + children: _children.map { $0.materializedRuntimeObject() }, + properties: runtimeObject.properties + ) + } + + @discardableResult + func appendRuntimeObjectChildPreservingCurrentDescendants(_ child: RuntimeObject) -> Bool { + let currentRuntimeObject = materializedRuntimeObject() + guard !currentRuntimeObject.children.contains(where: { $0.key == child.key }) else { + return false + } + runtimeObject = currentRuntimeObject.withAppendedChild(child) + return true + } + /// Recompute icons and the highlighted name. Called whenever /// `runtimeObject` changes (e.g. a new specialized child arrives, /// flipping the parent's `properties` bookkeeping). diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index bdc9122b..ded8b503 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -120,7 +120,7 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo .emitOnNextMainActor { [weak self] viewModel in guard let self else { return } #if os(macOS) - documentState.selectionRouter.trigger(.selectAtRoot(viewModel.runtimeObject)) + documentState.selectionRouter.trigger(.push(viewModel.runtimeObject)) #else self.router.trigger(.selectedObject(viewModel.runtimeObject)) #endif @@ -128,17 +128,24 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo .disposed(by: rx.disposeBag) // Visual selection follows whatever the document is currently - // inspecting at its root. The sidebar row click path already - // dispatched `.selectAtRoot` through `documentState.selectionRouter`, - // so observing `selectionStack` covers both that case (idempotent - // re-select on the already-highlighted row) and the specialization- - // completion case (new root object that has not yet been clicked). - documentState.$selectionStack - .asObservable() - .compactMap { $0.first } - .distinctUntilChanged() - .bind(to: pendingSelectRelay) - .disposed(by: rx.disposeBag) + // inspecting at the cursor. The sidebar row click path pushes + // onto the history (selectionRouter `.push`), and toolbar + // previous/next move the cursor without mutating the stack — + // both end up as a new `selectedRuntimeObject` value here, so + // tracking the cursor covers the sidebar click, the + // specialization-completion `.selectAtRoot`, and toolbar + // navigation in one place. + Observable.combineLatest( + documentState.$selectionStack.asObservable(), + documentState.$selectionIndex.asObservable() + ) + .compactMap { stack, index -> RuntimeObject? in + guard index >= 0, index < stack.count else { return nil } + return stack[index] + } + .distinctUntilChanged() + .bind(to: pendingSelectRelay) + .disposed(by: rx.disposeBag) let pendingResolved: Signal = pendingSelectRelay .asObservable() diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift index ecd0b5b5..bd269271 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift @@ -170,7 +170,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel .emitOnNextMainActor { [weak self] viewModel in guard let self else { return } #if os(macOS) - documentState.selectionRouter.trigger(.selectAtRoot(viewModel.runtimeObject)) + documentState.selectionRouter.trigger(.push(viewModel.runtimeObject)) #else self.router.trigger(.selectedObject(viewModel.runtimeObject)) #endif @@ -303,20 +303,14 @@ public class SidebarRuntimeObjectViewModel: ViewModel private func applySpecializationAdded(parent: RuntimeObject, child: RuntimeObject) { guard let parentViewModel = locate(parent, in: nodes) else { return } - // Append onto the cell's *current* runtimeObject (which already - // reflects every prior specialization spliced into this cell), not - // the event payload — the broadcast carries the originally selected - // generic, so a second specialization on the same parent would - // otherwise overwrite the first one's child. - let currentParent = parentViewModel.runtimeObject - - // De-dupe: a re-broadcast (e.g. server reconnect, repeated user - // action) would otherwise insert the same child twice. RuntimeObjectKey - // ignores `children`, which is the right identity for "is this the - // same specialized type already attached". - guard !currentParent.children.contains(where: { $0.key == child.key }) else { return } - - parentViewModel.runtimeObject = currentParent.withAppendedChild(child) + // Append onto a materialized copy of the cell's *current* subtree, not + // the event payload or the parent RuntimeObject's stale `children` + // snapshot. A descendant may already have received a specialization + // through its own cell viewmodel; rebuilding this parent from the stale + // snapshot would drop that descendant child. + guard parentViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(child) else { + return + } nodes = nodes if isFiltering { filteredNodes = FilterEngine.filter( @@ -347,7 +341,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel in viewModels: [SidebarRuntimeObjectCellViewModel] ) -> SidebarRuntimeObjectCellViewModel? { for viewModel in viewModels { - if viewModel.runtimeObject == object { return viewModel } + if viewModel.runtimeObject.key == object.key { return viewModel } if let matchedViewModel = locate(object, in: viewModel.children) { return matchedViewModel } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift index fd4c9080..d424cb40 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift @@ -1,10 +1,7 @@ import Foundation import FoundationToolbox import RuntimeViewerArchitectures - -#if canImport(RuntimeViewerSettings) import RuntimeViewerSettings -#endif @MainActor open class ViewModel: NSObject, ViewModelProtocol { @@ -14,17 +11,15 @@ 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() @@ -55,3 +50,6 @@ open class ViewModel: NSObject, ViewModelProtocol { self.router = router } } + +@MainActor +open class CellViewModel: NSObject {} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift index 224b6230..8a8e5939 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -73,26 +73,88 @@ extension Settings { @Codable @MemberInit public struct Indexing { + /// Master switch. When off, no background indexing runs regardless of + /// sub-mode toggles below. + @Default(false) + public var isEnabled: Bool + + /// Shared worker pool capacity used by both sub-modes (Settings UI + /// clamps to 1...processorCount). + @Default(4) + public var maxConcurrency: Int + + /// Heuristic discovery: at document open / engine swap, BFS the main + /// executable's dependency closure to `depth` levels and index every + /// image found. Does NOT subscribe to dyld add-image notifications — + /// images loaded after the initial sweep are not auto-indexed. @Codable @MemberInit - public struct BackgroundMode { - /// Whether background indexing is enabled - @Default(false) + public struct Heuristic { + /// Whether heuristic main-executable BFS is enabled. + @Default(true) public var isEnabled: Bool - /// Indexing depth (valid range enforced by the Settings UI: 1...5) + /// BFS depth from the main executable (Settings UI clamps to 1...5). @Default(1) public var depth: Int - /// Maximum concurrent indexing tasks (Settings UI clamps to 1...processorCount) - @Default(4) - public var maxConcurrency: Int + public static let `default` = Self() + } + + @Default(Heuristic.default) + public var heuristic: Heuristic + + /// One row in the user-configured "always-index" list. `identifier` + /// is either a full imagePath (leading `/`) matched verbatim against + /// the engine's `imageList`, or an imageName matched against the + /// last path component of any loaded image. Entries that don't + /// resolve to a loaded image are silently skipped (no-op, not + /// marked failed). + /// + /// `followDependencies` opts the entry into the BFS dependency + /// expansion that main-executable batches use; when false (the + /// default) the batch is constrained to the resolved image alone, + /// so adding "SwiftUICore" indexes SwiftUICore literally rather + /// than every framework it links against. + /// + /// Declared before `Custom` so MetaCodable's `@Codable` macro can + /// resolve the type when expanding `Custom`'s synthesized codable + /// implementation; macro-generated code does not see types declared + /// further down the same scope. + @Codable + @MemberInit + public struct AlwaysIndexEntry: Equatable { + @Default("") + public var identifier: String + + @Default(false) + public var followDependencies: Bool + + public static let `default` = Self() + } + + /// Custom always-index list: user-maintained images that get indexed + /// whenever a document opens, the engine changes, the entry list + /// changes, or a fullReload fires. + @Codable + @MemberInit + public struct Custom { + /// Whether the custom always-index list is honored. + @Default(true) + public var isEnabled: Bool + + /// Fully qualified path is required so MetaCodable's `@Codable` + /// macro can resolve the type from its generated source file + /// (the synthesized init lives in a separate compilation unit + /// that does not have `Settings.Indexing` as an enclosing scope). + @Default([]) + public var entries: [Settings.Indexing.AlwaysIndexEntry] public static let `default` = Self() } - @Default(BackgroundMode.default) - public var backgroundMode: BackgroundMode + @Default(Custom.default) + public var custom: Custom public static let `default` = Self() } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift index b50f4116..5f5c7caf 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings.swift @@ -21,7 +21,7 @@ public final class Settings { didSet { scheduleAutoSave() } } - @Default(TransformerSettings()) + @Default(TransformerSettings.default) public var transformer: TransformerSettings = .init() { didSet { scheduleAutoSave() } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift index 0192406a..78cc4267 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift @@ -13,22 +13,113 @@ struct IndexingSettingsView: View { var body: some View { SettingsForm { Section { - Toggle("Enable Background Indexing", isOn: $indexing.backgroundMode.isEnabled) + Toggle("Enable Background Indexing", isOn: $indexing.isEnabled) + + Stepper( + "Max Concurrent Tasks", + value: $indexing.maxConcurrency.asDouble, + in: 1...Self.maxConcurrencyUpperBound.double, + format: .number.precision(.fractionLength(0)) + ) + .disabled(!indexing.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.") + Text("Master switch for all background indexing. When off, neither sub-mode below runs. Max Concurrent Tasks limits how many images both sub-modes can index in parallel; higher values finish faster but use more CPU.") } Section { - Stepper("Depth", value: $indexing.backgroundMode.depth.asDouble, in: 1...5, format: .number.precision(.fractionLength(0))) - .disabled(!indexing.backgroundMode.isEnabled) + Toggle("Discover from Main Executable", isOn: $indexing.heuristic.isEnabled) + .disabled(!indexing.isEnabled) - Stepper("Max Concurrent Tasks", value: $indexing.backgroundMode.maxConcurrency.asDouble, in: 1...Self.maxConcurrencyUpperBound.double, format: .number.precision(.fractionLength(0))) - .disabled(!indexing.backgroundMode.isEnabled) + Stepper( + "Depth", + value: $indexing.heuristic.depth.asDouble, + in: 1...5, + format: .number.precision(.fractionLength(0)) + ) + .disabled(!indexing.isEnabled || !indexing.heuristic.isEnabled) + } header: { + Text("Heuristic Discovery") } 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.") + Text("When a document opens, Runtime Viewer parses ObjC and Swift metadata for the main executable and its dependency closure up to the configured depth so lookups are instant. Images dlopen'd after the initial sweep are not auto-indexed.") + } + + AlwaysIndexSection( + isEnabled: $indexing.custom.isEnabled, + entries: $indexing.custom.entries, + masterEnabled: indexing.isEnabled + ) + } + } +} + +/// Editor section for `Settings.Indexing.custom`. Renders the custom toggle, +/// each entry as an editable row (identifier field, Follow Dependencies +/// switch, delete button), plus a trailing Add button. The list is +/// order-preserving — duplicate / blank entries are accepted and only +/// filtered at the resolution step in the coordinator. +private struct AlwaysIndexSection: View { + @Binding var isEnabled: Bool + @Binding var entries: [RuntimeViewerSettings.Settings.Indexing.AlwaysIndexEntry] + let masterEnabled: Bool + + private var entryFieldsDisabled: Bool { !masterEnabled || !isEnabled } + + var body: some View { + Section { + Toggle("Always Index Listed Images", isOn: $isEnabled) + .disabled(!masterEnabled) + + ForEach(entries.indices, id: \.self) { index in + HStack { + TextField( + "imagePath or imageName", + text: Binding( + get: { entries.indices.contains(index) ? entries[index].identifier : "" }, + set: { newValue in + guard entries.indices.contains(index) else { return } + entries[index].identifier = newValue + } + ) + ) + .labelsHidden() + .textFieldStyle(.roundedBorder) + Toggle( + "Follow Dependencies", + isOn: Binding( + get: { entries.indices.contains(index) ? entries[index].followDependencies : false }, + set: { newValue in + guard entries.indices.contains(index) else { return } + entries[index].followDependencies = newValue + } + ) + ) + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + Button { + guard entries.indices.contains(index) else { return } + entries.remove(at: index) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + .disabled(entryFieldsDisabled) + } + + Button { + entries.append(.default) + } label: { + Label("Add Image", systemImage: "plus.circle") } + .disabled(entryFieldsDisabled) + } header: { + Text("Always Index") + } footer: { + Text("Images listed here are indexed in the background whenever a document opens, the runtime engine changes, or this list changes. Each entry may be a full path (starting with \"/\") or just the image's file name (matched against loaded images by last path component). Entries that don't match any loaded image are silently skipped. Enable Follow Dependencies on a row to also index the image's full dependency closure using the depth above; otherwise only the image itself is indexed.") } } } diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift new file mode 100644 index 00000000..f06bb6b4 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift @@ -0,0 +1,131 @@ +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +@Suite("SidebarRuntimeObjectCellViewModel") +@MainActor +struct SidebarRuntimeObjectCellViewModelTests { + @Test("ancestor specialization preserves existing nested specialization") + func ancestorSpecializationPreservesExistingNestedSpecialization() throws { + let failureReason = object( + name: "Phase.FailureReason", + displayName: "SwiftUI.EventListenerPhase.FailureReason" + ) + let value = object( + name: "Phase.Value", + displayName: "SwiftUI.EventListenerPhase.Value", + properties: [.isGeneric] + ) + let phase = object( + name: "Phase", + displayName: "SwiftUI.EventListenerPhase", + children: [failureReason, value], + properties: [.isGeneric] + ) + let phaseViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: phase, forOpenQuickly: false) + let valueViewModel = try #require( + phaseViewModel.children.first { $0.runtimeObject.displayName == "SwiftUI.EventListenerPhase.Value" } + ) + + let valueEvent = object( + name: "Phase.Value.Event", + displayName: "SwiftUI.EventListenerPhase.Value", + properties: [.isSpecialized] + ) + valueViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(valueEvent) + + let phasePan = object( + name: "Phase.PanEvent", + displayName: "SwiftUI.EventListenerPhase", + children: [ + object( + name: "Phase.PanEvent.FailureReason", + displayName: "SwiftUI.EventListenerPhase.FailureReason", + properties: [.isSpecialized] + ), + object( + name: "Phase.PanEvent.Value", + displayName: "SwiftUI.EventListenerPhase.Value", + properties: [.isSpecialized] + ), + ], + properties: [.isSpecialized] + ) + phaseViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(phasePan) + + let materializedPhase = phaseViewModel.materializedRuntimeObject() + let originalValue = try #require( + materializedPhase.children.first { $0.displayName == "SwiftUI.EventListenerPhase.Value" } + ) + + #expect(originalValue.children.map(\.displayName) == ["SwiftUI.EventListenerPhase.Value"]) + #expect(materializedPhase.children.contains { $0.displayName == "SwiftUI.EventListenerPhase" }) + } + + @Test("StableID distinguishes same RuntimeObject under different sidebar parents") + func stableIDDistinguishesSameObjectUnderDifferentParents() throws { + // Same Swift metadata `Value` reachable via two routes: + // * manually specializing the inner `Value` generic → Phase / Value / Value + // * auto-derived when outer `Phase` is specialized → Phase / Phase / Value + // The sidebar wants both to coexist as distinct rows, so their cell + // viewmodels MUST hash to different StableIDs even though the + // underlying RuntimeObject's (imagePath, name, kind) tuple is identical. + let valueOfEvent = object( + name: "Phase.Value.Event", + displayName: "SwiftUI.EventListenerPhase.Value", + properties: [.isSpecialized] + ) + + let manualValueGeneric = object( + name: "Phase.Value", + displayName: "SwiftUI.EventListenerPhase.Value", + children: [valueOfEvent], + properties: [.isGeneric] + ) + let manualPhase = object( + name: "Phase", + displayName: "SwiftUI.EventListenerPhase", + children: [manualValueGeneric], + properties: [.isGeneric] + ) + let manualPhaseViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: manualPhase, forOpenQuickly: false) + let manualValueViewModel = try #require(manualPhaseViewModel.children.first) + let manualLeaf = try #require(manualValueViewModel.children.first) + + let derivedPhaseOfEvent = object( + name: "Phase.Event", + displayName: "SwiftUI.EventListenerPhase", + children: [valueOfEvent], + properties: [.isSpecialized] + ) + let derivedPhase = object( + name: "Phase", + displayName: "SwiftUI.EventListenerPhase", + children: [derivedPhaseOfEvent], + properties: [.isGeneric] + ) + let derivedPhaseViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: derivedPhase, forOpenQuickly: false) + let derivedPhaseOfEventViewModel = try #require(derivedPhaseViewModel.children.first) + let derivedLeaf = try #require(derivedPhaseOfEventViewModel.children.first) + + #expect(manualLeaf.runtimeObject.key == derivedLeaf.runtimeObject.key) + #expect(manualLeaf.stableID != derivedLeaf.stableID) + } + + private func object( + name: String, + displayName: String, + children: [RuntimeObject] = [], + properties: RuntimeObject.Properties = [] + ) -> RuntimeObject { + RuntimeObject( + name: name, + displayName: displayName, + kind: .swift(.type(.struct)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", + children: children, + properties: properties + ) + } +} diff --git a/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift b/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift index c144053f..1f9da79e 100644 --- a/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift +++ b/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift @@ -8,12 +8,6 @@ import RuntimeViewerUtilities import LaunchServicesPrivate #endif -#if os(macOS) -import HelperCommunication -import HelperClient -import InjectedEndpointRegistryServiceInterface -#endif - #if canImport(UIKit) #if os(watchOS) import WatchKit.WKInterfaceDevice @@ -53,7 +47,7 @@ private enum RuntimeViewerServer { #log(.default, "Attach successfully") Task { do { - #log(.default, "Will Launch") + #log(.default, "RuntimeViewerServer Will Launch") #if os(macOS) || targetEnvironment(macCatalyst) @@ -63,12 +57,6 @@ private enum RuntimeViewerServer { } else { runtimeEngine = RuntimeEngine(source: .remote(name: processName, identifier: .init(rawValue: identifier), role: .server)) try await runtimeEngine?.connect() - - // Register the XPC listener endpoint with the Mach Service - // so the Host can reconnect after restart. - #if os(macOS) - await registerInjectedEndpoint() - #endif } #else @@ -81,36 +69,10 @@ private enum RuntimeViewerServer { #endif - #log(.default, "Did Launch") + #log(.default, "RuntimeViewerServer Did Launch") } catch { - #log(.error, "Failed to create runtime engine: \(error, privacy: .public)") + #log(.error, "RuntimeViewerServer failed to create runtime engine: \(error, privacy: .public)") } } } - - #if os(macOS) - private static func registerInjectedEndpoint() async { - guard let endpoint = await runtimeEngine?.xpcListenerEndpoint as? HelperPeerEndpoint else { - #log(.error, "Failed to get XPC listener endpoint for registration") - return - } - - do { - let helperClient = HelperClient() - try await helperClient.connectToTool( - machServiceName: RuntimeViewerMachServiceName, - isPrivilegedHelperTool: true - ) - try await helperClient.sendToTool(request: RegisterInjectedEndpointRequest( - pid: ProcessInfo.processInfo.processIdentifier, - appName: processName, - bundleIdentifier: Bundle.main.bundleIdentifier ?? "", - endpoint: endpoint - )) - #log(.info, "Registered injected endpoint with Mach Service (PID: \(ProcessInfo.processInfo.processIdentifier))") - } catch { - #log(.error, "Failed to register injected endpoint: \(error, privacy: .public)") - } - } - #endif } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index d00049c0..bfacd8f1 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -7,14 +7,26 @@ objects = { /* Begin PBXBuildFile section */ + 0C554F36A5732FDAF8C0A3F1 /* BatchExportingConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC848E755B855C64A7FFF5E8 /* BatchExportingConfigurationViewModel.swift */; }; + 12D07EA725AFECCD93E61DD7 /* BatchExportingCompletionRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */; }; + 5A6BBD9C254FFE24540D71F9 /* BatchExportingProgressRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */; }; + 620FD421F830F882232BCBCD /* BatchExportingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0809C7E1B5879399F4AD5CFD /* BatchExportingCoordinator.swift */; }; + 69943AEB856963F21AEA89C4 /* BatchExportingProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */; }; 735AF17B8EF9B5F85B95D1E9 /* UpdaterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BB7A834FDF101E624B4EEA8 /* UpdaterService.swift */; }; + 84FE4364758FE65EA7D5E9A7 /* BatchExportingImageSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A94DAF52736C3A88802C38 /* BatchExportingImageSelectionViewModel.swift */; }; 8FC7CF2AD73A74EC8D81C0CB /* InspectorRuntimeObjectTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E923F0D57DB6E4F581069CBA /* InspectorRuntimeObjectTabViewController.swift */; }; 8FC7CF2BD73A74EC8D81C0CB /* InspectorSwiftSpecializationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E923F0D67DB6E4F581069CBA /* InspectorSwiftSpecializationViewController.swift */; }; 8FC7CF2CD73A74EC8D81C0CB /* InspectorRuntimeObjectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E923F0D77DB6E4F581069CBA /* InspectorRuntimeObjectCoordinator.swift */; }; 8FC7CF2DD73A74EC8D81C0CB /* InspectorRelationshipsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E923F0D87DB6E4F581069CBA /* InspectorRelationshipsViewController.swift */; }; + 8FF2C3F9302864D9F3404E35 /* BatchExportingConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7366E572547581647D1A395 /* BatchExportingConfigurationViewController.swift */; }; 9677438C01BDD12085C27522 /* SpecializationTypePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C6DBFCCFA6B22702BC7667 /* SpecializationTypePickerViewController.swift */; }; + 9CA80CE73BB3CFC59ED54FA9 /* BatchExportingImageSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDE868F82139B514DD706D1 /* BatchExportingImageSelectionViewController.swift */; }; + A2519448E7F7FA797E155C73 /* BatchExportingImageSelectionCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061FB631FA9AEDDF6ED4549F /* BatchExportingImageSelectionCellViewModel.swift */; }; + A9F67D4F08F37DE09A9772C2 /* BatchExportingCompletionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */; }; CB5EBCC699B082FC52857966 /* SpecializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC4339B0319BDED6270B85F /* SpecializationCoordinator.swift */; }; + D16A49532E755901A1D32E5F /* BatchExportingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFA5EB1BB7210538B942D40 /* BatchExportingState.swift */; }; D9073E10AE2361972768175D /* SpecializationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D54B1B904A0B5FDFA93B2F /* SpecializationViewController.swift */; }; + D9593A7A868FB627B0966159 /* BatchExportingCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */; }; D9913AAAD5EC88F6427B25C1 /* SpecializationWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FBD344C22E90BA7623E9663 /* SpecializationWindowController.swift */; }; E90329732F5C6A450080B9CC /* dev.mxiris.runtimeviewer.service.plist in Embed LaunchDaemon */ = {isa = PBXBuildFile; fileRef = E90329712F5C6A280080B9CC /* dev.mxiris.runtimeviewer.service.plist */; }; E91CD9972C2127AD00C989CC /* EventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91CD9962C2127AD00C989CC /* EventMonitor.swift */; }; @@ -103,11 +115,11 @@ E9D470672F136E2A008BF7A9 /* SidebarRuntimeObjectListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D470662F136E2A008BF7A9 /* SidebarRuntimeObjectListViewController.swift */; }; E9D470692F136E52008BF7A9 /* SidebarRuntimeObjectBookmarkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D470682F136E52008BF7A9 /* SidebarRuntimeObjectBookmarkViewController.swift */; }; E9D4706B2F136E7F008BF7A9 /* SidebarRuntimeObjectTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D4706A2F136E7F008BF7A9 /* SidebarRuntimeObjectTabViewController.swift */; }; + E9DC0F0B2E8D000000000002 /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DC0F0A2E8D000000000001 /* LoadingButton.swift */; }; E9E900EB2C2D0D5B00FADDCC /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E900EA2C2D0D5B00FADDCC /* main.swift */; }; E9EB37AC2C397024003E2859 /* InspectorClassViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EB37AB2C397024003E2859 /* InspectorClassViewController.swift */; }; E9EE3F9E2FB573A700B540A3 /* SpecializationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EE3F9D2FB573A700B540A3 /* SpecializationCellViewModel.swift */; }; E9EEE84B2E071704008D85D1 /* CommonLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EEE84A2E071704008D85D1 /* CommonLoadingView.swift */; }; - E9DC0F0B2E8D000000000002 /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DC0F0A2E8D000000000001 /* LoadingButton.swift */; }; E9EEF7612E084D7D008D85D1 /* SidebarRuntimeObjectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EEF7602E084D7D008D85D1 /* SidebarRuntimeObjectCoordinator.swift */; }; E9EEF7652E08540B008D85D1 /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EEF7642E08540B008D85D1 /* ContentTextView.swift */; }; E9EEF7672E085420008D85D1 /* InspectorDisclosureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EEF7662E085420008D85D1 /* InspectorDisclosureView.swift */; }; @@ -118,6 +130,7 @@ E9F11E162F12671D0052B0A3 /* TabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F11E152F12671D0052B0A3 /* TabViewController.swift */; }; E9F11E182F12812B0052B0A3 /* SidebarRootBookmarkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F11E172F12812B0052B0A3 /* SidebarRootBookmarkViewController.swift */; }; E9F759422CF603DD00BE7A5F /* RuntimeObjectCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F759412CF603DD00BE7A5F /* RuntimeObjectCellView.swift */; }; + F43FDB3F17E51666B7FDF810 /* BatchExportingProgressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -232,10 +245,22 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 061FB631FA9AEDDF6ED4549F /* BatchExportingImageSelectionCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionCellViewModel.swift; sourceTree = ""; }; + 0809C7E1B5879399F4AD5CFD /* BatchExportingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCoordinator.swift; sourceTree = ""; }; 0FBD344C22E90BA7623E9663 /* SpecializationWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpecializationWindowController.swift; sourceTree = ""; }; + 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionViewController.swift; sourceTree = ""; }; + 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionRowViewModel.swift; sourceTree = ""; }; + 5FFA5EB1BB7210538B942D40 /* BatchExportingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingState.swift; sourceTree = ""; }; + 65A94DAF52736C3A88802C38 /* BatchExportingImageSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionViewModel.swift; sourceTree = ""; }; + 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressViewController.swift; sourceTree = ""; }; + 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressRowViewModel.swift; sourceTree = ""; }; + 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionViewModel.swift; sourceTree = ""; }; 99D54B1B904A0B5FDFA93B2F /* SpecializationViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpecializationViewController.swift; sourceTree = ""; }; 9BB7A834FDF101E624B4EEA8 /* UpdaterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterService.swift; sourceTree = ""; }; 9EC4339B0319BDED6270B85F /* SpecializationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpecializationCoordinator.swift; sourceTree = ""; }; + C7366E572547581647D1A395 /* BatchExportingConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingConfigurationViewController.swift; sourceTree = ""; }; + CC848E755B855C64A7FFF5E8 /* BatchExportingConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingConfigurationViewModel.swift; sourceTree = ""; }; + DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressViewModel.swift; sourceTree = ""; }; E5C6DBFCCFA6B22702BC7667 /* SpecializationTypePickerViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpecializationTypePickerViewController.swift; sourceTree = ""; }; E90329712F5C6A280080B9CC /* dev.mxiris.runtimeviewer.service.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = dev.mxiris.runtimeviewer.service.plist; sourceTree = ""; }; E91CD9962C2127AD00C989CC /* EventMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventMonitor.swift; sourceTree = ""; }; @@ -329,6 +354,7 @@ E9D470662F136E2A008BF7A9 /* SidebarRuntimeObjectListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarRuntimeObjectListViewController.swift; sourceTree = ""; }; E9D470682F136E52008BF7A9 /* SidebarRuntimeObjectBookmarkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarRuntimeObjectBookmarkViewController.swift; sourceTree = ""; }; E9D4706A2F136E7F008BF7A9 /* SidebarRuntimeObjectTabViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarRuntimeObjectTabViewController.swift; sourceTree = ""; }; + E9DC0F0A2E8D000000000001 /* LoadingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButton.swift; sourceTree = ""; }; E9E900D72C2CF96500FADDCC /* RuntimeViewerCatalystHelper.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RuntimeViewerCatalystHelper.entitlements; sourceTree = ""; }; E9E900DC2C2CF9A500FADDCC /* RuntimeViewerCatalystHelperPlugin.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RuntimeViewerCatalystHelperPlugin.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; E9E900E82C2D0D5B00FADDCC /* com.JH.RuntimeViewerService */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = com.JH.RuntimeViewerService; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -337,7 +363,6 @@ E9EC5BC52F1CBDBA00859091 /* com.mxiris.runtimeviewer.service */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = com.mxiris.runtimeviewer.service; sourceTree = BUILT_PRODUCTS_DIR; }; E9EE3F9D2FB573A700B540A3 /* SpecializationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecializationCellViewModel.swift; sourceTree = ""; }; E9EEE84A2E071704008D85D1 /* CommonLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonLoadingView.swift; sourceTree = ""; }; - E9DC0F0A2E8D000000000001 /* LoadingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButton.swift; sourceTree = ""; }; E9EEF7602E084D7D008D85D1 /* SidebarRuntimeObjectCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarRuntimeObjectCoordinator.swift; sourceTree = ""; }; E9EEF7642E08540B008D85D1 /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; E9EEF7662E085420008D85D1 /* InspectorDisclosureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorDisclosureView.swift; sourceTree = ""; }; @@ -352,6 +377,7 @@ E9F2E9B72ED3BD36001DCC3E /* Shared-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Shared-Debug.xcconfig"; path = ../Configurations/Shared/Debug.xcconfig; sourceTree = SOURCE_ROOT; }; E9F2E9B92ED3BD3E001DCC3E /* CodeSigning.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = CodeSigning.xcconfig; path = ../Configurations/CodeSigning.xcconfig; sourceTree = SOURCE_ROOT; }; E9F759412CF603DD00BE7A5F /* RuntimeObjectCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeObjectCellView.swift; sourceTree = ""; }; + EDDE868F82139B514DD706D1 /* BatchExportingImageSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -398,6 +424,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9B20F5E805C0E15B32D5F771 /* BatchExporting */ = { + isa = PBXGroup; + children = ( + 5FFA5EB1BB7210538B942D40 /* BatchExportingState.swift */, + 0809C7E1B5879399F4AD5CFD /* BatchExportingCoordinator.swift */, + 061FB631FA9AEDDF6ED4549F /* BatchExportingImageSelectionCellViewModel.swift */, + 65A94DAF52736C3A88802C38 /* BatchExportingImageSelectionViewModel.swift */, + EDDE868F82139B514DD706D1 /* BatchExportingImageSelectionViewController.swift */, + CC848E755B855C64A7FFF5E8 /* BatchExportingConfigurationViewModel.swift */, + C7366E572547581647D1A395 /* BatchExportingConfigurationViewController.swift */, + 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */, + DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */, + 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */, + 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */, + 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */, + 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */, + ); + path = BatchExporting; + sourceTree = ""; + }; BD36D2C74648FEB09A3B7E81 /* Specialization */ = { isa = PBXGroup; children = ( @@ -478,6 +524,7 @@ E9A9D8032F5F254800A10DD3 /* MCP */, E9BD1A142FA000050000ABCD /* BackgroundIndexing */, E92CB2E52F41E7560091450B /* Exporting */, + 9B20F5E805C0E15B32D5F771 /* BatchExporting */, BD36D2C74648FEB09A3B7E81 /* Specialization */, E9CE07BF2C14981D0070A6E8 /* Utils */, E94E36C72CF87BBC006101C8 /* Resources */, @@ -593,6 +640,7 @@ E9EEE84A2E071704008D85D1 /* CommonLoadingView.swift */, E9DC0F0A2E8D000000000001 /* LoadingButton.swift */, E9F11E152F12671D0052B0A3 /* TabViewController.swift */, + E9F759412CF603DD00BE7A5F /* RuntimeObjectCellView.swift */, ); path = Base; sourceTree = ""; @@ -696,7 +744,6 @@ E9EEF7602E084D7D008D85D1 /* SidebarRuntimeObjectCoordinator.swift */, E9D4706A2F136E7F008BF7A9 /* SidebarRuntimeObjectTabViewController.swift */, E9A825392C0E095500D9A85D /* SidebarRuntimeObjectViewController.swift */, - E9F759412CF603DD00BE7A5F /* RuntimeObjectCellView.swift */, E9D470662F136E2A008BF7A9 /* SidebarRuntimeObjectListViewController.swift */, E9D470682F136E52008BF7A9 /* SidebarRuntimeObjectBookmarkViewController.swift */, ); @@ -1077,6 +1124,19 @@ 8FC7CF2BD73A74EC8D81C0CB /* InspectorSwiftSpecializationViewController.swift in Sources */, 8FC7CF2CD73A74EC8D81C0CB /* InspectorRuntimeObjectCoordinator.swift in Sources */, 8FC7CF2DD73A74EC8D81C0CB /* InspectorRelationshipsViewController.swift in Sources */, + D16A49532E755901A1D32E5F /* BatchExportingState.swift in Sources */, + 620FD421F830F882232BCBCD /* BatchExportingCoordinator.swift in Sources */, + A2519448E7F7FA797E155C73 /* BatchExportingImageSelectionCellViewModel.swift in Sources */, + 84FE4364758FE65EA7D5E9A7 /* BatchExportingImageSelectionViewModel.swift in Sources */, + 9CA80CE73BB3CFC59ED54FA9 /* BatchExportingImageSelectionViewController.swift in Sources */, + 0C554F36A5732FDAF8C0A3F1 /* BatchExportingConfigurationViewModel.swift in Sources */, + 8FF2C3F9302864D9F3404E35 /* BatchExportingConfigurationViewController.swift in Sources */, + 69943AEB856963F21AEA89C4 /* BatchExportingProgressViewModel.swift in Sources */, + F43FDB3F17E51666B7FDF810 /* BatchExportingProgressViewController.swift in Sources */, + 12D07EA725AFECCD93E61DD7 /* BatchExportingCompletionRowViewModel.swift in Sources */, + A9F67D4F08F37DE09A9772C2 /* BatchExportingCompletionViewModel.swift in Sources */, + D9593A7A868FB627B0966159 /* BatchExportingCompletionViewController.swift in Sources */, + 5A6BBD9C254FFE24540D71F9 /* BatchExportingProgressRowViewModel.swift in Sources */, ); }; E947C3632C2A4D0400296B2E /* Sources */ = { diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift index f50740d2..0f3425fc 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift @@ -4,7 +4,10 @@ import RuntimeViewerUI import RuntimeViewerApplication import RuntimeViewerArchitectures -final class AttachToProcessViewController: AppKitViewController { +final class AttachToProcessViewController: UXKitViewController { + + static let shared = AttachToProcessViewController() + private let pickerViewController: RunningPickerTabViewController private let attachRelay = PublishRelay() @@ -12,16 +15,29 @@ final class AttachToProcessViewController: AppKitViewController() override init(viewModel: AttachToProcessViewModel? = nil) { - let applicationConfiguration = RunningPickerTabViewController.ApplicationConfiguration(title: "Attach To Process", description: "Select a running application to attach to", cancelButtonTitle: "Cancel", confirmButtonTitle: "Attach") - let processConfiguration = RunningPickerTabViewController.ProcessConfiguration(title: "Attach To Process", description: "Select a running application to attach to", cancelButtonTitle: "Cancel", confirmButtonTitle: "Attach") - self.pickerViewController = RunningPickerTabViewController(applicationConfiguration: applicationConfiguration, processConfiguration: processConfiguration) + let applicationConfiguration = RunningPickerTabViewController.ApplicationConfiguration( + title: "Attach To Application", + description: "Select a running application to attach to", + cancelButtonTitle: "Cancel", + confirmButtonTitle: "Attach" + ) + let processConfiguration = RunningPickerTabViewController.ProcessConfiguration( + title: "Attach To Process", + description: "Select a running process to attach to", + cancelButtonTitle: "Cancel", + confirmButtonTitle: "Attach" + ) + self.pickerViewController = RunningPickerTabViewController( + applicationConfiguration: applicationConfiguration, + processConfiguration: processConfiguration + ) super.init(viewModel: viewModel) } override func viewDidLoad() { super.viewDidLoad() - hierarchy { + contentView.hierarchy { pickerViewController } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift index e4a2a29b..5a55892a 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingNode.swift @@ -2,7 +2,8 @@ import RuntimeViewerCore import RxAppKit enum BackgroundIndexingNode: Hashable { - case section(SectionKind, batches: [BackgroundIndexingNode]) + case section(SectionKind, batchCount: Int, groups: [BackgroundIndexingNode]) + case reasonGroup(SectionKind, RuntimeIndexingBatchReason.Category, batchCount: Int, children: [BackgroundIndexingNode]) case batch(RuntimeIndexingBatch, items: [BackgroundIndexingNode]) case item(batchID: RuntimeIndexingBatchID, item: RuntimeIndexingTaskItem) @@ -15,7 +16,8 @@ enum BackgroundIndexingNode: Hashable { extension BackgroundIndexingNode: OutlineNodeType { var children: [BackgroundIndexingNode] { switch self { - case .section(_, let batches): return batches + case .section(_, _, let groups): return groups + case .reasonGroup(_, _, _, let children): return children case .batch(_, let items): return items case .item: return [] } @@ -25,18 +27,22 @@ extension BackgroundIndexingNode: OutlineNodeType { extension BackgroundIndexingNode: Differentiable { enum Identifier: Hashable { case section(SectionKind) + case reasonGroup(SectionKind, RuntimeIndexingBatchReason.Category) 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. + // Identifier for `.section` / `.reasonGroup` is intentionally key-only — + // not derived from children or counts. RxAppKit's staged changeset + // detects child insertions and removals as nested diffs without + // recreating the header row, which preserves the user's expand / collapse + // state across updates. var differenceIdentifier: Identifier { switch self { - case .section(let kind, _): + case .section(let kind, _, _): return .section(kind) + case .reasonGroup(let kind, let category, _, _): + return .reasonGroup(kind, category) 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 f4993bac..7f68f363 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -88,7 +88,7 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController NSView? in switch node { - case .section(let kind, let batches): + case .section(let kind, let batchCount, _): let cell = outlineView.box.makeView(ofClass: SectionHeaderCellView.self) - cell.configure(kind: kind, count: batches.count) + cell.configure(kind: kind, count: batchCount) + return cell + case .reasonGroup(_, let category, let batchCount, _): + let cell = outlineView.box.makeView(ofClass: ReasonGroupHeaderCellView.self) + cell.configure(category: category, count: batchCount) return cell case .batch(let batch, _): let cell = outlineView.box.makeView(ofClass: BatchCellView.self) @@ -227,12 +231,13 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController Bool { return false } + + /// Auto-expand reason groups when their parent section is expanded so the + /// user doesn't have to drill in twice (HISTORY → Always Index → batches) + /// to see the previously-flat list. Batches themselves stay collapsed + /// inside HISTORY so the popover doesn't blow up with finished item rows. + func outlineViewItemDidExpand(_ notification: Notification) { + guard let item = notification.userInfo?["NSObject"] as? BackgroundIndexingNode, + case .section = item else { return } + for child in item.children { + if case .reasonGroup = child, !outlineView.isItemExpanded(child) { + outlineView.expandItem(child, expandChildren: false) + } + } + } } extension BackgroundIndexingPopoverViewController { @@ -258,15 +277,15 @@ extension BackgroundIndexingPopoverViewController { $0.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) $0.textColor = .tertiaryLabelColor } - + override func setup() { super.setup() - + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) countLabel.setContentHuggingPriority(.required, for: .horizontal) countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - let stack = HStackView(alignment: .centerY, spacing: 6) { + let stack = HStackView(alignment: .center, spacing: 6) { titleLabel countLabel } @@ -288,6 +307,53 @@ extension BackgroundIndexingPopoverViewController { } } + /// Header for a reason category (Heuristic Discovery / Always Index / + /// Manual). Sits one level below the section header and one level above + /// the batch rows. + private final class ReasonGroupHeaderCellView: 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 + } + + override func setup() { + super.setup() + + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + countLabel.setContentHuggingPriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let stack = HStackView(alignment: .center, spacing: 6) { + titleLabel + countLabel + } + + addSubview(stack) + stack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(4) + make.bottom.equalToSuperview().offset(-4) + make.leading.trailing.equalToSuperview() + } + } + + func configure(category: RuntimeIndexingBatchReason.Category, count: Int) { + titleLabel.stringValue = Self.title(for: category) + countLabel.stringValue = "\(count)" + } + + private static func title(for category: RuntimeIndexingBatchReason.Category) -> String { + switch category { + case .heuristic: return "Heuristic Discovery" + case .alwaysIndex: return "Always Index" + case .manual: return "Manual" + } + } + } + private final class BatchCellView: TableCellView { private let titleLabel = Label("").then { $0.font = .systemFont(ofSize: 12, weight: .semibold) @@ -332,7 +398,7 @@ extension BackgroundIndexingPopoverViewController { cancelButton.setContentHuggingPriority(.required, for: .horizontal) cancelButton.setContentCompressionResistancePriority(.required, for: .horizontal) - let topRow = HStackView(alignment: .centerY, spacing: 6) { + let topRow = HStackView(alignment: .center, spacing: 6) { titleLabel countLabel cancelButton @@ -427,25 +493,26 @@ extension BackgroundIndexingPopoverViewController { } private static func title(for reason: RuntimeIndexingBatchReason) -> String { + // The parent ReasonGroupHeaderCellView already names the category + // (Heuristic Discovery / Always Index / Manual). Batch labels can + // drop the now-redundant prefix; for .alwaysIndex this means the + // user-supplied identifier surfaces directly (e.g. "SwiftUI" + // instead of "Always: SwiftUI"). 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" + case .alwaysIndex(let identifier): + return identifier } } } 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 { + private let iconImageView = ImageView().then { $0.imageScaling = .scaleProportionallyDown } @@ -459,7 +526,7 @@ extension BackgroundIndexingPopoverViewController { titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let stack = HStackView(alignment: .centerY, spacing: 6) { + let stack = HStackView(alignment: .center, spacing: 6) { iconImageView titleLabel } @@ -494,7 +561,7 @@ extension BackgroundIndexingPopoverViewController { // effect before deciding whether to attach a fresh one. iconImageView.removeAllSymbolEffects() if case .running = item.state { - iconImageView.addSymbolEffect(.rotate, options: .repeating) + iconImageView.addSymbolEffect(.rotate, options: .repeat(.periodic)) } let nameSource = item.resolvedPath ?? item.id diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift index 906bd04a..3f667270 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewModel.swift @@ -129,9 +129,12 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { /// Seeds `isEnabled` from settings once and registers the observation. /// Mirrors `RuntimeBackgroundIndexingCoordinator.bootstrapSettingsObservation`'s - /// "seed on bootstrap, only re-register on change" pattern. + /// "seed on bootstrap, only re-register on change" pattern. Bound to the + /// master switch — `isEnabled` represents "the background-indexing feature + /// is on", regardless of which sub-mode (heuristic / custom) is doing the + /// work. private func bootstrapIsEnabledObservation() { - isEnabled = settings.indexing.backgroundMode.isEnabled + isEnabled = settings.indexing.isEnabled registerIsEnabledObservation() } @@ -140,14 +143,14 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { /// the `onChange` closure runs once, then the tracker is gone. private func registerIsEnabledObservation() { withObservationTracking { - _ = settings.indexing.backgroundMode.isEnabled + _ = settings.indexing.isEnabled } onChange: { [weak self] in // `onChange` fires off the main actor right after a mutation; // hop back to the main actor to read the latest value and // re-register the observation. Task { @MainActor [weak self] in guard let self else { return } - self.isEnabled = self.settings.indexing.backgroundMode.isEnabled + self.isEnabled = self.settings.indexing.isEnabled self.registerIsEnabledObservation() } } @@ -158,18 +161,63 @@ final class BackgroundIndexingPopoverViewModel: ViewModel { history: [RuntimeIndexingBatch] ) -> [BackgroundIndexingNode] { - let activeBatchNodes = active.map(makeBatchNode) - var nodes: [BackgroundIndexingNode] = [.section(.active, batches: activeBatchNodes)] + var nodes: [BackgroundIndexingNode] = [ + .section(.active, batchCount: active.count, groups: makeReasonGroups(for: active, kind: .active)) + ] // 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)) + nodes.append(.section(.history, batchCount: history.count, groups: makeReasonGroups(for: history, kind: .history))) } return nodes } + /// Buckets a section's batches by `reason.category`, emitting one + /// `.reasonGroup` per non-empty category in a stable order + /// (heuristic → always index → manual). Batch order inside each group + /// preserves the input order, which matches how the coordinator queues + /// them. + /// + /// `.alwaysIndex` groups flatten their batches' items directly into the + /// group's children — each entry's batch typically contains just one item + /// (or a small follow-dependency closure), so an intermediate batch row + /// would just repeat the entry's image name. `.heuristic` and `.manual` + /// keep the batch nesting because their batches usually contain many + /// items each (full dependency closure for a document). + private static func makeReasonGroups( + for batches: [RuntimeIndexingBatch], + kind: BackgroundIndexingNode.SectionKind + ) + -> [BackgroundIndexingNode] { + let categoryOrder: [RuntimeIndexingBatchReason.Category] = [ + .heuristic, .alwaysIndex, .manual + ] + var bucketed: [RuntimeIndexingBatchReason.Category: [RuntimeIndexingBatch]] = [:] + for batch in batches { + bucketed[batch.reason.category, default: []].append(batch) + } + return categoryOrder.compactMap { category in + guard let groupBatches = bucketed[category], !groupBatches.isEmpty else { + return nil + } + let children: [BackgroundIndexingNode] + switch category { + case .alwaysIndex: + children = groupBatches.flatMap { batch in + batch.items.map { item in + BackgroundIndexingNode.item(batchID: batch.id, item: item) + } + } + case .heuristic, .manual: + children = groupBatches.map(makeBatchNode) + } + return BackgroundIndexingNode.reasonGroup( + kind, category, batchCount: groupBatches.count, children: children + ) + } + } + private static func makeBatchNode(_ batch: RuntimeIndexingBatch) -> BackgroundIndexingNode { let itemNodes = batch.items.map { item in diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base.lproj/MainMenu.xib b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base.lproj/MainMenu.xib index e9fb9da3..1d9718d7 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base.lproj/MainMenu.xib +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base.lproj/MainMenu.xib @@ -111,6 +111,12 @@ + + + + + + diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/RuntimeObjectCellView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift similarity index 100% rename from RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/RuntimeObjectCellView.swift rename to RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/ViewControllers.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/ViewControllers.swift index 25bd2866..44e73d17 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/ViewControllers.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/ViewControllers.swift @@ -11,6 +11,8 @@ open class UXKitViewController: UXViewController { public private(set) var contentView: NSView = UXView() + open var contentInsets: NSDirectionalEdgeInsets { .init() } + open var shouldDisplayCommonLoading: Bool { false } open var contentViewUsingSafeArea: Bool { false } @@ -38,9 +40,15 @@ open class UXKitViewController: UXViewController { contentView.snp.makeConstraints { make in if contentViewUsingSafeArea { - make.edges.equalTo(view.safeAreaLayoutGuide) + make.top.equalTo(view.safeAreaLayoutGuide).inset(contentInsets.top) + make.leading.equalTo(view.safeAreaLayoutGuide).inset(contentInsets.leading) + make.trailing.equalTo(view.safeAreaLayoutGuide).inset(contentInsets.trailing) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(contentInsets.bottom) } else { - make.edges.equalToSuperview() + make.top.equalToSuperview().inset(contentInsets.top) + make.leading.equalToSuperview().inset(contentInsets.leading) + make.trailing.equalToSuperview().inset(contentInsets.trailing) + make.bottom.equalToSuperview().inset(contentInsets.bottom) } } @@ -58,7 +66,7 @@ open class UXKitViewController: UXViewController { open func setupBindings(for viewModel: ViewModel) { loadViewIfNeeded() - + rx.disposeBag = DisposeBag() self.viewModel = viewModel @@ -129,9 +137,8 @@ open class UXKitViewController: UXViewController { open class UXEffectViewController: UXKitViewController { private lazy var effectView: NSView = { if #available(macOS 26.0, *) { - let view = UXView() + return UXView() // view.backgroundColor = .windowBackgroundColor - return view } else { return NSVisualEffectView() } @@ -140,38 +147,6 @@ open class UXEffectViewController: UXKitViewContro open override var contentView: NSView { effectView } } -open class AppKitViewController: NSViewController { - public private(set) var viewModel: ViewModel? - - public init(viewModel: ViewModel? = nil) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - open func setupBindings(for viewModel: ViewModel) { - rx.disposeBag = DisposeBag() - self.viewModel = viewModel - - viewModel.errorRelay - .asSignal() - .emitOnNextMainActor { [weak self] error in - guard let self else { return } - if let window = view.window { - NSAlert(error: error).beginSheetModal(for: window) - } else { - NSAlert(error: error).runModal() - } - } - .disposed(by: rx.disposeBag) - } -} - - open class UXKitNavigationController: UXNavigationController { open override func viewDidLoad() { super.viewDidLoad() diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift new file mode 100644 index 00000000..eed0467b --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift @@ -0,0 +1,19 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures + +final class BatchExportingCompletionRowViewModel: CellViewModel { + let outcome: BatchExportingPerImageOutcome + + init(outcome: BatchExportingPerImageOutcome) { + self.outcome = outcome + } +} + +extension BatchExportingCompletionRowViewModel: Differentiable { + var differenceIdentifier: String { outcome.image.path } + + func isContentEqual(to source: BatchExportingCompletionRowViewModel) -> Bool { + outcome.image == source.outcome.image + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift new file mode 100644 index 00000000..3a1e2e60 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift @@ -0,0 +1,293 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerUI + +final class BatchExportingCompletionViewController: UXKitViewController, ExportingStepViewController { + private let headerIconImageView = ImageView().then { + $0.image = .symbol(systemName: .checkmarkCircleFill) + $0.symbolConfiguration = .init(pointSize: 22, weight: .semibold) + $0.contentTintColor = .systemGreen + } + + private let headerTitleLabel = Label("Export Complete").then { + $0.font = .systemFont(ofSize: 16, weight: .semibold) + $0.textColor = .labelColor + } + + private let headerSubtitleLabel = Label().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabelColor + $0.lineBreakMode = .byTruncatingMiddle + $0.maximumNumberOfLines = 1 + } + + private let showInFinderButton = NSButton(title: "Show in Finder", target: nil, action: nil).then { + $0.bezelStyle = .accessoryBarAction + $0.controlSize = .small + } + + private let interfacesCard = StatCardView(label: "Interfaces") + private let imagesCard = StatCardView(label: "Images") + private let objcSwiftCard = StatCardView(label: "ObjC · Swift") + private let durationCard = StatCardView(label: "Duration") + + private let (scrollView, tableView): (ScrollView, SingleColumnTableView) = SingleColumnTableView.scrollableTableView() + + private lazy var headerTextStack = VStackView(alignment: .leading, spacing: 2) { + headerTitleLabel + headerSubtitleLabel + } + + private lazy var headerStack = HStackView(spacing: 10) { + headerIconImageView + headerTextStack + NSView() + showInFinderButton + } + + private lazy var statsStack = HStackView(distribution: .fillEqually, spacing: 8) { + interfacesCard + imagesCard + objcSwiftCard + durationCard + } + + override func viewDidLoad() { + super.viewDidLoad() + + contentView.hierarchy { + headerStack + statsStack + scrollView + } + + headerStack.snp.makeConstraints { make in + make.top.equalToSuperview().inset(16) + make.leading.trailing.equalToSuperview().inset(20) + } + + headerIconImageView.snp.makeConstraints { make in + make.size.equalTo(26) + } + + statsStack.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview().inset(20) + make.height.equalTo(64) + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(statsStack.snp.bottom).offset(14) + make.leading.trailing.bottom.equalToSuperview().inset(20) + } + + scrollView.do { + $0.hasVerticalScroller = true + $0.borderType = .lineBorder + $0.autohidesScrollers = true + } + + tableView.do { + $0.headerView = nil + $0.rowHeight = 36 + $0.gridStyleMask = [] + $0.intercellSpacing = NSSize(width: 0, height: 0) + $0.allowsMultipleSelection = false + $0.allowsEmptySelection = true + $0.selectionHighlightStyle = .none + } + } + + override func setupBindings(for viewModel: BatchExportingCompletionViewModel) { + super.setupBindings(for: viewModel) + + let input = BatchExportingCompletionViewModel.Input( + refresh: rx.viewDidAppear.asSignal(), + showInFinderClick: showInFinderButton.rx.click.asSignal() + ) + + let output = viewModel.transform(input) + + output.summary + .driveOnNext { [weak self] summary in + guard let self else { return } + applySummary(summary) + } + .disposed(by: rx.disposeBag) + + output.rows + .drive(tableView.rx.items) { (tableView: NSTableView, _: NSTableColumn?, _: Int, rowViewModel: BatchExportingCompletionRowViewModel) -> NSView? in + let cellView = tableView.box.makeView(ofClass: CellView.self) + cellView.configure(with: rowViewModel) + return cellView + } + .disposed(by: rx.disposeBag) + } + + private func applySummary(_ summary: BatchExportingCompletionViewModel.Summary) { + headerTitleLabel.stringValue = summary.headerTitle + headerSubtitleLabel.stringValue = summary.headerSubtitle + headerSubtitleLabel.isHidden = summary.headerSubtitle.isEmpty + interfacesCard.setValue(summary.interfacesValue) + imagesCard.setValue(summary.imagesValue) + objcSwiftCard.setValue(summary.objcSwiftValue) + durationCard.setValue(summary.durationValue) + + if summary.hasFailures { + headerIconImageView.image = .symbol(systemName: .exclamationmarkTriangleFill) + headerIconImageView.contentTintColor = .systemOrange + } else { + headerIconImageView.image = .symbol(systemName: .checkmarkCircleFill) + headerIconImageView.contentTintColor = .systemGreen + } + } +} + +extension BatchExportingCompletionViewController { + private final class StatCardView: LayerBackedView { + private let valueLabel = Label().then { + $0.font = .monospacedDigitSystemFont(ofSize: 20, weight: .semibold) + $0.textColor = .labelColor + $0.alignment = .center + $0.lineBreakMode = .byTruncatingMiddle + $0.maximumNumberOfLines = 1 + } + + private let labelLabel: Label + + init(label: String) { + self.labelLabel = Label(label).then { + $0.font = .systemFont(ofSize: 11, weight: .regular) + $0.textColor = .secondaryLabelColor + $0.alignment = .center + $0.lineBreakMode = .byTruncatingTail + $0.maximumNumberOfLines = 1 + } + super.init(frame: .zero) + + cornerRadius = 8 + borderWidth = 1 + borderPositions = .all + + updateLayerColors() + + hierarchy { + valueLabel + labelLabel + } + + valueLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(6) + make.top.equalToSuperview().inset(10) + } + + labelLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(6) + make.top.equalTo(valueLabel.snp.bottom).offset(2) + make.bottom.lessThanOrEqualToSuperview().inset(10) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateLayerColors() + } + + private func updateLayerColors() { + borderColor = NSColor(light: .black.withAlphaComponent(0.08), dark: .white.withAlphaComponent(0.10)) + backgroundColor = NSColor(light: .black.withAlphaComponent(0.025), dark: .white.withAlphaComponent(0.04)) + } + + func setValue(_ value: String) { + valueLabel.stringValue = value + } + } +} + +extension BatchExportingCompletionViewController { + private final class CellView: LayerBackedTableCellView { + private let statusIcon = ImageView().then { + $0.imageScaling = .scaleProportionallyUpOrDown + } + + private let nameLabel = Label().then { + $0.font = .systemFont(ofSize: 13, weight: .medium) + $0.textColor = .labelColor + $0.lineBreakMode = .byTruncatingTail + } + + private let detailLabel = Label().then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .secondaryLabelColor + $0.lineBreakMode = .byTruncatingTail + $0.alignment = .right + } + + override func setup() { + super.setup() + + hierarchy { + statusIcon + nameLabel + detailLabel + } + + statusIcon.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(10) + make.centerY.equalToSuperview() + make.size.equalTo(16) + } + + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(statusIcon.snp.trailing).offset(8) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(detailLabel.snp.leading).offset(-12) + } + + detailLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + } + detailLabel.setContentHuggingPriority(.required, for: .horizontal) + detailLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + func configure(with rowViewModel: BatchExportingCompletionRowViewModel) { + let outcome = rowViewModel.outcome + nameLabel.stringValue = outcome.image.name + switch outcome.outcome { + case .success(let result): + var parts: [String] = ["\(result.succeeded) interfaces"] + if result.failed > 0 { + parts.append("\(result.failed) failed") + } + parts.append(String(format: "%.1fs", result.totalDuration)) + detailLabel.stringValue = parts.joined(separator: " · ") + if result.failed > 0 { + statusIcon.image = .symbol(systemName: .exclamationmarkTriangleFill) + statusIcon.contentTintColor = .systemOrange + detailLabel.textColor = .systemOrange + toolTip = outcome.objectFailures.exportFailureTooltip + backgroundColor = NSColor(light: .systemOrange.withAlphaComponent(0.08), dark: .systemOrange.withAlphaComponent(0.16)) + } else { + statusIcon.image = .symbol(systemName: .checkmarkCircleFill) + statusIcon.contentTintColor = .systemGreen + detailLabel.textColor = .secondaryLabelColor + toolTip = nil + backgroundColor = NSColor.clear + } + case .failure(let description): + statusIcon.image = .symbol(systemName: .xmarkCircleFill) + statusIcon.contentTintColor = .systemRed + detailLabel.stringValue = "Failed" + detailLabel.textColor = .systemRed + toolTip = description + backgroundColor = NSColor(light: .systemRed.withAlphaComponent(0.08), dark: .systemRed.withAlphaComponent(0.16)) + } + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift new file mode 100644 index 00000000..0d7c0550 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift @@ -0,0 +1,158 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingCompletionViewModel: ViewModel { + struct Summary: Equatable { + let hasFailures: Bool + let headerTitle: String + let headerSubtitle: String + let interfacesValue: String + let imagesValue: String + let objcSwiftValue: String + let durationValue: String + + static let empty = Summary( + hasFailures: false, + headerTitle: "Export Complete", + headerSubtitle: "", + interfacesValue: "—", + imagesValue: "—", + objcSwiftValue: "—", + durationValue: "—" + ) + } + + struct Input { + let refresh: Signal + let showInFinderClick: Signal + } + + struct Output { + let summary: Driver + let rows: Driver<[BatchExportingCompletionRowViewModel]> + } + + @Observed private(set) var summary: Summary = .empty + + private let exportingState: BatchExportingState + + init(exportingState: BatchExportingState, documentState: DocumentState, router: any Router) { + self.exportingState = exportingState + super.init(documentState: documentState, router: router) + } + + func transform(_ input: Input) -> Output { + exportingState.$aggregatedResult + .asObservable() + .compactMap { $0 } + .subscribeOnNext { [weak self] result in + guard let self else { return } + summary = Self.makeSummary(result: result, destinationURL: exportingState.destinationURL) + } + .disposed(by: rx.disposeBag) + + input.refresh.emitOnNext { [weak self] in + guard let self else { return } + refreshFromState() + } + .disposed(by: rx.disposeBag) + + input.showInFinderClick.emitOnNext { [weak self] in + guard let self else { return } + guard let url = exportingState.destinationURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + .disposed(by: rx.disposeBag) + + let rows = exportingState.$perImageOutcomes.asDriver().map { outcomes in + outcomes.map { BatchExportingCompletionRowViewModel(outcome: $0) } + } + + return Output( + summary: $summary.asDriver(), + rows: rows + ) + } + + private func refreshFromState() { + if let result = exportingState.aggregatedResult { + summary = Self.makeSummary(result: result, destinationURL: exportingState.destinationURL) + } + } + + private static let integerFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + private static func formatInteger(_ value: Int) -> String { + integerFormatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + private static func makeSummary(result: BatchExportingAggregatedResult, destinationURL: URL?) -> Summary { + let totalImages = result.imagesSucceeded + result.imagesFailed + let totalInterfaces = result.interfacesSucceeded + result.interfacesFailed + let hasFailures = result.imagesFailed > 0 || result.interfacesFailed > 0 + let imagesWord = totalImages == 1 ? "image" : "images" + let headerTitle = hasFailures ? "Export Completed with Errors" : "Export Complete" + let destinationText = destinationURL.map { "Exported to \(tildeAbbreviated($0.path))" } ?? "Export finished" + let headerSubtitle: String + if hasFailures { + headerSubtitle = "\(result.imagesSucceeded) of \(totalImages) \(imagesWord) succeeded · \(destinationText)" + } else { + headerSubtitle = "\(totalImages) \(imagesWord) · \(destinationText)" + } + + let interfacesValue: String + if result.interfacesFailed > 0 { + interfacesValue = "\(formatInteger(result.interfacesSucceeded)) / \(formatInteger(totalInterfaces))" + } else { + interfacesValue = formatInteger(result.interfacesSucceeded) + } + + let imagesValue: String + if result.imagesFailed > 0 { + imagesValue = "\(result.imagesSucceeded) / \(totalImages)" + } else { + imagesValue = "\(totalImages)" + } + + let objcSwiftValue = "\(formatInteger(result.totalObjcCount)) · \(formatInteger(result.totalSwiftCount))" + let durationValue = String(format: "%.1f s", result.totalDuration) + + return Summary( + hasFailures: hasFailures, + headerTitle: headerTitle, + headerSubtitle: headerSubtitle, + interfacesValue: interfacesValue, + imagesValue: imagesValue, + objcSwiftValue: objcSwiftValue, + durationValue: durationValue + ) + } + + private static func tildeAbbreviated(_ path: String) -> String { + (path as NSString).abbreviatingWithTildeInPath + } +} + +extension BatchExportingCompletionViewModel: ExportingStepViewModel { + var title: Driver { + "Export Complete:" + } + + var nextTitle: Driver { + "Done" + } + + var isPreviousEnabled: Driver { + false + } + + var isNextEnabled: Driver { + true + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift new file mode 100644 index 00000000..81f2aaa4 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift @@ -0,0 +1,156 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerUI + +final class BatchExportingConfigurationViewController: UXKitViewController, ExportingStepViewController { + + private let summaryLabel = Label() + + private let objcSingleFileRadio = RadioButton() + private let objcDirectoryRadio = RadioButton() + + private let swiftSingleFileRadio = RadioButton() + private let swiftDirectoryRadio = RadioButton() + + private let includeMetadataCheckbox = CheckboxButton() + + private let objcTitleLabel = Label("Objective-C:").then { + $0.font = .systemFont(ofSize: 13, weight: .medium) + } + + private let objcSingleDesc = Label("Combine all ObjC interfaces into one .h file per image").then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .tertiaryLabelColor + } + + private let objcDirDesc = Label("Individual .h files in each image's ObjCHeaders/ subdirectory").then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .tertiaryLabelColor + } + + private let swiftTitleLabel = Label("Swift:").then { + $0.font = .systemFont(ofSize: 13, weight: .medium) + } + + private let swiftSingleDesc = Label("Combine all Swift interfaces into one .swiftinterface file per image").then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .tertiaryLabelColor + } + + private let swiftDirDesc = Label("Individual files in each image's SwiftInterfaces/ subdirectory").then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .tertiaryLabelColor + } + + private let metadataDesc = Label("Write README.md with RuntimeViewer, module, date, license, and dump option details").then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .tertiaryLabelColor + } + + private lazy var contentStack = VStackView(alignment: .leading, spacing: 16) { + summaryLabel + objcStack + swiftStack + metadataStack + } + + private lazy var objcStack = VStackView(alignment: .leading, spacing: 12) { + objcTitleLabel + VStackView(alignment: .leading, spacing: 4) { + objcSingleFileRadio + objcSingleDesc + } + VStackView(alignment: .leading, spacing: 4) { + objcDirectoryRadio + objcDirDesc + } + } + + private lazy var swiftStack = VStackView(alignment: .leading, spacing: 12) { + swiftTitleLabel + VStackView(alignment: .leading, spacing: 4) { + swiftSingleFileRadio + swiftSingleDesc + } + VStackView(alignment: .leading, spacing: 4) { + swiftDirectoryRadio + swiftDirDesc + } + } + + private lazy var metadataStack = VStackView(alignment: .leading, spacing: 4) { + includeMetadataCheckbox + metadataDesc + } + + override func viewDidLoad() { + super.viewDidLoad() + + summaryLabel.do { + $0.font = .systemFont(ofSize: 13) + $0.textColor = .secondaryLabelColor + $0.maximumNumberOfLines = 0 + } + + objcSingleFileRadio.do { + $0.title = "Single File (.h)" + $0.font = .systemFont(ofSize: 13) + } + + objcDirectoryRadio.do { + $0.title = "Directory Structure" + $0.font = .systemFont(ofSize: 13) + } + + swiftSingleFileRadio.do { + $0.title = "Single File (.swiftinterface)" + $0.font = .systemFont(ofSize: 13) + } + + swiftDirectoryRadio.do { + $0.title = "Directory Structure" + $0.font = .systemFont(ofSize: 13) + } + + includeMetadataCheckbox.do { + $0.title = "Include README metadata" + $0.font = .systemFont(ofSize: 13) + } + + contentView.hierarchy { + contentStack + } + + contentStack.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(20) + } + } + + override func setupBindings(for viewModel: BatchExportingConfigurationViewModel) { + super.setupBindings(for: viewModel) + + let input = BatchExportingConfigurationViewModel.Input( + objcFormatSelected: Signal.merge( + objcSingleFileRadio.rx.click.asSignal().map { ExportFormat.singleFile.rawValue }, + objcDirectoryRadio.rx.click.asSignal().map { ExportFormat.directory.rawValue } + ), + swiftFormatSelected: Signal.merge( + swiftSingleFileRadio.rx.click.asSignal().map { ExportFormat.singleFile.rawValue }, + swiftDirectoryRadio.rx.click.asSignal().map { ExportFormat.directory.rawValue } + ), + includeMetadataSelected: includeMetadataCheckbox.rx.state.asSignal().map { $0 == .on } + ) + + let output = viewModel.transform(input) + + output.objcFormat.map { $0 == .singleFile }.drive(objcSingleFileRadio.rx.isCheck).disposed(by: rx.disposeBag) + output.objcFormat.map { $0 == .directory }.drive(objcDirectoryRadio.rx.isCheck).disposed(by: rx.disposeBag) + output.swiftFormat.map { $0 == .singleFile }.drive(swiftSingleFileRadio.rx.isCheck).disposed(by: rx.disposeBag) + output.swiftFormat.map { $0 == .directory }.drive(swiftDirectoryRadio.rx.isCheck).disposed(by: rx.disposeBag) + output.includeMetadata.drive(includeMetadataCheckbox.rx.isCheck).disposed(by: rx.disposeBag) + + output.summary.drive(summaryLabel.rx.stringValue).disposed(by: rx.disposeBag) + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift new file mode 100644 index 00000000..2c7a9295 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift @@ -0,0 +1,90 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingConfigurationViewModel: ViewModel { + struct Input { + let objcFormatSelected: Signal + let swiftFormatSelected: Signal + let includeMetadataSelected: Signal + } + + struct Output { + let summary: Driver + let objcFormat: Driver + let swiftFormat: Driver + let includeMetadata: Driver + } + + let exportingState: BatchExportingState + + @UserDefault(key: ExportingDefaultsKey.objcFormat, defaultValue: .directory) + private var storedObjcFormat: ExportFormat + + @UserDefault(key: ExportingDefaultsKey.swiftFormat, defaultValue: .singleFile) + private var storedSwiftFormat: ExportFormat + + @UserDefault(key: ExportingDefaultsKey.includeMetadata, defaultValue: true) + private var storedIncludeMetadata: Bool + + init(exportingState: BatchExportingState, documentState: DocumentState, router: any Router) { + self.exportingState = exportingState + super.init(documentState: documentState, router: router) + exportingState.objcFormat = storedObjcFormat + exportingState.swiftFormat = storedSwiftFormat + exportingState.includeMetadata = storedIncludeMetadata + } + + func transform(_ input: Input) -> Output { + input.objcFormatSelected.emitOnNext { [weak self] index in + guard let self else { return } + let format = ExportFormat(rawValue: index) ?? .singleFile + storedObjcFormat = format + exportingState.objcFormat = format + } + .disposed(by: rx.disposeBag) + + input.swiftFormatSelected.emitOnNext { [weak self] index in + guard let self else { return } + let format = ExportFormat(rawValue: index) ?? .singleFile + storedSwiftFormat = format + exportingState.swiftFormat = format + } + .disposed(by: rx.disposeBag) + + input.includeMetadataSelected.emitOnNext { [weak self] includeMetadata in + guard let self else { return } + storedIncludeMetadata = includeMetadata + exportingState.includeMetadata = includeMetadata + } + .disposed(by: rx.disposeBag) + + let summary = exportingState.$selectedImagePaths.asDriver() + .map { selectedPaths -> String in + let imageWord = selectedPaths.count == 1 ? "image" : "images" + return "\(selectedPaths.count) \(imageWord) selected" + } + + return Output( + summary: summary, + objcFormat: exportingState.$objcFormat.asDriver(), + swiftFormat: exportingState.$swiftFormat.asDriver(), + includeMetadata: exportingState.$includeMetadata.asDriver() + ) + } +} + +extension BatchExportingConfigurationViewModel: ExportingStepViewModel { + var title: Driver { + "Export Configuration:" + } + + var isPreviousEnabled: Driver { + true + } + + var isNextEnabled: Driver { + true + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift new file mode 100644 index 00000000..c99f55de --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift @@ -0,0 +1,119 @@ +import AppKit +import FoundationToolbox +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore + +@Loggable(.private) +final class BatchExportingCoordinator: SceneCoordinator { + let exportingState: BatchExportingState + + let documentState: DocumentState + + init(documentState: DocumentState) { + self.exportingState = .init() + self.documentState = documentState + super.init(windowController: .init(), initialRoute: nil) + windowController.contentViewController = ExportingViewController(router: self) + contextTrigger(.initial) + loadAvailableImages() + } + + private func loadAvailableImages() { + Task { @MainActor [weak self] in + guard let self else { return } + let nodes = documentState.runtimeEngine.imageNodes + exportingState.availableImages = Self.flattenImageNodes(nodes) + } + } + + private static func flattenImageNodes(_ nodes: [RuntimeImageNode]) -> [BatchExportingImage] { + var result: [BatchExportingImage] = [] + for root in nodes { + collect(root, group: root.name, into: &result) + } + return result + } + + private static func collect(_ node: RuntimeImageNode, group: String, into result: inout [BatchExportingImage]) { + if node.isLeaf { + result.append(.init(path: node.path, name: node.name, group: group)) + } else { + for child in node.children { + collect(child, group: group, into: &result) + } + } + } + + override func prepareTransition(for route: ExportingRoute) -> ExportingTransition { + switch route { + case .initial: + let imageSelectionViewController = BatchExportingImageSelectionViewController() + let imageSelectionViewModel = BatchExportingImageSelectionViewModel(exportingState: exportingState, documentState: documentState, router: self) + imageSelectionViewController.setupBindings(for: imageSelectionViewModel) + + let configurationViewController = BatchExportingConfigurationViewController() + let configurationViewModel = BatchExportingConfigurationViewModel(exportingState: exportingState, documentState: documentState, router: self) + configurationViewController.setupBindings(for: configurationViewModel) + + let progressViewController = BatchExportingProgressViewController() + let progressViewModel = BatchExportingProgressViewModel(exportingState: exportingState, documentState: documentState, router: self) + progressViewController.setupBindings(for: progressViewModel) + + let completionViewController = BatchExportingCompletionViewController() + let completionViewModel = BatchExportingCompletionViewModel(exportingState: exportingState, documentState: documentState, router: self) + completionViewController.setupBindings(for: completionViewModel) + + return .multiple( + .set([imageSelectionViewController, configurationViewController, progressViewController, completionViewController]), + .select(index: 0) + ) + case .previous: + switch exportingState.currentStep { + case .imageSelection: + break + case .configuration: + exportingState.currentStep = .imageSelection + case .progress: + exportingState.destinationURL = nil + exportingState.currentStep = .configuration + case .completion: + break + } + return .select(index: exportingState.currentStep.rawValue) + case .next: + switch exportingState.currentStep { + case .imageSelection: + exportingState.currentStep = .configuration + case .configuration: + if exportingState.destinationURL == nil { + contextTrigger(.directoryPicker) + return .none() + } else { + exportingState.currentStep = .progress + } + case .progress: + exportingState.currentStep = .completion + case .completion: + removeFromParent() + return .endSheetOnTop() + } + return .select(index: exportingState.currentStep.rawValue) + case .cancel: + removeFromParent() + return .endSheetOnTop() + case .directoryPicker: + let panel = NSOpenPanel() + panel.allowedContentTypes = [.directory] + panel.canCreateDirectories = true + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.beginSheetModal(for: windowController.contentWindow) { [weak self, weak panel] result in + guard result == .OK, let self, let panel, let url = panel.url else { return } + exportingState.destinationURL = url + contextTrigger(.next) + } + return .none() + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift new file mode 100644 index 00000000..5255cd77 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift @@ -0,0 +1,32 @@ +import AppKit +import RuntimeViewerCore +import RuntimeViewerApplication +import RuntimeViewerArchitectures + +final class BatchExportingImageSelectionCellViewModel: CellViewModel { + let image: BatchExportingImage + + @Observed + private(set) var isSelected: Bool = false + + private let disposeBag = DisposeBag() + + init(image: BatchExportingImage, isSelected: Observable) { + self.image = image + super.init() + isSelected + .bind(to: $isSelected) + .disposed(by: disposeBag) + } +} + +extension BatchExportingImageSelectionCellViewModel: @MainActor Differentiable { + var differenceIdentifier: String { image.path } + + func isContentEqual(to source: BatchExportingImageSelectionCellViewModel) -> Bool { + // Selection isn't part of identity — the cell view drives its + // checkbox off `$isSelected` directly, so toggling never has to + // diff the row. + image == source.image + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift new file mode 100644 index 00000000..8b73b108 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift @@ -0,0 +1,187 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerUI + +final class BatchExportingImageSelectionViewController: UXKitViewController, ExportingStepViewController { + private let searchField = SearchField() + + private let selectAllButton = PushButton(title: "Select All", titleFont: .systemFont(ofSize: 13)) + + private let deselectAllButton = PushButton(title: "Deselect All", titleFont: .systemFont(ofSize: 13)) + + private let summaryLabel = Label() + + private let (scrollView, tableView): (ScrollView, SingleColumnTableView) = SingleColumnTableView.scrollableTableView() + + private let toggleImageRelay = PublishRelay() + + override var contentInsets: NSDirectionalEdgeInsets { .init(top: 16, leading: 16, bottom: 16, trailing: 16) } + + override func viewDidLoad() { + super.viewDidLoad() + + contentView.hierarchy { + searchField + selectAllButton + deselectAllButton + summaryLabel + scrollView + } + + searchField.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + + selectAllButton.snp.makeConstraints { make in + make.top.equalTo(searchField.snp.bottom).offset(8) + make.leading.equalToSuperview() + } + + deselectAllButton.snp.makeConstraints { make in + make.centerY.equalTo(selectAllButton) + make.leading.equalTo(selectAllButton.snp.trailing).offset(8) + } + + summaryLabel.snp.makeConstraints { make in + make.centerY.equalTo(selectAllButton) + make.trailing.equalToSuperview() + make.leading.greaterThanOrEqualTo(deselectAllButton.snp.trailing).offset(8) + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(selectAllButton.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview() + } + + searchField.do { + $0.focusRingType = .none + } + + scrollView.do { + $0.autohidesScrollers = true + } + + tableView.do { + $0.headerView = nil + $0.usesAutomaticRowHeights = true + $0.allowsMultipleSelection = false + $0.allowsEmptySelection = true + $0.selectionHighlightStyle = .none + } + + summaryLabel.do { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabelColor + $0.alignment = .right + } + } + + override func setupBindings(for viewModel: BatchExportingImageSelectionViewModel) { + super.setupBindings(for: viewModel) + + let input = BatchExportingImageSelectionViewModel.Input( + searchString: searchField.rx.stringValue.asSignal(onErrorJustReturn: ""), + selectAllClicked: selectAllButton.rx.click.asSignal(), + deselectAllClicked: deselectAllButton.rx.click.asSignal(), + toggleImage: toggleImageRelay.asSignal(), + ) + + let output = viewModel.transform(input) + + let toggleImageRelay = self.toggleImageRelay + + output.cellViewModels + .drive(tableView.rx.items) { (tableView: NSTableView, _: NSTableColumn?, _: Int, cellViewModel: BatchExportingImageSelectionCellViewModel) -> NSView? in + let cellView = tableView.box.makeView(ofClass: CellView.self) + cellView.bind(to: cellViewModel) { cellViewModel in + toggleImageRelay.accept(cellViewModel) + } + return cellView + } + .disposed(by: rx.disposeBag) + + output.selectionSummary.drive(summaryLabel.rx.stringValue).disposed(by: rx.disposeBag) + } +} + +extension BatchExportingImageSelectionViewController { + private final class CellView: TableCellView { + private let checkbox = CheckboxButton(title: "").then { + $0.font = .systemFont(ofSize: 13) + } + + private let nameLabel = Label().then { + $0.font = .systemFont(ofSize: 13) + $0.textColor = .labelColor + $0.lineBreakMode = .byTruncatingTail + } + + private let pathLabel = Label().then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .secondaryLabelColor + $0.lineBreakMode = .byTruncatingMiddle + } + + private let groupLabel = Label().then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .tertiaryLabelColor + $0.alignment = .right + } + + private lazy var textStack = VStackView(alignment: .leading, spacing: 2) { + nameLabel + .contentHugging(h: .defaultLow) + .contentCompressionResistance(h: .defaultLow) + pathLabel + .contentHugging(h: .defaultLow) + .contentCompressionResistance(h: .defaultLow) + } + + private lazy var contentStack = HStackView(spacing: 6) { + checkbox + textStack + MaxSpacer() + groupLabel + } + + private var cellViewModel: BatchExportingImageSelectionCellViewModel? + + private var onToggle: ((BatchExportingImageSelectionCellViewModel) -> Void)? + + override func setup() { + super.setup() + + checkbox.target = self + checkbox.action = #selector(checkboxClicked) + + hierarchy { + contentStack + } + + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(4) + } + } + + func bind(to cellViewModel: BatchExportingImageSelectionCellViewModel, onToggle: @escaping (BatchExportingImageSelectionCellViewModel) -> Void) { + rx.disposeBag = DisposeBag() + self.cellViewModel = cellViewModel + self.onToggle = onToggle + nameLabel.stringValue = cellViewModel.image.name + pathLabel.stringValue = cellViewModel.image.path + groupLabel.stringValue = cellViewModel.image.group + + cellViewModel.$isSelected + .map { $0 ? NSControl.StateValue.on : .off } + .bind(to: checkbox.rx.state) + .disposed(by: rx.disposeBag) + } + + @objc private func checkboxClicked() { + guard let cellViewModel else { return } + onToggle?(cellViewModel) + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift new file mode 100644 index 00000000..a58f1763 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift @@ -0,0 +1,119 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingImageSelectionViewModel: ViewModel { + struct Input { + let searchString: Signal + let selectAllClicked: Signal + let deselectAllClicked: Signal + let toggleImage: Signal + } + + struct Output { + let cellViewModels: Driver<[BatchExportingImageSelectionCellViewModel]> + let selectionSummary: Driver + } + + let exportingState: BatchExportingState + + init(exportingState: BatchExportingState, documentState: DocumentState, router: any Router) { + self.exportingState = exportingState + super.init(documentState: documentState, router: router) + } + + func transform(_ input: Input) -> Output { + input.searchString.emitOnNext { [weak self] string in + guard let self else { return } + exportingState.searchString = string + } + .disposed(by: rx.disposeBag) + + input.selectAllClicked.emitOnNext { [weak self] in + guard let self else { return } + let visiblePaths = filteredImages( + availableImages: exportingState.availableImages, + searchString: exportingState.searchString + ).map(\.path) + exportingState.selectedImagePaths.formUnion(visiblePaths) + } + .disposed(by: rx.disposeBag) + + input.deselectAllClicked.emitOnNext { [weak self] in + guard let self else { return } + let visiblePaths = filteredImages( + availableImages: exportingState.availableImages, + searchString: exportingState.searchString + ).map(\.path) + exportingState.selectedImagePaths.subtract(visiblePaths) + } + .disposed(by: rx.disposeBag) + + input.toggleImage.emitOnNext { [weak self] cellViewModel in + guard let self else { return } + let path = cellViewModel.image.path + if exportingState.selectedImagePaths.contains(path) { + exportingState.selectedImagePaths.remove(path) + } else { + exportingState.selectedImagePaths.insert(path) + } + } + .disposed(by: rx.disposeBag) + + let cellViewModels = Driver + .combineLatest( + exportingState.$availableImages.asDriver(), + exportingState.$searchString.asDriver() + ) + .map { [weak self] availableImages, searchString -> [BatchExportingImageSelectionCellViewModel] in + guard let self else { return [] } + return self.filteredImages(availableImages: availableImages, searchString: searchString).map { image in + let isSelected = self.exportingState.$selectedImagePaths + .asObservable() + .map { [path = image.path] in $0.contains(path) } + .distinctUntilChanged() + return BatchExportingImageSelectionCellViewModel(image: image, isSelected: isSelected) + } + } + + let selectionSummary = Driver + .combineLatest( + exportingState.$selectedImagePaths.asDriver(), + exportingState.$availableImages.asDriver() + ) + .map { selected, available -> String in + "\(selected.count) of \(available.count) selected" + } + + return Output(cellViewModels: cellViewModels, selectionSummary: selectionSummary) + } + + private func filteredImages(availableImages: [BatchExportingImage], searchString: String) -> [BatchExportingImage] { + let trimmed = searchString.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return availableImages } + return availableImages.filter { $0.name.localizedCaseInsensitiveContains(trimmed) } + } +} + +extension BatchExportingImageSelectionViewModel: ExportingStepViewModel { + var title: Driver { + "Select Images:" + } + + var previousTitle: Driver { + "Previous" + } + + var nextTitle: Driver { + "Next" + } + + var isPreviousEnabled: Driver { + false + } + + var isNextEnabled: Driver { + exportingState.$selectedImagePaths.asDriver().map { !$0.isEmpty } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift new file mode 100644 index 00000000..de4f7398 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift @@ -0,0 +1,68 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingProgressRowViewModel: CellViewModel { + enum Status: Sendable { + case queued + case running + case succeeded(RuntimeInterfaceExportResult) + case failed(errorDescription: String) + } + + let image: BatchExportingImage + + @Observed + private(set) var status: Status = .queued + + @Observed + private(set) var progress: Double = 0 + + @Observed + private(set) var currentObjectText: String = "" + + /// Objects whose interface failed during this image's export. Surfaced in the + /// row tooltip so a partially-failed (but still "succeeded") image isn't silent. + @Observed + private(set) var objectFailures: [BatchExportingObjectFailure] = [] + + init(image: BatchExportingImage) { + self.image = image + } + + func markRunning() { + status = .running + progress = 0 + currentObjectText = "Preparing…" + } + + func updatePhase(_ phaseText: String) { + currentObjectText = phaseText + } + + func updateProgress(_ value: Double, currentObject: String) { + progress = value + currentObjectText = currentObject + } + + func markSucceeded(_ result: RuntimeInterfaceExportResult, objectFailures: [BatchExportingObjectFailure] = []) { + self.objectFailures = objectFailures + status = .succeeded(result) + progress = 1 + currentObjectText = "" + } + + func markFailed(_ description: String) { + status = .failed(errorDescription: description) + currentObjectText = "" + } +} + +extension BatchExportingProgressRowViewModel: Differentiable { + var differenceIdentifier: String { image.path } + + func isContentEqual(to source: BatchExportingProgressRowViewModel) -> Bool { + true + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift new file mode 100644 index 00000000..2a1369a6 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift @@ -0,0 +1,237 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerUI + +final class BatchExportingProgressViewController: UXKitViewController, ExportingStepViewController { + private let titleLabel = Label().then { + $0.font = .systemFont(ofSize: 14, weight: .semibold) + $0.textColor = .controlTextColor + $0.lineBreakMode = .byTruncatingMiddle + } + + private let overallProgressBar = NSProgressIndicator().then { + $0.style = .bar + $0.isIndeterminate = false + $0.minValue = 0 + $0.maxValue = 1 + } + + private let progressLabel = Label().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabelColor + } + + private let (scrollView, tableView): (ScrollView, SingleColumnTableView) = SingleColumnTableView.scrollableTableView() + + override func viewDidLoad() { + super.viewDidLoad() + + contentView.hierarchy { + titleLabel + progressLabel + overallProgressBar + scrollView + } + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(20) + make.trailing.lessThanOrEqualTo(progressLabel.snp.leading).offset(-12) + } + + progressLabel.snp.makeConstraints { make in + make.centerY.equalTo(titleLabel) + make.trailing.equalToSuperview().inset(20) + } + + overallProgressBar.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(20) + make.height.equalTo(8) + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(overallProgressBar.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview().inset(20) + } + + scrollView.do { + $0.hasVerticalScroller = true + $0.borderType = .lineBorder + $0.autohidesScrollers = true + } + + tableView.do { + $0.headerView = nil + $0.rowHeight = 40 + $0.gridStyleMask = [] + $0.intercellSpacing = NSSize(width: 0, height: 0) + $0.allowsMultipleSelection = false + $0.allowsEmptySelection = true + } + } + + override func setupBindings(for viewModel: BatchExportingProgressViewModel) { + super.setupBindings(for: viewModel) + + let input = BatchExportingProgressViewModel.Input( + startExport: rx.viewDidAppear.asSignal(), + ) + + let output = viewModel.transform(input) + + output.titleText.drive(titleLabel.rx.stringValue).disposed(by: rx.disposeBag) + output.progressText.drive(progressLabel.rx.stringValue).disposed(by: rx.disposeBag) + output.overallProgress.drive(overallProgressBar.rx.doubleValue).disposed(by: rx.disposeBag) + + output.rows + .drive(tableView.rx.items) { (tableView: NSTableView, _: NSTableColumn?, _: Int, rowViewModel: BatchExportingProgressRowViewModel) -> NSView? in + let cellView = tableView.box.makeView(ofClass: CellView.self) + cellView.bind(to: rowViewModel) + return cellView + } + .disposed(by: rx.disposeBag) + } +} + +extension BatchExportingProgressViewController { + private final class CellView: TableCellView { + private let statusIcon = ImageView().then { + $0.imageScaling = .scaleProportionallyUpOrDown + } + + private let nameLabel = Label().then { + $0.font = .systemFont(ofSize: 13, weight: .medium) + $0.textColor = .labelColor + $0.lineBreakMode = .byTruncatingTail + } + + private let detailLabel = Label().then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .secondaryLabelColor + $0.lineBreakMode = .byTruncatingMiddle + } + + private let progressBar = NSProgressIndicator().then { + $0.style = .bar + $0.isIndeterminate = false + $0.minValue = 0 + $0.maxValue = 1 + $0.controlSize = .small + } + + private var isSymbolEffectRunning = false + + override func setup() { + super.setup() + + hierarchy { + statusIcon + nameLabel + detailLabel + progressBar + } + + statusIcon.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(8) + make.centerY.equalToSuperview() + make.size.equalTo(18) + } + + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(statusIcon.snp.trailing).offset(8) + make.top.equalToSuperview().inset(6) + make.trailing.lessThanOrEqualToSuperview().inset(8) + } + + detailLabel.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.trailing.equalToSuperview().inset(8) + make.top.equalTo(nameLabel.snp.bottom).offset(2) + } + + progressBar.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.trailing.equalToSuperview().inset(8) + make.centerY.equalTo(detailLabel) + make.height.equalTo(6) + } + } + + func bind(to rowViewModel: BatchExportingProgressRowViewModel) { + rx.disposeBag = DisposeBag() + + nameLabel.stringValue = rowViewModel.image.name + + Driver.combineLatest( + rowViewModel.$status.asDriver(), + rowViewModel.$progress.asDriver(), + rowViewModel.$currentObjectText.asDriver(), + rowViewModel.$objectFailures.asDriver(), + ) + .driveOnNext { [weak self] status, progress, currentObject, objectFailures in + guard let self else { return } + applyState(status: status, progress: progress, currentObject: currentObject, objectFailures: objectFailures) + } + .disposed(by: rx.disposeBag) + } + + private func applyState( + status: BatchExportingProgressRowViewModel.Status, + progress: Double, + currentObject: String, + objectFailures: [BatchExportingObjectFailure], + ) { + toolTip = objectFailures.exportFailureTooltip + switch status { + case .queued: + statusIcon.image = .symbol(systemName: .circle) + statusIcon.contentTintColor = .tertiaryLabelColor + detailLabel.stringValue = "Queued" + detailLabel.textColor = .tertiaryLabelColor + detailLabel.isHidden = false + progressBar.isHidden = true + case .running: + progressBar.doubleValue = progress + progressBar.isHidden = false + detailLabel.isHidden = true + if !isSymbolEffectRunning { + statusIcon.image = .symbol(systemName: .arrowTriangle2Circlepath) + statusIcon.contentTintColor = .systemBlue + statusIcon.addSymbolEffect(.rotate, options: .repeat(.periodic)) + isSymbolEffectRunning = true + } + return + case .succeeded(let result): + if result.failed > 0 { + statusIcon.image = .symbol(systemName: .exclamationmarkTriangleFill) + statusIcon.contentTintColor = .systemOrange + } else { + statusIcon.image = .symbol(systemName: .checkmarkCircleFill) + statusIcon.contentTintColor = .systemGreen + } + let parts: [String?] = [ + "\(result.succeeded) succeeded", + result.failed > 0 ? "\(result.failed) failed" : nil, + String(format: "%.1fs", result.totalDuration), + ] + detailLabel.stringValue = parts.compactMap(\.self).joined(separator: " · ") + detailLabel.textColor = result.failed > 0 ? .systemOrange : .secondaryLabelColor + detailLabel.isHidden = false + progressBar.isHidden = true + case .failed(let description): + statusIcon.image = .symbol(systemName: .xmarkCircleFill) + statusIcon.contentTintColor = .systemRed + detailLabel.stringValue = "Failed: \(description)" + detailLabel.textColor = .systemRed + detailLabel.isHidden = false + progressBar.isHidden = true + } + if isSymbolEffectRunning { + statusIcon.removeAllSymbolEffects() + isSymbolEffectRunning = false + } + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift new file mode 100644 index 00000000..9f9d23fe --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift @@ -0,0 +1,264 @@ +import AppKit +import Foundation +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingProgressViewModel: ViewModel { + struct Input { + let startExport: Signal + } + + struct Output { + let titleText: Driver + let progressText: Driver + let overallProgress: Driver + let rows: Driver<[BatchExportingProgressRowViewModel]> + } + + @Observed private(set) var titleText: String = "" + @Observed private(set) var progressText: String = "" + @Observed private(set) var overallProgress: Double = 0 + + private let exportingState: BatchExportingState + private var exportTask: Task? + + init(exportingState: BatchExportingState, documentState: DocumentState, router: any Router) { + self.exportingState = exportingState + super.init(documentState: documentState, router: router) + } + + deinit { + exportTask?.cancel() + } + + func transform(_ input: Input) -> Output { + input.startExport.emitOnNext { [weak self] in + guard let self else { return } + startExport() + } + .disposed(by: rx.disposeBag) + + return Output( + titleText: $titleText.asDriver(), + progressText: $progressText.asDriver(), + overallProgress: $overallProgress.asDriver(), + rows: exportingState.$progressRowViewModels.asDriver(), + ) + } + + private var isExporting = false + + private func startExport() { + guard !isExporting else { return } + isExporting = true + guard let directory = exportingState.destinationURL else { + isExporting = false + return + } + + var generationOptions = appDefaults.options + generationOptions.transformer = settings.transformer + + let images = exportingState.selectedImages + guard !images.isEmpty else { + isExporting = false + return + } + + let total = images.count + let runtimeEngine = documentState.runtimeEngine + let objcFormat = exportingState.objcFormat + let swiftFormat = exportingState.swiftFormat + let includeMetadata = exportingState.includeMetadata + let concurrency = max(2, min(8, ProcessInfo.processInfo.activeProcessorCount)) + + let rowViewModels = images.map { BatchExportingProgressRowViewModel(image: $0) } + exportingState.progressRowViewModels = rowViewModels + + titleText = "Exporting \(total) image\(total == 1 ? "" : "s")…" + progressText = "0 / \(total) completed" + overallProgress = 0 + + exportTask = Task { @MainActor [weak self] in + guard let self else { return } + exportingState.perImageOutcomes = [] + var succeededCount = 0 + var failedCount = 0 + var completedCount = 0 + + await withTaskGroup(of: BatchExportingPerImageOutcome.self) { group in + var iterator = rowViewModels.makeIterator() + + for _ in 0 ..< concurrency { + guard let rowViewModel = iterator.next() else { break } + group.addTask { @MainActor in + await Self.exportOne( + rowViewModel: rowViewModel, + baseDirectory: directory, + objcFormat: objcFormat, + swiftFormat: swiftFormat, + includeMetadata: includeMetadata, + generationOptions: generationOptions, + runtimeEngine: runtimeEngine, + ) + } + } + + while let outcome = await group.next() { + completedCount += 1 + if outcome.didSucceed { + succeededCount += 1 + } else { + failedCount += 1 + } + self.exportingState.perImageOutcomes.append(outcome) + self.overallProgress = Double(completedCount) / Double(total) + var parts = ["\(completedCount) / \(total) completed"] + if succeededCount > 0 { parts.append("\(succeededCount) succeeded") } + if failedCount > 0 { parts.append("\(failedCount) failed") } + self.progressText = parts.joined(separator: " · ") + + if let nextRowViewModel = iterator.next() { + group.addTask { @MainActor in + await Self.exportOne( + rowViewModel: nextRowViewModel, + baseDirectory: directory, + objcFormat: objcFormat, + swiftFormat: swiftFormat, + includeMetadata: includeMetadata, + generationOptions: generationOptions, + runtimeEngine: runtimeEngine, + ) + } + } + } + } + + overallProgress = 1 + titleText = "Completed \(total) image\(total == 1 ? "" : "s")" + exportingState.aggregatedResult = .init(outcomes: exportingState.perImageOutcomes) + router.trigger(.next) + } + } + + @MainActor + private static func exportOne( + rowViewModel: BatchExportingProgressRowViewModel, + baseDirectory: URL, + objcFormat: ExportFormat, + swiftFormat: ExportFormat, + includeMetadata: Bool, + generationOptions: RuntimeObjectInterface.GenerationOptions, + runtimeEngine: RuntimeEngine, + ) async -> BatchExportingPerImageOutcome { + let image = rowViewModel.image + + do { + if try await !runtimeEngine.isImageLoaded(path: image.path) { + try await runtimeEngine.loadImage(at: image.path) + } + } catch { + let description = error.localizedDescription + rowViewModel.markFailed(description) + return .init(image: image, outcome: .failure(errorDescription: description)) + } + + rowViewModel.markRunning() + + let sanitizedName = sanitize(image.name) + let perImageDirectory = baseDirectory.appendingPathComponent(sanitizedName, isDirectory: true) + do { + try FileManager.default.createDirectory(at: perImageDirectory, withIntermediateDirectories: true) + } catch { + let description = error.localizedDescription + rowViewModel.markFailed(description) + return .init(image: image, outcome: .failure(errorDescription: description)) + } + + let configuration = RuntimeInterfaceExportConfiguration( + imagePath: image.path, + imageName: sanitizedName, + directory: perImageDirectory, + objcFormat: objcFormat, + swiftFormat: swiftFormat, + generationOptions: generationOptions, + includeMetadata: includeMetadata, + ) + + let reporter = RuntimeInterfaceExportReporter() + let eventsTask: Task<(result: RuntimeInterfaceExportResult?, failures: [BatchExportingObjectFailure]), Never> = Task { @MainActor in + var capturedResult: RuntimeInterfaceExportResult? + var objectFailures: [BatchExportingObjectFailure] = [] + for await event in reporter.events { + switch event { + case .phaseStarted(let phase): + switch phase { + case .preparing: + rowViewModel.updatePhase("Preparing…") + case .exporting: + rowViewModel.updatePhase("Exporting interfaces…") + case .writing: + rowViewModel.updatePhase("Writing files…") + } + case .objectStarted(let object, let current, let totalObjects): + rowViewModel.updateProgress( + Double(current - 1) / Double(totalObjects), + currentObject: "\(object.displayName) (\(current)/\(totalObjects))", + ) + case .objectFailed(let object, let error): + objectFailures.append( + BatchExportingObjectFailure( + objectName: object.displayName, + errorDescription: error.localizedDescription, + ) + ) + case .completed(let result): + capturedResult = result + default: + break + } + } + return (capturedResult, objectFailures) + } + + do { + try await runtimeEngine.exportInterfaces(with: configuration, reporter: reporter) + let captured = await eventsTask.value + if let result = captured.result { + rowViewModel.markSucceeded(result, objectFailures: captured.failures) + return .init(image: image, outcome: .success(result), objectFailures: captured.failures) + } else { + let description = "No completion event received" + rowViewModel.markFailed(description) + return .init(image: image, outcome: .failure(errorDescription: description)) + } + } catch { + eventsTask.cancel() + _ = await eventsTask.value + let description = error.localizedDescription + rowViewModel.markFailed(description) + return .init(image: image, outcome: .failure(errorDescription: description)) + } + } + + private static func sanitize(_ name: String) -> String { + let unsafe: Set = ["/", ":", "\\", "*", "?", "\"", "<", ">", "|"] + let cleaned = String(name.map { unsafe.contains($0) ? "_" : $0 }) + return cleaned.isEmpty ? "Unnamed" : cleaned + } +} + +extension BatchExportingProgressViewModel: ExportingStepViewModel { + var title: Driver { + "Exporting..." + } + + var isPreviousEnabled: Driver { + false + } + + var isNextEnabled: Driver { + false + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift new file mode 100644 index 00000000..bbffa9a1 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift @@ -0,0 +1,168 @@ +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures + +enum BatchExportingStep: Int { + case imageSelection = 0 + case configuration = 1 + case progress = 2 + case completion = 3 +} + +struct BatchExportingImage: Hashable, Sendable { + let path: String + let name: String + let group: String +} + +/// A single object whose interface failed to export, kept so the per-image row +/// can surface *which* objects failed and *why* instead of only a failure count. +struct BatchExportingObjectFailure: Sendable, Hashable { + let objectName: String + let errorDescription: String +} + +extension Collection where Element == BatchExportingObjectFailure { + /// Multi-line tooltip listing each failed object and its reason, or `nil` + /// when nothing failed. Capped so a pathological image doesn't build a + /// thousand-line tooltip. + var exportFailureTooltip: String? { + guard !isEmpty else { return nil } + let total = count + let header = total == 1 ? "1 interface failed:" : "\(total) interfaces failed:" + var lines = [header] + prefix(50).map { "• \($0.objectName) — \($0.errorDescription)" } + if total > 50 { + lines.append("…and \(total - 50) more") + } + return lines.joined(separator: "\n") + } +} + +struct BatchExportingPerImageOutcome: Sendable { + let image: BatchExportingImage + let outcome: Outcome + /// Per-object interface failures collected from the export reporter. Empty + /// when the whole image failed up front (that error lives in `.failure`), + /// or when every object exported cleanly. + let objectFailures: [BatchExportingObjectFailure] + + init(image: BatchExportingImage, outcome: Outcome, objectFailures: [BatchExportingObjectFailure] = []) { + self.image = image + self.outcome = outcome + self.objectFailures = objectFailures + } + + enum Outcome: Sendable { + case success(RuntimeInterfaceExportResult) + case failure(errorDescription: String) + } + + var succeeded: Int { + if case .success(let result) = outcome { return result.succeeded } + return 0 + } + + var failed: Int { + if case .success(let result) = outcome { return result.failed } + return 0 + } + + var totalDuration: TimeInterval { + if case .success(let result) = outcome { return result.totalDuration } + return 0 + } + + var objcCount: Int { + if case .success(let result) = outcome { return result.objcCount } + return 0 + } + + var swiftCount: Int { + if case .success(let result) = outcome { return result.swiftCount } + return 0 + } + + var didSucceed: Bool { + if case .success = outcome { return true } + return false + } +} + +struct BatchExportingAggregatedResult: Sendable { + let imagesSucceeded: Int + let imagesFailed: Int + let interfacesSucceeded: Int + let interfacesFailed: Int + let totalDuration: TimeInterval + let totalObjcCount: Int + let totalSwiftCount: Int + + init(outcomes: [BatchExportingPerImageOutcome]) { + var imagesSucceeded = 0 + var imagesFailed = 0 + var interfacesSucceeded = 0 + var interfacesFailed = 0 + var totalDuration: TimeInterval = 0 + var totalObjcCount = 0 + var totalSwiftCount = 0 + for outcome in outcomes { + if outcome.didSucceed { + imagesSucceeded += 1 + } else { + imagesFailed += 1 + } + interfacesSucceeded += outcome.succeeded + interfacesFailed += outcome.failed + totalDuration += outcome.totalDuration + totalObjcCount += outcome.objcCount + totalSwiftCount += outcome.swiftCount + } + self.imagesSucceeded = imagesSucceeded + self.imagesFailed = imagesFailed + self.interfacesSucceeded = interfacesSucceeded + self.interfacesFailed = interfacesFailed + self.totalDuration = totalDuration + self.totalObjcCount = totalObjcCount + self.totalSwiftCount = totalSwiftCount + } +} + +@MainActor +final class BatchExportingState { + @Observed + var availableImages: [BatchExportingImage] = [] + + @Observed + var selectedImagePaths: Set = [] + + @Observed + var searchString: String = "" + + @Observed + var objcFormat: ExportFormat = .directory + + @Observed + var swiftFormat: ExportFormat = .singleFile + + @Observed + var includeMetadata: Bool = true + + @Observed + var destinationURL: URL? + + @Observed + var progressRowViewModels: [BatchExportingProgressRowViewModel] = [] + + @Observed + var perImageOutcomes: [BatchExportingPerImageOutcome] = [] + + @Observed + var aggregatedResult: BatchExportingAggregatedResult? + + @Observed + var currentStep: BatchExportingStep = .imageSelection + + var selectedImages: [BatchExportingImage] { + availableImages.filter { selectedImagePaths.contains($0.path) } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Content/ContentCoordinator.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Content/ContentCoordinator.swift index 17056158..50fc4c79 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Content/ContentCoordinator.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Content/ContentCoordinator.swift @@ -47,8 +47,8 @@ final class ContentCoordinator: ViewCoordinator case .next(let runtimeObject): return enterTextScene(for: runtimeObject) case .back: - if let last = documentState.selectionStack.last { - return enterTextScene(for: last) + if let selected = documentState.selectedRuntimeObject { + return enterTextScene(for: selected) } else { return enterPlaceholderScene() } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingCompletionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingCompletionViewController.swift index cc5064b4..9138086e 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingCompletionViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingCompletionViewController.swift @@ -3,7 +3,7 @@ import RuntimeViewerUI import RuntimeViewerArchitectures import RuntimeViewerApplication -final class ExportingCompletionViewController: AppKitViewController, ExportingStepViewController { +final class ExportingCompletionViewController: UXKitViewController, ExportingStepViewController { private let checkmarkImageView = NSImageView().then { $0.image = .symbol(systemName: .checkmarkCircleFill) $0.symbolConfiguration = .init(pointSize: 56, weight: .regular) @@ -39,7 +39,7 @@ final class ExportingCompletionViewController: AppKitViewController { struct Input { @@ -28,8 +23,14 @@ final class ExportingConfigurationViewModel: ViewModel { let exportingState: ExportingState - @AppStorage(ExportingAppStorageKeys.includeMetadata) - private var storedIncludeMetadata: Bool = true + @UserDefault(key: ExportingDefaultsKey.objcFormat, defaultValue: .directory) + private var storedObjcFormat: ExportFormat + + @UserDefault(key: ExportingDefaultsKey.swiftFormat, defaultValue: .singleFile) + private var storedSwiftFormat: ExportFormat + + @UserDefault(key: ExportingDefaultsKey.includeMetadata, defaultValue: true) + private var storedIncludeMetadata: Bool @Observed private(set) var isLoading: Bool = true @@ -40,6 +41,8 @@ final class ExportingConfigurationViewModel: ViewModel { init(exportingState: ExportingState, documentState: DocumentState, router: any Router) { self.exportingState = exportingState super.init(documentState: documentState, router: router) + exportingState.objcFormat = storedObjcFormat + exportingState.swiftFormat = storedSwiftFormat exportingState.includeMetadata = storedIncludeMetadata loadObjects() } @@ -60,13 +63,17 @@ final class ExportingConfigurationViewModel: ViewModel { func transform(_ input: Input) -> Output { input.objcFormatSelected.emitOnNext { [weak self] index in guard let self else { return } - exportingState.objcFormat = ExportFormat(rawValue: index) ?? .singleFile + let format = ExportFormat(rawValue: index) ?? .singleFile + storedObjcFormat = format + exportingState.objcFormat = format } .disposed(by: rx.disposeBag) input.swiftFormatSelected.emitOnNext { [weak self] index in guard let self else { return } - exportingState.swiftFormat = ExportFormat(rawValue: index) ?? .singleFile + let format = ExportFormat(rawValue: index) ?? .singleFile + storedSwiftFormat = format + exportingState.swiftFormat = format } .disposed(by: rx.disposeBag) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingProgressViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingProgressViewController.swift index da908668..8c39319d 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingProgressViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingProgressViewController.swift @@ -4,7 +4,7 @@ import RuntimeViewerUI import RuntimeViewerArchitectures import RuntimeViewerApplication -final class ExportingProgressViewController: AppKitViewController, ExportingStepViewController { +final class ExportingProgressViewController: UXKitViewController, ExportingStepViewController { private let progressPhaseLabel = Label("Preparing...").then { $0.font = .systemFont(ofSize: 20, weight: .bold) $0.textColor = .controlTextColor @@ -29,7 +29,7 @@ final class ExportingProgressViewController: AppKitViewController> { +final class GenerationOptionsViewController: UXKitViewController> { private enum OptionItem { case checkbox(title: String, keyPath: OptionKeyPath) case segmentedControl(title: String, labels: [String], selectedIndex: (RuntimeObjectInterface.GenerationOptions) -> Int, mutation: (Int) -> OptionsMutation) @@ -63,7 +63,7 @@ final class GenerationOptionsViewController: AppKitViewController {} -final class LoadFrameworksViewController: AppKitViewController { +final class LoadFrameworksViewController: UXKitViewController { override func viewDidLoad() { super.viewDidLoad() // Do view setup here. diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift index 6ce6c993..9f335fcf 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewController.swift @@ -5,7 +5,7 @@ import RuntimeViewerApplication import RuntimeViewerMCPBridge import RuntimeViewerSettingsUI -final class MCPStatusPopoverViewController: AppKitViewController> { +final class MCPStatusPopoverViewController: UXKitViewController> { // MARK: - Views private let statusCircle = ImageView() @@ -52,7 +52,7 @@ final class MCPStatusPopoverViewController: AppKitViewController, LateRe viewController.setupBindings(for: viewModel) return .presentOnRoot(viewController, mode: .asPopover(relativeToRect: sender.bounds, ofView: sender, preferredEdge: .maxY, behavior: .transient)) case .attachToProcess: - let viewController = AttachToProcessViewController() + let viewController = AttachToProcessViewController.shared let viewModel = AttachToProcessViewModel(documentState: documentState, router: self) viewController.setupBindings(for: viewModel) viewController.preferredContentSize = .init(width: 800, height: 600) @@ -95,6 +95,10 @@ final class MainCoordinator: SceneCoordinator, LateRe guard let exportingCoordinator = ExportingCoordinator(documentState: documentState) else { return .none() } addChild(exportingCoordinator) return .beginSheet(exportingCoordinator) + case .exportMultipleImages: + let batchExportingCoordinator = BatchExportingCoordinator(documentState: documentState) + addChild(batchExportingCoordinator) + return .beginSheet(batchExportingCoordinator) case .beginSpecializationSheet(let object): let specializationCoordinator = SpecializationCoordinator( documentState: documentState, @@ -158,6 +162,15 @@ final class MainCoordinator: SceneCoordinator, LateRe contentCoordinator.contextTrigger(.back) inspectorCoordinator.contextTrigger(.back) } + case .backward, .forward: + // History array unchanged — only the cursor moved. Re-enter + // the text/runtimeObject scene for the new + // `selectedRuntimeObject`. `.back` is the right vocabulary + // because both panes reuse their existing controller and + // just rebind to the cursor target (no push-transition + // flash). + contentCoordinator.contextTrigger(.back) + inspectorCoordinator.contextTrigger(.back) case .clear: contentCoordinator.contextTrigger(.placeholder) inspectorCoordinator.contextTrigger(.placeholder) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift index 6714f19f..01646b15 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainRoute.swift @@ -15,6 +15,7 @@ public enum MainRoute: Routable { case backgroundIndexing(sender: NSView) case dismiss case exportInterfaces + case exportMultipleImages /// Begin the document-window sheet that walks the user through /// specializing the supplied generic Swift type. Forwarded by /// `InspectorCoordinator` via its delegate. diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift index 543c8ae3..a422bbb1 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift @@ -6,6 +6,29 @@ import RuntimeViewerMCPBridge final class MainToolbarController: NSObject, NSToolbarDelegate { protocol Delegate: AnyObject {} + final class NavigationToolbarItem: NSToolbarItem { +// let previousItem = IconButtonToolbarItem(itemIdentifier: .Main.navigationPrevious, icon: .chevronBackward).then { +// $0.label = "Previous" +// } +// +// let nextItem = IconButtonToolbarItem(itemIdentifier: .Main.navigationNext, icon: .chevronForward).then { +// $0.label = "Next" +// } + + let segmentedControl = NSSegmentedControl() + + init() { + super.init(itemIdentifier: .Main.navigation) + isNavigational = true + view = segmentedControl + segmentedControl.segmentCount = 2 + segmentedControl.segmentStyle = .automatic + segmentedControl.trackingMode = .momentary + segmentedControl.setImage(.symbol(systemName: .chevronBackward), forSegment: 0) + segmentedControl.setImage(.symbol(systemName: .chevronForward), forSegment: 1) + } + } + class IconButtonToolbarItem: NSToolbarItem { let button = ToolbarButton() @@ -141,10 +164,7 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { $0.label = "Back" } - let contentBackItem = IconButtonToolbarItem(itemIdentifier: .Main.contentBack, icon: .chevronBackward).then { - $0.isNavigational = true - $0.label = "Back" - } + let navigationItem = NavigationToolbarItem() let titleItem = TitleToolbarItem() @@ -207,7 +227,7 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { .Main.sidebarBack, .flexibleSpace, .sidebarTrackingSeparator, - .Main.contentBack, + .Main.navigation, .Main.title, .flexibleSpace, .Main.switchSource, @@ -229,7 +249,7 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [ .Main.sidebarBack, - .Main.contentBack, + .Main.navigation, .Main.title, .flexibleSpace, .toggleSidebar, @@ -253,8 +273,8 @@ final class MainToolbarController: NSObject, NSToolbarDelegate { switch itemIdentifier { case .Main.sidebarBack: return sidebarBackItem - case .Main.contentBack: - return contentBackItem + case .Main.navigation: + return navigationItem case .Main.title: return titleItem case .Main.share: @@ -294,7 +314,9 @@ extension NSToolbarItem.Identifier: @retroactive ExpressibleByStringLiteral { extension NSToolbarItem.Identifier { enum Main { static let sidebarBack: NSToolbarItem.Identifier = "sidebarBack" - static let contentBack: NSToolbarItem.Identifier = "contentBack" + static let navigation: NSToolbarItem.Identifier = "navigation" + static let navigationPrevious: NSToolbarItem.Identifier = "navigationPrevious" + static let navigationNext: NSToolbarItem.Identifier = "navigationNext" static let title: NSToolbarItem.Identifier = "title" static let share: NSToolbarItem.Identifier = "share" static let save: NSToolbarItem.Identifier = "save" diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift index af64670b..5b3fb295 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift @@ -39,7 +39,8 @@ struct SwitchSourceState: Equatable { final class MainViewModel: ViewModel { struct Input { let sidebarBackClick: Signal - let contentBackClick: Signal + let navigationPreviousClick: Signal + let navigationNextClick: Signal let saveClick: Signal let switchSource: Signal let generationOptionsClick: Signal @@ -58,7 +59,9 @@ final class MainViewModel: ViewModel { let sharingServiceData: Observable<[SharingData]> let isSavable: Driver let isSidebarBackHidden: Driver - let isContentBackHidden: Driver + let isNavigationHidden: Driver + let canGoPrevious: Driver + let canGoNext: Driver let runtimeEngineSections: Driver<[RuntimeEngineSection]> let switchSourceState: Driver let requestFrameworkSelection: Signal @@ -156,9 +159,15 @@ final class MainViewModel: ViewModel { } .disposed(by: rx.disposeBag) - input.contentBackClick.emitOnNext { [weak self] in + input.navigationPreviousClick.emitOnNext { [weak self] in guard let self else { return } - documentState.selectionRouter.trigger(.pop) + documentState.selectionRouter.trigger(.backward) + } + .disposed(by: rx.disposeBag) + + input.navigationNextClick.emitOnNext { [weak self] in + guard let self else { return } + documentState.selectionRouter.trigger(.forward) } .disposed(by: rx.disposeBag) @@ -168,9 +177,16 @@ final class MainViewModel: ViewModel { input.backgroundIndexingClick.emit(with: self) { $0.router.trigger(.backgroundIndexing(sender: $1)) }.disposed(by: rx.disposeBag) - let selectedRuntimeObjectSignal = documentState.$selectionStack + let selectedRuntimeObjectObservable: Observable = Observable.combineLatest( + documentState.$selectionStack.asObservable(), + documentState.$selectionIndex.asObservable() + ).map { stack, index in + guard index >= 0, index < stack.count else { return nil } + return stack[index] + } + + let selectedRuntimeObjectSignal = selectedRuntimeObjectObservable .asSignal(onErrorSignalWith: .empty()) - .map(\.last) let requestSaveLocation = input.saveClick .withLatestFrom(selectedRuntimeObjectSignal) @@ -204,10 +220,9 @@ final class MainViewModel: ViewModel { owner.selectedEngineIdentifier = identifier }.disposed(by: rx.disposeBag) - let sharingServiceData = documentState.$selectionStack - .asObservable() - .map { [weak self] stack -> [SharingData] in - guard let self, let runtimeObjectType = stack.last else { return [] } + let sharingServiceData = selectedRuntimeObjectObservable + .map { [weak self] selected -> [SharingData] in + guard let self, let runtimeObjectType = selected else { return [] } let item = NSItemProvider() @@ -264,7 +279,14 @@ final class MainViewModel: ViewModel { sharingServiceData: sharingServiceData, isSavable: documentState.$selectionStack.asDriver().map { !$0.isEmpty }, isSidebarBackHidden: documentState.$currentImageNode.asDriver().map { $0 == nil }, - isContentBackHidden: documentState.$selectionStack.asDriver().map { $0.count <= 1 }, + isNavigationHidden: documentState.$selectionStack.asDriver().map { $0.isEmpty }, + canGoPrevious: documentState.$selectionIndex.asDriver().map { $0 > 0 }, + canGoNext: Driver.combineLatest( + documentState.$selectionStack.asDriver(), + documentState.$selectionIndex.asDriver() + ).map { stack, index in + index < stack.count - 1 + }, runtimeEngineSections: runtimeEngineManager.rx.runtimeEngineSections, switchSourceState: switchSourceState, requestFrameworkSelection: requestFrameworkSelection, diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift index 9337ebdc..696c5308 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift @@ -99,7 +99,8 @@ final class MainWindowController: XiblessWindowController { let input = MainViewModel.Input( sidebarBackClick: toolbarController.sidebarBackItem.button.rx.click.asSignal(), - contentBackClick: toolbarController.contentBackItem.button.rx.click.asSignal(), + navigationPreviousClick: toolbarController.navigationItem.segmentedControl.rx.selectedSegment.asSignal().filter { $0 == 0 }.mapToVoid(), + navigationNextClick: toolbarController.navigationItem.segmentedControl.rx.selectedSegment.asSignal().filter { $0 == 1 }.mapToVoid(), saveClick: toolbarController.saveItem.button.rx.click.asSignal(), switchSource: toolbarController.switchSourceItem.popUpButton.rx.selectedItemRepresentedObject(String.self).asSignal(), generationOptionsClick: toolbarController.generationOptionsItem.button.rx.clickWithSelf.asSignal().map { $0 }, @@ -149,7 +150,11 @@ final class MainWindowController: XiblessWindowController { output.isSidebarBackHidden.drive(toolbarController.sidebarBackItem.rx.isHidden).disposed(by: rx.disposeBag) - output.isContentBackHidden.drive(toolbarController.contentBackItem.rx.isHidden).disposed(by: rx.disposeBag) + output.isNavigationHidden.drive(toolbarController.navigationItem.rx.isHidden).disposed(by: rx.disposeBag) + + output.canGoPrevious.drive(toolbarController.navigationItem.segmentedControl.rx.enabledForSegment(at: 0)).disposed(by: rx.disposeBag) + + output.canGoNext.drive(toolbarController.navigationItem.segmentedControl.rx.enabledForSegment(at: 1)).disposed(by: rx.disposeBag) // Bind menu content + selection from sections and switchSourceState Driver.combineLatest(output.runtimeEngineSections, output.switchSourceState) @@ -231,10 +236,16 @@ final class MainWindowController: XiblessWindowController { viewModel?.router.trigger(.exportInterfaces) } + @IBAction func exportMultipleImages(_ sender: Any?) { + viewModel?.router.trigger(.exportMultipleImages) + } + override func responds(to aSelector: Selector!) -> Bool { switch aSelector { case #selector(exportInterface(_:)): return documentState.currentImageNode != nil + case #selector(exportMultipleImages(_:)): + return documentState.runtimeEngine.imageNodes.count >= 2 default: return super.responds(to: aSelector) } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift index 0074039c..e5e023d0 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift @@ -4,7 +4,7 @@ import RuntimeViewerUI import RuntimeViewerArchitectures import RuntimeViewerApplication -class SidebarRootViewController: UXKitViewController { +class SidebarRootViewController: UXKitViewController, NSOutlineViewDelegate { var isReorderable: Bool { false } @@ -79,7 +79,7 @@ class SidebarRootViewController: UXKitViewContr let output = viewModel.transform(input) - output.nodes.drive(isReorderable ? outlineView.rx.reorderableNodes : outlineView.rx.nodes)({ (outlineView: NSOutlineView, tableColumn: NSTableColumn?, node: SidebarRootCellViewModel) -> NSView? in + output.nodes.drive(outlineView.rx.nodes(options: isReorderable ? [.reorderable] : []))({ (outlineView: NSOutlineView, tableColumn: NSTableColumn?, node: SidebarRootCellViewModel) -> NSView? in let cellView = outlineView.box.makeView(ofClass: SidebarRootTableCellView.self) cellView.bind(to: node) return cellView @@ -151,5 +151,12 @@ class SidebarRootViewController: UXKitViewContr } } .disposed(by: rx.disposeBag) + + outlineView.rx.setDelegate(self).disposed(by: rx.disposeBag) + } + + func outlineView(_ outlineView: NSOutlineView, typeSelectStringFor tableColumn: NSTableColumn?, item: Any) -> String? { + guard let cellViewModel = item as? SidebarRootCellViewModel else { return nil } + return cellViewModel.name.string } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift index 99ee49cf..6f6bdd50 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift @@ -1,10 +1,11 @@ import AppKit +import Carbon import RuntimeViewerUI import RuntimeViewerArchitectures import RuntimeViewerCore import RuntimeViewerApplication -class SidebarRuntimeObjectViewController: UXKitViewController { +class SidebarRuntimeObjectViewController: UXKitViewController, NSOutlineViewDelegate { var isReorderable: Bool { false } @@ -114,8 +115,30 @@ class SidebarRuntimeObjectViewController Bool = { event in + guard let event else { return false } + switch event.type { + case .leftMouseUp: + return true + case .keyDown: + return arrowKeyCodes.contains(.init(event.keyCode)) + default: + return false + } + } let input = ViewModel.Input( - runtimeObjectClicked: imageLoadedView.outlineView.rx.modelSelected().asSignal(), + runtimeObjectClicked: .merge( + imageLoadedView.outlineView.rx.modelSelectedFilteringCurrentEvent(isExplicitSelection).asSignal(), + imageLoadedView.outlineView.rx + .modelSelectedFilteringCurrentEvent { !isExplicitSelection($0) } + .asSignal() + .debounce(.milliseconds(800)), + ), loadImageClicked: Signal.of( imageNotLoadedView.loadImageButton.rx.click.asSignal(), imageLoadErrorView.loadImageButton.rx.click.asSignal(), @@ -128,7 +151,7 @@ class SidebarRuntimeObjectViewController NSView? in + output.runtimeObjects.drive(imageLoadedView.outlineView.rx.nodes(options: isReorderable ? [.reorderable] : [])) { (outlineView: NSOutlineView, tableColumn: NSTableColumn?, viewModel: SidebarRuntimeObjectCellViewModel) -> NSView? in let cellView = outlineView.box.makeView(ofClass: RuntimeObjectCellView.self) cellView.bind(to: viewModel) return cellView @@ -201,9 +224,16 @@ class SidebarRuntimeObjectViewController String? { + guard let cellViewModel = item as? SidebarRuntimeObjectCellViewModel else { return nil } + return cellViewModel.title.string + } } extension SidebarRuntimeObjectViewController { @@ -252,7 +282,7 @@ extension SidebarRuntimeObjectViewController { override init(frame frameRect: CGRect) { super.init(frame: frameRect) - let contentStack = VStackView(alignment: .vStackCenter, spacing: 8) { + let contentStack = VStackView(alignment: .center, spacing: 8) { progressIndicator descriptionLabel countLabel @@ -297,7 +327,7 @@ extension SidebarRuntimeObjectViewController { let loadImageButton = PushButton() - lazy var contentView = VStackView(alignment: .vStackCenter, spacing: 10) { + lazy var contentView = VStackView(alignment: .center, spacing: 10) { titleLabel .gravity(.center) loadImageButton diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerViewController.swift index 064099ef..acc54596 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerViewController.swift @@ -51,7 +51,7 @@ final class SpecializationTypePickerViewController: UXKitViewController Bool { - false - } -} +//extension SpecializationViewController: NSOutlineViewDelegate { +// func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { +// false +// } +//} // MARK: - ParameterRowCellView diff --git a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Info.plist b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Info.plist index 1fd819d8..c19dab39 100644 --- a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Info.plist +++ b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Info.plist @@ -2,10 +2,14 @@ + ITSAppUsesNonExemptEncryption + NSBonjourServices _runtimeviewer._tcp + NSLocalNetworkUsageDescription + Runtime Viewer uses the local network to discover other Runtime Viewer instances on the same Wi-Fi for runtime engine mirroring. The feature is optional and the app works fully on a single device. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift index ba7febbc..062fb39d 100644 --- a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift @@ -195,7 +195,7 @@ extension SidebarRuntimeObjectViewController { let loadImageButton = UIButton(type: .system) - lazy var contentView = VStackView(alignment: .vStackCenter, spacing: 10) { + lazy var contentView = VStackView(alignment: .center, spacing: 10) { titleLabel loadImageButton }