From 53001278a9787a12d5f534a6ba6763fd9738d3a5 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 27 May 2026 23:45:45 +0800 Subject: [PATCH 01/68] chore(deps): bump RuntimeViewerCore Package.resolved --- RuntimeViewerCore/Package.resolved | 71 ++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index d6750096..0d01da62 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ba6c41a4750f97271f03e2502227aa324dbbbbebf505309fc479b44d05101a4e", + "originHash" : "9b80534424bac9f966ab4ca0d141726a87ba275387c91708ee8eff6c9de7ce16", "pins" : [ { "identity" : "associatedobject", @@ -64,6 +64,33 @@ "version" : "0.3.0" } }, + { + "identity" : "machokit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", + "state" : { + "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", + "version" : "0.50.100" + } + }, + { + "identity" : "machoobjcsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", + "state" : { + "revision" : "6d71be050b9131ff59e832483d54de685781c42f", + "version" : "0.7.101" + } + }, + { + "identity" : "machoswiftsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", + "state" : { + "revision" : "8b34efb02340298e3a2cee69541f99a8e701a719", + "version" : "0.12.0-beta.1" + } + }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -109,6 +136,15 @@ "version" : "0.1.0" } }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" + } + }, { "identity" : "swift-apinotes", "kind" : "remoteSourceControl", @@ -208,6 +244,15 @@ "version" : "3.15.1" } }, + { + "identity" : "swift-demangling", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", + "state" : { + "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", + "version" : "0.4.0" + } + }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", @@ -244,6 +289,15 @@ "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-literal-type-inference", "kind" : "remoteSourceControl", @@ -265,10 +319,10 @@ { "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" } }, { @@ -289,6 +343,15 @@ "version" : "0.5.0" } }, + { + "identity" : "swift-semantic-string", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", + "state" : { + "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", + "version" : "0.1.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", From 0e0cd5e040abe0f6afe2ca61d924c6bfe54ad350 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 27 May 2026 23:46:34 +0800 Subject: [PATCH 02/68] feat(batch-export): export multiple images from File menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-image Export still requires entering an image first; bulk extraction across many frameworks was repetitive. Add a parallel File → Export Multiple Images… wizard that picks N images up front, then runs the per-image export concurrently (up to 8 in parallel) into destinationURL// subdirectories via the unchanged single-image RuntimeEngine.exportInterfaces API. Progress is shown as an NSTableView of per-image rows (queued / running with progress bar / succeeded / failed). The wizard container (ExportingViewController + ExportingRoute) is reused; the single-image entry remains untouched. --- .../project.pbxproj | 60 +++++ .../Base.lproj/MainMenu.xib | 6 + ...BatchExportingCompletionRowViewModel.swift | 18 ++ ...tchExportingCompletionViewController.swift | 148 +++++++++++ .../BatchExportingCompletionViewModel.swift | 97 +++++++ ...ExportingConfigurationViewController.swift | 170 ++++++++++++ ...BatchExportingConfigurationViewModel.swift | 122 +++++++++ .../BatchExportingCoordinator.swift | 119 +++++++++ ...ExportingImageSelectionCellViewModel.swift | 21 ++ ...xportingImageSelectionViewController.swift | 174 +++++++++++++ ...atchExportingImageSelectionViewModel.swift | 122 +++++++++ .../BatchExportingProgressRowViewModel.swift | 61 +++++ ...BatchExportingProgressViewController.swift | 218 ++++++++++++++++ .../BatchExportingProgressViewModel.swift | 245 ++++++++++++++++++ .../BatchExporting/BatchExportingState.swift | 138 ++++++++++ .../Main/MainCoordinator.swift | 4 + .../Main/MainRoute.swift | 1 + .../Main/MainWindowController.swift | 6 + 18 files changed, 1730 insertions(+) create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift create mode 100644 RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index d00049c0..9c63303c 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -118,6 +118,19 @@ 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 */; }; + D16A49532E755901A1D32E5F /* BatchExportingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFA5EB1BB7210538B942D40 /* BatchExportingState.swift */; }; + 620FD421F830F882232BCBCD /* BatchExportingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0809C7E1B5879399F4AD5CFD /* BatchExportingCoordinator.swift */; }; + A2519448E7F7FA797E155C73 /* BatchExportingImageSelectionCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061FB631FA9AEDDF6ED4549F /* BatchExportingImageSelectionCellViewModel.swift */; }; + 84FE4364758FE65EA7D5E9A7 /* BatchExportingImageSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A94DAF52736C3A88802C38 /* BatchExportingImageSelectionViewModel.swift */; }; + 9CA80CE73BB3CFC59ED54FA9 /* BatchExportingImageSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDE868F82139B514DD706D1 /* BatchExportingImageSelectionViewController.swift */; }; + 0C554F36A5732FDAF8C0A3F1 /* BatchExportingConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC848E755B855C64A7FFF5E8 /* BatchExportingConfigurationViewModel.swift */; }; + 8FF2C3F9302864D9F3404E35 /* BatchExportingConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7366E572547581647D1A395 /* BatchExportingConfigurationViewController.swift */; }; + 69943AEB856963F21AEA89C4 /* BatchExportingProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */; }; + F43FDB3F17E51666B7FDF810 /* BatchExportingProgressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */; }; + 12D07EA725AFECCD93E61DD7 /* BatchExportingCompletionRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */; }; + A9F67D4F08F37DE09A9772C2 /* BatchExportingCompletionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */; }; + D9593A7A868FB627B0966159 /* BatchExportingCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */; }; + 5A6BBD9C254FFE24540D71F9 /* BatchExportingProgressRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -352,6 +365,19 @@ 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 = ""; }; + 5FFA5EB1BB7210538B942D40 /* BatchExportingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingState.swift; sourceTree = ""; }; + 0809C7E1B5879399F4AD5CFD /* BatchExportingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCoordinator.swift; sourceTree = ""; }; + 061FB631FA9AEDDF6ED4549F /* BatchExportingImageSelectionCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionCellViewModel.swift; sourceTree = ""; }; + 65A94DAF52736C3A88802C38 /* BatchExportingImageSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionViewModel.swift; sourceTree = ""; }; + EDDE868F82139B514DD706D1 /* BatchExportingImageSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionViewController.swift; sourceTree = ""; }; + CC848E755B855C64A7FFF5E8 /* BatchExportingConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingConfigurationViewModel.swift; sourceTree = ""; }; + C7366E572547581647D1A395 /* BatchExportingConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingConfigurationViewController.swift; sourceTree = ""; }; + DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressViewModel.swift; sourceTree = ""; }; + 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressViewController.swift; sourceTree = ""; }; + 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionRowViewModel.swift; sourceTree = ""; }; + 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionViewModel.swift; sourceTree = ""; }; + 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionViewController.swift; sourceTree = ""; }; + 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressRowViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -429,6 +455,26 @@ path = Exporting; sourceTree = ""; }; + 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 */, + DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */, + 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */, + 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */, + 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */, + 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */, + 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */, + ); + path = BatchExporting; + sourceTree = ""; + }; E9432FD92C0D614900362862 = { isa = PBXGroup; children = ( @@ -478,6 +524,7 @@ E9A9D8032F5F254800A10DD3 /* MCP */, E9BD1A142FA000050000ABCD /* BackgroundIndexing */, E92CB2E52F41E7560091450B /* Exporting */, + 9B20F5E805C0E15B32D5F771 /* BatchExporting */, BD36D2C74648FEB09A3B7E81 /* Specialization */, E9CE07BF2C14981D0070A6E8 /* Utils */, E94E36C72CF87BBC006101C8 /* Resources */, @@ -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/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/BatchExporting/BatchExportingCompletionRowViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift new file mode 100644 index 00000000..7ae04c3a --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift @@ -0,0 +1,18 @@ +import AppKit +import RuntimeViewerArchitectures + +final class BatchExportingCompletionRowViewModel: NSObject, @unchecked Sendable { + 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..33b8739f --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift @@ -0,0 +1,148 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerUI + +final class BatchExportingCompletionViewController: AppKitViewController, ExportingStepViewController { + private let checkmarkImageView = NSImageView().then { + $0.image = .symbol(systemName: .checkmarkCircleFill) + $0.symbolConfiguration = .init(pointSize: 40, weight: .regular) + $0.contentTintColor = .systemGreen + } + + private let titleLabel = Label("Export Complete").then { + $0.font = .systemFont(ofSize: 18, weight: .bold) + $0.alignment = .center + } + + private let summaryLabel = Label().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabelColor + $0.alignment = .center + $0.maximumNumberOfLines = 0 + $0.preferredMaxLayoutWidth = 600 + } + + private let showInFinderButton = PushButton(title: "Show in Finder", titleFont: .systemFont(ofSize: 13)) + + private let (scrollView, tableView): (ScrollView, SingleColumnTableView) = SingleColumnTableView.scrollableTableView() + + private lazy var headerStack = VStackView(alignment: .centerX, spacing: 6) { + checkmarkImageView + titleLabel + summaryLabel + .customSpacing(8) + showInFinderButton + } + + override func viewDidLoad() { + super.viewDidLoad() + + hierarchy { + headerStack + scrollView + } + + headerStack.snp.makeConstraints { make in + make.top.equalToSuperview().inset(16) + make.centerX.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().inset(20) + make.trailing.lessThanOrEqualToSuperview().inset(20) + } + + scrollView.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom).offset(16) + 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.allowsMultipleSelection = false + $0.allowsEmptySelection = true + } + } + + 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.summaryText.drive(summaryLabel.rx.stringValue).disposed(by: rx.disposeBag) + + output.rows + .drive(tableView.rx.items) { (tableView: NSTableView, _: NSTableColumn?, _: Int, rowVM: BatchExportingCompletionRowViewModel) -> NSView? in + let cellView = tableView.box.makeView(ofClass: CellView.self) + cellView.configure(with: rowVM) + return cellView + } + .disposed(by: rx.disposeBag) + } +} + +extension BatchExportingCompletionViewController { + fileprivate final class CellView: TableCellView { + 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 + } + + override func setup() { + super.setup() + + hierarchy { + nameLabel + detailLabel + } + + nameLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(8) + make.top.equalToSuperview().inset(4) + make.trailing.lessThanOrEqualToSuperview().inset(8) + } + + detailLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(8) + make.top.equalTo(nameLabel.snp.bottom).offset(2) + make.trailing.lessThanOrEqualToSuperview().inset(8) + make.bottom.lessThanOrEqualToSuperview().inset(4) + } + } + + func configure(with rowVM: BatchExportingCompletionRowViewModel) { + let outcome = rowVM.outcome + nameLabel.stringValue = outcome.image.name + switch outcome.outcome { + case .success(let result): + let parts: [String] = [ + "\(result.succeeded) succeeded", + result.failed > 0 ? "\(result.failed) failed" : nil, + String(format: "%.1fs", result.totalDuration), + ].compactMap { $0 } + detailLabel.stringValue = parts.joined(separator: " · ") + detailLabel.textColor = .secondaryLabelColor + case .failure(let description): + detailLabel.stringValue = "Failed: \(description)" + detailLabel.textColor = .systemRed + } + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift new file mode 100644 index 00000000..6f846b43 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift @@ -0,0 +1,97 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingCompletionViewModel: ViewModel { + struct Input { + let refresh: Signal + let showInFinderClick: Signal + } + + struct Output { + let summaryText: Driver + let rows: Driver<[BatchExportingCompletionRowViewModel]> + } + + @Observed private(set) var summaryText: String = "" + + 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 } + summaryText = Self.makeSummary(from: result) + } + .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( + summaryText: $summaryText.asDriver(), + rows: rows + ) + } + + private func refreshFromState() { + if let result = exportingState.aggregatedResult { + summaryText = Self.makeSummary(from: result) + } + } + + private static func makeSummary(from result: BatchExportingAggregatedResult) -> String { + let totalImages = result.imagesSucceeded + result.imagesFailed + let imagesWord = totalImages == 1 ? "image" : "images" + var lines: [String] = [] + if result.imagesFailed > 0 { + lines.append("\(totalImages) \(imagesWord) processed · \(result.imagesSucceeded) succeeded · \(result.imagesFailed) failed") + } else { + lines.append("\(totalImages) \(imagesWord) exported successfully") + } + lines.append("\(result.interfacesSucceeded) interface\(result.interfacesSucceeded == 1 ? "" : "s") generated\(result.interfacesFailed > 0 ? " · \(result.interfacesFailed) failed" : "")") + lines.append("ObjC: \(result.totalObjcCount) · Swift: \(result.totalSwiftCount)") + lines.append(String(format: "Duration: %.1fs", result.totalDuration)) + return lines.joined(separator: "\n") + } +} + +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..86792e5f --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift @@ -0,0 +1,170 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerUI + +final class BatchExportingConfigurationViewController: UXKitViewController, ExportingStepViewController { + + override var shouldDisplayCommonLoading: Bool { true } + + 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.hasObjC.driveOnNext { [weak self] hasObjC in + guard let self else { return } + objcStack.isHidden = !hasObjC + } + .disposed(by: rx.disposeBag) + + output.hasSwift.driveOnNext { [weak self] hasSwift in + guard let self else { return } + swiftStack.isHidden = !hasSwift + } + .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..43c195dd --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift @@ -0,0 +1,122 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import SwiftUI + +final class BatchExportingConfigurationViewModel: ViewModel { + struct Input { + let objcFormatSelected: Signal + let swiftFormatSelected: Signal + let includeMetadataSelected: Signal + } + + struct Output { + let summary: Driver + let hasObjC: Driver + let hasSwift: Driver + let objcFormat: Driver + let swiftFormat: Driver + let includeMetadata: Driver + } + + let exportingState: BatchExportingState + + @AppStorage("Exporting.includeMetadata") + private var storedIncludeMetadata: Bool = true + + @Observed private(set) var isLoading: Bool = true + + override var delayedLoading: Driver { + $isLoading.asDriver() + } + + init(exportingState: BatchExportingState, documentState: DocumentState, router: any Router) { + self.exportingState = exportingState + super.init(documentState: documentState, router: router) + exportingState.includeMetadata = storedIncludeMetadata + loadObjects() + } + + private func loadObjects() { + Task { @MainActor [weak self] in + guard let self else { return } + do { + var newObjectsByImage: [String: [RuntimeObject]] = [:] + for image in exportingState.selectedImages { + let objects = try await documentState.runtimeEngine.objects(in: image.path) + newObjectsByImage[image.path] = objects + } + exportingState.objectsByImage = newObjectsByImage + isLoading = false + } catch { + errorRelay.accept(error) + } + } + } + + func transform(_ input: Input) -> Output { + input.objcFormatSelected.emitOnNext { [weak self] index in + guard let self else { return } + exportingState.objcFormat = ExportFormat(rawValue: index) ?? .singleFile + } + .disposed(by: rx.disposeBag) + + input.swiftFormatSelected.emitOnNext { [weak self] index in + guard let self else { return } + exportingState.swiftFormat = ExportFormat(rawValue: index) ?? .singleFile + } + .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 counts = exportingState.$objectsByImage.asDriver() + .map { dict -> (objcCount: Int, swiftCount: Int) in + var objcCount = 0 + var swiftCount = 0 + for objects in dict.values { + objcCount += objects.count { $0.kind.isObjC } + swiftCount += objects.count { $0.kind.isSwift } + } + return (objcCount, swiftCount) + } + + let summary = Driver + .combineLatest(exportingState.$selectedImagePaths.asDriver(), counts) + .map { selectedPaths, counts -> String in + let imageWord = selectedPaths.count == 1 ? "image" : "images" + var parts = ["\(selectedPaths.count) \(imageWord) selected"] + if counts.objcCount > 0 { parts.append("\(counts.objcCount) ObjC") } + if counts.swiftCount > 0 { parts.append("\(counts.swiftCount) Swift") } + return parts.joined(separator: " · ") + } + + return Output( + summary: summary, + hasObjC: counts.map { $0.objcCount > 0 }, + hasSwift: counts.map { $0.swiftCount > 0 }, + 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..c3b038bf --- /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 = await 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..5961404a --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift @@ -0,0 +1,21 @@ +import AppKit +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingImageSelectionCellViewModel: NSObject, @unchecked Sendable { + let image: BatchExportingImage + let isSelected: Bool + + init(image: BatchExportingImage, isSelected: Bool) { + self.image = image + self.isSelected = isSelected + } +} + +extension BatchExportingImageSelectionCellViewModel: Differentiable { + var differenceIdentifier: String { image.path } + + func isContentEqual(to source: BatchExportingImageSelectionCellViewModel) -> Bool { + image == source.image && isSelected == source.isSelected + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift new file mode 100644 index 00000000..29ac4973 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift @@ -0,0 +1,174 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerUI + +final class BatchExportingImageSelectionViewController: AppKitViewController, ExportingStepViewController { + private let filterSearchField = FilterSearchField() + + 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().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabelColor + $0.alignment = .right + } + + private let (scrollView, tableView): (ScrollView, SingleColumnTableView) = SingleColumnTableView.scrollableTableView() + + private let toggleImageRelay = PublishRelay() + + override func viewDidLoad() { + super.viewDidLoad() + + hierarchy { + filterSearchField + selectAllButton + deselectAllButton + summaryLabel + scrollView + } + + filterSearchField.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + + selectAllButton.snp.makeConstraints { make in + make.top.equalTo(filterSearchField.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() + } + + scrollView.do { + $0.hasVerticalScroller = true + $0.borderType = .lineBorder + $0.autohidesScrollers = true + } + + tableView.do { + $0.headerView = nil + $0.rowHeight = 22 + $0.gridStyleMask = [] + $0.intercellSpacing = NSSize(width: 0, height: 0) + $0.allowsMultipleSelection = false + $0.allowsEmptySelection = true + } + } + + override func setupBindings(for viewModel: BatchExportingImageSelectionViewModel) { + super.setupBindings(for: viewModel) + + let input = BatchExportingImageSelectionViewModel.Input( + searchString: filterSearchField.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.configure(with: cellViewModel) { image in + toggleImageRelay.accept(image) + } + return cellView + } + .disposed(by: rx.disposeBag) + + output.selectionSummary.drive(summaryLabel.rx.stringValue).disposed(by: rx.disposeBag) + } +} + +extension BatchExportingImageSelectionViewController { + fileprivate final class CellView: TableCellView { + private let checkbox = NSButton().then { + $0.setButtonType(.switch) + $0.title = "" + $0.font = .systemFont(ofSize: 13) + } + + private let nameLabel = Label().then { + $0.font = .systemFont(ofSize: 13) + $0.textColor = .labelColor + $0.lineBreakMode = .byTruncatingTail + } + + private let groupLabel = Label().then { + $0.font = .systemFont(ofSize: 11) + $0.textColor = .tertiaryLabelColor + $0.alignment = .right + } + + private var image: BatchExportingImage? + + private var onToggle: ((BatchExportingImage) -> Void)? + + override func setup() { + super.setup() + + checkbox.target = self + checkbox.action = #selector(checkboxClicked) + + hierarchy { + checkbox + nameLabel + groupLabel + } + + checkbox.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(8) + make.centerY.equalToSuperview() + } + + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(checkbox.snp.trailing).offset(6) + make.centerY.equalToSuperview() + } + + groupLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(nameLabel.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(8) + make.centerY.equalToSuperview() + } + + nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + groupLabel.setContentHuggingPriority(.required, for: .horizontal) + } + + func configure(with cellViewModel: BatchExportingImageSelectionCellViewModel, onToggle: @escaping (BatchExportingImage) -> Void) { + image = cellViewModel.image + self.onToggle = onToggle + checkbox.state = cellViewModel.isSelected ? .on : .off + nameLabel.stringValue = cellViewModel.image.name + groupLabel.stringValue = cellViewModel.image.group + } + + @objc private func checkboxClicked() { + guard let image else { return } + onToggle?(image) + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift new file mode 100644 index 00000000..a9f23ff8 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift @@ -0,0 +1,122 @@ +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] image in + guard let self else { return } + if exportingState.selectedImagePaths.contains(image.path) { + exportingState.selectedImagePaths.remove(image.path) + } else { + exportingState.selectedImagePaths.insert(image.path) + } + } + .disposed(by: rx.disposeBag) + + let filteredDriver = Driver + .combineLatest( + exportingState.$availableImages.asDriver(), + exportingState.$searchString.asDriver() + ) + .map { [weak self] availableImages, searchString -> [BatchExportingImage] in + self?.filteredImages(availableImages: availableImages, searchString: searchString) ?? [] + } + + let cellViewModels = Driver + .combineLatest(filteredDriver, exportingState.$selectedImagePaths.asDriver()) + .map { filtered, selectedPaths -> [BatchExportingImageSelectionCellViewModel] in + filtered.map { + BatchExportingImageSelectionCellViewModel( + image: $0, + isSelected: selectedPaths.contains($0.path) + ) + } + } + + 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..72d35ae7 --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift @@ -0,0 +1,61 @@ +import AppKit +import RuntimeViewerArchitectures +import RuntimeViewerCore + +final class BatchExportingProgressRowViewModel: NSObject, @unchecked Sendable { + 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 = "" + + 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) { + 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..598c704a --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift @@ -0,0 +1,218 @@ +import AppKit +import RuntimeViewerApplication +import RuntimeViewerArchitectures +import RuntimeViewerCore +import RuntimeViewerUI + +final class BatchExportingProgressViewController: AppKitViewController, 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() + + 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 { + fileprivate 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 + } + + 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() + ) + .driveOnNext { [weak self] status, progress, currentObject in + guard let self else { return } + applyState(status: status, progress: progress, currentObject: currentObject) + } + .disposed(by: rx.disposeBag) + } + + private func applyState( + status: BatchExportingProgressRowViewModel.Status, + progress: Double, + currentObject: String + ) { + 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: + statusIcon.image = .symbol(systemName: .arrowtriangleRightFill) + statusIcon.contentTintColor = .systemBlue + progressBar.doubleValue = progress + progressBar.isHidden = false + detailLabel.isHidden = true + case .succeeded(let result): + 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), + ].compactMap { $0 } + detailLabel.stringValue = parts.joined(separator: " · ") + detailLabel.textColor = .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 + } + } + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift new file mode 100644 index 00000000..628f515e --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift @@ -0,0 +1,245 @@ +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.. 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 + 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 = Task { @MainActor in + var capturedResult: RuntimeInterfaceExportResult? + 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 .completed(let result): + capturedResult = result + default: + break + } + } + return capturedResult + } + + do { + try await runtimeEngine.exportInterfaces(with: configuration, reporter: reporter) + let captured = await eventsTask.value + if let result = captured { + rowViewModel.markSucceeded(result) + return .init(image: image, outcome: .success(result)) + } 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..523948ce --- /dev/null +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift @@ -0,0 +1,138 @@ +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 +} + +struct BatchExportingPerImageOutcome: Sendable { + let image: BatchExportingImage + let outcome: Outcome + + 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 objectsByImage: [String: [RuntimeObject]] = [:] + + @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/Main/MainCoordinator.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift index 7506d64a..49692abf 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift @@ -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, 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/MainWindowController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift index 9337ebdc..9f0ddd15 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift @@ -231,10 +231,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 true default: return super.responds(to: aSelector) } From d6aec901a894d6a292214bf3b3251b0e049b7b9d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 28 May 2026 17:26:35 +0800 Subject: [PATCH 03/68] chore(deps): bump RuntimeViewerCore Package.resolved --- RuntimeViewerCore/Package.resolved | 65 +----------------------------- 1 file changed, 1 insertion(+), 64 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index 0d01da62..87463652 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9b80534424bac9f966ab4ca0d141726a87ba275387c91708ee8eff6c9de7ce16", + "originHash" : "f8b3a73452a979ed9f634cd2e3ea3dabee9f31e1d00685ca0a2d63a27870f23e", "pins" : [ { "identity" : "associatedobject", @@ -64,33 +64,6 @@ "version" : "0.3.0" } }, - { - "identity" : "machokit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", - "state" : { - "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", - "version" : "0.50.100" - } - }, - { - "identity" : "machoobjcsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", - "state" : { - "revision" : "6d71be050b9131ff59e832483d54de685781c42f", - "version" : "0.7.101" - } - }, - { - "identity" : "machoswiftsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - "state" : { - "revision" : "8b34efb02340298e3a2cee69541f99a8e701a719", - "version" : "0.12.0-beta.1" - } - }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -136,15 +109,6 @@ "version" : "0.1.0" } }, - { - "identity" : "sparkle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle", - "state" : { - "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", - "version" : "2.9.2" - } - }, { "identity" : "swift-apinotes", "kind" : "remoteSourceControl", @@ -244,15 +208,6 @@ "version" : "3.15.1" } }, - { - "identity" : "swift-demangling", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", - "state" : { - "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", - "version" : "0.4.0" - } - }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", @@ -289,15 +244,6 @@ "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-literal-type-inference", "kind" : "remoteSourceControl", @@ -343,15 +289,6 @@ "version" : "0.5.0" } }, - { - "identity" : "swift-semantic-string", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", - "state" : { - "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", - "version" : "0.1.1" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", From 629c6b21b8d1835e798b3671b0b3996df7eaf0f5 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 28 May 2026 17:27:19 +0800 Subject: [PATCH 04/68] refactor(view-controllers): drop AppKitViewController, add contentInsets Folds the few remaining AppKitViewController subclasses into UXKitViewController and gives the base an overridable contentInsets so sheet/popover screens can inset the rooted contentView without hand-rolling padding constraints. Roots that previously called hierarchy {} on self now drive contentView.hierarchy {} instead. Also restacks the batch-export image-selection cell into nested HStack/VStack with a per-row path subtitle, switching the table to usesAutomaticRowHeights for the taller two-line rows. --- .../project.pbxproj | 92 +++++++++--------- .../AttachToProcessViewController.swift | 4 +- .../Base/ViewControllers.swift | 49 +++------- ...tchExportingCompletionViewController.swift | 6 +- ...xportingImageSelectionViewController.swift | 93 ++++++++++--------- ...BatchExportingProgressViewController.swift | 6 +- .../ExportingCompletionViewController.swift | 4 +- .../ExportingProgressViewController.swift | 4 +- .../GenerationOptionsViewController.swift | 4 +- .../LoadFrameworksViewController.swift | 2 +- .../MCP/MCPStatusPopoverViewController.swift | 4 +- 11 files changed, 125 insertions(+), 143 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index 9c63303c..36a4344c 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,19 +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 */; }; - D16A49532E755901A1D32E5F /* BatchExportingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFA5EB1BB7210538B942D40 /* BatchExportingState.swift */; }; - 620FD421F830F882232BCBCD /* BatchExportingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0809C7E1B5879399F4AD5CFD /* BatchExportingCoordinator.swift */; }; - A2519448E7F7FA797E155C73 /* BatchExportingImageSelectionCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 061FB631FA9AEDDF6ED4549F /* BatchExportingImageSelectionCellViewModel.swift */; }; - 84FE4364758FE65EA7D5E9A7 /* BatchExportingImageSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A94DAF52736C3A88802C38 /* BatchExportingImageSelectionViewModel.swift */; }; - 9CA80CE73BB3CFC59ED54FA9 /* BatchExportingImageSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDE868F82139B514DD706D1 /* BatchExportingImageSelectionViewController.swift */; }; - 0C554F36A5732FDAF8C0A3F1 /* BatchExportingConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC848E755B855C64A7FFF5E8 /* BatchExportingConfigurationViewModel.swift */; }; - 8FF2C3F9302864D9F3404E35 /* BatchExportingConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7366E572547581647D1A395 /* BatchExportingConfigurationViewController.swift */; }; - 69943AEB856963F21AEA89C4 /* BatchExportingProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */; }; F43FDB3F17E51666B7FDF810 /* BatchExportingProgressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */; }; - 12D07EA725AFECCD93E61DD7 /* BatchExportingCompletionRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */; }; - A9F67D4F08F37DE09A9772C2 /* BatchExportingCompletionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */; }; - D9593A7A868FB627B0966159 /* BatchExportingCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */; }; - 5A6BBD9C254FFE24540D71F9 /* BatchExportingProgressRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -245,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 = ""; }; @@ -342,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; }; @@ -350,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 = ""; }; @@ -365,19 +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 = ""; }; - 5FFA5EB1BB7210538B942D40 /* BatchExportingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingState.swift; sourceTree = ""; }; - 0809C7E1B5879399F4AD5CFD /* BatchExportingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCoordinator.swift; sourceTree = ""; }; - 061FB631FA9AEDDF6ED4549F /* BatchExportingImageSelectionCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionCellViewModel.swift; sourceTree = ""; }; - 65A94DAF52736C3A88802C38 /* BatchExportingImageSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionViewModel.swift; sourceTree = ""; }; EDDE868F82139B514DD706D1 /* BatchExportingImageSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingImageSelectionViewController.swift; sourceTree = ""; }; - CC848E755B855C64A7FFF5E8 /* BatchExportingConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingConfigurationViewModel.swift; sourceTree = ""; }; - C7366E572547581647D1A395 /* BatchExportingConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingConfigurationViewController.swift; sourceTree = ""; }; - DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressViewModel.swift; sourceTree = ""; }; - 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressViewController.swift; sourceTree = ""; }; - 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionRowViewModel.swift; sourceTree = ""; }; - 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionViewModel.swift; sourceTree = ""; }; - 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingCompletionViewController.swift; sourceTree = ""; }; - 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchExportingProgressRowViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -424,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 = ( @@ -455,26 +475,6 @@ path = Exporting; sourceTree = ""; }; - 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 */, - DF5295BA547818A41EC080DA /* BatchExportingProgressViewModel.swift */, - 71878BCA0F09ADE6484478B0 /* BatchExportingProgressViewController.swift */, - 4C83BCFA7DEDB3FD171EB6C6 /* BatchExportingCompletionRowViewModel.swift */, - 96DBB7C902543EF2B169FC99 /* BatchExportingCompletionViewModel.swift */, - 35D4C6E546DAF4CAF7E7AA37 /* BatchExportingCompletionViewController.swift */, - 8249F8BA66937D5C7CCFB2BE /* BatchExportingProgressRowViewModel.swift */, - ); - path = BatchExporting; - sourceTree = ""; - }; E9432FD92C0D614900362862 = { isa = PBXGroup; children = ( diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift index f50740d2..0b6473c4 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift @@ -4,7 +4,7 @@ import RuntimeViewerUI import RuntimeViewerApplication import RuntimeViewerArchitectures -final class AttachToProcessViewController: AppKitViewController { +final class AttachToProcessViewController: UXKitViewController { private let pickerViewController: RunningPickerTabViewController private let attachRelay = PublishRelay() @@ -21,7 +21,7 @@ final class AttachToProcessViewController: AppKitViewController: 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/BatchExportingCompletionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift index 33b8739f..90f5a9e0 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift @@ -3,7 +3,7 @@ import RuntimeViewerApplication import RuntimeViewerArchitectures import RuntimeViewerUI -final class BatchExportingCompletionViewController: AppKitViewController, ExportingStepViewController { +final class BatchExportingCompletionViewController: UXKitViewController, ExportingStepViewController { private let checkmarkImageView = NSImageView().then { $0.image = .symbol(systemName: .checkmarkCircleFill) $0.symbolConfiguration = .init(pointSize: 40, weight: .regular) @@ -38,7 +38,7 @@ final class BatchExportingCompletionViewController: AppKitViewController, ExportingStepViewController { - private let filterSearchField = FilterSearchField() +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().then { - $0.font = .systemFont(ofSize: 12) - $0.textColor = .secondaryLabelColor - $0.alignment = .right - } + 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() - hierarchy { - filterSearchField + contentView.hierarchy { + searchField selectAllButton deselectAllButton summaryLabel scrollView } - filterSearchField.snp.makeConstraints { make in + searchField.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } selectAllButton.snp.makeConstraints { make in - make.top.equalTo(filterSearchField.snp.bottom).offset(8) + make.top.equalTo(searchField.snp.bottom).offset(8) make.leading.equalToSuperview() } @@ -57,30 +55,36 @@ final class BatchExportingImageSelectionViewController: AppKitViewController Void)? @@ -132,30 +156,12 @@ extension BatchExportingImageSelectionViewController { checkbox.action = #selector(checkboxClicked) hierarchy { - checkbox - nameLabel - groupLabel + contentStack } - checkbox.snp.makeConstraints { make in - make.leading.equalToSuperview().inset(8) - make.centerY.equalToSuperview() + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview() } - - nameLabel.snp.makeConstraints { make in - make.leading.equalTo(checkbox.snp.trailing).offset(6) - make.centerY.equalToSuperview() - } - - groupLabel.snp.makeConstraints { make in - make.leading.greaterThanOrEqualTo(nameLabel.snp.trailing).offset(12) - make.trailing.equalToSuperview().inset(8) - make.centerY.equalToSuperview() - } - - nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - groupLabel.setContentHuggingPriority(.required, for: .horizontal) } func configure(with cellViewModel: BatchExportingImageSelectionCellViewModel, onToggle: @escaping (BatchExportingImage) -> Void) { @@ -163,6 +169,7 @@ extension BatchExportingImageSelectionViewController { self.onToggle = onToggle checkbox.state = cellViewModel.isSelected ? .on : .off nameLabel.stringValue = cellViewModel.image.name + pathLabel.stringValue = cellViewModel.image.path groupLabel.stringValue = cellViewModel.image.group } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift index 598c704a..3b9040f3 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift @@ -4,7 +4,7 @@ import RuntimeViewerArchitectures import RuntimeViewerCore import RuntimeViewerUI -final class BatchExportingProgressViewController: AppKitViewController, ExportingStepViewController { +final class BatchExportingProgressViewController: UXKitViewController, ExportingStepViewController { private let titleLabel = Label().then { $0.font = .systemFont(ofSize: 14, weight: .semibold) $0.textColor = .controlTextColor @@ -28,7 +28,7 @@ final class BatchExportingProgressViewController: 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, 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) @@ -76,7 +76,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 Date: Thu, 28 May 2026 17:28:27 +0800 Subject: [PATCH 05/68] refactor(specialization): swap refusesFirstResponder for focusRingType --- .../Specialization/SpecializationTypePickerViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 Date: Thu, 28 May 2026 17:28:55 +0800 Subject: [PATCH 06/68] refactor(batch-export): always offer ObjC/Swift format pickers Drops the per-image objects probe that gated the ObjC/Swift format choices behind whether a selected image actually exposed each runtime. The probe ran in the configuration step's init before the user had picked any images, so the radios stayed hidden on entry. Both format groups are now always visible; objectsByImage/loading state on BatchExportingState is no longer needed and goes away with it. --- ...ExportingConfigurationViewController.swift | 14 ------ ...BatchExportingConfigurationViewModel.swift | 49 ++----------------- .../BatchExporting/BatchExportingState.swift | 3 -- 3 files changed, 3 insertions(+), 63 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift index 86792e5f..81f2aaa4 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewController.swift @@ -6,8 +6,6 @@ import RuntimeViewerUI final class BatchExportingConfigurationViewController: UXKitViewController, ExportingStepViewController { - override var shouldDisplayCommonLoading: Bool { true } - private let summaryLabel = Label() private let objcSingleFileRadio = RadioButton() @@ -153,18 +151,6 @@ final class BatchExportingConfigurationViewController: UXKitViewController { struct Output { let summary: Driver - let hasObjC: Driver - let hasSwift: Driver let objcFormat: Driver let swiftFormat: Driver let includeMetadata: Driver @@ -25,34 +23,10 @@ final class BatchExportingConfigurationViewModel: ViewModel { @AppStorage("Exporting.includeMetadata") private var storedIncludeMetadata: Bool = true - @Observed private(set) var isLoading: Bool = true - - override var delayedLoading: Driver { - $isLoading.asDriver() - } - init(exportingState: BatchExportingState, documentState: DocumentState, router: any Router) { self.exportingState = exportingState super.init(documentState: documentState, router: router) exportingState.includeMetadata = storedIncludeMetadata - loadObjects() - } - - private func loadObjects() { - Task { @MainActor [weak self] in - guard let self else { return } - do { - var newObjectsByImage: [String: [RuntimeObject]] = [:] - for image in exportingState.selectedImages { - let objects = try await documentState.runtimeEngine.objects(in: image.path) - newObjectsByImage[image.path] = objects - } - exportingState.objectsByImage = newObjectsByImage - isLoading = false - } catch { - errorRelay.accept(error) - } - } } func transform(_ input: Input) -> Output { @@ -75,31 +49,14 @@ final class BatchExportingConfigurationViewModel: ViewModel { } .disposed(by: rx.disposeBag) - let counts = exportingState.$objectsByImage.asDriver() - .map { dict -> (objcCount: Int, swiftCount: Int) in - var objcCount = 0 - var swiftCount = 0 - for objects in dict.values { - objcCount += objects.count { $0.kind.isObjC } - swiftCount += objects.count { $0.kind.isSwift } - } - return (objcCount, swiftCount) - } - - let summary = Driver - .combineLatest(exportingState.$selectedImagePaths.asDriver(), counts) - .map { selectedPaths, counts -> String in + let summary = exportingState.$selectedImagePaths.asDriver() + .map { selectedPaths -> String in let imageWord = selectedPaths.count == 1 ? "image" : "images" - var parts = ["\(selectedPaths.count) \(imageWord) selected"] - if counts.objcCount > 0 { parts.append("\(counts.objcCount) ObjC") } - if counts.swiftCount > 0 { parts.append("\(counts.swiftCount) Swift") } - return parts.joined(separator: " · ") + return "\(selectedPaths.count) \(imageWord) selected" } return Output( summary: summary, - hasObjC: counts.map { $0.objcCount > 0 }, - hasSwift: counts.map { $0.swiftCount > 0 }, objcFormat: exportingState.$objcFormat.asDriver(), swiftFormat: exportingState.$swiftFormat.asDriver(), includeMetadata: exportingState.$includeMetadata.asDriver() diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift index 523948ce..b88123f2 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift @@ -105,9 +105,6 @@ final class BatchExportingState { @Observed var searchString: String = "" - @Observed - var objectsByImage: [String: [RuntimeObject]] = [:] - @Observed var objcFormat: ExportFormat = .directory From 105c9f7e7676d7ab6727c590e6f6f97ee2cadb4f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 01:07:51 +0800 Subject: [PATCH 07/68] chore(deps): force-enable local UIFoundation and refresh Package.resolved Flip UIFoundation's local checkout to `isEnabled: true` so the workspace build no longer toggles between local and SPM-resolved copies depending on the per-machine `usingLocalDependencies` flag; bump both Package.resolved files to match the resulting checkout graph. --- RuntimeViewerCore/Package.resolved | 72 +++++++++-- RuntimeViewerPackages/Package.resolved | 162 ++++++++++++++++++++++--- RuntimeViewerPackages/Package.swift | 2 +- 3 files changed, 208 insertions(+), 28 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index 87463652..8e24496f 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "4ab712e98990bb3a44a5ef160bd0b0c3f03ace08", - "version" : "0.5.5" + "revision" : "5b558bade955658e5c90ee06e7dca06043b39641", + "version" : "0.7.0" } }, { @@ -64,6 +64,33 @@ "version" : "0.3.0" } }, + { + "identity" : "machokit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", + "state" : { + "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", + "version" : "0.50.100" + } + }, + { + "identity" : "machoobjcsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", + "state" : { + "revision" : "6d71be050b9131ff59e832483d54de685781c42f", + "version" : "0.7.101" + } + }, + { + "identity" : "machoswiftsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", + "state" : { + "revision" : "8b34efb02340298e3a2cee69541f99a8e701a719", + "version" : "0.12.0-beta.1" + } + }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -121,10 +148,10 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "5e0406d68a4937f21ae5f670b8f89dad1d156a1c", - "version" : "1.8.0" + "revision" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", + "version" : "1.8.1" } }, { @@ -186,8 +213,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,6 +235,15 @@ "version" : "3.15.1" } }, + { + "identity" : "swift-demangling", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", + "state" : { + "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", + "version" : "0.4.0" + } + }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", @@ -244,6 +280,15 @@ "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-literal-type-inference", "kind" : "remoteSourceControl", @@ -289,6 +334,15 @@ "version" : "0.5.0" } }, + { + "identity" : "swift-semantic-string", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", + "state" : { + "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", + "version" : "0.1.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -330,8 +384,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.resolved b/RuntimeViewerPackages/Package.resolved index c94dc61b..8c2ca29e 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5d30de8440b0a6bd270a101d248638191711eb76bcab03663473d2a25d71ed2a", + "originHash" : "0ba59ea63535f5eda5614c3aa8cb2ea9271e026121bc98e0307ea26c80655a6b", "pins" : [ { "identity" : "associatedobject", @@ -28,6 +28,15 @@ "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", @@ -60,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "4ab712e98990bb3a44a5ef160bd0b0c3f03ace08", - "version" : "0.5.5" + "revision" : "5b558bade955658e5c90ee06e7dca06043b39641", + "version" : "0.7.0" } }, { @@ -127,6 +136,33 @@ "version" : "0.3.0" } }, + { + "identity" : "machokit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", + "state" : { + "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", + "version" : "0.50.100" + } + }, + { + "identity" : "machoobjcsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", + "state" : { + "revision" : "6d71be050b9131ff59e832483d54de685781c42f", + "version" : "0.7.101" + } + }, + { + "identity" : "machoswiftsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", + "state" : { + "revision" : "8b34efb02340298e3a2cee69541f99a8e701a719", + "version" : "0.12.0-beta.1" + } + }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -145,6 +181,15 @@ "version" : "0.5.0" } }, + { + "identity" : "openuxkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenUXKit/OpenUXKit", + "state" : { + "branch" : "main", + "revision" : "21b944e638ff66d45ba1f550483c875be3c1f93f" + } + }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -163,6 +208,24 @@ "version" : "2.1.1" } }, + { + "identity" : "runningapplicationkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RunningApplicationKit", + "state" : { + "revision" : "8c64a39bbcbe96b7758afd0e2e0a9f3d82f7305a", + "version" : "0.3.3" + } + }, + { + "identity" : "rxappkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RxAppKit", + "state" : { + "revision" : "e4ea5c272acdc4f1c220bcc0a44d79cebf1ed98b", + "version" : "0.3.1" + } + }, { "identity" : "rxcombine", "kind" : "remoteSourceControl", @@ -208,6 +271,15 @@ "version" : "0.2.3" } }, + { + "identity" : "rxuikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RxUIKit", + "state" : { + "revision" : "f4397315d83edf4f9390dbe96e212c892577c7eb", + "version" : "0.1.1" + } + }, { "identity" : "semaphore", "kind" : "remoteSourceControl", @@ -256,10 +328,10 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "5e0406d68a4937f21ae5f670b8f89dad1d156a1c", - "version" : "1.8.0" + "revision" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", + "version" : "1.8.1" } }, { @@ -330,8 +402,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 +429,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", - "version" : "1.5.0" + "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-demangling", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", + "state" : { + "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", + "version" : "0.4.0" } }, { @@ -397,6 +478,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", @@ -418,19 +517,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" } }, { @@ -460,6 +559,15 @@ "version" : "2.0.10" } }, + { + "identity" : "swift-semantic-string", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", + "state" : { + "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", + "version" : "0.1.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -496,6 +604,15 @@ "version" : "0.1.0" } }, + { + "identity" : "uxkitcoordinator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenUXKit/UXKitCoordinator", + "state" : { + "branch" : "main", + "revision" : "481806584ed23911fbe0449fdda57085f8615779" + } + }, { "identity" : "version", "kind" : "remoteSourceControl", @@ -505,6 +622,15 @@ "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", @@ -519,8 +645,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..0c1193ce 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -179,7 +179,7 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("UIFoundation"), isRelative: true, - isEnabled: usingLocalDependencies, + isEnabled: true, traits: UIFoundationTraits, ), .package( From 95263d2b55bf9a3ce4a167a9adb6b0c6738a9b8d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 01:08:09 +0800 Subject: [PATCH 08/68] refactor(core): unify engine command dispatch via RuntimeEngineRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every server-bound command used to be declared three times — once in the public API's local/remote split, once in setupMessageHandlerForServer, and once in RuntimeEngineProxyServer's handler registry. Adding a new command meant editing all three call sites, and missing the proxy registration silently broke remote mirroring. Collapse the trio into a single RuntimeEngineRequest protocol whose conformers describe the wire name and own a perform(on:) local implementation. RuntimeEngine.dispatch(_:) routes through the same conformer for both client (sendMessage) and local arms, and a single registerSharedHandlers(on:engine:) call wires every shared command on both the server arm and the proxy server. engineList stays server-only (no proxy equivalent), iconRequest stays proxy-only, and the objectsInImage progress-pumping variant remains a server-side override. Adding a new command is now: declare an XxxRequest conformer + append one register(...) line — both server entry points pick it up automatically. --- .../RuntimeEngine+BackgroundIndexing.swift | 54 +++-- .../RuntimeEngine+GenericSpecialization.swift | 93 +++----- .../RuntimeEngine+Requests.swift | 147 +++++++++++++ .../RuntimeViewerCore/RuntimeEngine.swift | 200 ++++++++---------- .../RuntimeEngineProxyServer.swift | 81 +------ .../RuntimeEngineRequest.swift | 62 ++++++ 6 files changed, 361 insertions(+), 276 deletions(-) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 14df362c..4f08f453 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,18 +33,18 @@ 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 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 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. + func _loadImageForBackgroundIndexing(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) + loadedImagePaths.insert(canonical) } } 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..b152e599 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift @@ -0,0 +1,147 @@ +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 { + try 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 + } + } +} + +// 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..a0898de3 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -326,33 +326,26 @@ 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() + guard let connection else { + #log(.default, "Connection is nil when setting up server message handlers") + return } - 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 + // 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. + 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) - } - connection?.setMessageHandler(name: CommandNames.specializationRequestForCandidate.commandName) { [weak self] (request: SpecializationRequestForCandidateRequest) -> RuntimeSpecializationRequest in - guard let self else { throw RequestError.senderConnectionIsLose } - return try await self.specializationRequest(for: request) - } - 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?() ?? [] @@ -503,7 +496,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 +510,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 +587,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 { @@ -732,17 +709,17 @@ extension RuntimeEngine { } 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 +732,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)) + try await dispatch(MemberAddressesRequest(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) - } + 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,11 +762,12 @@ 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 { diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift index dfaf6baf..4b9e8847 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift @@ -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..0b24ae48 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift @@ -0,0 +1,62 @@ +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(ImageNameOfObjectRequest.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) + } +} From 2dde37806e539769d4bc70e6a32ff9da5bf3d12e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 01:08:18 +0800 Subject: [PATCH 09/68] refactor(ui): suppress row selection via highlightStyle instead of delegate Both BatchExportingImageSelectionViewController and SpecializationViewController previously blocked the table/outline selection by hooking shouldSelectItem in an NSOutlineViewDelegate. Setting selectionHighlightStyle = .none achieves the same visual result without the delegate plumbing, so retire the delegate extensions. SpecializationViewController also drops the now-redundant rx.setDelegate(self) wiring; the image-selection sheet gains a small inset around its content stack while we are touching the layout. --- ...BatchExportingImageSelectionViewController.swift | 3 ++- .../SpecializationViewController.swift | 13 +++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift index 6725fa12..aa132f4c 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift @@ -68,6 +68,7 @@ final class BatchExportingImageSelectionViewController: UXKitViewController Bool { - false - } -} +//extension SpecializationViewController: NSOutlineViewDelegate { +// func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { +// false +// } +//} // MARK: - ParameterRowCellView From bb0d2509d5fe229e104aa6efd1e1174a82581b6e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 01:08:35 +0800 Subject: [PATCH 10/68] refactor(batch-export): redesign completion summary with stat cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion step used to render a single concatenated multi-line summary string and a centered checkmark hero. Swap that for a horizontally laid-out header (compact icon + title + destination subtitle) and four stat cards (Interfaces, Images, ObjC·Swift, Duration) driven by a typed Summary struct on the ViewModel. The ViewModel now formats and partitions the numbers up front (failure ratios, locale-aware integer grouping, tilde-abbreviated destination path) so the view stays a thin renderer. --- ...tchExportingCompletionViewController.swift | 213 +++++++++++++++--- .../BatchExportingCompletionViewModel.swift | 87 +++++-- 2 files changed, 250 insertions(+), 50 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift index 90f5a9e0..a11bbf6f 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift @@ -4,54 +4,81 @@ import RuntimeViewerArchitectures import RuntimeViewerUI final class BatchExportingCompletionViewController: UXKitViewController, ExportingStepViewController { - private let checkmarkImageView = NSImageView().then { + private let headerIconImageView = ImageView().then { $0.image = .symbol(systemName: .checkmarkCircleFill) - $0.symbolConfiguration = .init(pointSize: 40, weight: .regular) + $0.symbolConfiguration = .init(pointSize: 22, weight: .semibold) $0.contentTintColor = .systemGreen } - private let titleLabel = Label("Export Complete").then { - $0.font = .systemFont(ofSize: 18, weight: .bold) - $0.alignment = .center + private let headerTitleLabel = Label("Export Complete").then { + $0.font = .systemFont(ofSize: 16, weight: .semibold) + $0.textColor = .labelColor } - private let summaryLabel = Label().then { + private let headerSubtitleLabel = Label().then { $0.font = .systemFont(ofSize: 12) $0.textColor = .secondaryLabelColor - $0.alignment = .center - $0.maximumNumberOfLines = 0 - $0.preferredMaxLayoutWidth = 600 + $0.lineBreakMode = .byTruncatingMiddle + $0.maximumNumberOfLines = 1 } - private let showInFinderButton = PushButton(title: "Show in Finder", titleFont: .systemFont(ofSize: 13)) + 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 headerStack = VStackView(alignment: .centerX, spacing: 6) { - checkmarkImageView - titleLabel - summaryLabel - .customSpacing(8) + 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.centerX.equalToSuperview() - make.leading.greaterThanOrEqualToSuperview().inset(20) - make.trailing.lessThanOrEqualToSuperview().inset(20) + make.leading.trailing.equalToSuperview().inset(20) } - scrollView.snp.makeConstraints { make in + 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) } @@ -64,8 +91,11 @@ final class BatchExportingCompletionViewController: UXKitViewController NSView? in + .drive(tableView.rx.items) { (tableView: NSTableView, _: NSTableColumn?, _: Int, rowViewModel: BatchExportingCompletionRowViewModel) -> NSView? in let cellView = tableView.box.makeView(ofClass: CellView.self) - cellView.configure(with: rowVM) + 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: TableCellView { + private let statusIcon = ImageView().then { + $0.imageScaling = .scaleProportionallyUpOrDown + } + private let nameLabel = Label().then { $0.font = .systemFont(ofSize: 13, weight: .medium) $0.textColor = .labelColor @@ -103,45 +224,63 @@ extension BatchExportingCompletionViewController { $0.font = .systemFont(ofSize: 11) $0.textColor = .secondaryLabelColor $0.lineBreakMode = .byTruncatingTail + $0.alignment = .right } override func setup() { super.setup() + wantsLayer = true + 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.equalToSuperview().inset(8) - make.top.equalToSuperview().inset(4) - make.trailing.lessThanOrEqualToSuperview().inset(8) + 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.leading.equalToSuperview().inset(8) - make.top.equalTo(nameLabel.snp.bottom).offset(2) - make.trailing.lessThanOrEqualToSuperview().inset(8) - make.bottom.lessThanOrEqualToSuperview().inset(4) + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() } + detailLabel.setContentHuggingPriority(.required, for: .horizontal) + detailLabel.setContentCompressionResistancePriority(.required, for: .horizontal) } - func configure(with rowVM: BatchExportingCompletionRowViewModel) { - let outcome = rowVM.outcome + func configure(with rowViewModel: BatchExportingCompletionRowViewModel) { + let outcome = rowViewModel.outcome nameLabel.stringValue = outcome.image.name switch outcome.outcome { case .success(let result): - let parts: [String] = [ - "\(result.succeeded) succeeded", - result.failed > 0 ? "\(result.failed) failed" : nil, - String(format: "%.1fs", result.totalDuration), - ].compactMap { $0 } + statusIcon.image = .symbol(systemName: .checkmarkCircleFill) + statusIcon.contentTintColor = .systemGreen + 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: " · ") detailLabel.textColor = .secondaryLabelColor + toolTip = nil + layer?.backgroundColor = NSColor.clear.cgColor case .failure(let description): - detailLabel.stringValue = "Failed: \(description)" + statusIcon.image = .symbol(systemName: .xmarkCircleFill) + statusIcon.contentTintColor = .systemRed + detailLabel.stringValue = "Failed" detailLabel.textColor = .systemRed + toolTip = description + layer?.backgroundColor = NSColor(light: .systemRed.withAlphaComponent(0.08), dark: .systemRed.withAlphaComponent(0.16)).cgColor } } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift index 6f846b43..0d7c0550 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewModel.swift @@ -4,17 +4,37 @@ 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 summaryText: Driver + let summary: Driver let rows: Driver<[BatchExportingCompletionRowViewModel]> } - @Observed private(set) var summaryText: String = "" + @Observed private(set) var summary: Summary = .empty private let exportingState: BatchExportingState @@ -29,7 +49,7 @@ final class BatchExportingCompletionViewModel: ViewModel { .compactMap { $0 } .subscribeOnNext { [weak self] result in guard let self else { return } - summaryText = Self.makeSummary(from: result) + summary = Self.makeSummary(result: result, destinationURL: exportingState.destinationURL) } .disposed(by: rx.disposeBag) @@ -51,30 +71,71 @@ final class BatchExportingCompletionViewModel: ViewModel { } return Output( - summaryText: $summaryText.asDriver(), + summary: $summary.asDriver(), rows: rows ) } private func refreshFromState() { if let result = exportingState.aggregatedResult { - summaryText = Self.makeSummary(from: result) + summary = Self.makeSummary(result: result, destinationURL: exportingState.destinationURL) } } - private static func makeSummary(from result: BatchExportingAggregatedResult) -> String { + 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" - var lines: [String] = [] + 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 { - lines.append("\(totalImages) \(imagesWord) processed · \(result.imagesSucceeded) succeeded · \(result.imagesFailed) failed") + imagesValue = "\(result.imagesSucceeded) / \(totalImages)" } else { - lines.append("\(totalImages) \(imagesWord) exported successfully") + imagesValue = "\(totalImages)" } - lines.append("\(result.interfacesSucceeded) interface\(result.interfacesSucceeded == 1 ? "" : "s") generated\(result.interfacesFailed > 0 ? " · \(result.interfacesFailed) failed" : "")") - lines.append("ObjC: \(result.totalObjcCount) · Swift: \(result.totalSwiftCount)") - lines.append(String(format: "Duration: %.1fs", result.totalDuration)) - return lines.joined(separator: "\n") + + 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 } } From 673824c19fa315fb194b0aa2a316ac283b0140dc Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 01:08:48 +0800 Subject: [PATCH 11/68] docs(claude): document LayerBackedView conventions and borderPositions gotcha Codify the project rule that any custom AppKit view needing layer-level visuals (corner radius, border, background, shadow) MUST inherit UIFoundationAppKit.LayerBackedView rather than hand-rolling wantsLayer + layer?.xxx. Call out the borderPositions = [] default that silently swallows non-zero borderWidth + non-nil borderColor so future contributors don't lose an afternoon to it. --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 From 5755e0192e2d3c99d1d45cc6b19ea2f887b6bd85 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 18:01:41 +0800 Subject: [PATCH 12/68] feat(exporting): persist per-language export format selections Replace the SwiftUI @AppStorage-backed includeMetadata flag with the project @UserDefault wrapper and persist the ObjC/Swift format choices as well, so export layout preferences survive across sessions for both single and batch export. Make Format Codable so @UserDefault can store it, and centralize the defaults keys in ExportingDefaultsKey. --- .../RuntimeInterfaceExportConfiguration.swift | 2 +- ...BatchExportingConfigurationViewModel.swift | 21 ++++++++++++---- .../ExportingConfigurationViewModel.swift | 25 ++++++++++++------- .../Exporting/ExportingState.swift | 6 +++++ 4 files changed, 39 insertions(+), 15 deletions(-) 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/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift index 0786f842..2c7a9295 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingConfigurationViewModel.swift @@ -2,7 +2,6 @@ import AppKit import RuntimeViewerApplication import RuntimeViewerArchitectures import RuntimeViewerCore -import SwiftUI final class BatchExportingConfigurationViewModel: ViewModel { struct Input { @@ -20,25 +19,37 @@ final class BatchExportingConfigurationViewModel: ViewModel { let exportingState: BatchExportingState - @AppStorage("Exporting.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 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 } - 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/ExportingConfigurationViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingConfigurationViewModel.swift index 83b246b0..7613436b 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingConfigurationViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingConfigurationViewModel.swift @@ -2,11 +2,6 @@ import AppKit import RuntimeViewerCore import RuntimeViewerArchitectures import RuntimeViewerApplication -import SwiftUI - -private enum ExportingAppStorageKeys { - static let includeMetadata = "Exporting.includeMetadata" -} final class ExportingConfigurationViewModel: ViewModel { 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/ExportingState.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingState.swift index f00e9385..daee58f3 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingState.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Exporting/ExportingState.swift @@ -5,6 +5,12 @@ import OrderedCollections typealias ExportFormat = RuntimeInterfaceExportConfiguration.Format +enum ExportingDefaultsKey { + static let objcFormat = "Exporting.objcFormat" + static let swiftFormat = "Exporting.swiftFormat" + static let includeMetadata = "Exporting.includeMetadata" +} + enum ExportingStep: Int { case configuration case progress From 7d388d9f9d5874b9a6d925fb0546d5366d3b6e17 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 18:01:46 +0800 Subject: [PATCH 13/68] refactor(ui): animate in-progress icons with rotate symbol effect Adopt the current .repeat(.periodic) symbol-effect API and convey activity with a rotating SF Symbol: restore the ImageView wrapper for the indexing row icon and spin a refresh glyph while a batch export row is running. --- ...kgroundIndexingPopoverViewController.swift | 8 ++---- ...BatchExportingProgressViewController.swift | 27 +++++++++++++------ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index f4993bac..fcf79b50 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -441,11 +441,7 @@ extension BackgroundIndexingPopoverViewController { } 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 } @@ -494,7 +490,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/BatchExporting/BatchExportingProgressViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift index 3b9040f3..161123bd 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift @@ -76,7 +76,7 @@ final class BatchExportingProgressViewController: UXKitViewController 0 ? "\(result.failed) failed" : nil, String(format: "%.1fs", result.totalDuration), - ].compactMap { $0 } - detailLabel.stringValue = parts.joined(separator: " · ") + ] + detailLabel.stringValue = parts.compactMap(\.self).joined(separator: " · ") detailLabel.textColor = .secondaryLabelColor detailLabel.isHidden = false progressBar.isHidden = true @@ -213,6 +220,10 @@ extension BatchExportingProgressViewController { detailLabel.isHidden = false progressBar.isHidden = true } + if isSymbolEffectRunning { + statusIcon.removeAllSymbolEffects() + isSymbolEffectRunning = false + } } } } From 410da5a420431cab15584effffae826da34432ff Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 18:01:53 +0800 Subject: [PATCH 14/68] refactor(batch-export): back completion rows with LayerBackedTableCellView Drop the hand-rolled wantsLayer + layer?.backgroundColor handling in favor of LayerBackedTableCellView's backgroundColor property, matching the project's layer-backed view conventions. --- .../BatchExportingCompletionViewController.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift index a11bbf6f..e240e0bf 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift @@ -209,7 +209,7 @@ extension BatchExportingCompletionViewController { } extension BatchExportingCompletionViewController { - private final class CellView: TableCellView { + private final class CellView: LayerBackedTableCellView { private let statusIcon = ImageView().then { $0.imageScaling = .scaleProportionallyUpOrDown } @@ -230,8 +230,6 @@ extension BatchExportingCompletionViewController { override func setup() { super.setup() - wantsLayer = true - hierarchy { statusIcon nameLabel @@ -273,14 +271,14 @@ extension BatchExportingCompletionViewController { detailLabel.stringValue = parts.joined(separator: " · ") detailLabel.textColor = .secondaryLabelColor toolTip = nil - layer?.backgroundColor = NSColor.clear.cgColor + backgroundColor = NSColor.clear case .failure(let description): statusIcon.image = .symbol(systemName: .xmarkCircleFill) statusIcon.contentTintColor = .systemRed detailLabel.stringValue = "Failed" detailLabel.textColor = .systemRed toolTip = description - layer?.backgroundColor = NSColor(light: .systemRed.withAlphaComponent(0.08), dark: .systemRed.withAlphaComponent(0.16)).cgColor + backgroundColor = NSColor(light: .systemRed.withAlphaComponent(0.08), dark: .systemRed.withAlphaComponent(0.16)) } } } From 6029e79d5f08dd73e03d8c44226da2b2c8ca0260 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 29 May 2026 18:01:58 +0800 Subject: [PATCH 15/68] refactor(batch-export): collapse image selection driver chain Fold the two-stage filtered/selection combineLatest into a single map so cell view models are built in one pass, reading selection state directly from exportingState. --- .../BatchExportingImageSelectionViewModel.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift index a9f23ff8..6efbe906 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift @@ -60,22 +60,17 @@ final class BatchExportingImageSelectionViewModel: ViewModel { } .disposed(by: rx.disposeBag) - let filteredDriver = Driver + let cellViewModels = Driver .combineLatest( exportingState.$availableImages.asDriver(), exportingState.$searchString.asDriver() ) - .map { [weak self] availableImages, searchString -> [BatchExportingImage] in - self?.filteredImages(availableImages: availableImages, searchString: searchString) ?? [] - } - - let cellViewModels = Driver - .combineLatest(filteredDriver, exportingState.$selectedImagePaths.asDriver()) - .map { filtered, selectedPaths -> [BatchExportingImageSelectionCellViewModel] in - filtered.map { + .map { [weak self] availableImages, searchString -> [BatchExportingImageSelectionCellViewModel] in + guard let self else { return [] } + return self.filteredImages(availableImages: availableImages, searchString: searchString).map { BatchExportingImageSelectionCellViewModel( image: $0, - isSelected: selectedPaths.contains($0.path) + isSelected: self.exportingState.selectedImagePaths.contains($0.path) ) } } From 537a3330b6837d9356d39eb2bd24578f91ab3c54 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 31 May 2026 23:26:07 +0800 Subject: [PATCH 16/68] chore(deps): refresh Package.resolved for forks and new packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MachInjector 0.2.0 → 0.3.0, RunningApplicationKit 0.3.2 → 0.3.3, SwiftyXPC 0.5.100 → 0.5.102. Point swift-mobile-gestalt and swift-navigation at MxIris-Library-Forks (0.5.0 / 2.8.100) for the in-progress patches the upstream repos don't carry. Pick up swift-helper-service 0.1.3 and swift-identified-collections 1.1.1 (transitive from the helper service); drop NSAttributedStringBuilder which no longer has any callers. --- .../xcshareddata/swiftpm/Package.resolved | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) 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" } }, { From 7f9582710eac549b2dbb7a39a1f44bee930952e9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 31 May 2026 23:26:18 +0800 Subject: [PATCH 17/68] fix(core): decode runtimeObjectsInImage as ObjectsInImageRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-only progress-bearing override for runtimeObjectsInImage was declaring its payload as a bare String, but every caller — dispatch(ObjectsInImageRequest:), _remoteObjectsWithProgress, and the shared handler this override replaces — sends the structured ObjectsInImageRequest envelope. The mismatch failed every objects(in:) call against a remote engine with NSCocoaErrorDomain 4864. Send ObjectsInImageRequest from _remoteObjectsWithProgress and decode it the same way on the server arm so both objects(in:) and objectsWithProgress(in:) hit a symmetric wire form. Also drop the unused `try` on IsImageIndexedRequest.perform — its underlying _isImageIndexed is non-throwing. --- .../RuntimeEngine+Requests.swift | 2 +- .../RuntimeViewerCore/RuntimeEngine.swift | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift index b152e599..d121d539 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift @@ -16,7 +16,7 @@ extension RuntimeEngine { let path: String static var commandName: String { CommandNames.isImageIndexed.commandName } func perform(on engine: RuntimeEngine) async throws -> Bool { - try await engine._isImageIndexed(path: path) + await engine._isImageIndexed(path: path) } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index a0898de3..cd61cf76 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -338,9 +338,15 @@ public actor RuntimeEngine { // Server-only override: progress-bearing variant of // `runtimeObjectsInImage`. Re-registering after the shared handler is // intentional — the last `setMessageHandler` wins. - connection.setMessageHandler(name: CommandNames.runtimeObjectsInImage.commandName) { [weak self] (imagePath: String) -> [RuntimeObject] in + // + // 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._serverObjectsWithProgress(in: imagePath) + return try await self._serverObjectsWithProgress(in: request.image) } // Server-only: manager-layer engine list lookup. Not part of the @@ -705,7 +711,11 @@ extension RuntimeEngine { 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] { From aef275e67eb40708e4037551d2cb9c2c241e9ae2 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 31 May 2026 23:26:26 +0800 Subject: [PATCH 18/68] fix(core): clamp export file names below APFS NAME_MAX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C++ template type names routinely produce display names longer than the 255-byte NAME_MAX on APFS/HFS+, which made the export pipeline throw NSFileWriteInvalidFileNameError (Cocoa 642) for fully-spelled std::unordered_map<…> and friends. Clamp every export file name to 255 UTF-8 bytes, truncating the base on a Character boundary so the result stays valid UTF-8 and never splits a multi-byte scalar. Append an FNV-1a-based ~8-hex disambiguator computed over the *full* base name 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 (Hashable.hashValue is per-run seeded and unsuitable). The suffix (extension / category tag) is never dropped. --- .../Common/RuntimeObject+Export.swift | 81 +++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) 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 } } From 298be7ec0d5b98a082b3c69f07235403de6a3048 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 31 May 2026 23:26:37 +0800 Subject: [PATCH 19/68] feat(batch-export): surface per-object export failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "succeeded" image whose object count silently included a non-zero failure tally was indistinguishable from a fully clean export — the row showed the green checkmark and only the small "N failed" detail tag. Capture .objectFailed events from RuntimeInterfaceExportReporter into a new BatchExportingObjectFailure list, thread it through the per-image outcome, and render partial failures distinctly: - progress + completion rows switch to an orange triangle icon and an orange detail color when failed > 0 - both views attach a multi-line tooltip listing each failed object and its error (capped at 50 entries with "…and N more" overflow) Also pre-flight the image with isImageLoaded / loadImage before exporting so an unloaded image fails the row up front with a real error description instead of silently producing zero objects. --- ...tchExportingCompletionViewController.swift | 18 +++++--- .../BatchExportingProgressRowViewModel.swift | 8 +++- ...BatchExportingProgressViewController.swift | 18 +++++--- .../BatchExportingProgressViewModel.swift | 43 +++++++++++++------ .../BatchExporting/BatchExportingState.swift | 33 ++++++++++++++ 5 files changed, 97 insertions(+), 23 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift index e240e0bf..3a1e2e60 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionViewController.swift @@ -261,17 +261,25 @@ extension BatchExportingCompletionViewController { nameLabel.stringValue = outcome.image.name switch outcome.outcome { case .success(let result): - statusIcon.image = .symbol(systemName: .checkmarkCircleFill) - statusIcon.contentTintColor = .systemGreen 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: " · ") - detailLabel.textColor = .secondaryLabelColor - toolTip = nil - backgroundColor = NSColor.clear + 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 diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift index 72d35ae7..73cbaaf9 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift @@ -21,6 +21,11 @@ final class BatchExportingProgressRowViewModel: NSObject, @unchecked Sendable { @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 } @@ -40,7 +45,8 @@ final class BatchExportingProgressRowViewModel: NSObject, @unchecked Sendable { currentObjectText = currentObject } - func markSucceeded(_ result: RuntimeInterfaceExportResult) { + func markSucceeded(_ result: RuntimeInterfaceExportResult, objectFailures: [BatchExportingObjectFailure] = []) { + self.objectFailures = objectFailures status = .succeeded(result) progress = 1 currentObjectText = "" diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift index 161123bd..2a1369a6 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewController.swift @@ -168,10 +168,11 @@ extension BatchExportingProgressViewController { rowViewModel.$status.asDriver(), rowViewModel.$progress.asDriver(), rowViewModel.$currentObjectText.asDriver(), + rowViewModel.$objectFailures.asDriver(), ) - .driveOnNext { [weak self] status, progress, currentObject in + .driveOnNext { [weak self] status, progress, currentObject, objectFailures in guard let self else { return } - applyState(status: status, progress: progress, currentObject: currentObject) + applyState(status: status, progress: progress, currentObject: currentObject, objectFailures: objectFailures) } .disposed(by: rx.disposeBag) } @@ -180,7 +181,9 @@ extension BatchExportingProgressViewController { status: BatchExportingProgressRowViewModel.Status, progress: Double, currentObject: String, + objectFailures: [BatchExportingObjectFailure], ) { + toolTip = objectFailures.exportFailureTooltip switch status { case .queued: statusIcon.image = .symbol(systemName: .circle) @@ -201,15 +204,20 @@ extension BatchExportingProgressViewController { } return case .succeeded(let result): - statusIcon.image = .symbol(systemName: .checkmarkCircleFill) - statusIcon.contentTintColor = .systemGreen + 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 = .secondaryLabelColor + detailLabel.textColor = result.failed > 0 ? .systemOrange : .secondaryLabelColor detailLabel.isHidden = false progressBar.isHidden = true case .failed(let description): diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift index 628f515e..9f9d23fe 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressViewModel.swift @@ -43,7 +43,7 @@ final class BatchExportingProgressViewModel: ViewModel { titleText: $titleText.asDriver(), progressText: $progressText.asDriver(), overallProgress: $overallProgress.asDriver(), - rows: exportingState.$progressRowViewModels.asDriver() + rows: exportingState.$progressRowViewModels.asDriver(), ) } @@ -90,7 +90,7 @@ final class BatchExportingProgressViewModel: ViewModel { await withTaskGroup(of: BatchExportingPerImageOutcome.self) { group in var iterator = rowViewModels.makeIterator() - for _ in 0.. { swiftFormat: swiftFormat, includeMetadata: includeMetadata, generationOptions: generationOptions, - runtimeEngine: runtimeEngine + runtimeEngine: runtimeEngine, ) } } @@ -128,7 +128,7 @@ final class BatchExportingProgressViewModel: ViewModel { swiftFormat: swiftFormat, includeMetadata: includeMetadata, generationOptions: generationOptions, - runtimeEngine: runtimeEngine + runtimeEngine: runtimeEngine, ) } } @@ -150,9 +150,20 @@ final class BatchExportingProgressViewModel: ViewModel { swiftFormat: ExportFormat, includeMetadata: Bool, generationOptions: RuntimeObjectInterface.GenerationOptions, - runtimeEngine: RuntimeEngine + 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) @@ -172,12 +183,13 @@ final class BatchExportingProgressViewModel: ViewModel { objcFormat: objcFormat, swiftFormat: swiftFormat, generationOptions: generationOptions, - includeMetadata: includeMetadata + includeMetadata: includeMetadata, ) let reporter = RuntimeInterfaceExportReporter() - let eventsTask: Task = Task { @MainActor in + 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): @@ -192,7 +204,14 @@ final class BatchExportingProgressViewModel: ViewModel { case .objectStarted(let object, let current, let totalObjects): rowViewModel.updateProgress( Double(current - 1) / Double(totalObjects), - currentObject: "\(object.displayName) (\(current)/\(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 @@ -200,15 +219,15 @@ final class BatchExportingProgressViewModel: ViewModel { break } } - return capturedResult + return (capturedResult, objectFailures) } do { try await runtimeEngine.exportInterfaces(with: configuration, reporter: reporter) let captured = await eventsTask.value - if let result = captured { - rowViewModel.markSucceeded(result) - return .init(image: image, outcome: .success(result)) + 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) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift index b88123f2..bbffa9a1 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingState.swift @@ -15,9 +15,42 @@ struct BatchExportingImage: Hashable, Sendable { 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) From 3056edfde55aa031c4eb197a063948006337fa3c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 1 Jun 2026 18:17:42 +0800 Subject: [PATCH 20/68] feat(navigation): drive toolbar navigation off selection history cursor Replace the single-shot Back toolbar item with a Previous/Next navigation group whose visibility, enablement, and target object are derived from `DocumentState.selectionStack` and a new `selectionIndex` cursor. Sidebar clicks now `.push` onto the history instead of replacing the root, so the user can walk backward and forward through every object they've inspected (browser-style). New `.backward`/`.forward` routes move the cursor without mutating the array; `.push` from mid-history truncates the abandoned forward branch first; `.pop` keeps its prior removeLast semantics for callers that need to shrink the history. Content/Inspector coordinators now read `selectedRuntimeObject` (stack[cursor]) instead of `stack.last`, and SidebarRuntimeObjectList's visual selection follows the cursor so previous/next also re-highlight the matching sidebar row when the object lives in the current image. --- .../DocumentState.swift | 68 ++++++++++++++++--- .../SelectionRoute.swift | 23 +++++-- .../SidebarRuntimeObjectListViewModel.swift | 31 +++++---- .../SidebarRuntimeObjectViewModel.swift | 2 +- .../Content/ContentCoordinator.swift | 4 +- .../Inspector/InspectorCoordinator.swift | 4 +- .../Main/MainCoordinator.swift | 9 +++ .../Main/MainToolbarController.swift | 40 ++++++++--- .../Main/MainViewModel.swift | 44 +++++++++--- .../Main/MainWindowController.swift | 9 ++- 10 files changed, 180 insertions(+), 54 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 08d155f2..58453e50 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -25,13 +25,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,10 +47,27 @@ 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 @@ -101,20 +126,45 @@ 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) 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/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..d3d3f43b 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 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/Inspector/InspectorCoordinator.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Inspector/InspectorCoordinator.swift index 32ed2975..44486ee9 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Inspector/InspectorCoordinator.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Inspector/InspectorCoordinator.swift @@ -69,8 +69,8 @@ final class InspectorCoordinator: ViewCoordinator, 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/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 9f0ddd15..ec2b5847 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) From 192c3ba98cd6c5897336d44b79e96f513a6022ca Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 1 Jun 2026 18:17:52 +0800 Subject: [PATCH 21/68] refactor(cell-view): hoist RuntimeObjectCellView into Base group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cell view is no longer Sidebar-specific — moving it into Base keeps it reachable from non-sidebar surfaces (Open Quickly, popovers) without crossing module folders. --- .../RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj | 2 +- .../{Sidebar/RuntimeObject => Base}/RuntimeObjectCellView.swift | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/{Sidebar/RuntimeObject => Base}/RuntimeObjectCellView.swift (100%) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index 36a4344c..bfacd8f1 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -640,6 +640,7 @@ E9EEE84A2E071704008D85D1 /* CommonLoadingView.swift */, E9DC0F0A2E8D000000000001 /* LoadingButton.swift */, E9F11E152F12671D0052B0A3 /* TabViewController.swift */, + E9F759412CF603DD00BE7A5F /* RuntimeObjectCellView.swift */, ); path = Base; sourceTree = ""; @@ -743,7 +744,6 @@ E9EEF7602E084D7D008D85D1 /* SidebarRuntimeObjectCoordinator.swift */, E9D4706A2F136E7F008BF7A9 /* SidebarRuntimeObjectTabViewController.swift */, E9A825392C0E095500D9A85D /* SidebarRuntimeObjectViewController.swift */, - E9F759412CF603DD00BE7A5F /* RuntimeObjectCellView.swift */, E9D470662F136E2A008BF7A9 /* SidebarRuntimeObjectListViewController.swift */, E9D470682F136E52008BF7A9 /* SidebarRuntimeObjectBookmarkViewController.swift */, ); 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 From 85b79da1c2a961989ab52859fba3015df7925010 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 1 Jun 2026 18:18:00 +0800 Subject: [PATCH 22/68] refactor(ui): use stack view's semantic alignment names Replace ad-hoc `.centerY` / `.left` / `.vStackCenter` alignments with the standard `.center` / `.leading` set so the call sites match the axis-aware naming the stack view wrappers expose elsewhere. --- .../BackgroundIndexingPopoverViewController.swift | 8 ++++---- .../GenerationOptionsViewController.swift | 2 +- .../SidebarRuntimeObjectViewController.swift | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index fcf79b50..6d16ea32 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -88,7 +88,7 @@ final class BackgroundIndexingPopoverViewController: UXKitViewController Date: Mon, 1 Jun 2026 18:18:05 +0800 Subject: [PATCH 23/68] chore(deps): refresh RuntimeViewerCore Package.resolved --- RuntimeViewerCore/Package.resolved | 65 +++++------------------------- 1 file changed, 10 insertions(+), 55 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index 8e24496f..c09bb3a9 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f8b3a73452a979ed9f634cd2e3ea3dabee9f31e1d00685ca0a2d63a27870f23e", + "originHash" : "9b80534424bac9f966ab4ca0d141726a87ba275387c91708ee8eff6c9de7ce16", "pins" : [ { "identity" : "associatedobject", @@ -64,33 +64,6 @@ "version" : "0.3.0" } }, - { - "identity" : "machokit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", - "state" : { - "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", - "version" : "0.50.100" - } - }, - { - "identity" : "machoobjcsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", - "state" : { - "revision" : "6d71be050b9131ff59e832483d54de685781c42f", - "version" : "0.7.101" - } - }, - { - "identity" : "machoswiftsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - "state" : { - "revision" : "8b34efb02340298e3a2cee69541f99a8e701a719", - "version" : "0.12.0-beta.1" - } - }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -136,6 +109,15 @@ "version" : "0.1.0" } }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", + "version" : "2.9.1" + } + }, { "identity" : "swift-apinotes", "kind" : "remoteSourceControl", @@ -235,15 +217,6 @@ "version" : "3.15.1" } }, - { - "identity" : "swift-demangling", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", - "state" : { - "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", - "version" : "0.4.0" - } - }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", @@ -280,15 +253,6 @@ "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-literal-type-inference", "kind" : "remoteSourceControl", @@ -334,15 +298,6 @@ "version" : "0.5.0" } }, - { - "identity" : "swift-semantic-string", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", - "state" : { - "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", - "version" : "0.1.1" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", From f326e7a2063f4ba62deaa9c25cacbb62212b3707 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 11:24:03 +0800 Subject: [PATCH 24/68] refactor(deps): default LocalSearchPath isEnabled to usingLocalDependencies Hoist the usingLocalDependencies flag into the LocalSearchPath.package default so every local checkout no longer has to repeat isEnabled: usingLocalDependencies, and uncomment the secondary RxAppKit local path now that the default applies. --- RuntimeViewerPackages/Package.swift | 30 +++++------------------------ 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 0c1193ce..a53805b2 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -79,7 +79,7 @@ struct MxIrisStudioWorkspace: RawRepresentable, ExpressibleByStringLiteral, Cust 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 { @@ -179,13 +179,11 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("UIFoundation"), isRelative: true, - isEnabled: true, traits: UIFoundationTraits, ), .package( path: "../../UIFoundation", isRelative: true, - isEnabled: usingLocalDependencies, traits: UIFoundationTraits, ), remote: .package( @@ -199,12 +197,10 @@ 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", @@ -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", @@ -233,12 +227,10 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("UXKitCoordinator"), isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../UXKitCoordinator", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/OpenUXKit/UXKitCoordinator", @@ -250,7 +242,6 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("RxSwiftPlus"), isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/Mx-Iris/RxSwiftPlus", @@ -262,12 +253,10 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("OpenUXKit"), isRelative: true, - isEnabled: usingLocalDependencies, ), .package( path: "../../OpenUXKit", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/OpenUXKit/OpenUXKit", @@ -279,13 +268,11 @@ 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", @@ -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,12 +298,10 @@ 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", @@ -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", From 99015817244538b4c9f644cee3fc64c8f61bc23e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 11:24:08 +0800 Subject: [PATCH 25/68] refactor(sidebar): adopt rx.nodes(options:) for reorderable outline views Replace the isReorderable-driven ternary between rx.reorderableNodes and rx.nodes with the new rx.nodes(options:) overload, keeping the binding site uniform regardless of whether reordering is enabled. --- .../Sidebar/Root/SidebarRootViewController.swift | 2 +- .../RuntimeObject/SidebarRuntimeObjectViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift index 0074039c..173cd207 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift @@ -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 diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift index bd402b19..73febb85 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift @@ -128,7 +128,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 From ba4eb0f5884fd6a54f1134cc0d6252591223b8f0 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 12:13:24 +0800 Subject: [PATCH 26/68] chore(release): prepare v2.1.0-beta.2 - Bump MachOSwiftSection exact pin to 0.12.0-beta.2 (specialization fixes) - Refresh RuntimeViewerCore / RuntimeViewerPackages Package.resolved against new dependency tags (RxAppKit 0.4.0, MachOObjCSection 0.7.102, FrameworkToolbox 0.7.1, Semaphore 0.1.1, swift-demangling 0.4.1, swift-dyld-private 1.2.1, swift-objc-dump 0.8.101). - Refresh workspace-level Package.resolved for both Debug and Distribution workspaces so CI builds against the same pins as the local Xcode session. - Add Changelogs/v2.1.0-beta.2.md. --- Changelogs/v2.1.0-beta.2.md | 112 +++++++++ .../xcshareddata/swiftpm/Package.resolved | 226 +++++------------ .../xcshareddata/swiftpm/Package.resolved | 235 +++++------------- RuntimeViewerCore/Package.resolved | 93 +++++-- RuntimeViewerCore/Package.swift | 2 +- RuntimeViewerPackages/Package.resolved | 59 +++-- 6 files changed, 334 insertions(+), 393 deletions(-) create mode 100644 Changelogs/v2.1.0-beta.2.md 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/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved index 670b40c8..c1472a69 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" } }, { @@ -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", @@ -189,15 +144,6 @@ "version" : "0.5.0" } }, - { - "identity" : "openuxkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenUXKit/OpenUXKit", - "state" : { - "branch" : "main", - "revision" : "a8d89523afd0aa9e06e81ee9b7748eb288ff2630" - } - }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -210,30 +156,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", @@ -275,17 +203,8 @@ "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" + "revision" : "d40a7d551b58ce3006eaabcaa3721b99db72ea78", + "version" : "0.2.3" } }, { @@ -293,8 +212,8 @@ "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" } }, { @@ -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,8 +383,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", - "version" : "1.5.0" + "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", + "version" : "1.6.0" } }, { @@ -473,8 +392,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", "state" : { - "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", - "version" : "0.4.0" + "revision" : "f165282b354bd2fdeeb3b48a54cf173b25a3bb7b", + "version" : "0.4.1" } }, { @@ -491,8 +410,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 +432,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 +455,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 +471,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 +491,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 +536,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Cocoanetics/SwiftMCP", "state" : { - "revision" : "c96b2412b16fca49156d6914b18568dc07fe978e", - "version" : "1.4.4" + "revision" : "5e8961f73834abb6f05fede365831a019a68be91", + "version" : "1.4.7" } }, { @@ -635,8 +554,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" } }, { @@ -648,24 +567,6 @@ "version" : "0.1.0" } }, - { - "identity" : "uifoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/UIFoundation", - "state" : { - "revision" : "40f1b90194e66e8613397f5ce1d601afc230dfd0", - "version" : "0.8.2" - } - }, - { - "identity" : "uxkitcoordinator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenUXKit/UXKitCoordinator", - "state" : { - "branch" : "main", - "revision" : "6e103a7628d8c8108a3b5d6dabafb61ee6fffb09" - } - }, { "identity" : "version", "kind" : "remoteSourceControl", @@ -675,15 +576,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 +590,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..6d4a202a 100644 --- a/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RuntimeViewer.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", @@ -54,24 +45,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 +54,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 +72,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 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", - "version" : "8.8.1" + "revision" : "cf8be20d07654570554c8a8a4952bc8a5766a8b0", + "version" : "8.9.0" } }, { @@ -167,8 +122,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 +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", @@ -198,15 +144,6 @@ "version" : "0.5.0" } }, - { - "identity" : "openuxkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenUXKit/OpenUXKit", - "state" : { - "branch" : "main", - "revision" : "a8d89523afd0aa9e06e81ee9b7748eb288ff2630" - } - }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -219,30 +156,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", @@ -284,17 +203,8 @@ "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" + "revision" : "d40a7d551b58ce3006eaabcaa3721b99db72ea78", + "version" : "0.2.3" } }, { @@ -302,8 +212,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 +221,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 +248,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 +257,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 +266,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 +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" } }, { @@ -419,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" } }, { @@ -437,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" } }, { @@ -446,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" } }, { @@ -473,8 +383,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 +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,35 +446,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 +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" } }, { @@ -617,8 +527,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Cocoanetics/SwiftMCP", "state" : { - "revision" : "c96b2412b16fca49156d6914b18568dc07fe978e", - "version" : "1.4.4" + "revision" : "5e8961f73834abb6f05fede365831a019a68be91", + "version" : "1.4.7" } }, { @@ -635,8 +545,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" } }, { @@ -648,24 +558,6 @@ "version" : "0.1.0" } }, - { - "identity" : "uifoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/UIFoundation", - "state" : { - "revision" : "d7c490fd668e26ccf82e1776ce77c32e4ca8f3e3", - "version" : "0.5.1" - } - }, - { - "identity" : "uxkitcoordinator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenUXKit/UXKitCoordinator", - "state" : { - "branch" : "main", - "revision" : "6e103a7628d8c8108a3b5d6dabafb61ee6fffb09" - } - }, { "identity" : "version", "kind" : "remoteSourceControl", @@ -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/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index c09bb3a9..ec3525bb 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9b80534424bac9f966ab4ca0d141726a87ba275387c91708ee8eff6c9de7ce16", + "originHash" : "1e2bd6b67d9e2ca5857c9ea519dd8c825b4e24a29cf0549462472a9f0694ce95", "pins" : [ { "identity" : "associatedobject", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "5b558bade955658e5c90ee06e7dca06043b39641", - "version" : "0.7.0" + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" } }, { @@ -64,6 +64,33 @@ "version" : "0.3.0" } }, + { + "identity" : "machokit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", + "state" : { + "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", + "version" : "0.50.100" + } + }, + { + "identity" : "machoobjcsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", + "state" : { + "revision" : "ed06cc3708517c6fa6047ca9e17fee53d3f3bf3c", + "version" : "0.7.102" + } + }, + { + "identity" : "machoswiftsection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", + "state" : { + "revision" : "ece412d7986a1a847b198ff8e2457f17bb34dbab", + "version" : "0.12.0-beta.2" + } + }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -96,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Library-Forks/Semaphore", "state" : { - "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", - "version" : "0.1.0" + "revision" : "e6a244dec033ed1033878d5da5911a3ba2489701", + "version" : "0.1.1" } }, { @@ -109,28 +136,19 @@ "version" : "0.1.0" } }, - { - "identity" : "sparkle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle", - "state" : { - "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", - "version" : "2.9.1" - } - }, { "identity" : "swift-apinotes", "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" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", "version" : "1.8.1" @@ -168,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-clang", "state" : { - "revision" : "f92a834ed33249612d9ef30a41d8254d32a7602a", - "version" : "0.2.0" + "revision" : "89195b5191f6678da7e782cec24f2cbc8d75cf79", + "version" : "0.3.0" } }, { @@ -217,6 +235,15 @@ "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", @@ -231,8 +258,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" } }, { @@ -253,6 +280,15 @@ "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-literal-type-inference", "kind" : "remoteSourceControl", @@ -283,10 +319,10 @@ { "identity" : "swift-objc-dump", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump.git", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump", "state" : { - "revision" : "4206040acd64db453c5c28c1539b98fa5befa8fc", - "version" : "0.8.100" + "revision" : "641d257d60385b231a608ab8c4ecdf31348c779e", + "version" : "0.8.101" } }, { @@ -298,6 +334,15 @@ "version" : "0.5.0" } }, + { + "identity" : "swift-semantic-string", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", + "state" : { + "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", + "version" : "0.1.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 0b20160e..6f3f2562 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -132,7 +132,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - exact: "0.12.0-beta.1", + exact: "0.12.0-beta.2", ), ), .package( diff --git a/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index 8c2ca29e..01ec6ed4 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0ba59ea63535f5eda5614c3aa8cb2ea9271e026121bc98e0307ea26c80655a6b", + "originHash" : "a70fed552de3c09c9c85131ec1d7121b0378afadc84481088a35a4fee87aefbd", "pins" : [ { "identity" : "associatedobject", @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "5b558bade955658e5c90ee06e7dca06043b39641", - "version" : "0.7.0" + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" } }, { @@ -148,10 +148,10 @@ { "identity" : "machoobjcsection", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", "state" : { - "revision" : "6d71be050b9131ff59e832483d54de685781c42f", - "version" : "0.7.101" + "revision" : "ed06cc3708517c6fa6047ca9e17fee53d3f3bf3c", + "version" : "0.7.102" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", "state" : { - "revision" : "8b34efb02340298e3a2cee69541f99a8e701a719", - "version" : "0.12.0-beta.1" + "revision" : "ece412d7986a1a847b198ff8e2457f17bb34dbab", + "version" : "0.12.0-beta.2" } }, { @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/RxAppKit", "state" : { - "revision" : "e4ea5c272acdc4f1c220bcc0a44d79cebf1ed98b", - "version" : "0.3.1" + "revision" : "8194bca7c33b10f40d63894cf827890850390f04", + "version" : "0.4.0" } }, { @@ -285,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Library-Forks/Semaphore", "state" : { - "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", - "version" : "0.1.0" + "revision" : "e6a244dec033ed1033878d5da5911a3ba2489701", + "version" : "0.1.1" } }, { @@ -294,8 +294,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/SFSymbols", "state" : { - "revision" : "98c7f4a22419d8034a058a8bfaec7cba4e53dcca", - "version" : "0.2.0" + "revision" : "373b919278adc197759ebcc2018956cacff1cc57", + "version" : "0.3.0" } }, { @@ -321,8 +321,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-apinotes", "state" : { - "revision" : "2fe208c1824f053c04778c5dad2a6fe93d8ab3bf", - "version" : "0.1.0" + "revision" : "e762ac739f71adf83ed2e05f40211c96cd91f6c5", + "version" : "0.2.0" } }, { @@ -375,8 +375,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-DeveloperTool/swift-clang", "state" : { - "revision" : "f92a834ed33249612d9ef30a41d8254d32a7602a", - "version" : "0.2.0" + "revision" : "89195b5191f6678da7e782cec24f2cbc8d75cf79", + "version" : "0.3.0" } }, { @@ -438,8 +438,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/swift-demangling", "state" : { - "revision" : "85a3242fe3773bab2245cf7bf5c9e18abd88d069", - "version" : "0.4.0" + "revision" : "f165282b354bd2fdeeb3b48a54cf173b25a3bb7b", + "version" : "0.4.1" } }, { @@ -456,8 +456,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" } }, { @@ -535,10 +535,10 @@ { "identity" : "swift-objc-dump", "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump.git", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump", "state" : { - "revision" : "4206040acd64db453c5c28c1539b98fa5befa8fc", - "version" : "0.8.100" + "revision" : "641d257d60385b231a608ab8c4ecdf31348c779e", + "version" : "0.8.101" } }, { @@ -604,6 +604,15 @@ "version" : "0.1.0" } }, + { + "identity" : "uifoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/UIFoundation", + "state" : { + "revision" : "d821c32ff9efa9aa13506c12457ea0d430110a20", + "version" : "0.10.0" + } + }, { "identity" : "uxkitcoordinator", "kind" : "remoteSourceControl", From 5335d1af8ba2aea9b9a439eb6817389b279caf4c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 14:29:09 +0800 Subject: [PATCH 27/68] fix(application): gate macOS-only selectionRouter usage for iOS build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap `SelectionRouter` and `EmptyRouteTransitionContext` in DocumentState behind `#if os(macOS)` since they conform to CocoaCoordinator's `TransitionContext` (which XCoordinator does not provide on iOS), and gate the three remaining cross-platform call sites — InspectorRelationships, ContentText, Sidebar — that still triggered `documentState.selectionRouter` without a guard. Wrap `InspectorSwiftSpecializationViewModel` in `#if os(macOS)` because it addresses `InspectorRuntimeObjectRoute.requestSpecializationSheet`, which only exists in the macOS branch of that route enum. Guard the `settings.general.sidebarMaxExpansionDepth` lookup in SidebarRootViewModel behind `#if canImport(RuntimeViewerSettings)` — the Settings target is macOS-only, so iOS falls back to `.max`. --- .../Content/ContentTextViewModel.swift | 2 ++ .../RuntimeViewerApplication/DocumentState.swift | 10 +++++++--- .../Inspector/InspectorRelationshipsViewModel.swift | 5 +++++ .../InspectorSwiftSpecializationViewModel.swift | 2 ++ .../Sidebar/SidebarRootViewModel.swift | 5 +++++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift index a79a46c2..2071b80a 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift @@ -98,7 +98,9 @@ public final class ContentTextViewModel: ViewModel { } .emit(with: self) { target, interface in if let interface { + #if os(macOS) target.documentState.selectionRouter.trigger(.push(interface.object)) + #endif } else { runtimeObjectNotFoundRelay.accept(()) } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 58453e50..8faa6689 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -69,6 +69,7 @@ public final class DocumentState { /// history entry. Drives toolbar next-button enablement. public var canGoNext: Bool { selectionIndex < selectionStack.count - 1 } + #if os(macOS) /// Mutation surface for every observable state on this `DocumentState`. /// View models trigger routes on this router /// (`documentState.selectionRouter.trigger(.drillInto(x))`). The router @@ -82,8 +83,9 @@ 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) + #endif @Observed public var currentSubtitle: String = "" @@ -104,13 +106,14 @@ public final class DocumentState { public private(set) lazy var backgroundIndexingCoordinator = RuntimeBackgroundIndexingCoordinator(documentState: self) } +#if os(macOS) private final class SelectionRouter: Router { typealias Route = SelectionRoute unowned let documentState: DocumentState - + let routeRelay = PublishRelay() - + init(documentState: DocumentState) { self.documentState = documentState } @@ -175,3 +178,4 @@ private struct EmptyRouteTransitionContext: TransitionContext { static let shared = EmptyRouteTransitionContext() var presentables: [any Presentable] { [] } } +#endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift index fa808f5c..2266446b 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift @@ -42,8 +42,13 @@ public final class InspectorRelationshipsViewModel: ViewModel Output { input.selectRelationshipClicked.emitOnNext { [weak self] cellViewModel in + #if os(macOS) guard let self else { return } documentState.selectionRouter.trigger(.push(cellViewModel.runtimeObject)) + #else + _ = self + _ = cellViewModel + #endif } .disposed(by: rx.disposeBag) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationViewModel.swift index 12eee623..96202535 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationViewModel.swift @@ -5,6 +5,7 @@ import RuntimeViewerUI import RuntimeViewerArchitectures import MemberwiseInit +#if os(macOS) @Loggable(.private) public final class InspectorSwiftSpecializationViewModel: ViewModel { @Observed @@ -51,3 +52,4 @@ public final class InspectorSwiftSpecializationViewModel: ViewModel { toggleExpansion: input.clickedNode .filter { !$0.isLeaf } .compactMap { [weak self] item -> (item: SidebarRootCellViewModel, maxDepth: Int)? in + #if canImport(RuntimeViewerSettings) guard let self else { return nil } return (item, settings.general.sidebarMaxExpansionDepth) + #else + _ = self + return (item, .max) + #endif } .asSignal(onErrorSignalWith: .empty()), ) From 6ac542f6308e9b64e2a9936480689d2d85644721 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 14:47:27 +0800 Subject: [PATCH 28/68] refactor(application): make selection routing and Settings cross-platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SelectionRouter` no longer conforms to the coordinator framework's `Router` protocol — its purpose is to mutate `DocumentState` and emit a route, not perform a UI transition, so the coordinator machinery (and the `TransitionContext` / `TransitionProtocol` divergence between CocoaCoordinator and XCoordinator) was buying nothing. It's now a plain `@MainActor` class with a public `trigger(_:)`. Drop `EmptyRouteTransitionContext` accordingly. `RuntimeViewerSettings` was already platform-agnostic in its sources (only imports Foundation/Observation/MetaCodable/RuntimeViewerCore), so the `condition: .when(platforms: appkitPlatforms)` on the `RuntimeViewerApplication` dependency was unnecessarily fencing iOS off from settings access. Make it an unconditional dependency and drop the matching `#if canImport(RuntimeViewerSettings)` guards in `ViewModel`. Drop the `#if os(macOS)` around `InspectorRuntimeObjectRoute` — every case references only `RuntimeObject` (cross-platform) so the iOS `typealias InspectorRuntimeObjectRoute = InspectorRoute` workaround is no longer necessary. Revert the temporary platform guards added in 5335d1a around `documentState.selectionRouter`, `settings.general.sidebarMaxExpansionDepth`, and the whole `InspectorSwiftSpecializationViewModel` — they all build on iOS now. --- .../xcshareddata/swiftpm/Package.resolved | 72 +++++++++++++++++++ RuntimeViewerPackages/Package.resolved | 2 +- RuntimeViewerPackages/Package.swift | 2 +- .../Content/ContentTextViewModel.swift | 2 - .../DocumentState.swift | 38 ++++------ .../InspectorRelationshipsViewModel.swift | 5 -- .../Inspector/InspectorRoute.swift | 4 -- ...nspectorSwiftSpecializationViewModel.swift | 2 - .../Sidebar/SidebarRootViewModel.swift | 5 -- .../RuntimeViewerApplication/ViewModel.swift | 9 +-- 10 files changed, 91 insertions(+), 50 deletions(-) diff --git a/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6d4a202a..cdc9e4a4 100644 --- a/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RuntimeViewer.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "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", @@ -144,6 +153,15 @@ "version" : "0.5.0" } }, + { + "identity" : "openuxkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenUXKit/OpenUXKit", + "state" : { + "branch" : "main", + "revision" : "21b944e638ff66d45ba1f550483c875be3c1f93f" + } + }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -162,6 +180,24 @@ "version" : "2.1.1" } }, + { + "identity" : "runningapplicationkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RunningApplicationKit", + "state" : { + "revision" : "8c64a39bbcbe96b7758afd0e2e0a9f3d82f7305a", + "version" : "0.3.3" + } + }, + { + "identity" : "rxappkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RxAppKit", + "state" : { + "revision" : "8194bca7c33b10f40d63894cf827890850390f04", + "version" : "0.4.0" + } + }, { "identity" : "rxcombine", "kind" : "remoteSourceControl", @@ -207,6 +243,15 @@ "version" : "0.2.3" } }, + { + "identity" : "rxuikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/RxUIKit", + "state" : { + "revision" : "f4397315d83edf4f9390dbe96e212c892577c7eb", + "version" : "0.1.1" + } + }, { "identity" : "semaphore", "kind" : "remoteSourceControl", @@ -558,6 +603,24 @@ "version" : "0.1.0" } }, + { + "identity" : "uifoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/UIFoundation", + "state" : { + "revision" : "d821c32ff9efa9aa13506c12457ea0d430110a20", + "version" : "0.10.0" + } + }, + { + "identity" : "uxkitcoordinator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenUXKit/UXKitCoordinator", + "state" : { + "branch" : "main", + "revision" : "481806584ed23911fbe0449fdda57085f8615779" + } + }, { "identity" : "version", "kind" : "remoteSourceControl", @@ -567,6 +630,15 @@ "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", diff --git a/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index 01ec6ed4..aac9b7b2 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a70fed552de3c09c9c85131ec1d7121b0378afadc84481088a35a4fee87aefbd", + "originHash" : "e7c8e5648fa6350261c9c4de1ead5e068b41a58866ef0cf71f3d47eb4bf68715", "pins" : [ { "identity" : "associatedobject", diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index a53805b2..67b114aa 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -479,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/Content/ContentTextViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift index 2071b80a..a79a46c2 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift @@ -98,9 +98,7 @@ public final class ContentTextViewModel: ViewModel { } .emit(with: self) { target, interface in if let interface { - #if os(macOS) target.documentState.selectionRouter.trigger(.push(interface.object)) - #endif } else { runtimeObjectNotFoundRelay.accept(()) } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 8faa6689..5b8ffef6 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -69,14 +69,13 @@ public final class DocumentState { /// history entry. Drives toolbar next-button enablement. public var canGoNext: Bool { selectionIndex < selectionStack.count - 1 } - #if os(macOS) /// 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. - public var selectionRouter: any Router { _selectionRouter } + public var selectionRouter: SelectionRouter { _selectionRouter } /// Hot stream of selection routes. Emits **after** the corresponding /// state update on this `DocumentState` has been applied, so subscribers @@ -85,7 +84,6 @@ public final class DocumentState { public var routeSignal: Signal { _selectionRouter.routeRelay.asSignal() } private lazy var _selectionRouter = SelectionRouter(documentState: self) - #endif @Observed public var currentSubtitle: String = "" @@ -106,23 +104,24 @@ public final class DocumentState { public private(set) lazy var backgroundIndexingCoordinator = RuntimeBackgroundIndexingCoordinator(documentState: self) } -#if os(macOS) -private final class SelectionRouter: Router { - typealias Route = SelectionRoute - - unowned let documentState: DocumentState +/// Routes selection-state mutations on a `DocumentState`. +/// +/// Standalone type rather than a `Router` conformance — `CocoaCoordinator.Router` +/// and `XCoordinator.Router` disagree on their `ContextPresentationHandler` +/// (`TransitionContext` vs `TransitionProtocol`), and this router exists purely +/// to mutate state and emit a route — it never performs a UI transition, so +/// the coordinator-framework machinery is unnecessary. +@MainActor +public final class SelectionRouter { + fileprivate unowned let documentState: DocumentState - let routeRelay = PublishRelay() + fileprivate let routeRelay = PublishRelay() - init(documentState: DocumentState) { + fileprivate init(documentState: DocumentState) { self.documentState = documentState } - func contextTrigger( - _ route: SelectionRoute, - with options: TransitionOptions, - completion: ContextPresentationHandler? - ) { + public func trigger(_ route: SelectionRoute) { switch route { case .switchEngine(let engine): if documentState.runtimeEngine === engine, documentState.currentImageNode == nil, documentState.selectionStack.isEmpty { return } @@ -170,12 +169,5 @@ private final class SelectionRouter: Router { documentState.selectionIndex = -1 } routeRelay.accept(route) - completion?(EmptyRouteTransitionContext.shared) } } - -private struct EmptyRouteTransitionContext: TransitionContext { - static let shared = EmptyRouteTransitionContext() - var presentables: [any Presentable] { [] } -} -#endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift index 2266446b..fa808f5c 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsViewModel.swift @@ -42,13 +42,8 @@ public final class InspectorRelationshipsViewModel: ViewModel Output { input.selectRelationshipClicked.emitOnNext { [weak self] cellViewModel in - #if os(macOS) guard let self else { return } documentState.selectionRouter.trigger(.push(cellViewModel.runtimeObject)) - #else - _ = self - _ = cellViewModel - #endif } .disposed(by: rx.disposeBag) 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/Inspector/InspectorSwiftSpecializationViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationViewModel.swift index 96202535..12eee623 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationViewModel.swift @@ -5,7 +5,6 @@ import RuntimeViewerUI import RuntimeViewerArchitectures import MemberwiseInit -#if os(macOS) @Loggable(.private) public final class InspectorSwiftSpecializationViewModel: ViewModel { @Observed @@ -52,4 +51,3 @@ public final class InspectorSwiftSpecializationViewModel: ViewModel { toggleExpansion: input.clickedNode .filter { !$0.isLeaf } .compactMap { [weak self] item -> (item: SidebarRootCellViewModel, maxDepth: Int)? in - #if canImport(RuntimeViewerSettings) guard let self else { return nil } return (item, settings.general.sidebarMaxExpansionDepth) - #else - _ = self - return (item, .max) - #endif } .asSignal(onErrorSignalWith: .empty()), ) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift index fd4c9080..0462d772 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,16 +11,14 @@ 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() From 95baeab82e49fff4f08aecf0ff5e9f0d6668fb31 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 14:57:49 +0800 Subject: [PATCH 29/68] refactor(application): restore SelectionRouter Router conformance with iOS XCoordinator parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the standalone `SelectionRouter` class introduced in 6ac542f back to a real `Router` conformance — macOS keeps its CocoaCoordinator-based implementation untouched, and iOS gets a parallel XCoordinator-based one using the equivalent types: `Router` instead of `Router`, and `EmptyRouteTransitionContext: TransitionProtocol` in place of `TransitionContext` (XCoordinator merges the `TransitionContext.presentables` surface into `TransitionProtocol`). XCoordinator's `Router` extends `Presentable`, so the iOS branch adds a deliberately no-op `viewController`/`router(for:)` surface — this router mutates state and emits a route, it never actually presents anything. Public surface is unchanged: callers see `any Router`, so `documentState.selectionRouter.trigger(.push(x))` works the same on both platforms. --- .../DocumentState.swift | 63 ++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 5b8ffef6..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 { @@ -75,7 +78,7 @@ public final class DocumentState { /// applies the state mutation synchronously, then emits to /// `routeSignal` so scene-level subscribers (`MainCoordinator`) can /// fan out to their child coordinators. - public var selectionRouter: SelectionRouter { _selectionRouter } + public var selectionRouter: any Router { _selectionRouter } /// Hot stream of selection routes. Emits **after** the corresponding /// state update on this `DocumentState` has been applied, so subscribers @@ -104,24 +107,34 @@ public final class DocumentState { public private(set) lazy var backgroundIndexingCoordinator = RuntimeBackgroundIndexingCoordinator(documentState: self) } -/// Routes selection-state mutations on a `DocumentState`. -/// -/// Standalone type rather than a `Router` conformance — `CocoaCoordinator.Router` -/// and `XCoordinator.Router` disagree on their `ContextPresentationHandler` -/// (`TransitionContext` vs `TransitionProtocol`), and this router exists purely -/// to mutate state and emit a route — it never performs a UI transition, so -/// the coordinator-framework machinery is unnecessary. -@MainActor -public final class SelectionRouter { - fileprivate unowned let documentState: DocumentState +private final class SelectionRouter: Router { + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + typealias Route = SelectionRoute + #else + typealias RouteType = SelectionRoute + #endif + + unowned let documentState: DocumentState - fileprivate let routeRelay = PublishRelay() + let routeRelay = PublishRelay() - fileprivate init(documentState: DocumentState) { + init(documentState: DocumentState) { self.documentState = documentState } - public func trigger(_ route: SelectionRoute) { + #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, + completion: ContextPresentationHandler? + ) { switch route { case .switchEngine(let engine): if documentState.runtimeEngine === engine, documentState.currentImageNode == nil, documentState.selectionStack.isEmpty { return } @@ -169,5 +182,27 @@ public final class SelectionRouter { 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 From 8bf28ef1a03e910964fe22780c243ca4f7d8c130 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 16:33:49 +0800 Subject: [PATCH 30/68] fix(uikit): use .center alignment on VStackView --- .../Sidebar/SidebarRuntimeObjectViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 } From 5bfeaa539af4b621994e19d4fb82506981b5049c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 18:17:07 +0800 Subject: [PATCH 31/68] fix(batch-export): preserve checkbox state across scroll via reactive cell view model The image-selection step lost its checkboxes after scrolling because the cell view model captured `isSelected` as an immutable snapshot and the parent driver did not re-emit on `selectedImagePaths` changes (and could not, without reloading the whole table and resetting scroll position). Make `isSelected` a `@Observed` projected from an `Observable` injected at init. The view model derives that stream per-image from `exportingState.$selectedImagePaths` so the cell view's `bind(to:)` can drive its checkbox off `$isSelected` reactively. Differentiable identity no longer includes selection, so toggling never re-diffs the row. Toggle stays in the parent view model: the `toggleImage` input now carries the cell view model (not the bare image) and the cell view's checkbox callback forwards its own cell view model up. --- ...ExportingImageSelectionCellViewModel.swift | 18 ++++++++++--- ...xportingImageSelectionViewController.swift | 25 +++++++++++-------- ...atchExportingImageSelectionViewModel.swift | 22 ++++++++-------- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift index 5961404a..9d2deeca 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift @@ -4,11 +4,18 @@ import RuntimeViewerCore final class BatchExportingImageSelectionCellViewModel: NSObject, @unchecked Sendable { let image: BatchExportingImage - let isSelected: Bool - init(image: BatchExportingImage, isSelected: Bool) { + @Observed + private(set) var isSelected: Bool = false + + private let disposeBag = DisposeBag() + + init(image: BatchExportingImage, isSelected: Observable) { self.image = image - self.isSelected = isSelected + super.init() + isSelected + .bind(to: $isSelected) + .disposed(by: disposeBag) } } @@ -16,6 +23,9 @@ extension BatchExportingImageSelectionCellViewModel: Differentiable { var differenceIdentifier: String { image.path } func isContentEqual(to source: BatchExportingImageSelectionCellViewModel) -> Bool { - image == source.image && isSelected == source.isSelected + // 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 index aa132f4c..8b73b108 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewController.swift @@ -15,7 +15,7 @@ final class BatchExportingImageSelectionViewController: UXKitViewController() + private let toggleImageRelay = PublishRelay() override var contentInsets: NSDirectionalEdgeInsets { .init(top: 16, leading: 16, bottom: 16, trailing: 16) } @@ -95,8 +95,8 @@ final class BatchExportingImageSelectionViewController: UXKitViewController NSView? in let cellView = tableView.box.makeView(ofClass: CellView.self) - cellView.configure(with: cellViewModel) { image in - toggleImageRelay.accept(image) + cellView.bind(to: cellViewModel) { cellViewModel in + toggleImageRelay.accept(cellViewModel) } return cellView } @@ -146,9 +146,9 @@ extension BatchExportingImageSelectionViewController { groupLabel } - private var image: BatchExportingImage? + private var cellViewModel: BatchExportingImageSelectionCellViewModel? - private var onToggle: ((BatchExportingImage) -> Void)? + private var onToggle: ((BatchExportingImageSelectionCellViewModel) -> Void)? override func setup() { super.setup() @@ -165,18 +165,23 @@ extension BatchExportingImageSelectionViewController { } } - func configure(with cellViewModel: BatchExportingImageSelectionCellViewModel, onToggle: @escaping (BatchExportingImage) -> Void) { - image = cellViewModel.image + func bind(to cellViewModel: BatchExportingImageSelectionCellViewModel, onToggle: @escaping (BatchExportingImageSelectionCellViewModel) -> Void) { + rx.disposeBag = DisposeBag() + self.cellViewModel = cellViewModel self.onToggle = onToggle - checkbox.state = cellViewModel.isSelected ? .on : .off 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 image else { return } - onToggle?(image) + guard let cellViewModel else { return } + onToggle?(cellViewModel) } } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift index 6efbe906..a58f1763 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionViewModel.swift @@ -8,7 +8,7 @@ final class BatchExportingImageSelectionViewModel: ViewModel { let searchString: Signal let selectAllClicked: Signal let deselectAllClicked: Signal - let toggleImage: Signal + let toggleImage: Signal } struct Output { @@ -50,12 +50,13 @@ final class BatchExportingImageSelectionViewModel: ViewModel { } .disposed(by: rx.disposeBag) - input.toggleImage.emitOnNext { [weak self] image in + input.toggleImage.emitOnNext { [weak self] cellViewModel in guard let self else { return } - if exportingState.selectedImagePaths.contains(image.path) { - exportingState.selectedImagePaths.remove(image.path) + let path = cellViewModel.image.path + if exportingState.selectedImagePaths.contains(path) { + exportingState.selectedImagePaths.remove(path) } else { - exportingState.selectedImagePaths.insert(image.path) + exportingState.selectedImagePaths.insert(path) } } .disposed(by: rx.disposeBag) @@ -67,11 +68,12 @@ final class BatchExportingImageSelectionViewModel: ViewModel { ) .map { [weak self] availableImages, searchString -> [BatchExportingImageSelectionCellViewModel] in guard let self else { return [] } - return self.filteredImages(availableImages: availableImages, searchString: searchString).map { - BatchExportingImageSelectionCellViewModel( - image: $0, - isSelected: self.exportingState.selectedImagePaths.contains($0.path) - ) + 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) } } From d29954ab628e88217bd91c13c8ae31578b917d0d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 3 Jun 2026 18:34:15 +0800 Subject: [PATCH 32/68] feat(sidebar): enable type-select keyboard navigation in outline Conform SidebarRootViewController to NSOutlineViewDelegate and implement typeSelectStringFor so users can jump to rows by typing. --- .../Sidebar/Root/SidebarRootViewController.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift index 173cd207..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 } @@ -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 } } From 92c0fc96c1cc6b0b4879a937a88324ef4e8cff76 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 2 Jun 2026 01:18:28 +0800 Subject: [PATCH 33/68] refactor(packages): unify local-dependency switch across Core/Packages/MCP Default LocalSearchPath.isEnabled to usingLocalDependencies and add a traits parameter so call sites no longer have to repeat the same boilerplate per entry. Hoist usingLocalDependencies above the extension in Core and Packages so it can serve as the default value, and bring the same envEnable + LocalSearchPath wiring (plus the .package.env loader) to RuntimeViewerMCP, which previously had no local-dependency support at all. --- RuntimeViewerCore/Package.swift | 15 ++---- RuntimeViewerMCP/Package.swift | 75 +++++++++++++++++++++++++++++ RuntimeViewerPackages/Package.swift | 4 +- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 6f3f2562..7c02122e 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,7 +116,6 @@ let package = Package( local: .package( path: "../../MachOObjCSection", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", @@ -128,7 +126,6 @@ let package = Package( local: .package( path: "../../MachOSwiftSection", isRelative: true, - isEnabled: usingLocalDependencies, ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", @@ -155,12 +152,10 @@ let package = 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", 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.swift b/RuntimeViewerPackages/Package.swift index 67b114aa..6bea70ae 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -77,6 +77,8 @@ struct MxIrisStudioWorkspace: RawRepresentable, ExpressibleByStringLiteral, Cust } } +let usingLocalDependencies = envEnable("USING_LOCAL_DEPENDENCIES") + extension Package.Dependency { enum LocalSearchPath { case package(path: String, isRelative: Bool, isEnabled: Bool = usingLocalDependencies, traits: Set = [.defaults]) @@ -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"] From bec9f4be7f2f981f1609e030c53ed2db01ed73a0 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 3 Jun 2026 22:58:08 +0800 Subject: [PATCH 34/68] fix(communication): prevent stack overflow on batched receives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RuntimeMessageChannel.processReceivedData` for-loop touched two `@Mutex`-decorated properties per iteration. The macro's `_modify` coroutine accessor pins ~280 bytes of coroutine context on the caller's frame per access and the frames don't fully unwind between iterations, so a single Bonjour `NWConnection.receive` carrying ~50 small frames overflows the dispatch-queue worker stack with 13k+ frames and crashes the process inside `swift_task_create` / `AsyncStream._Storage.yield`. Two changes: - `processReceivedData` snapshots `receivedDataContinuation` and `onMessageReceived` into locals before the hot loop. The plain `get` accessor (`withLock { $0 }`) returns by copy without entering the `_modify` coroutine, so per-iteration cost is constant and the stack unwinds normally. - `RuntimeNetworkConnection.observeIncomingMessages` now routes the callback through an unbounded `AsyncStream` drained by one long-lived consumer task, matching `RuntimeLocalSocketConnection` and `RuntimeDirectTCPConnection`. The callback body only does a non-blocking `yield`; `handleReceivedMessage` no longer runs in a per-message Task on the connection's dispatch queue. This also preserves the early-message ordering the f2b6324 callback rewrite was originally chasing — the stream buffers anything yielded before the consumer reaches `for await`. Adds two `Suite`s under `RuntimeMessageChannelTests`: - `RuntimeMessageChannel Burst Regression` — drives the channel with a 10k-frame burst from a dispatch queue and verifies the buffered-stream bridge delivers FIFO with no loss. - `RuntimeNetworkConnection Stack Overflow Reproducer` (disabled by default) — replays the pre-fix per-message-Task callback shape so the regression can be reproduced manually; the disabled trait keeps it from killing the test process in CI. --- .../RuntimeNetworkConnection.swift | 46 +++++- .../RuntimeMessageChannel.swift | 20 ++- .../RuntimeMessageChannelTests.swift | 144 ++++++++++++++++++ 3 files changed, 205 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift index 8c010fc2..bec07236 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift @@ -66,6 +66,18 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se private let connection: NWConnection private let messageChannel = RuntimeMessageChannel() + /// Buffered hand-off between `RuntimeMessageChannel.onMessageReceived` (which + /// fires synchronously on the connection's dispatch queue, inside the hot + /// `processReceivedData` loop) and the long-lived consumer task. The + /// callback only enqueues into this stream — it never creates a per-message + /// Task — so a batched receive carrying many small frames (heartbeats, + /// acks, batch-export chunks) doesn't accumulate `swift_task_create` stack + /// frames on the dispatch-queue thread, which previously overflowed the + /// worker-thread stack (commit f2b6324). + private let incomingMessageStream: AsyncStream + private let incomingMessageContinuation: AsyncStream.Continuation + private var incomingMessageTask: Task? + private var isStarted = false private var waitingTimeoutWork: DispatchWorkItem? private let queue = DispatchQueue(label: "com.RuntimeViewer.RuntimeViewerCommunication.RuntimeNetworkConnection") @@ -89,6 +101,10 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se parameters.includePeerToPeer = true parameters.serviceClass = .responsiveData + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + self.incomingMessageStream = stream + self.incomingMessageContinuation = continuation + self.connection = NWConnection(to: endpoint, using: parameters) try start() } @@ -98,6 +114,11 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se /// - Parameter connection: The accepted connection from NWListener. init(connection: NWConnection) throws { #log(.info, "Creating incoming connection: \(connection.debugDescription, privacy: .public)") + + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + self.incomingMessageStream = stream + self.incomingMessageContinuation = continuation + self.connection = connection try start() } @@ -124,6 +145,8 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se waitingTimeoutWork = nil connection.stateUpdateHandler = nil connection.cancel() + incomingMessageContinuation.finish() + incomingMessageTask = nil messageChannel.finishReceiving() stateSubject.send(.disconnected(error: nil)) @@ -138,6 +161,8 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se waitingTimeoutWork = nil connection.stateUpdateHandler = nil connection.cancel() + incomingMessageContinuation.finish() + incomingMessageTask = nil messageChannel.finishReceiving() stateSubject.send(.disconnected(error: error)) @@ -210,9 +235,26 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se } private func observeIncomingMessages() { - messageChannel.onMessageReceived = { [weak self] data in + // The message-channel callback fires inside `processReceivedData`'s + // synchronous for-loop on the connection's dispatch queue. Keep the + // callback work to a single non-blocking `yield`; never spawn a Task + // here — Swift Concurrency's task-inlining on the dispatch-queue + // thread would otherwise accumulate ~10 stack frames per message and + // overflow on a burst of small frames. + messageChannel.onMessageReceived = { [continuation = incomingMessageContinuation] data in + continuation.yield(data) + } + + // One long-lived consumer drains the buffered stream serially. The + // `AsyncStream` buffers everything yielded before this task actually + // reaches the `for await`, so we don't lose any early messages — that + // was the race the original f2b6324 commit tried to fix when it + // (wrongly) swapped the for-await loop for per-message Tasks. + incomingMessageTask = Task { [weak self] in guard let self else { return } - Task { await self.handleReceivedMessage(data) } + for await data in self.incomingMessageStream { + await self.handleReceivedMessage(data) + } } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift index 0030cf5c..1ae2c140 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift @@ -211,7 +211,6 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { /// Processes the receiving buffer and extracts complete messages. private func processReceivedData() { - let hasContinuation = receivedDataContinuation != nil var extractedMessages: [Data] = [] var remainingBufferSize = 0 @@ -234,13 +233,28 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { remainingBufferSize = buffer.count } + // Snapshot both `@Mutex`-backed values into locals BEFORE the hot loop. + // + // Each `@Mutex`-generated property exposes a `_modify` coroutine + // accessor. Inside a hot for-loop on a dispatch-queue worker thread, + // every `receivedDataContinuation?.yield(...)` / `onMessageReceived?(...)` + // call enters that coroutine, and the per-iteration coroutine frames + // accumulate on the caller's stack instead of fully unwinding — + // a burst of ~50 small frames overflows the worker stack with + // 13_000+ frames. Reading once through the plain `get` accessor (which + // is `withLock { $0 }` and returns the value by copy) sidesteps the + // `_modify` path entirely; the for-loop then only touches local vars. + let continuation = receivedDataContinuation + let callback = onMessageReceived + let hasContinuation = continuation != 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) - onMessageReceived?(messageData) + continuation?.yield(messageData) + callback?(messageData) } } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift index ce0316c5..e819bb8a 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift @@ -496,6 +496,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.. Date: Wed, 3 Jun 2026 22:59:32 +0800 Subject: [PATCH 35/68] chore(release): prepare v2.1.0-beta.3 - Add Changelogs/v2.1.0-beta.3.md. This is a stability-only bump on top of beta.2 (single crash fix in the Bonjour message dispatch path). Dependency pins are unchanged: no local dependency carried changes that the fix needs. --- Changelogs/v2.1.0-beta.3.md | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 Changelogs/v2.1.0-beta.3.md diff --git a/Changelogs/v2.1.0-beta.3.md b/Changelogs/v2.1.0-beta.3.md new file mode 100644 index 00000000..e762133b --- /dev/null +++ b/Changelogs/v2.1.0-beta.3.md @@ -0,0 +1,57 @@ +# v2.1.0-beta.3 + +Third public preview of the **2.1** line. This is a focused stability release +on top of [beta.2](v2.1.0-beta.2.md): one critical crash fix in the +Bonjour-based network connection. 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. + +--- + +## 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. From a6824a75327c866c35c9a715aa611c241d6665c8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 3 Jun 2026 23:11:35 +0800 Subject: [PATCH 36/68] chore(release): bump MachOSwiftSection to 0.12.0-beta.3 Picks up an opaque-type symbolic-ref fix (cross-image descriptors no longer crash with a __PAGEZERO read when an indexed image resolves a SwiftUI body call into a sibling loaded image), plus a SymbolIndexStore self-cleanup hook and the public SharedCache management API. Refreshed RuntimeViewerCore / RuntimeViewerPackages Package.resolved against the new tag. Workspace-level Package.resolved files don't pin MachO*Section under USING_LOCAL_DEPENDENCIES=1 and don't need refresh. Changelog updated in the same commit to mention the bump. --- Changelogs/v2.1.0-beta.3.md | 30 ++++++++++++++++++++++---- RuntimeViewerCore/Package.resolved | 8 +++---- RuntimeViewerCore/Package.swift | 2 +- RuntimeViewerPackages/Package.resolved | 6 +++--- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/Changelogs/v2.1.0-beta.3.md b/Changelogs/v2.1.0-beta.3.md index e762133b..d3f8824d 100644 --- a/Changelogs/v2.1.0-beta.3.md +++ b/Changelogs/v2.1.0-beta.3.md @@ -1,9 +1,10 @@ # v2.1.0-beta.3 -Third public preview of the **2.1** line. This is a focused stability release -on top of [beta.2](v2.1.0-beta.2.md): one critical crash fix in the -Bonjour-based network connection. Everything else carries over from beta.2 -unchanged. +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. @@ -47,6 +48,27 @@ 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 diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index ec3525bb..d2f52716 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1e2bd6b67d9e2ca5857c9ea519dd8c825b4e24a29cf0549462472a9f0694ce95", + "originHash" : "24d5c845392d39a37911618b5d945b6a1557209e64c41417f824a05df71c032e", "pins" : [ { "identity" : "associatedobject", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", "state" : { - "revision" : "ece412d7986a1a847b198ff8e2457f17bb34dbab", - "version" : "0.12.0-beta.2" + "revision" : "da7abcf91fc9a1208f53b7314aad288da3dafb14", + "version" : "0.12.0-beta.3" } }, { @@ -148,7 +148,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", "version" : "1.8.1" diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 7c02122e..0493eb95 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -129,7 +129,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - exact: "0.12.0-beta.2", + exact: "0.12.0-beta.3", ), ), .package( diff --git a/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index aac9b7b2..62356d55 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e7c8e5648fa6350261c9c4de1ead5e068b41a58866ef0cf71f3d47eb4bf68715", + "originHash" : "a34be8fb0f116226a71d77f2010368841123cc2ddfc739e6fec9aa0cdcbd27b1", "pins" : [ { "identity" : "associatedobject", @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", "state" : { - "revision" : "ece412d7986a1a847b198ff8e2457f17bb34dbab", - "version" : "0.12.0-beta.2" + "revision" : "da7abcf91fc9a1208f53b7314aad288da3dafb14", + "version" : "0.12.0-beta.3" } }, { From 18210c6838d387aec2b1a01d4c8293c7f2291988 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 3 Jun 2026 23:48:57 +0800 Subject: [PATCH 37/68] chore(deps): bump remote pins to latest tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `from:` lower bounds with the current latest published tag for every non-pinned dependency. Verified by archiving the macOS workspace with USING_LOCAL_DEPENDENCIES=0 — build succeeds with no errors / no warnings. RuntimeViewerCore (Package.swift only — Package.resolved already pinned to the same versions): - MachOObjCSection 0.6.101 → 0.7.102 - Asynchrone 0.23.0-fork → 0.23.0-fork.1 - Semaphore 0.1.0 → 0.1.1 - swift-collections 1.1.0 → 1.5.1 - FrameworkToolbox 0.5.5 → 0.7.1 - swift-helper-service 0.1.2 → 0.1.3 - Version 2.2.0 → 2.2.1 RuntimeViewerPackages (Package.swift + resolved, two major bumps land): - UIFoundation 0.8.2 → 0.10.0 - XCoordinator 3.0.0-beta → 3.0.0-beta.1 - RxSwiftPlus 0.2.2 → 0.2.3 - RxAppKit 0.3.0 → 0.4.0 - swift-helper-service 0.1.2 → 0.1.3 - SnapKit 5.0.0 → 6.0.0 (major, source-compat verified) - RxSwift 6.0.0 → 6.10.2 - SFSymbols 0.2.0 → 0.3.0 - swift-dependencies 1.9.4 → 1.12.0 - Ifrit 3.0.0 → 4.0.0 (major, source-compat verified) - swiftui-introspect 26.0.0 → 26.0.1 - Rearrange 2.0.0 → 2.1.1 RuntimeViewerMCP intentionally untouched: SwiftMCP 1.5.x requires swift-syntax 602+, which conflicts with MachOSwiftSection 0.12.0-beta.3's `509.1.0 ..< 602.0.0` window. SwiftMCP stays at `from: "1.4.0"` and RuntimeViewerMCP/Package.resolved keeps its existing (workspace-resolved) swift-syntax 601.0.1 pin. --- RuntimeViewerCore/Package.swift | 14 +++++++------- RuntimeViewerPackages/Package.resolved | 10 +++++----- RuntimeViewerPackages/Package.swift | 24 ++++++++++++------------ 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 0493eb95..97176200 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -119,7 +119,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", - from: "0.6.101", + from: "0.7.102", ), ), .package( @@ -134,19 +134,19 @@ let package = Package( ), .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( @@ -159,7 +159,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/swift-helper-service", - from: "0.1.2", + from: "0.1.3", ), ), .package( @@ -168,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/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index 62356d55..34e28869 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a34be8fb0f116226a71d77f2010368841123cc2ddfc739e6fec9aa0cdcbd27b1", + "originHash" : "092d8eb5d636d09a0e45564cde60c0e0ff18e41fcf705fcd7fc5ff35eb4cb43a", "pins" : [ { "identity" : "associatedobject", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ukushu/Ifrit", "state" : { - "revision" : "9b9556e14cee24ad16b19d0eb099283cf79a7d94", - "version" : "3.0.0" + "revision" : "7c889a67bad90c5efefa56889b2d61bfbb831473", + "version" : "4.0.0" } }, { @@ -303,8 +303,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SnapKit/SnapKit", "state" : { - "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", - "version" : "5.7.1" + "revision" : "e27a338a03a5f388de759da63f9baf7988ed9e00", + "version" : "6.0.0" } }, { diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 6bea70ae..3813626e 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -188,7 +188,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/UIFoundation", - from: "0.8.2", + from: "0.10.0", traits: UIFoundationTraits, ), ), @@ -204,7 +204,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Library-Forks/XCoordinator", - from: "3.0.0-beta", + from: "3.0.0-beta.1", ), ), @@ -245,7 +245,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/RxSwiftPlus", - from: "0.2.2", + from: "0.2.3", ), ), @@ -275,7 +275,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/RxAppKit", - from: "0.3.0", + from: "0.4.0", ), ), @@ -305,7 +305,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/swift-helper-service", - from: "0.1.2", + from: "0.1.3", ), ), @@ -337,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( @@ -372,7 +372,7 @@ let package = Package( .package( url: "https://github.com/pointfreeco/swift-dependencies", - from: "1.9.4", + from: "1.12.0", ), .package( @@ -382,7 +382,7 @@ let package = Package( .package( url: "https://github.com/ukushu/Ifrit", - from: "3.0.0", + from: "4.0.0", ), .package( @@ -402,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", ), ], From 17312557bf05c1f8432af51272834ef986e83728 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 4 Jun 2026 00:03:15 +0800 Subject: [PATCH 38/68] fix(release): force-checkout when restoring appcast on detached HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xcodebuild -resolvePackageDependencies` during archive mutates the workspace's Package.resolved. On tag-triggered CI (which checks out the tag directly, so HEAD is detached), ArchiveScript copies the freshly generated docs/appcast.xml to a tmp file, then `git checkout -B` onto origin/main. The plain checkout aborts because git refuses to overwrite the dirty Package.resolved — that's how v2.1.0-beta.2's appcast PR silently failed (archive / notarize / GitHub Release all OK; appcast never reached docs/appcast.xml). Adds `-f` to the checkout. The appcast is already saved off-tree, and the archive-time Package.resolved drift is uninteresting once the release is published, so discarding it is safe. --- ArchiveScript.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From 97f3a26999ca95c76b490c0a2438b1a95b9d0b1c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 4 Jun 2026 18:33:59 +0800 Subject: [PATCH 39/68] chore(deps): drop unused remote pins from Package.resolved Workspace-driven local checkouts replace the remote MachOKit / MachO*Section pins at resolve time; persisting them only causes spurious diff churn. --- RuntimeViewerCore/Package.resolved | 60 +++--------- RuntimeViewerPackages/Package.resolved | 121 +------------------------ 2 files changed, 14 insertions(+), 167 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index d2f52716..f3b8b553 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "24d5c845392d39a37911618b5d945b6a1557209e64c41417f824a05df71c032e", + "originHash" : "de4fe8e81b6660cc9d70a2a197b55d233a119c573ce3ce7a40eb30e32e50bfbd", "pins" : [ { "identity" : "associatedobject", @@ -64,33 +64,6 @@ "version" : "0.3.0" } }, - { - "identity" : "machokit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", - "state" : { - "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", - "version" : "0.50.100" - } - }, - { - "identity" : "machoobjcsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", - "state" : { - "revision" : "ed06cc3708517c6fa6047ca9e17fee53d3f3bf3c", - "version" : "0.7.102" - } - }, - { - "identity" : "machoswiftsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - "state" : { - "revision" : "da7abcf91fc9a1208f53b7314aad288da3dafb14", - "version" : "0.12.0-beta.3" - } - }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -136,6 +109,15 @@ "version" : "0.1.0" } }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", + "version" : "2.9.1" + } + }, { "identity" : "swift-apinotes", "kind" : "remoteSourceControl", @@ -249,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", - "version" : "1.12.0" + "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", + "version" : "1.13.0" } }, { @@ -280,15 +262,6 @@ "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-literal-type-inference", "kind" : "remoteSourceControl", @@ -316,15 +289,6 @@ "version" : "0.5.0" } }, - { - "identity" : "swift-objc-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump", - "state" : { - "revision" : "641d257d60385b231a608ab8c4ecdf31348c779e", - "version" : "0.8.101" - } - }, { "identity" : "swift-object-association", "kind" : "remoteSourceControl", diff --git a/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index 34e28869..9aa612e2 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -28,15 +28,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", @@ -136,33 +127,6 @@ "version" : "0.3.0" } }, - { - "identity" : "machokit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", - "state" : { - "revision" : "d83be31cca95efe72e39f914d35f1c4da799777e", - "version" : "0.50.100" - } - }, - { - "identity" : "machoobjcsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", - "state" : { - "revision" : "ed06cc3708517c6fa6047ca9e17fee53d3f3bf3c", - "version" : "0.7.102" - } - }, - { - "identity" : "machoswiftsection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - "state" : { - "revision" : "da7abcf91fc9a1208f53b7314aad288da3dafb14", - "version" : "0.12.0-beta.3" - } - }, { "identity" : "metacodable", "kind" : "remoteSourceControl", @@ -181,15 +145,6 @@ "version" : "0.5.0" } }, - { - "identity" : "openuxkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenUXKit/OpenUXKit", - "state" : { - "branch" : "main", - "revision" : "21b944e638ff66d45ba1f550483c875be3c1f93f" - } - }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -208,24 +163,6 @@ "version" : "2.1.1" } }, - { - "identity" : "runningapplicationkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/RunningApplicationKit", - "state" : { - "revision" : "8c64a39bbcbe96b7758afd0e2e0a9f3d82f7305a", - "version" : "0.3.3" - } - }, - { - "identity" : "rxappkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/RxAppKit", - "state" : { - "revision" : "8194bca7c33b10f40d63894cf827890850390f04", - "version" : "0.4.0" - } - }, { "identity" : "rxcombine", "kind" : "remoteSourceControl", @@ -271,15 +208,6 @@ "version" : "0.2.3" } }, - { - "identity" : "rxuikit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/RxUIKit", - "state" : { - "revision" : "f4397315d83edf4f9390dbe96e212c892577c7eb", - "version" : "0.1.1" - } - }, { "identity" : "semaphore", "kind" : "remoteSourceControl", @@ -447,8 +375,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", - "version" : "1.12.0" + "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", + "version" : "1.13.0" } }, { @@ -478,15 +406,6 @@ "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", @@ -532,15 +451,6 @@ "version" : "2.8.100" } }, - { - "identity" : "swift-objc-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump", - "state" : { - "revision" : "641d257d60385b231a608ab8c4ecdf31348c779e", - "version" : "0.8.101" - } - }, { "identity" : "swift-object-association", "kind" : "remoteSourceControl", @@ -604,24 +514,6 @@ "version" : "0.1.0" } }, - { - "identity" : "uifoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mx-Iris/UIFoundation", - "state" : { - "revision" : "d821c32ff9efa9aa13506c12457ea0d430110a20", - "version" : "0.10.0" - } - }, - { - "identity" : "uxkitcoordinator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/OpenUXKit/UXKitCoordinator", - "state" : { - "branch" : "main", - "revision" : "481806584ed23911fbe0449fdda57085f8615779" - } - }, { "identity" : "version", "kind" : "remoteSourceControl", @@ -631,15 +523,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", From 453644c2d9569239ec116404c67f0b85dbaf3a41 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 4 Jun 2026 18:34:08 +0800 Subject: [PATCH 40/68] feat(indexing): add user-configured always-index image list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users pin images (full path or image name) that should always be indexed in the background regardless of which engine is loaded — covering frameworks outside the main executable's dependency closure that users frequently inspect. Unresolved entries are silently skipped; the list re-triggers on document open, engine swap, settings change, off-to-on toggle, and engine fullReload (so remote-engine imageList arriving asynchronously still resolves). --- .../RuntimeIndexingBatchReason.swift | 4 + ...untimeBackgroundIndexingManagerTests.swift | 39 ++++++ ...RuntimeBackgroundIndexingCoordinator.swift | 117 +++++++++++++++++- .../Settings+Types.swift | 11 ++ .../Components/IndexingSettingsView.swift | 49 ++++++++ ...kgroundIndexingPopoverViewController.swift | 2 + 6 files changed, 221 insertions(+), 1 deletion(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift index 5982fb78..f3dc549d 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift @@ -3,4 +3,8 @@ public enum RuntimeIndexingBatchReason: Sendable, Hashable { case imageLoaded(path: String) case settingsEnabled case manual + /// Triggered by an entry in `Settings.Indexing.alwaysIndexIdentifiers`. + /// `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) } diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index a221a090..0dcd90f4 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -329,6 +329,45 @@ import Testing await manager.cancelBatch(firstId) } + /// `.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/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index feefe78d..227741b4 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -60,7 +60,15 @@ public final class RuntimeBackgroundIndexingCoordinator { private var eventPumpTask: Task? private var imageLoadedPumpTask: Task? + /// 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. Manager dedup keeps already-running batches unique. + private var reloadDataPumpTask: Task? private var lastKnownIsEnabled: Bool = false + private var lastKnownAlwaysIndexIdentifiers: [String] = [] public init(documentState: DocumentState) { self.documentState = documentState @@ -68,6 +76,7 @@ public final class RuntimeBackgroundIndexingCoordinator { startEventPump() #if canImport(RuntimeViewerSettings) startImageLoadedPump() + startReloadDataPump() bootstrapSettingsObservation() #endif bootstrapEngineObservation() @@ -76,6 +85,7 @@ public final class RuntimeBackgroundIndexingCoordinator { deinit { eventPumpTask?.cancel() imageLoadedPumpTask?.cancel() + reloadDataPumpTask?.cancel() } // MARK: - Public observables for UI @@ -250,8 +260,10 @@ public final class RuntimeBackgroundIndexingCoordinator { // 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 @@ -284,8 +296,10 @@ public final class RuntimeBackgroundIndexingCoordinator { startEventPump() #if canImport(RuntimeViewerSettings) startImageLoadedPump() + startReloadDataPump() // 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 } @@ -295,6 +309,7 @@ public final class RuntimeBackgroundIndexingCoordinator { extension RuntimeBackgroundIndexingCoordinator { public func documentDidOpen() { startMainExecutableBatch(reason: .appLaunch) + startAlwaysIndexBatches() } /// Shared logic for "index the main executable" batches. Both the document @@ -379,13 +394,93 @@ extension RuntimeBackgroundIndexingCoordinator { self.staging.insertDocumentBatchID(id) } + // MARK: - Always-index list + + /// Reads `Settings.Indexing.alwaysIndexIdentifiers` and starts one batch + /// per resolvable identifier. Identifiers that don't resolve to a path + /// in the engine's `imageList` are silently skipped — they're recorded + /// in `lastKnownAlwaysIndexIdentifiers` as still-pending so the next + /// fullReload retry can pick them up. + /// + /// 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). + private func startAlwaysIndexBatches() { + let identifiers = currentAlwaysIndexIdentifiers() + guard !identifiers.isEmpty else { return } + Task { [weak self, engine] in + guard let self else { return } + let settings = self.currentBackgroundIndexingSettings() + guard settings.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. + let imageList = await engine.imageList + for identifier in identifiers { + guard let resolvedPath = resolveAlwaysIndexIdentifier(identifier, in: imageList) else { continue } + let id = await engine.backgroundIndexingManager.startBatch( + rootImagePath: resolvedPath, + depth: settings.depth, + maxConcurrency: settings.maxConcurrency, + reason: .alwaysIndex(identifier: identifier)) + 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 } + } + } + + /// 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 } + self.startAlwaysIndexBatches() + } + } + } + } + private func currentBackgroundIndexingSettings() -> Settings.Indexing.BackgroundMode { @Dependency(\.settings) var settings return settings.indexing.backgroundMode } + private func currentAlwaysIndexIdentifiers() -> [String] { + @Dependency(\.settings) var settings + return settings.indexing.alwaysIndexIdentifiers + } + private func bootstrapSettingsObservation() { self.lastKnownIsEnabled = currentBackgroundIndexingSettings().isEnabled + self.lastKnownAlwaysIndexIdentifiers = currentAlwaysIndexIdentifiers() self.subscribeToSettings() } @@ -395,6 +490,9 @@ extension RuntimeBackgroundIndexingCoordinator { _ = snapshot.isEnabled _ = snapshot.depth _ = snapshot.maxConcurrency + // Track always-index identifiers too so the observation re-fires + // when the user adds / edits / removes a row in Settings UI. + _ = currentAlwaysIndexIdentifiers() } 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. @@ -413,13 +511,30 @@ extension RuntimeBackgroundIndexingCoordinator { 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". + // the misleading "App launch indexing". Also re-trigger the + // always-index list since this is effectively a fresh start. startMainExecutableBatch(reason: .settingsEnabled) + startAlwaysIndexBatches() } else if wasEnabled && !latest.isEnabled { Task { [engine] in await engine.backgroundIndexingManager.cancelAllBatches() } } + + // Identifier list changes: trigger always-index when content actually + // changed and the feature is enabled. Adding / editing entries kicks + // off batches for the new content; removing entries is silent — + // already-running batches keep running unless the user cancels them + // from the popover. + let latestIdentifiers = currentAlwaysIndexIdentifiers() + let identifiersChanged = latestIdentifiers != lastKnownAlwaysIndexIdentifiers + lastKnownAlwaysIndexIdentifiers = latestIdentifiers + // Skip when 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 identifiersChanged, latest.isEnabled, wasEnabled { + startAlwaysIndexBatches() + } // depth / maxConcurrency changes: intentional no-op; next startBatch picks // up the new values. } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift index 224b6230..6b5782e9 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -94,6 +94,17 @@ extension Settings { @Default(BackgroundMode.default) public var backgroundMode: BackgroundMode + /// User-configured "always-index" list. Each entry 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). + /// + /// Lives at `Indexing` scope rather than inside `BackgroundMode` so + /// users can edit it even when background indexing is disabled. + @Default([]) + public var alwaysIndexIdentifiers: [String] + public static let `default` = Self() } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift index 0192406a..69f9080b 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift @@ -29,6 +29,55 @@ struct IndexingSettingsView: View { } 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.") } + + AlwaysIndexSection(identifiers: $indexing.alwaysIndexIdentifiers) + } + } +} + +/// Editor section for `Settings.Indexing.alwaysIndexIdentifiers`. Renders each +/// entry as an editable row with a 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 identifiers: [String] + + var body: some View { + Section { + ForEach(identifiers.indices, id: \.self) { index in + HStack { + TextField( + "imagePath or imageName", + text: Binding( + get: { identifiers.indices.contains(index) ? identifiers[index] : "" }, + set: { newValue in + guard identifiers.indices.contains(index) else { return } + identifiers[index] = newValue + } + ) + ) + .labelsHidden() + .textFieldStyle(.roundedBorder) + Button { + guard identifiers.indices.contains(index) else { return } + identifiers.remove(at: index) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + } + + Button { + identifiers.append("") + } label: { + Label("Add Image", systemImage: "plus.circle") + } + } header: { + Text("Always Index") + } footer: { + Text("These images 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.") } } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift index 6d16ea32..79a0ffc0 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -436,6 +436,8 @@ extension BackgroundIndexingPopoverViewController { return "Settings enabled" case .manual: return "Manual indexing" + case .alwaysIndex(let identifier): + return "Always: \(identifier)" } } } From 6849d7893e520a66c07cbbb5b35c5511ccca56a3 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 4 Jun 2026 18:34:14 +0800 Subject: [PATCH 41/68] refactor(attach-process): reuse a single AttachToProcessViewController instance Each invocation rebuilt the picker tab controller and lost the user's last-selected tab / running-row. Promote the controller to a shared instance so transient presentations keep their state across reopens, and clarify the two tabs' titles (Application vs Process). --- .../AttachToProcessViewController.swift | 22 ++++++++++++++++--- .../Main/MainCoordinator.swift | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift index 0b6473c4..0f3425fc 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift @@ -5,6 +5,9 @@ import RuntimeViewerApplication import RuntimeViewerArchitectures final class AttachToProcessViewController: UXKitViewController { + + static let shared = AttachToProcessViewController() + private let pickerViewController: RunningPickerTabViewController private let attachRelay = PublishRelay() @@ -12,9 +15,22 @@ final class AttachToProcessViewController: UXKitViewController() 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) } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift index 39ae1137..53dadc9c 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainCoordinator.swift @@ -84,7 +84,7 @@ final class MainCoordinator: SceneCoordinator, 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) From 28fd4c4ffeb2f738559f101ffc4f397ba81b61c8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 4 Jun 2026 18:34:21 +0800 Subject: [PATCH 42/68] fix(sidebar): debounce type-select navigation to skip intermediate loads Type-to-select fires a selection event on every keystroke, so each intermediate row was loading its (potentially expensive) interface before the user finished typing. Route click and arrow-key selections through the original immediate signal, but route the type-select path through an 800ms debounce so only the landing row loads. Provides the outline view a typeSelectStringFor delegate so type-select matches the displayed title rather than the model's stringValue. --- .../SidebarRuntimeObjectViewController.swift | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift index 73febb85..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(), @@ -201,9 +224,16 @@ class SidebarRuntimeObjectViewController String? { + guard let cellViewModel = item as? SidebarRuntimeObjectCellViewModel else { return nil } + return cellViewModel.title.string + } } extension SidebarRuntimeObjectViewController { From 0fdca62a11418c56509f50235c15acc1ee1ad73c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 4 Jun 2026 22:22:32 +0800 Subject: [PATCH 43/68] fix(communication): revert @Mutex macro back to manual Mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In release builds the SwiftStdlibToolbox @Mutex macro's _modify coroutine accessor still pins coroutine context on the caller frame across iterations of processReceivedData's hot loop. Even after the beta.3 fix (snapshot to locals + AsyncStream bridge in RuntimeNetworkConnection), v2.1.0-beta.3's release binary continued to overflow the dispatch queue worker stack on a Bonjour batched receive — the IPS crash report showed two RuntimeViewer offsets (0xe88a0 / 0x6c33c) recursing tightly inside AsyncStream.yield. Replace every @Mutex property in RuntimeMessageChannel with the hand-rolled Mutex + withLock pattern that shipped in v2.1.0-beta.1 (which never crashed in this path). withLock is a regular synchronous function, so there is no coroutine context to pin and no per-iteration frame accumulation. The hot loop snapshots both continuation and callback into locals once before iterating; subsequent reads touch plain locals only. processReceivedData also drops the dead 'hasContinuation' debug-log variable that was inlined into the same expression via the snapshot. --- .../RuntimeMessageChannel.swift | 61 +++++++------------ 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift index 1ae2c140..d6704d3e 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,7 +176,7 @@ 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. @@ -192,7 +186,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { /// - 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 { + guard let pending = pendingRequests.withLock({ $0.removeValue(forKey: identifier) }) else { return false } #log(.debug, "Delivered response to pending request: \(identifier, privacy: .public)") @@ -205,7 +199,7 @@ 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() } @@ -214,7 +208,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { 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 @@ -233,18 +227,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { remainingBufferSize = buffer.count } - // Snapshot both `@Mutex`-backed values into locals BEFORE the hot loop. - // - // Each `@Mutex`-generated property exposes a `_modify` coroutine - // accessor. Inside a hot for-loop on a dispatch-queue worker thread, - // every `receivedDataContinuation?.yield(...)` / `onMessageReceived?(...)` - // call enters that coroutine, and the per-iteration coroutine frames - // accumulate on the caller's stack instead of fully unwinding — - // a burst of ~50 small frames overflows the worker stack with - // 13_000+ frames. Reading once through the plain `get` accessor (which - // is `withLock { $0 }` and returns the value by copy) sidesteps the - // `_modify` path entirely; the for-loop then only touches local vars. - let continuation = receivedDataContinuation + let continuation = receivedDataContinuation.withLock { $0 } let callback = onMessageReceived let hasContinuation = continuation != nil @@ -270,7 +253,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 { @@ -281,7 +264,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 @@ -298,7 +281,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 @@ -344,7 +327,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { // 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[requestData.identifier] = 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 @@ -355,7 +338,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { let timeoutTask = Task { 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) }) { + if let pending = self.pendingRequests.withLock({ $0.removeValue(forKey: identifier) }) { #log(.error, "Request \(identifier, privacy: .public) timed out after \(timeout, privacy: .public)s") pending.continuation.resume(throwing: RuntimeMessageChannelError.requestTimeout) } @@ -368,7 +351,7 @@ final class RuntimeMessageChannel: Sendable, RuntimeMessageProtocol { try await writer(dataToSend) } catch { // Remove pending request and resume with error - if let pending = self._pendingRequests.withLock({ $0.removeValue(forKey: requestData.identifier) }) { + 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)") pending.cancelTimeoutTask() pending.continuation.resume(throwing: error) From 8d8e5edecf104b68aaea0b6238aecfe5cd0437c3 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 4 Jun 2026 22:23:07 +0800 Subject: [PATCH 44/68] chore(release): prepare v2.1.0-beta.4 - Add Changelogs/v2.1.0-beta.4.md. Re-roll of the Bonjour crash fix from beta.3. No code changes outside the @Mutex revert in the previous commit; dependency pins are unchanged. --- Changelogs/v2.1.0-beta.4.md | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 Changelogs/v2.1.0-beta.4.md diff --git a/Changelogs/v2.1.0-beta.4.md b/Changelogs/v2.1.0-beta.4.md new file mode 100644 index 00000000..f36c5df7 --- /dev/null +++ b/Changelogs/v2.1.0-beta.4.md @@ -0,0 +1,59 @@ +# 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. + +--- + +## 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. From f7e5fdf083d0e86c1db265850afd2bad83d4ae32 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 5 Jun 2026 01:03:36 +0800 Subject: [PATCH 45/68] feat: Always indexing --- .../RuntimeIndexingBatchReason.swift | 2 +- ...RuntimeBackgroundIndexingCoordinator.swift | 117 +++++++++++++----- .../Settings+Types.swift | 35 ++++-- .../RuntimeViewerSettings/Settings.swift | 2 +- .../Components/IndexingSettingsView.swift | 42 ++++--- 5 files changed, 145 insertions(+), 53 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift index f3dc549d..372123e5 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift @@ -3,7 +3,7 @@ public enum RuntimeIndexingBatchReason: Sendable, Hashable { case imageLoaded(path: String) case settingsEnabled case manual - /// Triggered by an entry in `Settings.Indexing.alwaysIndexIdentifiers`. + /// Triggered by an entry in `Settings.Indexing.alwaysIndexEntries`. /// `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) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 227741b4..4e96c1b4 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift @@ -65,10 +65,21 @@ public final class RuntimeBackgroundIndexingCoordinator { /// 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. Manager dedup keeps already-running batches unique. + /// 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? private var lastKnownIsEnabled: Bool = false - private var lastKnownAlwaysIndexIdentifiers: [String] = [] + #if canImport(RuntimeViewerSettings) + private var lastKnownAlwaysIndexEntries: [Settings.Indexing.AlwaysIndexEntry] = [] + /// Identifiers from `alwaysIndexEntries` 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, + /// off→on toggle, and entry-list change so genuinely new work re-runs. + private var dispatchedAlwaysIndexIdentifiers: Set = [] + #endif public init(documentState: DocumentState) { self.documentState = documentState @@ -297,6 +308,10 @@ public final class RuntimeBackgroundIndexingCoordinator { #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. @@ -396,19 +411,35 @@ extension RuntimeBackgroundIndexingCoordinator { // MARK: - Always-index list - /// Reads `Settings.Indexing.alwaysIndexIdentifiers` and starts one batch - /// per resolvable identifier. Identifiers that don't resolve to a path - /// in the engine's `imageList` are silently skipped — they're recorded - /// in `lastKnownAlwaysIndexIdentifiers` as still-pending so the next + /// Reads `Settings.Indexing.alwaysIndexEntries` 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 + /// `lastKnownAlwaysIndexEntries` 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 global `BackgroundMode.depth` is used + /// and 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). private func startAlwaysIndexBatches() { - let identifiers = currentAlwaysIndexIdentifiers() - guard !identifiers.isEmpty else { return } + let entries = currentAlwaysIndexEntries() + 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 } let settings = self.currentBackgroundIndexingSettings() @@ -417,17 +448,21 @@ extension RuntimeBackgroundIndexingCoordinator { // 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. + // `[]` — `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 identifier in identifiers { - guard let resolvedPath = resolveAlwaysIndexIdentifier(identifier, in: imageList) else { continue } + for entry in pendingEntries { + guard let resolvedPath = resolveAlwaysIndexIdentifier(entry.identifier, in: imageList) else { continue } + let effectiveDepth = entry.followDependencies ? settings.depth : 0 let id = await engine.backgroundIndexingManager.startBatch( rootImagePath: resolvedPath, - depth: settings.depth, + depth: effectiveDepth, maxConcurrency: settings.maxConcurrency, - reason: .alwaysIndex(identifier: identifier)) + reason: .alwaysIndex(identifier: entry.identifier)) guard self.engine === engine else { return } self.staging.insertDocumentBatchID(id) + self.dispatchedAlwaysIndexIdentifiers.insert(entry.identifier) } } } @@ -473,14 +508,14 @@ extension RuntimeBackgroundIndexingCoordinator { return settings.indexing.backgroundMode } - private func currentAlwaysIndexIdentifiers() -> [String] { + private func currentAlwaysIndexEntries() -> [Settings.Indexing.AlwaysIndexEntry] { @Dependency(\.settings) var settings - return settings.indexing.alwaysIndexIdentifiers + return settings.indexing.alwaysIndexEntries } private func bootstrapSettingsObservation() { self.lastKnownIsEnabled = currentBackgroundIndexingSettings().isEnabled - self.lastKnownAlwaysIndexIdentifiers = currentAlwaysIndexIdentifiers() + self.lastKnownAlwaysIndexEntries = currentAlwaysIndexEntries() self.subscribeToSettings() } @@ -490,9 +525,10 @@ extension RuntimeBackgroundIndexingCoordinator { _ = snapshot.isEnabled _ = snapshot.depth _ = snapshot.maxConcurrency - // Track always-index identifiers too so the observation re-fires - // when the user adds / edits / removes a row in Settings UI. - _ = currentAlwaysIndexIdentifiers() + // Track always-index 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. + _ = currentAlwaysIndexEntries() } 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. @@ -512,7 +548,9 @@ extension RuntimeBackgroundIndexingCoordinator { // Scenario E: off→on. Use `.settingsEnabled` so the popover's // title-by-reason mapping shows "Settings enabled" instead of // the misleading "App launch indexing". Also re-trigger the - // always-index list since this is effectively a fresh start. + // always-index list since this is effectively a fresh start — + // clear the dispatched gate first so every entry runs again. + dispatchedAlwaysIndexIdentifiers.removeAll() startMainExecutableBatch(reason: .settingsEnabled) startAlwaysIndexBatches() } else if wasEnabled && !latest.isEnabled { @@ -521,18 +559,39 @@ extension RuntimeBackgroundIndexingCoordinator { } } - // Identifier list changes: trigger always-index when content actually - // changed and the feature is enabled. Adding / editing entries kicks - // off batches for the new content; removing entries is silent — - // already-running batches keep running unless the user cancels them - // from the popover. - let latestIdentifiers = currentAlwaysIndexIdentifiers() - let identifiersChanged = latestIdentifiers != lastKnownAlwaysIndexIdentifiers - lastKnownAlwaysIndexIdentifiers = latestIdentifiers + // Entry list changes: trigger always-index when content actually + // changed and the feature is 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 = lastKnownAlwaysIndexEntries + let latestEntries = currentAlwaysIndexEntries() + let entriesChanged = latestEntries != previousEntries + lastKnownAlwaysIndexEntries = latestEntries // Skip when 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 identifiersChanged, latest.isEnabled, wasEnabled { + if entriesChanged, latest.isEnabled, wasEnabled { + // 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 diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift index 6b5782e9..cdc5054a 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -94,16 +94,35 @@ extension Settings { @Default(BackgroundMode.default) public var backgroundMode: BackgroundMode - /// User-configured "always-index" list. Each entry 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). + /// 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). /// - /// Lives at `Indexing` scope rather than inside `BackgroundMode` so - /// users can edit it even when background indexing is disabled. + /// `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. + @Codable + @MemberInit + public struct AlwaysIndexEntry: Equatable { + @Default("") + public var identifier: String + + @Default(false) + public var followDependencies: Bool + + public static let `default` = Self() + } + + /// User-configured "always-index" list. Lives at `Indexing` scope + /// rather than inside `BackgroundMode` so users can edit it even + /// when background indexing is disabled. @Default([]) - public var alwaysIndexIdentifiers: [String] + public var alwaysIndexEntries: [AlwaysIndexEntry] 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 69f9080b..729ed9be 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift @@ -30,37 +30,51 @@ struct IndexingSettingsView: View { 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.") } - AlwaysIndexSection(identifiers: $indexing.alwaysIndexIdentifiers) + AlwaysIndexSection(entries: $indexing.alwaysIndexEntries) } } } -/// Editor section for `Settings.Indexing.alwaysIndexIdentifiers`. Renders each -/// entry as an editable row with a 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. +/// Editor section for `Settings.Indexing.alwaysIndexEntries`. Renders each +/// entry as an editable row with an identifier field, a "Follow Dependencies" +/// toggle, and a 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 identifiers: [String] + @Binding var entries: [RuntimeViewerSettings.Settings.Indexing.AlwaysIndexEntry] var body: some View { Section { - ForEach(identifiers.indices, id: \.self) { index in + ForEach(entries.indices, id: \.self) { index in HStack { TextField( "imagePath or imageName", text: Binding( - get: { identifiers.indices.contains(index) ? identifiers[index] : "" }, + get: { entries.indices.contains(index) ? entries[index].identifier : "" }, set: { newValue in - guard identifiers.indices.contains(index) else { return } - identifiers[index] = newValue + 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 identifiers.indices.contains(index) else { return } - identifiers.remove(at: index) + guard entries.indices.contains(index) else { return } + entries.remove(at: index) } label: { Image(systemName: "minus.circle.fill") .foregroundStyle(.secondary) @@ -70,14 +84,14 @@ private struct AlwaysIndexSection: View { } Button { - identifiers.append("") + entries.append(.default) } label: { Label("Add Image", systemImage: "plus.circle") } } header: { Text("Always Index") } footer: { - Text("These images 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.") + Text("These images 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 global depth above; otherwise only the image itself is indexed.") } } } From aa8ba436da4dbff1dbec009e90d2149c1dbbd25c Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 5 Jun 2026 01:40:24 +0800 Subject: [PATCH 46/68] revert(communication): roll back bec9f4b stack-overflow guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0fdca62 already eliminated the @Mutex `_modify` coroutine accessor that bec9f4b's `processReceivedData` snapshot and `RuntimeNetworkConnection` AsyncStream consumer were guarding against. With the real trigger gone the guards are dead weight, and the AsyncStream consumer's strictly serial drain was incidentally compounding latency when a peer echoed handler errors at high rate. The v2.1.0-beta.4 sim-side image-loading regression turned out not to be a channel-layer issue — it was a background-indexing handler echoing `DyldOpenError` for the peer's own main executable, triggering a peer-error ping-pong on `sendSemaphore`. That requires a separate fix on the indexing handler; see v2.1.0-beta.4 changelog known-issue note. --- .../RuntimeNetworkConnection.swift | 46 +------------------ .../RuntimeMessageChannel.swift | 9 ++-- 2 files changed, 5 insertions(+), 50 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift index bec07236..8c010fc2 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift @@ -66,18 +66,6 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se private let connection: NWConnection private let messageChannel = RuntimeMessageChannel() - /// Buffered hand-off between `RuntimeMessageChannel.onMessageReceived` (which - /// fires synchronously on the connection's dispatch queue, inside the hot - /// `processReceivedData` loop) and the long-lived consumer task. The - /// callback only enqueues into this stream — it never creates a per-message - /// Task — so a batched receive carrying many small frames (heartbeats, - /// acks, batch-export chunks) doesn't accumulate `swift_task_create` stack - /// frames on the dispatch-queue thread, which previously overflowed the - /// worker-thread stack (commit f2b6324). - private let incomingMessageStream: AsyncStream - private let incomingMessageContinuation: AsyncStream.Continuation - private var incomingMessageTask: Task? - private var isStarted = false private var waitingTimeoutWork: DispatchWorkItem? private let queue = DispatchQueue(label: "com.RuntimeViewer.RuntimeViewerCommunication.RuntimeNetworkConnection") @@ -101,10 +89,6 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se parameters.includePeerToPeer = true parameters.serviceClass = .responsiveData - let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .unbounded) - self.incomingMessageStream = stream - self.incomingMessageContinuation = continuation - self.connection = NWConnection(to: endpoint, using: parameters) try start() } @@ -114,11 +98,6 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se /// - Parameter connection: The accepted connection from NWListener. init(connection: NWConnection) throws { #log(.info, "Creating incoming connection: \(connection.debugDescription, privacy: .public)") - - let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .unbounded) - self.incomingMessageStream = stream - self.incomingMessageContinuation = continuation - self.connection = connection try start() } @@ -145,8 +124,6 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se waitingTimeoutWork = nil connection.stateUpdateHandler = nil connection.cancel() - incomingMessageContinuation.finish() - incomingMessageTask = nil messageChannel.finishReceiving() stateSubject.send(.disconnected(error: nil)) @@ -161,8 +138,6 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se waitingTimeoutWork = nil connection.stateUpdateHandler = nil connection.cancel() - incomingMessageContinuation.finish() - incomingMessageTask = nil messageChannel.finishReceiving() stateSubject.send(.disconnected(error: error)) @@ -235,26 +210,9 @@ final class RuntimeNetworkConnection: RuntimeUnderlyingConnection, @unchecked Se } private func observeIncomingMessages() { - // The message-channel callback fires inside `processReceivedData`'s - // synchronous for-loop on the connection's dispatch queue. Keep the - // callback work to a single non-blocking `yield`; never spawn a Task - // here — Swift Concurrency's task-inlining on the dispatch-queue - // thread would otherwise accumulate ~10 stack frames per message and - // overflow on a burst of small frames. - messageChannel.onMessageReceived = { [continuation = incomingMessageContinuation] data in - continuation.yield(data) - } - - // One long-lived consumer drains the buffered stream serially. The - // `AsyncStream` buffers everything yielded before this task actually - // reaches the `for await`, so we don't lose any early messages — that - // was the race the original f2b6324 commit tried to fix when it - // (wrongly) swapped the for-await loop for per-message Tasks. - incomingMessageTask = Task { [weak self] in + messageChannel.onMessageReceived = { [weak self] data in guard let self else { return } - for await data in self.incomingMessageStream { - await self.handleReceivedMessage(data) - } + Task { await self.handleReceivedMessage(data) } } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift index d6704d3e..e3929b2e 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift @@ -227,17 +227,14 @@ final class RuntimeMessageChannel: @unchecked Sendable, RuntimeMessageProtocol { remainingBufferSize = buffer.count } - let continuation = receivedDataContinuation.withLock { $0 } - let callback = onMessageReceived - let hasContinuation = continuation != nil - + 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))") - continuation?.yield(messageData) - callback?(messageData) + receivedDataContinuation.withLock { $0?.yield(messageData) } + onMessageReceived?(messageData) } } From e3077fd8f7c51d8527f3e5a62f655ccfec871c0b Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 5 Jun 2026 01:41:12 +0800 Subject: [PATCH 47/68] docs(changelog): note beta.4 known-issue on remote-Server background indexing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the remote engine serving as the Server side has background indexing on, the indexer's `LoadImageForBackgroundIndexingRequest` handler raises `DyldOpenError` on the peer's own main exec, the error response carries no `identifier`, the peer envelope-decodes it as `keyNotFound`, and both sides ping-pong on the shared `sendSemaphore` — image-list pushes and progress events starve out. Workaround: disable background indexing on the remote-Server side. Two-part fix targeted at the next build. --- Changelogs/v2.1.0-beta.4.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Changelogs/v2.1.0-beta.4.md b/Changelogs/v2.1.0-beta.4.md index f36c5df7..9ba1c9c9 100644 --- a/Changelogs/v2.1.0-beta.4.md +++ b/Changelogs/v2.1.0-beta.4.md @@ -42,6 +42,37 @@ a non-blocking `yield` and the long-lived consumer task drains --- +## 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: From 1d9becf49ca42db0ffb2b877f41e9123a8f2d526 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 5 Jun 2026 17:48:38 +0800 Subject: [PATCH 48/68] fix(communication): per-round-trip nonce + swallow decode failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old sendRequest held 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 send-in-flight at the local outbox. Stamp a UUID nonce per round trip, key pendingRequests by nonce, and release the semaphore as soon as the write returns; concurrent sendRequests now wait on responses in parallel and identifier-keyed collisions on the routing table are impossible. Adjacent fix: the receive catch arm was echoing a bare RuntimeNetworkRequestError carrying no identifier when envelope decode failed, which caused the peer to also fail decode and echo back — both sides ping-ponging on the same semaphore and starving every other request. Split into two arms: decode failures swallow silently; handler failures echo via the same nonce-keyed envelope used by successful responses, so the peer's sendRequest routes the error back to its matching pending entry instead of triggering a re-echo loop. Wire format is backward compatible: nonce is optional, missing values fall back to identifier-keyed routing. See Changelogs/v2.1.0-beta.4.md "Remote-Server background indexing stalls image loading" for the failure mode this addresses. --- .../RuntimeDirectTCPConnection.swift | 77 ++++++++++++------ .../RuntimeLocalSocketConnection.swift | 77 ++++++++++++------ .../RuntimeNetworkConnection.swift | 64 +++++++++++---- .../Connections/RuntimeStdioConnection.swift | 79 ++++++++++++------- .../RuntimeMessageChannel.swift | 76 ++++++++++++------ .../RuntimeNetwork.swift | 21 ++++- .../RuntimeMessageChannelTests.swift | 43 ++++++---- 7 files changed, 300 insertions(+), 137 deletions(-) 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/RuntimeMessageChannel.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift index e3929b2e..8ac8d56e 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift @@ -179,17 +179,23 @@ final class RuntimeMessageChannel: @unchecked Sendable, RuntimeMessageProtocol { 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 @@ -298,8 +304,20 @@ final class RuntimeMessageChannel: @unchecked 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 @@ -311,45 +329,57 @@ final class RuntimeMessageChannel: @unchecked 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) } @@ -357,7 +387,7 @@ final class RuntimeMessageChannel: @unchecked 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/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCommunicationTests/RuntimeMessageChannelTests.swift index e819bb8a..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 From b276703b85c371177ffb13e68bd54ade1de9ef0e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 5 Jun 2026 17:48:51 +0800 Subject: [PATCH 49/68] fix(indexing): unblock remote-engine background indexing Two changes that together let background indexing work on non-local (XPC / Bonjour / iOS Simulator) engines. 1. _loadImageForBackgroundIndexing now skips dlopen when imageList already contains the canonical path. 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, resolving to (no such file) on disk. The previous-commit transport hardening makes the spurious DyldOpenError no longer fatal, but guarding at source is cheaper. 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 stuck at the root and batches only ever indexed the main executable instead of the full dependency closure. Wire-format addition: RuntimeDependencyEntry struct replaces the tuple-typed dependencies return value at the dispatch seam (tuples aren't Codable). The manager-facing API still uses tuples and needs no changes. --- .../RuntimeEngine+BackgroundIndexing.swift | 125 ++++++++++++++++-- .../RuntimeViewerCore/RuntimeEngine.swift | 3 + .../RuntimeEngineRequest.swift | 3 + 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index 4f08f453..f4dec726 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -39,9 +39,26 @@ extension RuntimeEngine { /// 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) - try DyldUtilities.loadImage(at: canonical) + if !imageList.contains(canonical) { + try DyldUtilities.loadImage(at: canonical) + } _ = try await objcSectionFactory.section(for: canonical) _ = try await swiftSectionFactory.section(for: canonical) loadedImagePaths.insert(canonical) @@ -51,21 +68,64 @@ extension RuntimeEngine { // 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. + (try? await 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 [] @@ -122,7 +182,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.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index cd61cf76..99b84786 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -56,6 +56,9 @@ public actor RuntimeEngine { case isImageIndexed case mainExecutablePath case loadImageForBackgroundIndexing + case canOpenImage + case rpathsForImage + case dependenciesForImage case patchImagePathForDyld case runtimeObjectHierarchy case runtimeRelationshipsForObject diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift index 0b24ae48..a4bae28f 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift @@ -48,6 +48,9 @@ extension RuntimeEngine { 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(ObjectsInImageRequest.self, on: connection, engine: engine) register(InterfaceRequest.self, on: connection, engine: engine) From 786df06757adef37d026c60a7b218d511da23bbe Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 6 Jun 2026 21:37:46 +0800 Subject: [PATCH 50/68] feat(indexing): split Background Indexing into master + heuristic + custom modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single `BackgroundMode` toggle with a three-level switch: - `isEnabled` (master) — overall on/off; when off, neither sub-mode runs. - `heuristic.isEnabled` — main-executable BFS at document open / engine swap. - `custom.isEnabled` — always-index list (entries moved into `custom.entries`). `maxConcurrency` is now shared between sub-modes; `depth` lives under `heuristic`. The coordinator reconciles each toggle independently in `handleSettingsChange` and uses the new `cancelBatches(matching:)` Manager API to scope cancellation to one sub-mode without disturbing the other. Drop the dyld add-image pump entirely — heuristic discovery now relies solely on document-open / fullReload triggers, matching the new sub-mode's documented semantics ("images dlopen'd after the initial sweep are not auto-indexed"). Popover groups batches by `RuntimeIndexingBatchReason.Category` so HEURISTIC / ALWAYS INDEX / MANUAL each get their own collapsible header. Always-index groups flatten one level (entry → item) so single-image entries render as a flat list instead of an empty intermediate batch row. --- .../RuntimeBackgroundIndexingManager.swift | 28 +- .../RuntimeIndexingBatchReason.swift | 37 ++- ...untimeBackgroundIndexingManagerTests.swift | 69 ++++- ...RuntimeBackgroundIndexingCoordinator.swift | 284 ++++++++++-------- .../Settings+Types.swift | 62 +++- .../Components/IndexingSettingsView.swift | 52 +++- .../BackgroundIndexingNode.swift | 20 +- ...kgroundIndexingPopoverViewController.swift | 87 +++++- .../BackgroundIndexingPopoverViewModel.swift | 64 +++- 9 files changed, 516 insertions(+), 187 deletions(-) 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 372123e5..5bf02724 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/BackgroundIndexing/RuntimeIndexingBatchReason.swift @@ -1,10 +1,43 @@ public enum RuntimeIndexingBatchReason: Sendable, Hashable { case appLaunch - case imageLoaded(path: String) case settingsEnabled case manual - /// Triggered by an entry in `Settings.Indexing.alwaysIndexEntries`. + /// 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/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift index 0dcd90f4..c79aa4c1 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/BackgroundIndexing/RuntimeBackgroundIndexingManagerTests.swift @@ -300,11 +300,12 @@ import Testing // No crash; batch still completes. No .taskPrioritized emitted. } - /// Real-world double-batch: `documentDidOpen` dispatches `.appLaunch` on the - /// main executable; concurrently `imageDidLoadPublisher` fires for the same - /// path and `handleImageLoaded` dispatches `.imageLoaded`. Without dedup - /// these become two parallel batches indexing the same dependency graph. - /// The manager must collapse them to a single batch (same id returned). + /// Real-world double-batch: `documentDidOpen` dispatches `.appLaunch` on + /// the main executable; concurrently the user flips the master switch off + /// and back on while the first batch is still pending, dispatching a + /// second batch with `.settingsEnabled`. Without dedup these become two + /// parallel batches indexing the same dependency graph. The manager must + /// collapse them to a single batch (same id returned). @Test func startBatchDedupsByRootImagePathAcrossDifferentReasons() async { let engine = keep(MockBackgroundIndexingEngine()) // depth 0 with `isIndexed: false` → batch contains one pending item @@ -318,7 +319,7 @@ import Testing maxConcurrency: 1, reason: .appLaunch) let secondId = await manager.startBatch( rootImagePath: "/App", depth: 0, - maxConcurrency: 1, reason: .imageLoaded(path: "/App")) + maxConcurrency: 1, reason: .settingsEnabled) #expect( firstId == secondId, @@ -329,6 +330,62 @@ import Testing await manager.cancelBatch(firstId) } + /// `cancelBatches(matching:)` cancels only the batches matching the + /// supplied predicate, leaving others running. Used by the Coordinator to + /// scope cancellation to a single sub-mode (Heuristic / Custom) when the + /// user disables that sub-mode while batches are in flight. + @Test func cancelBatchesMatchingScopesCancellationByPredicate() async { + let engine = keep(MockBackgroundIndexingEngine()) + // The mock's default 5ms sleep inside loadImageForBackgroundIndexing + // is enough headroom for cancelBatches(matching:) to reach both + // batches before they finalize, since actor calls serialize and + // cancellation runs before any in-flight load wakes up. + engine.program(path: "/App", .init()) + engine.program(path: "/System/Library/Frameworks/Foundation.framework/Foundation", + .init()) + let manager = RuntimeBackgroundIndexingManager(engine: engine) + + let events = manager.events + let heuristicId = await manager.startBatch( + rootImagePath: "/App", depth: 0, + maxConcurrency: 1, reason: .appLaunch) + let customId = await manager.startBatch( + rootImagePath: "/System/Library/Frameworks/Foundation.framework/Foundation", + depth: 0, maxConcurrency: 1, + reason: .alwaysIndex(identifier: "Foundation")) + + await manager.cancelBatches(matching: { $0.reason.isHeuristic }) + + // Drain terminal events for both ids — the heuristic must report + // `batchCancelled`, the custom must report `batchFinished` (it runs + // to completion because cancelBatches only matched the heuristic + // reason). + var heuristicCancelled = false + var customFinishedNaturally = false + let observer = Task { () -> (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 diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/BackgroundIndexing/RuntimeBackgroundIndexingCoordinator.swift index 4e96c1b4..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,7 +59,6 @@ public final class RuntimeBackgroundIndexingCoordinator { private static let coalesceWindowNanos: UInt64 = 16_000_000 private var eventPumpTask: Task? - private var imageLoadedPumpTask: Task? /// 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 @@ -70,14 +69,20 @@ public final class RuntimeBackgroundIndexingCoordinator { /// this engine session — otherwise every batch finish → `reloadData` → /// pump → empty-batch-start → finish → loop would spin forever. private var reloadDataPumpTask: Task? - private var lastKnownIsEnabled: Bool = false + /// 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 lastKnownAlwaysIndexEntries: [Settings.Indexing.AlwaysIndexEntry] = [] - /// Identifiers from `alwaysIndexEntries` that have successfully resolved + 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, - /// off→on toggle, and entry-list change so genuinely new work re-runs. + /// master off→on, custom off→on, and entry-list change so genuinely + /// new work re-runs. private var dispatchedAlwaysIndexIdentifiers: Set = [] #endif @@ -86,7 +91,6 @@ public final class RuntimeBackgroundIndexingCoordinator { self.engine = documentState.runtimeEngine startEventPump() #if canImport(RuntimeViewerSettings) - startImageLoadedPump() startReloadDataPump() bootstrapSettingsObservation() #endif @@ -95,7 +99,6 @@ public final class RuntimeBackgroundIndexingCoordinator { deinit { eventPumpTask?.cancel() - imageLoadedPumpTask?.cancel() reloadDataPumpTask?.cancel() } @@ -270,22 +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 { @@ -306,7 +307,6 @@ 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 @@ -323,15 +323,22 @@ public final class RuntimeBackgroundIndexingCoordinator { #if canImport(RuntimeViewerSettings) extension RuntimeBackgroundIndexingCoordinator { public func documentDidOpen() { - startMainExecutableBatch(reason: .appLaunch) - startAlwaysIndexBatches() + 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. @@ -342,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. @@ -351,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 @@ -372,63 +382,27 @@ 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 - guard let self else { return } - // Combine.Publisher.values bridges to AsyncSequence on macOS 12+ / - // iOS 15+; the project's deployment targets satisfy this. Errors are - // Never on this publisher, so no try is needed. - for await path in engine.imageDidLoadPublisher.values { - await self.handleImageLoaded(path: path, on: engine) - } - } - } - - private func handleImageLoaded(path: String, on engine: RuntimeEngine) async { - let settings = currentBackgroundIndexingSettings() - guard settings.isEnabled else { return } - // If `documentDidOpen` is currently indexing the same path (e.g. dyld - // fires this notification for the main executable right after launch), - // the manager dedups by `rootImagePath` and returns the existing - // batch's id. Inserting it into `documentBatchIDs` is a no-op on the - // Set when it's already tracked. - let id = await engine.backgroundIndexingManager.startBatch( - rootImagePath: path, - depth: settings.depth, - maxConcurrency: settings.maxConcurrency, - reason: .imageLoaded(path: path)) - // If the engine swapped while we were suspended on `startBatch`, the - // id belongs to the old manager and `handleEngineSwap` has already - // cleared `documentBatchIDs`; don't reintroduce a stale id. - guard self.engine === engine else { return } - self.staging.insertDocumentBatchID(id) - } - // MARK: - Always-index list - /// Reads `Settings.Indexing.alwaysIndexEntries` 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 - /// `lastKnownAlwaysIndexEntries` as still-pending so the next - /// fullReload retry can pick them up. + /// 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 global `BackgroundMode.depth` is used - /// and the BFS walks the full dependency closure like the main-executable + /// 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). + /// settings change, engine swap). Callers are responsible for checking + /// master / custom enablement before invoking. private func startAlwaysIndexBatches() { - let entries = currentAlwaysIndexEntries() + 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 @@ -442,8 +416,10 @@ extension RuntimeBackgroundIndexingCoordinator { guard !pendingEntries.isEmpty else { return } 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 (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 @@ -454,11 +430,11 @@ extension RuntimeBackgroundIndexingCoordinator { let imageList = await engine.imageList for entry in pendingEntries { guard let resolvedPath = resolveAlwaysIndexIdentifier(entry.identifier, in: imageList) else { continue } - let effectiveDepth = entry.followDependencies ? settings.depth : 0 + let effectiveDepth = entry.followDependencies ? indexing.heuristic.depth : 0 let id = await engine.backgroundIndexingManager.startBatch( rootImagePath: resolvedPath, depth: effectiveDepth, - maxConcurrency: settings.maxConcurrency, + maxConcurrency: indexing.maxConcurrency, reason: .alwaysIndex(identifier: entry.identifier)) guard self.engine === engine else { return } self.staging.insertDocumentBatchID(id) @@ -497,38 +473,40 @@ extension RuntimeBackgroundIndexingCoordinator { 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 currentBackgroundIndexingSettings() -> Settings.Indexing.BackgroundMode { + private func currentIndexingSettings() -> Settings.Indexing { @Dependency(\.settings) var settings - return settings.indexing.backgroundMode - } - - private func currentAlwaysIndexEntries() -> [Settings.Indexing.AlwaysIndexEntry] { - @Dependency(\.settings) var settings - return settings.indexing.alwaysIndexEntries + return settings.indexing } private func bootstrapSettingsObservation() { - self.lastKnownIsEnabled = currentBackgroundIndexingSettings().isEnabled - self.lastKnownAlwaysIndexEntries = currentAlwaysIndexEntries() + 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 - // Track always-index 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. - _ = currentAlwaysIndexEntries() + _ = 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. @@ -540,42 +518,108 @@ 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". Also re-trigger the - // always-index list since this is effectively a fresh start — - // clear the dispatched gate first so every entry runs again. + 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() - startMainExecutableBatch(reason: .settingsEnabled) - startAlwaysIndexBatches() - } else if wasEnabled && !latest.isEnabled { + 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 the feature is 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 = lastKnownAlwaysIndexEntries - let latestEntries = currentAlwaysIndexEntries() + // 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 - lastKnownAlwaysIndexEntries = latestEntries - // Skip when 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, latest.isEnabled, wasEnabled { + 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 @@ -594,8 +638,6 @@ extension RuntimeBackgroundIndexingCoordinator { } startAlwaysIndexBatches() } - // depth / maxConcurrency changes: intentional no-op; next startBatch picks - // up the new values. } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift index cdc5054a..8a8e5939 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettings/Settings+Types.swift @@ -73,26 +73,36 @@ 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(BackgroundMode.default) - public var backgroundMode: BackgroundMode + @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 @@ -106,6 +116,11 @@ extension Settings { /// 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 { @@ -118,11 +133,28 @@ extension Settings { public static let `default` = Self() } - /// User-configured "always-index" list. Lives at `Indexing` scope - /// rather than inside `BackgroundMode` so users can edit it even - /// when background indexing is disabled. - @Default([]) - public var alwaysIndexEntries: [AlwaysIndexEntry] + /// 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(Custom.default) + public var custom: Custom public static let `default` = Self() } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift index 729ed9be..78cc4267 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/IndexingSettingsView.swift @@ -13,38 +13,64 @@ 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(entries: $indexing.alwaysIndexEntries) + AlwaysIndexSection( + isEnabled: $indexing.custom.isEnabled, + entries: $indexing.custom.entries, + masterEnabled: indexing.isEnabled + ) } } } -/// Editor section for `Settings.Indexing.alwaysIndexEntries`. Renders each -/// entry as an editable row with an identifier field, a "Follow Dependencies" -/// toggle, and a delete button, plus a trailing "Add" button. The list is +/// 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( @@ -81,6 +107,7 @@ private struct AlwaysIndexSection: View { } .buttonStyle(.borderless) } + .disabled(entryFieldsDisabled) } Button { @@ -88,10 +115,11 @@ private struct AlwaysIndexSection: View { } label: { Label("Add Image", systemImage: "plus.circle") } + .disabled(entryFieldsDisabled) } header: { Text("Always Index") } footer: { - Text("These images 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 global depth above; otherwise only the image itself is indexed.") + 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/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 79a0ffc0..7f68f363 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BackgroundIndexing/BackgroundIndexingPopoverViewController.swift @@ -202,9 +202,13 @@ 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,10 +277,10 @@ 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) @@ -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) @@ -427,17 +493,20 @@ 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 "Always: \(identifier)" + return identifier } } } 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 From 8b14323447befa49549f448d25e61cdf02b7f023 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 6 Jun 2026 21:37:55 +0800 Subject: [PATCH 51/68] refactor(engine): expose imageNodes as nonisolated read-only property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `imageNodes` is now a `nonisolated` getter backed by the underlying `CurrentValueSubject`, with all mutation routed through the existing actor-isolated `setImageNodes(_:)`. Callers no longer need an `await` hop just to read the current snapshot. Use this to: - Synchronously gate the "Export Multiple Images" menu item in `MainWindowController.validateMenuItem` on `imageNodes.count >= 2` — `responds(to:)` runs on the main thread and can't suspend. - Drop the redundant `await` in `BatchExportingCoordinator.loadAvailableImages`. - Drop the redundant `await` in `RuntimeEngineProxyServer.sendInitialData`. --- .../RuntimeViewerCore/RuntimeEngine.swift | 52 +++++++++---------- .../RuntimeEngineProxyServer.swift | 2 +- .../BatchExportingCoordinator.swift | 2 +- .../Main/MainWindowController.swift | 2 +- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 99b84786..0ce93029 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -121,7 +121,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() @@ -140,9 +140,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. @@ -213,17 +212,17 @@ public actor RuntimeEngine { /// `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 @@ -349,7 +348,7 @@ public actor RuntimeEngine { // 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._serverObjectsWithProgress(in: request.image) + return try await _serverObjectsWithProgress(in: request.image) } // Server-only: manager-layer engine list lookup. Not part of the @@ -367,7 +366,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) } @@ -428,7 +427,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)") @@ -445,7 +444,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)) @@ -491,7 +490,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 @@ -660,11 +659,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() @@ -677,7 +675,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) @@ -707,7 +705,7 @@ 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 @@ -784,8 +782,8 @@ extension RuntimeEngine { } 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 @@ -827,7 +825,7 @@ extension RuntimeEngine { public func exportInterfaces( with configuration: RuntimeInterfaceExportConfiguration, - reporter: RuntimeInterfaceExportReporter + reporter: RuntimeInterfaceExportReporter, ) async throws { defer { reporter.finish() } let startTime = CFAbsoluteTimeGetCurrent() @@ -854,7 +852,7 @@ extension RuntimeEngine { let item = RuntimeInterfaceExportItem( object: object, plainText: runtimeInterface.interfaceString.string, - suggestedFileName: object.exportFileName + suggestedFileName: object.exportFileName, ) results.append(item) succeeded += 1 @@ -881,12 +879,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)) @@ -901,12 +899,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)) @@ -921,7 +919,7 @@ extension RuntimeEngine { objcInterfaceCount: objcCount, swiftInterfaceCount: swiftCount, succeeded: succeeded, - failed: failed + writeFailed + failed: failed + writeFailed, ) try RuntimeInterfaceExportWriter.writeMetadata(metadata, to: configuration.directory) } @@ -937,7 +935,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 4b9e8847..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( diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift index c3b038bf..c99f55de 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCoordinator.swift @@ -22,7 +22,7 @@ final class BatchExportingCoordinator: SceneCoordinator { case #selector(exportInterface(_:)): return documentState.currentImageNode != nil case #selector(exportMultipleImages(_:)): - return true + return documentState.runtimeEngine.imageNodes.count >= 2 default: return super.responds(to: aSelector) } From 5b970826bfefd22698a428e68866eff351c36a04 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 6 Jun 2026 21:38:05 +0800 Subject: [PATCH 52/68] chore: silence unused result + drop stale OpenUXKit local-path switches - `RuntimeMessageChannel`: discard the `yield` result explicitly so the compiler doesn't warn about an unused `Discarding` value. - `RuntimeViewerPackages/Package.swift`: comment out the local-path branches for `UXKitCoordinator` and `OpenUXKit` so the repo resolves against the OpenUXKit remote unconditionally. The local checkouts those pointed at no longer exist for most contributors. --- .../RuntimeMessageChannel.swift | 2 +- RuntimeViewerPackages/Package.swift | 32 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift index 8ac8d56e..da7ec54e 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeMessageChannel.swift @@ -239,7 +239,7 @@ final class RuntimeMessageChannel: @unchecked Sendable, RuntimeMessageProtocol { } for messageData in extractedMessages { #log(.debug, "[MessageChannel] processReceivedData: yielding message (\(messageData.count, privacy: .public) bytes, continuation=\(hasContinuation, privacy: .public))") - receivedDataContinuation.withLock { $0?.yield(messageData) } + _ = receivedDataContinuation.withLock { $0?.yield(messageData) } onMessageReceived?(messageData) } } diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 3813626e..d6444f8c 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -224,14 +224,14 @@ let package = Package( ), .package( - local: .package( - path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("UXKitCoordinator"), - isRelative: true, - ), - .package( - path: "../../UXKitCoordinator", - isRelative: true, - ), +// 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,14 +250,14 @@ let package = Package( ), .package( - local: .package( - path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("OpenUXKit"), - isRelative: true, - ), - .package( - path: "../../OpenUXKit", - isRelative: true, - ), +// local: .package( +// path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("OpenUXKit"), +// isRelative: true, +// ), +// .package( +// path: "../../OpenUXKit", +// isRelative: true, +// ), remote: .package( url: "https://github.com/OpenUXKit/OpenUXKit", branch: "main", From fe2edd066ec0abefac0c81a36cbaa319dd29c65f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 6 Jun 2026 21:59:43 +0800 Subject: [PATCH 53/68] =?UTF-8?q?chore(deps):=20bump=20UIFoundation=200.10?= =?UTF-8?q?.0=E2=86=920.10.2,=20RxAppKit=200.4.0=E2=86=920.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both bumps are semver-compatible with the existing `from:` pins and synchronize the declared floor with the latest published tags: - UIFoundation 0.10.2 adds an opt-out for the visual-effect host on `UXKitViewController` and respects safeAreaLayoutGuide on macOS 11+. - RxAppKit 0.5.0 drives `rx.itemSelected` (NSTableView / NSOutlineView) off `selectionDidChangeNotification` so keyboard / type-select selection events surface alongside clicks — already relied on by the type-select navigation debounce shipped in 28fd4c4. --- RuntimeViewerPackages/Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index d6444f8c..ecf25e85 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -188,7 +188,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/UIFoundation", - from: "0.10.0", + from: "0.10.2", traits: UIFoundationTraits, ), ), @@ -275,7 +275,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/RxAppKit", - from: "0.4.0", + from: "0.5.0", ), ), From a766ec03123aad99b29a5641397839145d43f3e7 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 6 Jun 2026 22:01:33 +0800 Subject: [PATCH 54/68] =?UTF-8?q?chore(deps):=20bump=20swift-dependencies?= =?UTF-8?q?=201.12.0=E2=86=921.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.13.0 declares the trait set (Clocks / CombineSchedulers / Foundation / FoundationNetworking) the resolved graph enables; 1.12.0 doesn't, so release-channel resolves under USING_LOCAL_DEPENDENCIES=0 fail with "declares no traits". Catches up the main-branch pin with the floor that already shipped on the v2.1.0-beta.4 release tag (0e7b9f2). --- RuntimeViewerPackages/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index ecf25e85..ddc8b3ed 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -372,7 +372,7 @@ let package = Package( .package( url: "https://github.com/pointfreeco/swift-dependencies", - from: "1.12.0", + from: "1.13.0", ), .package( From 4132fb8604814483c45be7c551d7da40cde6e7c0 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 6 Jun 2026 22:03:17 +0800 Subject: [PATCH 55/68] chore(release): prepare v2.1.0-beta.5 - Add Changelogs/v2.1.0-beta.5.md. Clears beta.4's known-issue ping-pong on remote-Server background indexing (b276703 + 1d9becf), re-rolls the Always Index list that beta.4 had to revert (453644c stayed on main), and reorganizes Background Indexing settings into master + heuristic + custom. Includes the imageNodes nonisolated refactor + UIFoundation / RxAppKit / swift-dependencies pin catch-up. --- Changelogs/v2.1.0-beta.5.md | 167 ++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 Changelogs/v2.1.0-beta.5.md 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. From a921e4c7477fc11c124253c21d642b22799b44ad Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 7 Jun 2026 00:48:51 +0800 Subject: [PATCH 56/68] chore: update appcast for v2.1.0-beta.5 --- docs/appcast.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/appcast.xml b/docs/appcast.xml index 9cb73c8e..510ac2e7 100644 --- a/docs/appcast.xml +++ b/docs/appcast.xml @@ -10,6 +10,15 @@ https://mxiris-reverse-engineering.github.io/RuntimeViewer/appcast.xml RuntimeViewer release feed en + + 2.1.0 + Sun, 07 Jun 2026 00:47:56 +0800 + beta + 20260607.00.36 + 2.1.0 + 15.0 + + 2.1.0 Mon, 18 May 2026 10:04:51 +0800 From 4f7a98d113bb7f4a730f9b4599070b413be074d7 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 7 Jun 2026 01:03:17 +0800 Subject: [PATCH 57/68] Revert "chore: update appcast for v2.1.0-beta.5" This reverts commit a921e4c7477fc11c124253c21d642b22799b44ad. --- docs/appcast.xml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/appcast.xml b/docs/appcast.xml index 510ac2e7..9cb73c8e 100644 --- a/docs/appcast.xml +++ b/docs/appcast.xml @@ -10,15 +10,6 @@ https://mxiris-reverse-engineering.github.io/RuntimeViewer/appcast.xml RuntimeViewer release feed en - - 2.1.0 - Sun, 07 Jun 2026 00:47:56 +0800 - beta - 20260607.00.36 - 2.1.0 - 15.0 - - 2.1.0 Mon, 18 May 2026 10:04:51 +0800 From 17bdb6f49e6c2533c7731d83b1cf83648cda31da Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 7 Jun 2026 23:50:16 +0800 Subject: [PATCH 58/68] refactor: hoist batch-export row models onto shared CellViewModel base Introduce a `@MainActor`-isolated `CellViewModel` base in RuntimeViewerApplication and rebase the three batch-export row/cell view models on it, replacing the per-class `NSObject, @unchecked Sendable` boilerplate. `BatchExportingImageSelectionCellViewModel`'s `Differentiable` conformance is now declared `@MainActor` to match. Also refresh Package.resolved across the workspace and per-package manifests to pick up new dependency revisions. --- .../xcshareddata/swiftpm/Package.resolved | 67 ++++++++----------- RuntimeViewerCore/Package.resolved | 35 ++-------- RuntimeViewerPackages/Package.resolved | 54 +++++---------- .../RuntimeViewerApplication/ViewModel.swift | 5 +- ...BatchExportingCompletionRowViewModel.swift | 3 +- ...ExportingImageSelectionCellViewModel.swift | 7 +- .../BatchExportingProgressRowViewModel.swift | 3 +- 7 files changed, 63 insertions(+), 111 deletions(-) diff --git a/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved index c1472a69..2daa21a2 100644 --- a/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RuntimeViewer-Distribution.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ukushu/Ifrit", "state" : { - "revision" : "9b9556e14cee24ad16b19d0eb099283cf79a7d94", - "version" : "3.0.0" + "revision" : "7c889a67bad90c5efefa56889b2d61bfbb831473", + "version" : "4.0.0" } }, { @@ -144,6 +144,15 @@ "version" : "0.5.0" } }, + { + "identity" : "openuxkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenUXKit/OpenUXKit", + "state" : { + "branch" : "main", + "revision" : "21b944e638ff66d45ba1f550483c875be3c1f93f" + } + }, { "identity" : "rainbow", "kind" : "remoteSourceControl", @@ -198,15 +207,6 @@ "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", @@ -230,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SnapKit/SnapKit", "state" : { - "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", - "version" : "5.7.1" + "revision" : "e27a338a03a5f388de759da63f9baf7988ed9e00", + "version" : "6.0.0" } }, { @@ -387,22 +387,13 @@ "version" : "1.6.0" } }, - { - "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" } }, { @@ -514,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" } }, { @@ -536,8 +527,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Cocoanetics/SwiftMCP", "state" : { - "revision" : "5e8961f73834abb6f05fede365831a019a68be91", - "version" : "1.4.7" + "revision" : "df3ef01ab09bcdf775f4669be242065e05d61feb", + "version" : "1.5.1" } }, { @@ -559,12 +550,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" } }, { diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index f3b8b553..efac29a3 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "de4fe8e81b6660cc9d70a2a197b55d233a119c573ce3ce7a40eb30e32e50bfbd", + "originHash" : "dc766f6e99ca9f3794bde24cce3eefec3b3208e385f26c807b068020b36d0855", "pins" : [ { "identity" : "associatedobject", @@ -109,15 +109,6 @@ "version" : "0.1.0" } }, - { - "identity" : "sparkle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle", - "state" : { - "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", - "version" : "2.9.1" - } - }, { "identity" : "swift-apinotes", "kind" : "remoteSourceControl", @@ -130,10 +121,10 @@ { "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" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", - "version" : "1.8.1" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -217,15 +208,6 @@ "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", @@ -298,15 +280,6 @@ "version" : "0.5.0" } }, - { - "identity" : "swift-semantic-string", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", - "state" : { - "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", - "version" : "0.1.1" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/RuntimeViewerPackages/Package.resolved b/RuntimeViewerPackages/Package.resolved index 9aa612e2..d1549771 100644 --- a/RuntimeViewerPackages/Package.resolved +++ b/RuntimeViewerPackages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "092d8eb5d636d09a0e45564cde60c0e0ff18e41fcf705fcd7fc5ff35eb4cb43a", + "originHash" : "87c078de9798a5eb9f341245f98da58b0ac955198ef85190eb208c2ee84eb723", "pins" : [ { "identity" : "associatedobject", @@ -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,15 +208,6 @@ "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", @@ -256,10 +256,10 @@ { "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" : "ca37474853a4b5f59a22c74bfdd449b1f6bc4cc2", - "version" : "1.8.1" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -361,15 +361,6 @@ "version" : "1.6.0" } }, - { - "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", @@ -469,15 +460,6 @@ "version" : "2.0.10" } }, - { - "identity" : "swift-semantic-string", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", - "state" : { - "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", - "version" : "0.1.1" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -506,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" } }, { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift index 0462d772..d424cb40 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift @@ -19,7 +19,7 @@ open class ViewModel: NSObject, ViewModelProtocol { @Dependency(\.settings) public var settings - + public let errorRelay = PublishRelay() package let _commonLoading = ActivityIndicator() @@ -50,3 +50,6 @@ open class ViewModel: NSObject, ViewModelProtocol { self.router = router } } + +@MainActor +open class CellViewModel: NSObject {} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift index 7ae04c3a..eed0467b 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingCompletionRowViewModel.swift @@ -1,7 +1,8 @@ import AppKit +import RuntimeViewerApplication import RuntimeViewerArchitectures -final class BatchExportingCompletionRowViewModel: NSObject, @unchecked Sendable { +final class BatchExportingCompletionRowViewModel: CellViewModel { let outcome: BatchExportingPerImageOutcome init(outcome: BatchExportingPerImageOutcome) { diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift index 9d2deeca..5255cd77 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingImageSelectionCellViewModel.swift @@ -1,8 +1,9 @@ import AppKit -import RuntimeViewerArchitectures import RuntimeViewerCore +import RuntimeViewerApplication +import RuntimeViewerArchitectures -final class BatchExportingImageSelectionCellViewModel: NSObject, @unchecked Sendable { +final class BatchExportingImageSelectionCellViewModel: CellViewModel { let image: BatchExportingImage @Observed @@ -19,7 +20,7 @@ final class BatchExportingImageSelectionCellViewModel: NSObject, @unchecked Send } } -extension BatchExportingImageSelectionCellViewModel: Differentiable { +extension BatchExportingImageSelectionCellViewModel: @MainActor Differentiable { var differenceIdentifier: String { image.path } func isContentEqual(to source: BatchExportingImageSelectionCellViewModel) -> Bool { diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift index 73cbaaf9..de4f7398 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/BatchExporting/BatchExportingProgressRowViewModel.swift @@ -1,8 +1,9 @@ import AppKit +import RuntimeViewerApplication import RuntimeViewerArchitectures import RuntimeViewerCore -final class BatchExportingProgressRowViewModel: NSObject, @unchecked Sendable { +final class BatchExportingProgressRowViewModel: CellViewModel { enum Status: Sendable { case queued case running From 8a81824421d02b74c325276b1a975451c45b0673 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 7 Jun 2026 23:50:22 +0800 Subject: [PATCH 59/68] chore(ios): declare encryption status and local network usage in Info.plist - `ITSAppUsesNonExemptEncryption=false` skips the per-build App Store Connect export-compliance prompt. - `NSLocalNetworkUsageDescription` explains the optional Bonjour mirror feature; required by iOS before `_runtimeviewer._tcp` discovery can prompt for local-network access. --- RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Info.plist | 4 ++++ 1 file changed, 4 insertions(+) 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 From dc16ccad2bbf2b0911b28fc5abec567197d30fd8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 8 Jun 2026 16:20:19 +0800 Subject: [PATCH 60/68] =?UTF-8?q?chore(deps):=20bump=20MachOObjCSection=20?= =?UTF-8?q?0.7.102=E2=86=920.7.103?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RuntimeViewerCore/Package.resolved | 20 +++++++++++++++++++- RuntimeViewerCore/Package.swift | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index efac29a3..5d3f389d 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "dc766f6e99ca9f3794bde24cce3eefec3b3208e385f26c807b068020b36d0855", + "originHash" : "52454f2d1779089b9c6ae08b6c6b64023e8264f9809a2ba047215a945805be4f", "pins" : [ { "identity" : "associatedobject", @@ -208,6 +208,15 @@ "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", @@ -280,6 +289,15 @@ "version" : "0.5.0" } }, + { + "identity" : "swift-semantic-string", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", + "state" : { + "revision" : "1c3438861ea6dbba0856ab02071d683914468e82", + "version" : "0.1.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 97176200..4126bb0b 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -119,7 +119,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", - from: "0.7.102", + from: "0.7.103", ), ), .package( From d437c228c291bd433f12f14a66bcd00b5f4a1d47 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 8 Jun 2026 16:20:28 +0800 Subject: [PATCH 61/68] refactor(engine): keep transport details out of the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeEngine no longer caches an XPC listener endpoint or downcasts its connection to a transport-specific protocol. Announcing the listener endpoint to the Mach Service is now self-contained in RuntimeXPCServerConnection — the only place that already knows it is an XPC server with an endpoint to publish — so neither the engine nor the injected RuntimeViewerServer entry point needs to mention XPC. Collapse the ad-hoc bonjourEndpoint: / xpcServerEndpoint: parameters of connect(...) into a single credential: RuntimeConnectionCredential, so session-scoped endpoints stop being passed around as untyped (any Sendable) and the call sites describe their intent (.bonjour / .xpcServer) directly. --- .../Connections/RuntimeXPCConnection.swift | 55 +++++++++++++------ .../RuntimeCommunicator.swift | 20 +++++-- .../RuntimeConnectionCredential.swift | 39 +++++++++++++ .../RuntimeViewerCore/RuntimeEngine.swift | 20 ++----- .../Engine/RuntimeEngineManager.swift | 4 +- .../RuntimeViewerServer.swift | 44 +-------------- 6 files changed, 102 insertions(+), 80 deletions(-) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeConnectionCredential.swift 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/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 0ce93029..cf2d4030 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -197,16 +197,9 @@ 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 @@ -235,31 +228,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() 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/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 } From b53fa7cb049934c31139bd71b595682886af6e29 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 8 Jun 2026 16:21:22 +0800 Subject: [PATCH 62/68] style(indexing): normalize trailing commas and brace placement --- .../RuntimeEngine+BackgroundIndexing.swift | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift index f4dec726..b7be3aaf 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+BackgroundIndexing.swift @@ -82,7 +82,7 @@ extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { // Errors map to `false` (treat as "can't open"), matching the // pre-dispatch behaviour where a missing image silently returned // `false` rather than throwing. - (try? await dispatch(CanOpenImageRequest(path: path))) ?? false + await (try? dispatch(CanOpenImageRequest(path: path))) ?? false } func rpaths(for path: String) async throws -> [String] { @@ -92,14 +92,13 @@ extension RuntimeEngine: RuntimeBackgroundIndexingEngineRepresenting { func dependencies(for path: String, ancestorRpaths: [String], mainExecutablePath: String) async throws - -> [(installName: String, resolvedPath: String?)] - { + -> [(installName: String, resolvedPath: String?)] { let entries: [RuntimeDependencyEntry] = try await dispatch( DependenciesRequest( path: path, ancestorRpaths: ancestorRpaths, - mainExecutablePath: mainExecutablePath - ) + 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 @@ -125,8 +124,7 @@ extension RuntimeEngine { func _dependencies(for path: String, ancestorRpaths: [String], mainExecutablePath: String) - -> [RuntimeDependencyEntry] - { + -> [RuntimeDependencyEntry] { guard let image = DyldUtilities.machOImage(forPath: path) else { return [] } @@ -144,7 +142,7 @@ extension RuntimeEngine { 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: @@ -230,7 +228,7 @@ extension RuntimeEngine { await engine._dependencies( for: path, ancestorRpaths: ancestorRpaths, - mainExecutablePath: mainExecutablePath + mainExecutablePath: mainExecutablePath, ) } } From 9a3716b50383b3fa92c3fb247f63d5b8fdf8724e Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 9 Jun 2026 21:55:58 +0800 Subject: [PATCH 63/68] Fix export module metadata resolution (#72) --- .../RuntimeInterfaceExportMetadata.swift | 25 ++++++------ .../RuntimeEngine+Requests.swift | 13 +++++++ .../RuntimeViewerCore/RuntimeEngine.swift | 9 ++++- .../RuntimeEngineRequest.swift | 1 + .../Utils/DyldUtilities.swift | 20 +++++++--- .../RuntimeViewerCoreTests/ExportTests.swift | 39 +++++++++++++++++++ 6 files changed, 90 insertions(+), 17 deletions(-) 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+Requests.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift index d121d539..cb6f117f 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine+Requests.swift @@ -56,6 +56,19 @@ extension RuntimeEngine { 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 diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index cf2d4030..9a907782 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -65,6 +65,7 @@ public actor RuntimeEngine { case runtimeObjectInfo case imageNameOfClassName case observeRuntime + case runtimeInterfaceExportModuleInfo case runtimeInterfaceForRuntimeObjectInImageWithOptions case runtimeObjectsOfKindInImage case runtimeObjectsInImage @@ -320,7 +321,6 @@ public actor RuntimeEngine { #log(.default, "Connection is nil when setting up server message handlers") return } - // Shared registry — same set of commands that // `RuntimeEngineProxyServer.setupRequestHandlers()` installs. Self.registerSharedHandlers(on: connection, engine: self) @@ -902,8 +902,15 @@ 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, diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift index a4bae28f..2f6cebf5 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineRequest.swift @@ -52,6 +52,7 @@ extension RuntimeEngine { 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) 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/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 From ea9feaabfa1691923c2474ebe7999a8363c186e2 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 9 Jun 2026 18:32:41 +0800 Subject: [PATCH 64/68] Preserve runtime specialization tree identity --- .../Common/RuntimeObject.swift | 19 ++++-- .../Common/RuntimeSpecialization.swift | 10 ++-- .../Core/RuntimeSwiftSection.swift | 60 ++++++++++++++++--- .../RuntimeObjectTests.swift | 26 ++++++++ .../FilterEngine.swift | 8 ++- .../SidebarRuntimeObjectCellViewModel.swift | 6 +- 6 files changed, 107 insertions(+), 22 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift index c38d8948..a35677bf 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift @@ -32,6 +32,10 @@ public struct RuntimeObject: Hashable, Identifiable, Sendable { public let children: [RuntimeObject] + @Default("") + @Init(default: "") + public let identityPath: String + @Default([]) @Init(default: []) public let properties: Properties @@ -41,7 +45,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, identityPath: identityPath, properties: properties) } /// Returns a copy of this object with `child` appended to its `children`. @@ -55,16 +59,19 @@ public struct RuntimeObject: Hashable, Identifiable, Sendable { secondaryKind: secondaryKind, imagePath: imagePath, children: children + [child], + identityPath: identityPath, properties: properties, ) } } extension RuntimeObject: ComparableBuildable { - public static var comparableDefinition: some ComparisonStep { - compare(\.imagePath) - compare(\.kind) - compare(\.displayName) + public static var comparableDefinition: ComparableDefinition { + makeComparable { + compare(\.imagePath) + compare(\.kind) + compare(\.displayName) + } } } @@ -82,11 +89,13 @@ public struct RuntimeObjectKey: Hashable, Sendable { private let imagePath: String private let name: String private let kind: RuntimeObjectKind + private let identityPath: String public init(_ object: RuntimeObject) { self.imagePath = object.imagePath self.name = object.name self.kind = object.kind + self.identityPath = object.identityPath } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift index 99a54027..12d136f5 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift @@ -87,10 +87,12 @@ extension RuntimeSpecializationRequest { case `class` } - public static var comparableDefinition: some ComparisonStep { - compare(\.imagePath) - compare(\.kind) - compare(\.displayName) + public static var comparableDefinition: ComparableDefinition { + makeComparable { + compare(\.imagePath) + compare(\.kind) + compare(\.displayName) + } } } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index 507f9e79..a42947f2 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] = [:] @@ -188,15 +190,30 @@ 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, + parentIdentityPath: String? = nil + ) throws -> RuntimeObject { + let mangledName = try mangleAsString(typeDefinition.typeName.node) + let identityPath = [parentIdentityPath, mangledName] + .compactMap { $0 } + .joined(separator: "/") + let typeChildren = try typeDefinition.typeChildren.map { + try makeRuntimeObject(for: $0, isChild: true, parentIdentityPath: identityPath) + } 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, + parentIdentityPath: identityPath + ) } 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 +224,27 @@ 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) - if isSpecialized, let unspecializedTypeName { - interfaceDefinitionNameByObject[runtimeObject.key] = .specializedType(unspecialized: unspecializedTypeName, specialized: typeDefinition.typeName) + let runtimeObject = RuntimeObject( + name: mangledName, + displayName: displayName, + kind: typeDefinition.typeName.runtimeObjectKind, + secondaryKind: nil, + imagePath: imagePath, + children: allChildren, + identityPath: identityPath, + properties: properties + ) + let specializedLookupTypeName: SwiftInterface.TypeName? + if let unspecializedTypeName { + specializedLookupTypeName = unspecializedTypeName + } else if isSpecialized { + specializedLookupTypeName = typeDefinition.typeName + } else { + specializedLookupTypeName = nil + } + if isSpecialized, let specializedLookupTypeName { + interfaceDefinitionNameByObject[runtimeObject.key] = .specializedType(unspecialized: specializedLookupTypeName, specialized: typeDefinition.typeName) + specializedDefinitionByObject[runtimeObject.key] = typeDefinition } else if isChild { interfaceDefinitionNameByObject[runtimeObject.key] = .childType(typeDefinition.typeName) } else { @@ -238,6 +273,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 @@ -446,7 +485,9 @@ 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) } @@ -600,6 +641,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 diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift index 0288810d..ee8357d0 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift @@ -173,6 +173,32 @@ struct RuntimeObjectTests { #expect(a.key != differentKind.key) } + @Test("RuntimeObjectKey distinguishes same object name under different tree paths") + func runtimeObjectKeyDiscriminatesIdentityPath() { + let manualNested = RuntimeObject( + name: "ValueOfHoverEvent", + displayName: "Value", + kind: .swift(.type(.struct)), + secondaryKind: nil, + imagePath: "/p", + children: [], + identityPath: "Phase/Value/ValueOfHoverEvent", + properties: [.isSpecialized] + ) + let derivedNested = RuntimeObject( + name: "ValueOfHoverEvent", + displayName: "Value", + kind: .swift(.type(.struct)), + secondaryKind: nil, + imagePath: "/p", + children: [], + identityPath: "Phase/PhaseOfHoverEvent/ValueOfHoverEvent", + properties: [.isSpecialized] + ) + + #expect(manualNested.key != derivedNested.key) + } + @Test("RuntimeObjectKey-keyed Set acts as a dedup oracle for accumulated children") func runtimeObjectKeyDedup() { // Mirrors the sidebar's de-dup guard: diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift index c228bc2a..13b78aa7 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift @@ -105,9 +105,11 @@ struct FuzzySrchResultWrapper: ComparableBuildable { wrappedValue[keyPath: keyPath] } - static var comparableDefinition: some ComparisonStep { - compare(\.wrappedValue.diffScore) - compare(\.resultsScore) + static var comparableDefinition: ComparableDefinition { + makeComparable { + compare(\.wrappedValue.diffScore) + compare(\.resultsScore) + } } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index 982747a4..ecaf7277 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -20,6 +20,7 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, public let imagePath: String public let name: String public let kind: RuntimeObjectKind + public let identityPath: String } /// Mutable so the sidebar can splice in a new specialized child via @@ -38,7 +39,7 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, public let forOpenQuickly: Bool public var stableID: StableID { - StableID(imagePath: runtimeObject.imagePath, name: runtimeObject.name, kind: runtimeObject.kind) + StableID(imagePath: runtimeObject.imagePath, name: runtimeObject.name, kind: runtimeObject.kind, identityPath: runtimeObject.identityPath) } public var children: [SidebarRuntimeObjectCellViewModel] { @@ -170,7 +171,8 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, let childStableID = StableID( imagePath: childRuntimeObject.imagePath, name: childRuntimeObject.name, - kind: childRuntimeObject.kind + kind: childRuntimeObject.kind, + identityPath: childRuntimeObject.identityPath ) if let recycledChild = recycledChildrenByStableID[childStableID] { recycledChild.runtimeObject = childRuntimeObject // recurses via didSet From 2365853e86f4eb9a61fdce785d8634205f724236 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 9 Jun 2026 19:39:53 +0800 Subject: [PATCH 65/68] Preserve nested sidebar specializations --- .../SidebarRuntimeObjectCellViewModel.swift | 26 ++++++ .../SidebarRuntimeObjectViewModel.swift | 24 ++--- ...debarRuntimeObjectCellViewModelTests.swift | 90 +++++++++++++++++++ 3 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index ecaf7277..e84dd572 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -187,6 +187,32 @@ 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() }, + identityPath: runtimeObject.identityPath, + 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/SidebarRuntimeObjectViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift index d3d3f43b..bd269271 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift @@ -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/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift new file mode 100644 index 00000000..92140071 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift @@ -0,0 +1,90 @@ +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", + identityPath: "Phase/FailureReason" + ) + let value = object( + name: "Phase.Value", + displayName: "SwiftUI.EventListenerPhase.Value", + identityPath: "Phase/Value", + properties: [.isGeneric] + ) + let phase = object( + name: "Phase", + displayName: "SwiftUI.EventListenerPhase", + children: [failureReason, value], + identityPath: "Phase", + 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", + identityPath: "Phase/Value/ValueEvent", + properties: [.isSpecialized] + ) + valueViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(valueEvent) + + let phasePan = object( + name: "Phase.PanEvent", + displayName: "SwiftUI.EventListenerPhase", + children: [ + object( + name: "Phase.PanEvent.FailureReason", + displayName: "SwiftUI.EventListenerPhase.FailureReason", + identityPath: "Phase/PhasePan/FailureReasonPan", + properties: [.isSpecialized] + ), + object( + name: "Phase.PanEvent.Value", + displayName: "SwiftUI.EventListenerPhase.Value", + identityPath: "Phase/PhasePan/ValuePan", + properties: [.isSpecialized] + ), + ], + identityPath: "Phase/PhasePan", + 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" }) + } + + private func object( + name: String, + displayName: String, + children: [RuntimeObject] = [], + identityPath: String, + properties: RuntimeObject.Properties = [] + ) -> RuntimeObject { + RuntimeObject( + name: name, + displayName: displayName, + kind: .swift(.type(.struct)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", + children: children, + identityPath: identityPath, + properties: properties + ) + } +} From 27f8a2038d1e510b16030a7ee8df2553f0119c6f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 10 Jun 2026 08:24:32 +0800 Subject: [PATCH 66/68] refactor(comparable): keep comparableDefinition on legacy some ComparisonStep form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ComparableDefinition { makeComparable { … } }` form requires a newer FrameworkToolbox than batch-export currently pins, and is unrelated to the nested-specialization fix that follows. Roll the three call sites back to the `some ComparisonStep` form already supported in the pinned dependency set so the bugfix branch can build against batch-export's deps without forcing an unrelated SPM bump. --- .../RuntimeViewerCore/Common/RuntimeObject.swift | 10 ++++------ .../Common/RuntimeSpecialization.swift | 10 ++++------ .../RuntimeViewerApplication/FilterEngine.swift | 8 +++----- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift index a35677bf..195bd89c 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift @@ -66,12 +66,10 @@ public struct RuntimeObject: Hashable, Identifiable, Sendable { } extension RuntimeObject: ComparableBuildable { - public static var comparableDefinition: ComparableDefinition { - makeComparable { - compare(\.imagePath) - compare(\.kind) - compare(\.displayName) - } + public static var comparableDefinition: some ComparisonStep { + compare(\.imagePath) + compare(\.kind) + compare(\.displayName) } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift index 12d136f5..99a54027 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeSpecialization.swift @@ -87,12 +87,10 @@ extension RuntimeSpecializationRequest { case `class` } - public static var comparableDefinition: ComparableDefinition { - makeComparable { - compare(\.imagePath) - compare(\.kind) - compare(\.displayName) - } + public static var comparableDefinition: some ComparisonStep { + compare(\.imagePath) + compare(\.kind) + compare(\.displayName) } } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift index 13b78aa7..c228bc2a 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift @@ -105,11 +105,9 @@ struct FuzzySrchResultWrapper: ComparableBuildable { wrappedValue[keyPath: keyPath] } - static var comparableDefinition: ComparableDefinition { - makeComparable { - compare(\.wrappedValue.diffScore) - compare(\.resultsScore) - } + static var comparableDefinition: some ComparisonStep { + compare(\.wrappedValue.diffScore) + compare(\.resultsScore) } } From 039cd21735caad5c6a1ef552a88e2479db756ee9 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 10 Jun 2026 08:25:00 +0800 Subject: [PATCH 67/68] refactor(sidebar): move specialization identity from RuntimeObject to cell viewmodel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preceding two commits introduced an identityPath String on every RuntimeObject to disambiguate the same Swift metadata appearing under different sidebar paths (e.g. EventListenerPhase.Value reached via the inner generic vs. via Phase). That coupling makes the data-layer model carry a UI concern and costs ~30-80MB on large indexes where N RuntimeObject instances each pay for a per-node path String. Drop identityPath from RuntimeObject and RuntimeObjectKey so the latter returns to its metadata-identity role. Recreate the disambiguation on SidebarRuntimeObjectCellViewModel.StableID by folding a recursive parent fingerprint into the cell's own (imagePath, name, kind). The fingerprint is not cached: stableID reads it on demand so late-binding parent changes show up automatically and runtimeObject.children is excluded so splicing a child does not flip the parent's fingerprint. The regression scenario still holds — EventListenerPhase.Value survives an ancestor EventListenerPhase specialization, as covered by the existing ancestorSpecializationPreservesExistingNestedSpecialization test. The new stableIDDistinguishesSameObjectUnderDifferentParents test takes over the role of the deleted runtimeObjectKeyDiscriminatesIdentityPath test by asserting that two cells whose runtimeObject.key matches still hash to distinct StableIDs when their sidebar parents differ. Also fix RuntimeObject.withImagePath dropping the properties field — isGeneric / isSpecialized would otherwise be silently cleared on any imagePath rewrite. --- .../Common/RuntimeObject.swift | 9 +-- .../Core/RuntimeSwiftSection.swift | 12 +--- .../RuntimeObjectTests.swift | 26 -------- .../SidebarRuntimeObjectCellViewModel.swift | 45 ++++++++++++-- ...debarRuntimeObjectCellViewModelTests.swift | 61 ++++++++++++++++--- 5 files changed, 94 insertions(+), 59 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift index 195bd89c..12b9f150 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObject.swift @@ -32,10 +32,6 @@ public struct RuntimeObject: Hashable, Identifiable, Sendable { public let children: [RuntimeObject] - @Default("") - @Init(default: "") - public let identityPath: String - @Default([]) @Init(default: []) public let properties: Properties @@ -45,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, identityPath: identityPath, properties: properties) + .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`. @@ -59,7 +55,6 @@ public struct RuntimeObject: Hashable, Identifiable, Sendable { secondaryKind: secondaryKind, imagePath: imagePath, children: children + [child], - identityPath: identityPath, properties: properties, ) } @@ -87,13 +82,11 @@ public struct RuntimeObjectKey: Hashable, Sendable { private let imagePath: String private let name: String private let kind: RuntimeObjectKind - private let identityPath: String public init(_ object: RuntimeObject) { self.imagePath = object.imagePath self.name = object.name self.kind = object.kind - self.identityPath = object.identityPath } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index a42947f2..c11bf76d 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -193,23 +193,18 @@ actor RuntimeSwiftSection { private func makeRuntimeObject( for typeDefinition: TypeDefinition, isChild: Bool, - unspecializedTypeName: SwiftInterface.TypeName? = nil, - parentIdentityPath: String? = nil + unspecializedTypeName: SwiftInterface.TypeName? = nil ) throws -> RuntimeObject { let mangledName = try mangleAsString(typeDefinition.typeName.node) - let identityPath = [parentIdentityPath, mangledName] - .compactMap { $0 } - .joined(separator: "/") let typeChildren = try typeDefinition.typeChildren.map { - try makeRuntimeObject(for: $0, isChild: true, parentIdentityPath: identityPath) + 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, - parentIdentityPath: identityPath + unspecializedTypeName: typeDefinition.typeName ) } let allChildren = typeChildren + protocolChildren + specializedChildren @@ -231,7 +226,6 @@ actor RuntimeSwiftSection { secondaryKind: nil, imagePath: imagePath, children: allChildren, - identityPath: identityPath, properties: properties ) let specializedLookupTypeName: SwiftInterface.TypeName? diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift index ee8357d0..0288810d 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjectTests.swift @@ -173,32 +173,6 @@ struct RuntimeObjectTests { #expect(a.key != differentKind.key) } - @Test("RuntimeObjectKey distinguishes same object name under different tree paths") - func runtimeObjectKeyDiscriminatesIdentityPath() { - let manualNested = RuntimeObject( - name: "ValueOfHoverEvent", - displayName: "Value", - kind: .swift(.type(.struct)), - secondaryKind: nil, - imagePath: "/p", - children: [], - identityPath: "Phase/Value/ValueOfHoverEvent", - properties: [.isSpecialized] - ) - let derivedNested = RuntimeObject( - name: "ValueOfHoverEvent", - displayName: "Value", - kind: .swift(.type(.struct)), - secondaryKind: nil, - imagePath: "/p", - children: [], - identityPath: "Phase/PhaseOfHoverEvent/ValueOfHoverEvent", - properties: [.isSpecialized] - ) - - #expect(manualNested.key != derivedNested.key) - } - @Test("RuntimeObjectKey-keyed Set acts as a dedup oracle for accumulated children") func runtimeObjectKeyDedup() { // Mirrors the sidebar's de-dup guard: diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index e84dd572..3bec28dd 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -16,11 +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 identityPath: String + public let parentFingerprint: Int } /// Mutable so the sidebar can splice in a new specialized child via @@ -38,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, identityPath: runtimeObject.identityPath) + StableID( + imagePath: runtimeObject.imagePath, + name: runtimeObject.name, + kind: runtimeObject.kind, + parentFingerprint: parent?.fingerprint ?? 0 + ) } public var children: [SidebarRuntimeObjectCellViewModel] { @@ -150,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() } @@ -163,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 } @@ -172,13 +206,13 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, imagePath: childRuntimeObject.imagePath, name: childRuntimeObject.name, kind: childRuntimeObject.kind, - identityPath: childRuntimeObject.identityPath + 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 @@ -198,7 +232,6 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, secondaryKind: runtimeObject.secondaryKind, imagePath: runtimeObject.imagePath, children: _children.map { $0.materializedRuntimeObject() }, - identityPath: runtimeObject.identityPath, properties: runtimeObject.properties ) } diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift index 92140071..f06bb6b4 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRuntimeObjectCellViewModelTests.swift @@ -9,20 +9,17 @@ struct SidebarRuntimeObjectCellViewModelTests { func ancestorSpecializationPreservesExistingNestedSpecialization() throws { let failureReason = object( name: "Phase.FailureReason", - displayName: "SwiftUI.EventListenerPhase.FailureReason", - identityPath: "Phase/FailureReason" + displayName: "SwiftUI.EventListenerPhase.FailureReason" ) let value = object( name: "Phase.Value", displayName: "SwiftUI.EventListenerPhase.Value", - identityPath: "Phase/Value", properties: [.isGeneric] ) let phase = object( name: "Phase", displayName: "SwiftUI.EventListenerPhase", children: [failureReason, value], - identityPath: "Phase", properties: [.isGeneric] ) let phaseViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: phase, forOpenQuickly: false) @@ -33,7 +30,6 @@ struct SidebarRuntimeObjectCellViewModelTests { let valueEvent = object( name: "Phase.Value.Event", displayName: "SwiftUI.EventListenerPhase.Value", - identityPath: "Phase/Value/ValueEvent", properties: [.isSpecialized] ) valueViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(valueEvent) @@ -45,17 +41,14 @@ struct SidebarRuntimeObjectCellViewModelTests { object( name: "Phase.PanEvent.FailureReason", displayName: "SwiftUI.EventListenerPhase.FailureReason", - identityPath: "Phase/PhasePan/FailureReasonPan", properties: [.isSpecialized] ), object( name: "Phase.PanEvent.Value", displayName: "SwiftUI.EventListenerPhase.Value", - identityPath: "Phase/PhasePan/ValuePan", properties: [.isSpecialized] ), ], - identityPath: "Phase/PhasePan", properties: [.isSpecialized] ) phaseViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(phasePan) @@ -69,11 +62,60 @@ struct SidebarRuntimeObjectCellViewModelTests { #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] = [], - identityPath: String, properties: RuntimeObject.Properties = [] ) -> RuntimeObject { RuntimeObject( @@ -83,7 +125,6 @@ struct SidebarRuntimeObjectCellViewModelTests { secondaryKind: nil, imagePath: "/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", children: children, - identityPath: identityPath, properties: properties ) } From 9b955da2f7f7edc8532c61611d8ca30808caede5 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 10 Jun 2026 15:24:10 +0800 Subject: [PATCH 68/68] fix(swift-section): register derived nested specialization under dedicated case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's `derivingNestedSpecializationsWith` produces specialized nested children (e.g. `Phase.Value` from specializing `Phase`) whose generic ancestor is `Phase.Value`, not addressable from the derived parent. `makeRuntimeObject` used to register these as `.specializedType(unspecialized: self, specialized: self)`, which is incoherent — `unspecialized` should be the canonical generic parent, not the same bound typeName. The two current consumers (`interface(for:)`, `memberAddresses(...)`) mask this by checking `specializedDefinitionByObject` first, but any third consumer reading `unspecialized` would hit `invalidRuntimeObject`. Introduce `.derivedSpecializedType(TypeName)` as the dedicated case — docs make the contract explicit (only addressable via `specializedDefinitionByObject`). Route both existing consumers through the cache directly. Also clear `specializedDefinitionByObject` alongside `interfaceDefinitionNameByObject` on `showCImportedTypes` reconfig, otherwise the section keeps `TypeDefinition` references no consumer can reach. --- .../Core/RuntimeSwiftSection.swift | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index c11bf76d..4ad27522 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -109,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) @@ -228,16 +239,18 @@ actor RuntimeSwiftSection { children: allChildren, properties: properties ) - let specializedLookupTypeName: SwiftInterface.TypeName? - if let unspecializedTypeName { - specializedLookupTypeName = unspecializedTypeName + if isSpecialized, let unspecializedTypeName { + interfaceDefinitionNameByObject[runtimeObject.key] = .specializedType(unspecialized: unspecializedTypeName, specialized: typeDefinition.typeName) + specializedDefinitionByObject[runtimeObject.key] = typeDefinition } else if isSpecialized { - specializedLookupTypeName = typeDefinition.typeName - } else { - specializedLookupTypeName = nil - } - if isSpecialized, let specializedLookupTypeName { - interfaceDefinitionNameByObject[runtimeObject.key] = .specializedType(unspecialized: specializedLookupTypeName, specialized: typeDefinition.typeName) + // 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) @@ -281,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)) @@ -485,6 +503,10 @@ extension RuntimeSwiftSection { 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] { @@ -1039,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 {