From 372ce3db4befc5ca1a29a40a453232ed946e6164 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 3 Mar 2026 23:25:05 +0800 Subject: [PATCH 01/17] feat: add Bonjour self-discovery filtering and local server advertisement - Embed instance ID in Bonjour TXT record to identify each app process - Filter out self-discovered endpoints in RuntimeEngineManager - Start local Bonjour server to advertise this Mac on the network - Extract makeService/instanceID helpers to reduce duplication - Exclude instanceID from Hashable/Equatable (metadata, not identity) --- .../RuntimeNetworkConnection.swift | 4 +- .../RuntimeNetwork.swift | 46 +++++++++++++++---- .../Utils/RuntimeEngineManager.swift | 30 +++++++++++- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift index 3dc1844..694298d 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift @@ -361,7 +361,7 @@ final class RuntimeNetworkServerConnection: RuntimeConnectionBase NWListener.Service { + var txtRecord = NWTXTRecord() + txtRecord[instanceIDKey] = localInstanceID + return NWListener.Service(name: name, type: type, txtRecord: txtRecord) + } + + static func instanceID(from metadata: NWBrowser.Result.Metadata) -> String? { + guard case .bonjour(let txtRecord) = metadata else { return nil } + return txtRecord[instanceIDKey] + } } public struct RuntimeNetworkEndpoint: Sendable, Codable, Hashable { public let name: String - + public let instanceID: String? + let endpoint: NWEndpoint - - init(name: String, endpoint: NWEndpoint) { + + init(name: String, instanceID: String? = nil, endpoint: NWEndpoint) { self.name = name + self.instanceID = instanceID self.endpoint = endpoint } - + + // Exclude instanceID from equality — it is metadata, not identity. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name && lhs.endpoint == rhs.endpoint + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(endpoint) + } + private enum CodableError: Error { case unsupported } - + public init(from decoder: any Decoder) throws { throw CodableError.unsupported } - + public func encode(to encoder: any Encoder) throws { throw CodableError.unsupported } @@ -85,13 +111,15 @@ public class RuntimeNetworkBrowser { switch change { case .added(let result): if case .service(let name, _, _, _) = result.endpoint { - #log(.info, "Discovered new endpoint: \(name, privacy: .public)") - onAdded(.init(name: name, endpoint: result.endpoint)) + let instanceID = RuntimeNetworkBonjour.instanceID(from: result.metadata) + #log(.info, "Discovered new endpoint: \(name, privacy: .public), instanceID: \(instanceID ?? "nil", privacy: .public)") + onAdded(.init(name: name, instanceID: instanceID, endpoint: result.endpoint)) } case .removed(let result): if case .service(let name, _, _, _) = result.endpoint { + let instanceID = RuntimeNetworkBonjour.instanceID(from: result.metadata) #log(.info, "Endpoint removed: \(name, privacy: .public)") - onRemoved(.init(name: name, endpoint: result.endpoint)) + onRemoved(.init(name: name, instanceID: instanceID, endpoint: result.endpoint)) } default: break diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift index 790ea19..4b909ff 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift @@ -1,6 +1,7 @@ import Foundation import FoundationToolbox import ServiceManagement +import SystemConfiguration import RuntimeViewerCore import RuntimeViewerCommunication import RuntimeViewerArchitectures @@ -25,6 +26,8 @@ public final class RuntimeEngineManager: Loggable { private static let maxRetryAttempts = 3 private static let retryBaseDelay: UInt64 = 2_000_000_000 // 2 seconds in nanoseconds + private var bonjourServerEngine: RuntimeEngine? + @Dependency(\.helperServiceManager) private var helperServiceManager @@ -35,10 +38,15 @@ public final class RuntimeEngineManager: Loggable { private var runtimeHelperClient private init() { - Self.logger.info("RuntimeEngineManager initializing...") + Self.logger.info("RuntimeEngineManager initializing, local instance ID: \(RuntimeNetworkBonjour.localInstanceID, privacy: .public)") + browser.start( onAdded: { [weak self] endpoint in guard let self else { return } + if endpoint.instanceID == RuntimeNetworkBonjour.localInstanceID { + Self.logger.info("Skipping self Bonjour endpoint: \(endpoint.name, privacy: .public)") + return + } Self.logger.info("Bonjour endpoint discovered: \(endpoint.name, privacy: .public), attempting connection...") Task { @MainActor in await self.connectToBonjourEndpoint(endpoint) @@ -52,6 +60,9 @@ public final class RuntimeEngineManager: Loggable { } } ) + + startBonjourServer() + Task { do { Self.logger.info("Launching system runtime engines...") @@ -63,6 +74,23 @@ public final class RuntimeEngineManager: Loggable { } } + private func startBonjourServer() { + let name = SCDynamicStoreCopyComputerName(nil, nil) as? String ?? ProcessInfo.processInfo.hostName + let engine = RuntimeEngine(source: .bonjourServer(name: name, identifier: .init(rawValue: name))) + bonjourServerEngine = engine + + Self.logger.info("Starting Bonjour server with name: \(name, privacy: .public)") + + Task { + do { + try await engine.connect() + Self.logger.info("Bonjour server connected with name: \(name, privacy: .public)") + } catch { + Self.logger.error("Failed to start Bonjour server: \(error, privacy: .public)") + } + } + } + @MainActor private func connectToBonjourEndpoint(_ endpoint: RuntimeNetworkEndpoint, attempt: Int = 0) async { guard !knownBonjourEndpointNames.contains(endpoint.name) else { From 1d6d70a6f10aeaf4a48781e387bf245430b7e028 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 15 Mar 2026 22:22:01 +0800 Subject: [PATCH 02/17] refactor: pin dependencies to versioned releases instead of branch refs Switch multiple SPM dependencies from branch-based to version-based resolution for more stable and reproducible builds. Remove unused AppKitUI dependency and clean up commented-out code. Signed-off-by: Mx-Iris --- RuntimeViewerCore/Package.swift | 6 ++--- RuntimeViewerPackages/Package.swift | 34 +++++++++-------------------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 05139d3..1ab4b67 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -69,7 +69,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOKit.git", - branch: "main" + from: "0.46.100" ) ), .package( @@ -80,7 +80,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", - from: "0.6.0-patch.1" + from: "0.6.100" ) ), .package( @@ -104,7 +104,7 @@ let package = Package( ), .package( url: "https://github.com/Mx-Iris/FrameworkToolbox.git", - branch: "main" + from: "0.4.0" ), .package( url: "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index dc2d1be..0673da4 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -126,7 +126,6 @@ let package = Package( targets: ["RuntimeViewerCatalystExtensions"] ), - ], dependencies: [ .package( @@ -149,7 +148,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/UIFoundation", - branch: "main" + from: "0.3.1" ) ), @@ -214,11 +213,11 @@ let package = Package( ), .package( url: "https://github.com/MxIris-Library-Forks/NSAttributedStringBuilder", - branch: "master" + from: "0.4.2" ), .package( url: "https://github.com/Mx-Iris/SFSymbols", - branch: "main" + from: "0.2.0" ), .package( url: "https://github.com/CombineCommunity/RxCombine", @@ -245,7 +244,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/RxAppKit", - branch: "main" + from: "0.3.0" ) ), .package( @@ -261,7 +260,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/RxUIKit", - branch: "main" + from: "0.1.1" ) ), .package( @@ -272,7 +271,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-macOS-Library-Forks/filter-ui", - branch: "main" + from: "0.1.1" ) ), .package( @@ -297,7 +296,7 @@ let package = Package( ), .package( url: "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", - branch: "main" + from: "0.5.100" ), .package( local: .package( @@ -323,13 +322,9 @@ let package = Package( url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.4" ), -// .package( -// url: "https://github.com/dagronf/DSFInspectorPanes", -// from: "3.0.0" -// ), .package( url: "https://github.com/MxIris-Library-Forks/LateResponders", - branch: "develop" + from: "1.1.0" ), .package( url: "https://github.com/ukushu/Ifrit", @@ -339,10 +334,6 @@ let package = Package( url: "https://github.com/database-utility/fuzzy-search.git", branch: "main" ), - .package( - url: "https://github.com/MxIris-macOS-Library-Forks/AppKitUI", - branch: "main" - ), .package( url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0" @@ -360,7 +351,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-macOS-Library-Forks/DSFQuickActionBar", - branch: "main" + from: "6.2.100" ) ), .package( @@ -371,7 +362,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/SystemHUD", - branch: "main" + from: "0.1.0" ) ), .package( @@ -387,7 +378,6 @@ let package = Package( from: "26.0.0" ), - ], targets: [ .target( @@ -409,7 +399,6 @@ let package = Package( .product(name: usingSystemUXKit ? "UXKitCoordinator" : "OpenUXKitCoordinator", package: "CocoaCoordinator", condition: .when(platforms: appkitPlatforms)), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "SwiftNavigation", package: "swift-navigation"), -// .product(name: "SwiftUINavigation", package: "swift-navigation"), .product(name: "AppKitNavigation", package: "swift-navigation", condition: .when(platforms: appkitPlatforms)), .product(name: "UIKitNavigation", package: "swift-navigation", condition: .when(platforms: uikitPlatforms)), ], @@ -431,10 +420,8 @@ let package = Package( .product(name: "RunningApplicationKit", package: "RunningApplicationKit", condition: .when(platforms: appkitPlatforms)), .product(name: "UIFoundationAppleInternal", package: "UIFoundation"), .product(name: "LateResponders", package: "LateResponders"), - .product(name: "AppKitUI", package: "AppKitUI", condition: .when(platforms: appkitPlatforms)), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts", condition: .when(platforms: appkitPlatforms)), .product(name: "DSFQuickActionBar", package: "DSFQuickActionBar", condition: .when(platforms: appkitPlatforms)), -// .product(name: "DSFInspectorPanes", package: "DSFInspectorPanes", condition: .when(platforms: appkitPlatforms)), .product(name: "SystemHUD", package: "SystemHUD", condition: .when(platforms: appkitPlatforms)), ], @@ -506,7 +493,6 @@ let package = Package( ] ), - ], swiftLanguageModes: [.v5] ) From 0b6db56445992757a25c9d17327da711ac9adb65 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 16 Mar 2026 11:45:43 +0800 Subject: [PATCH 03/17] refactor: pin remaining dependencies to versioned releases and remove unused products Pin CocoaCoordinator, RxSwiftPlus, fuzzy-search, and swift-navigation to version tags instead of branch refs. Switch fuzzy-search to fork with tagged releases. Remove unused AppKitNavigation and UIKitNavigation product dependencies from RuntimeViewerArchitectures. Signed-off-by: Mx-Iris --- RuntimeViewerPackages/Package.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 0673da4..6db7422 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -177,7 +177,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/CocoaCoordinator", - branch: "main" + from: "0.4.0" ) ), .package( @@ -197,7 +197,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/RxSwiftPlus", - branch: "main" + from: "0.2.2" ) ), .package( @@ -331,8 +331,8 @@ let package = Package( from: "3.0.0" ), .package( - url: "https://github.com/database-utility/fuzzy-search.git", - branch: "main" + url: "https://github.com/MxIris-Library-Forks/fuzzy-search", + from: "0.1.0" ), .package( url: "https://github.com/sindresorhus/KeyboardShortcuts", @@ -370,8 +370,8 @@ let package = Package( from: "2.0.1" ), .package( - url: "https://github.com/MxIris-Library-Forks/swift-navigation", - branch: "main" + url: "https://github.com/pointfreeco/swift-navigation", + from: "2.7.0" ), .package( url: "https://github.com/siteline/swiftui-introspect", @@ -399,8 +399,6 @@ let package = Package( .product(name: usingSystemUXKit ? "UXKitCoordinator" : "OpenUXKitCoordinator", package: "CocoaCoordinator", condition: .when(platforms: appkitPlatforms)), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "SwiftNavigation", package: "swift-navigation"), - .product(name: "AppKitNavigation", package: "swift-navigation", condition: .when(platforms: appkitPlatforms)), - .product(name: "UIKitNavigation", package: "swift-navigation", condition: .when(platforms: uikitPlatforms)), ], swiftSettings: sharedSwiftSettings ), From d94745386687bcaf5a29947aa32f7af6e2a57236 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 16 Mar 2026 18:37:13 +0800 Subject: [PATCH 04/17] refactor: pin Core dependencies and update RunningApplicationKit version Pin Semaphore and SwiftyXPC to versioned releases in RuntimeViewerCore. Revert CocoaCoordinator to branch ref (no suitable release tag). Bump RunningApplicationKit from 0.1.1 to 0.2.0. Signed-off-by: Mx-Iris --- RuntimeViewerCore/Package.swift | 4 ++-- RuntimeViewerPackages/Package.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 1ab4b67..07dec57 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -100,7 +100,7 @@ let package = Package( ), .package( url: "https://github.com/MxIris-Library-Forks/Semaphore", - branch: "main" + from: "0.1.0" ), .package( url: "https://github.com/Mx-Iris/FrameworkToolbox.git", @@ -108,7 +108,7 @@ let package = Package( ), .package( url: "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", - branch: "main" + from: "0.5.100" ), .package( url: "https://github.com/MxIris-Library-Forks/swift-memberwise-init-macro", diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 6db7422..1c9a520 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -177,7 +177,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/CocoaCoordinator", - from: "0.4.0" + branch: "main" ) ), .package( @@ -311,7 +311,7 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/RunningApplicationKit", - from: "0.1.1" + from: "0.2.0" ) ), .package( From 46bc416c73b7c2b9d3e64c568d53c4b710957e2e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 18 Mar 2026 01:12:07 +0800 Subject: [PATCH 05/17] refactor: consolidate LaunchServices extensions and update dependencies Move LaunchServicesPrivate dependency from app/server targets into RuntimeViewerCore/RuntimeViewerUtilities, consolidating sandbox detection (LSBundleProxy.isSandbox, NSRunningApplication.isSandbox) into a shared location. Update AttachToProcess to support RunningPickerTabViewController with both application and process attachment. Pin CocoaCoordinator to versioned release, add UXKitCoordinator package, bump filter-ui to 0.1.2, switch SwiftMCP to upstream Cocoanetics repo, and move per-tool @MCPTool naming to server-level toolNaming on @MCPServer. Co-Authored-By: Claude Opus 4.5 Signed-off-by: Mx-Iris --- RuntimeViewerCore/Package.swift | 5 ++ .../LaunchServices+.swift | 25 ++++++++++ RuntimeViewerMCP/Package.swift | 2 +- .../MCPBridgeServer.swift | 26 +++++----- RuntimeViewerPackages/Package.swift | 10 ++-- .../project.pbxproj | 28 ----------- .../RuntimeViewerServer.swift | 17 +------ .../project.pbxproj | 19 +++---- .../AttachToProcessViewController.swift | 23 +++++---- .../AttachToProcessViewModel.swift | 49 ++++++++++--------- 10 files changed, 99 insertions(+), 105 deletions(-) create mode 100644 RuntimeViewerCore/Sources/RuntimeViewerUtilities/LaunchServices+.swift diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 07dec57..6e52c7e 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -126,6 +126,10 @@ let package = Package( url: "https://github.com/p-x9/swift-mobile-gestalt", branch: "main" ), + .package( + url: "https://github.com/MxIris-Reverse-Engineering/LaunchServicesPrivate", + from: "0.1.0" + ), ], targets: [ .target( @@ -166,6 +170,7 @@ let package = Package( name: "RuntimeViewerUtilities", dependencies: [ .product(name: "SwiftMobileGestalt", package: "swift-mobile-gestalt"), + .product(name: "LaunchServicesPrivate", package: "LaunchServicesPrivate"), ] ), .testTarget( diff --git a/RuntimeViewerCore/Sources/RuntimeViewerUtilities/LaunchServices+.swift b/RuntimeViewerCore/Sources/RuntimeViewerUtilities/LaunchServices+.swift new file mode 100644 index 0000000..c3646e2 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerUtilities/LaunchServices+.swift @@ -0,0 +1,25 @@ +#if os(macOS) || targetEnvironment(macCatalyst) + +import AppKit +import LaunchServicesPrivate + +extension LSBundleProxy { + public var isSandbox: Bool { + guard let entitlements = entitlements else { return false } + guard let isSandbox = entitlements["com.apple.security.app-sandbox"] as? Bool else { return false } + return isSandbox + } +} + +extension NSRunningApplication { + public var applicationProxy: LSApplicationProxy? { + guard let bundleIdentifier else { return nil } + return LSApplicationProxy(forIdentifier: bundleIdentifier) + } + + public var isSandbox: Bool { + applicationProxy?.isSandbox ?? false + } +} + +#endif diff --git a/RuntimeViewerMCP/Package.swift b/RuntimeViewerMCP/Package.swift index 5e36ad8..8f86cd1 100644 --- a/RuntimeViewerMCP/Package.swift +++ b/RuntimeViewerMCP/Package.swift @@ -16,7 +16,7 @@ let package = Package( dependencies: [ .package(path: "../RuntimeViewerCore"), .package(path: "../RuntimeViewerPackages"), - .package(url: "https://github.com/MxIris-Library-Forks/SwiftMCP", branch: "main"), + .package(url: "https://github.com/Cocoanetics/SwiftMCP", from: "1.4.0"), ], targets: [ .target( diff --git a/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPBridgeServer.swift b/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPBridgeServer.swift index b6a3dc7..fd161e4 100644 --- a/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPBridgeServer.swift +++ b/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPBridgeServer.swift @@ -57,7 +57,7 @@ private enum ObjectLoadResult { // MARK: - MCP Bridge Server -@MCPServer(name: "RuntimeViewer") +@MCPServer(name: "RuntimeViewer", toolNaming: .pascalCase) public actor MCPBridgeServer { private let documentProvider: MCPBridgeDocumentProvider private var objectsLoadedPaths: Set = [] @@ -134,7 +134,7 @@ public actor MCPBridgeServer { /// Each entry contains: identifier (stable per window session), display title, key-window flag, /// and the currently selected type's name, image path, and image name (if any). /// Returns an empty list when no documents are open; in that case, ask the user to launch RuntimeViewer and open a document. - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func listWindows() async -> MCPListWindowsResponse { let windows = await documentProvider.allDocumentContexts().map { context in MCPWindowInfo( @@ -155,7 +155,7 @@ public actor MCPBridgeServer { /// Response includes: name, display name, kind, image path, and the full generated interface text. /// Throws an error if no type is selected. /// - Parameter windowIdentifier: The window identifier obtained from listWindows - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func selectedType(windowIdentifier: String) async throws -> MCPSelectedTypeResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) guard let runtimeObject = context.selectedRuntimeObject else { @@ -191,7 +191,7 @@ public actor MCPBridgeServer { /// - Parameter typeName: Exact type name — matches against both internal name and display name /// - Parameter imagePath: Full path of the image (framework/dylib) containing the type. Mutually exclusive with imageName. /// - Parameter imageName: Short name of the image without path or extension (e.g. 'AppKit'). Case-insensitive. Mutually exclusive with imagePath. - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func typeInterface(windowIdentifier: String, typeName: String, imagePath: String? = nil, imageName: String? = nil) async throws -> MCPTypeInterfaceResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -244,7 +244,7 @@ public actor MCPBridgeServer { /// - Parameter windowIdentifier: The window identifier obtained from listWindows /// - Parameter imagePath: Full path of the image to list types from. Mutually exclusive with imageName. /// - Parameter imageName: Short name of the image without path or extension. Case-insensitive. Mutually exclusive with imagePath. - @MCPTool(naming: .pascalCase, hints: [.idempotent]) + @MCPTool(hints: [.idempotent]) func listTypes(windowIdentifier: String, imagePath: String? = nil, imageName: String? = nil) async throws -> MCPListTypesResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -298,7 +298,7 @@ public actor MCPBridgeServer { /// - Parameter query: Case-insensitive substring to match against type names /// - Parameter imagePath: Restrict search to a specific image path. Mutually exclusive with imageName. /// - Parameter imageName: Restrict search to images matching this short name. Case-insensitive. Mutually exclusive with imagePath. - @MCPTool(naming: .pascalCase, hints: [.idempotent]) + @MCPTool(hints: [.idempotent]) func searchTypes(windowIdentifier: String, query: String, imagePath: String? = nil, imageName: String? = nil) async throws -> MCPSearchTypesResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -354,7 +354,7 @@ public actor MCPBridgeServer { /// Returns the full file system path of every image registered in dyld. /// Use this to discover available images before querying types. /// - Parameter windowIdentifier: The window identifier obtained from listWindows - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func listImages(windowIdentifier: String) async throws -> MCPListImagesResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -366,7 +366,7 @@ public actor MCPBridgeServer { /// Use this to find the correct imagePath before calling other tools. /// - Parameter windowIdentifier: The window identifier obtained from listWindows /// - Parameter query: Case-insensitive substring to match against image paths - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func searchImages(windowIdentifier: String, query: String) async throws -> MCPSearchImagesResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -391,7 +391,7 @@ public actor MCPBridgeServer { /// - Parameter imagePath: Full path of the image containing the type. Mutually exclusive with imageName. /// - Parameter imageName: Short name of the image. Case-insensitive. Mutually exclusive with imagePath. /// - Parameter memberName: Filter to members whose name contains this string (case-insensitive). - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func memberAddresses(windowIdentifier: String, typeName: String, imagePath: String? = nil, imageName: String? = nil, memberName: String? = nil) async throws -> MCPMemberAddressesResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -430,7 +430,7 @@ public actor MCPBridgeServer { /// - Parameter windowIdentifier: The window identifier obtained from listWindows /// - Parameter imagePath: Full file system path of the image to load /// - Parameter loadObjects: If true, also enumerate and cache runtime objects. Defaults to false. - @MCPTool(naming: .pascalCase, hints: [.idempotent]) + @MCPTool(hints: [.idempotent]) func loadImage(windowIdentifier: String, imagePath: String, loadObjects: Bool = false) async throws -> MCPLoadImageResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -462,7 +462,7 @@ public actor MCPBridgeServer { /// Use this to check before deciding whether to call loadImage. /// - Parameter windowIdentifier: The window identifier obtained from listWindows /// - Parameter imagePath: Full file system path of the image to check - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func isImageLoaded(windowIdentifier: String, imagePath: String) async throws -> MCPIsImageLoadedResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -475,7 +475,7 @@ public actor MCPBridgeServer { /// Once loaded, objects are available for listTypes, searchTypes, getTypeInterface, etc. /// - Parameter windowIdentifier: The window identifier obtained from listWindows /// - Parameter imagePath: Full file system path of the image to load objects from - @MCPTool(naming: .pascalCase, hints: [.idempotent]) + @MCPTool(hints: [.idempotent]) func loadObjects(windowIdentifier: String, imagePath: String) async throws -> MCPLoadObjectsResponse { let context = try await documentProvider.documentContext(forIdentifier: windowIdentifier) let engine = context.runtimeEngine @@ -497,7 +497,7 @@ public actor MCPBridgeServer { /// Use this to decide whether to call loadObjects before querying types. /// - Parameter windowIdentifier: The window identifier obtained from listWindows /// - Parameter imagePath: Full file system path of the image to check - @MCPTool(naming: .pascalCase, hints: [.readOnly]) + @MCPTool(hints: [.readOnly]) func isObjectsLoaded(windowIdentifier: String, imagePath: String) async throws -> MCPIsObjectsLoadedResponse { MCPIsObjectsLoadedResponse(imagePath: imagePath, isLoaded: objectsLoadedPaths.contains(imagePath)) } diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 1c9a520..ac436ea 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -177,9 +177,13 @@ let package = Package( ), remote: .package( url: "https://github.com/Mx-Iris/CocoaCoordinator", - branch: "main" + from: "0.4.1" ) ), + .package( + url: "https://github.com/OpenUXKit/UXKitCoordinator", + branch: "main" + ), .package( url: "https://github.com/SnapKit/SnapKit", from: "5.0.0" @@ -271,7 +275,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-macOS-Library-Forks/filter-ui", - from: "0.1.1" + from: "0.1.2" ) ), .package( @@ -396,7 +400,7 @@ let package = Package( .product(name: "XCoordinatorRx", package: "XCoordinator", condition: .when(platforms: uikitPlatforms)), .product(name: "CocoaCoordinator", package: "CocoaCoordinator", condition: .when(platforms: appkitPlatforms)), .product(name: "RxCocoaCoordinator", package: "CocoaCoordinator", condition: .when(platforms: appkitPlatforms)), - .product(name: usingSystemUXKit ? "UXKitCoordinator" : "OpenUXKitCoordinator", package: "CocoaCoordinator", condition: .when(platforms: appkitPlatforms)), + .product(name: usingSystemUXKit ? "UXKitCoordinator" : "OpenUXKitCoordinator", package: "UXKitCoordinator", condition: .when(platforms: appkitPlatforms)), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "SwiftNavigation", package: "swift-navigation"), ], diff --git a/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj b/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj index 9b5a094..f2ede87 100644 --- a/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj +++ b/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj @@ -7,10 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - E978F9272F1C98A8004C09B4 /* LaunchServicesPrivate in Frameworks */ = {isa = PBXBuildFile; productRef = E978F9262F1C98A8004C09B4 /* LaunchServicesPrivate */; }; E978F92A2F1C98F1004C09B4 /* RuntimeViewerCore in Frameworks */ = {isa = PBXBuildFile; productRef = E978F9292F1C98F1004C09B4 /* RuntimeViewerCore */; }; E994E3C82F325593009DD28A /* RuntimeViewerCore in Frameworks */ = {isa = PBXBuildFile; productRef = E994E3C72F325593009DD28A /* RuntimeViewerCore */; }; - E994E3CA2F3255D1009DD28A /* LaunchServicesPrivate in Frameworks */ = {isa = PBXBuildFile; productRef = E994E3C92F3255D1009DD28A /* LaunchServicesPrivate */; }; E9F1A0012F4C0001004C09B4 /* RuntimeViewerUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = E9F1A0022F4C0001004C09B4 /* RuntimeViewerUtilities */; }; E9F1A0032F4C0002004C09B4 /* RuntimeViewerUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = E9F1A0042F4C0002004C09B4 /* RuntimeViewerUtilities */; }; /* End PBXBuildFile section */ @@ -36,7 +34,6 @@ files = ( E978F92A2F1C98F1004C09B4 /* RuntimeViewerCore in Frameworks */, E9F1A0012F4C0001004C09B4 /* RuntimeViewerUtilities in Frameworks */, - E978F9272F1C98A8004C09B4 /* LaunchServicesPrivate in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -46,7 +43,6 @@ files = ( E994E3C82F325593009DD28A /* RuntimeViewerCore in Frameworks */, E9F1A0032F4C0002004C09B4 /* RuntimeViewerUtilities in Frameworks */, - E994E3CA2F3255D1009DD28A /* LaunchServicesPrivate in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -117,7 +113,6 @@ ); name = RuntimeViewerServer; packageProductDependencies = ( - E978F9262F1C98A8004C09B4 /* LaunchServicesPrivate */, E978F9292F1C98F1004C09B4 /* RuntimeViewerCore */, E9F1A0022F4C0001004C09B4 /* RuntimeViewerUtilities */, ); @@ -144,7 +139,6 @@ name = RuntimeViewerMobileServer; packageProductDependencies = ( E994E3C72F325593009DD28A /* RuntimeViewerCore */, - E994E3C92F3255D1009DD28A /* LaunchServicesPrivate */, E9F1A0042F4C0002004C09B4 /* RuntimeViewerUtilities */, ); productName = RuntimeViewerServer; @@ -176,7 +170,6 @@ mainGroup = E978F90F2F1BBB38004C09B4; minimizedProjectReferenceProxies = 1; packageReferences = ( - E978F9252F1C98A8004C09B4 /* XCRemoteSwiftPackageReference "LaunchServicesPrivate" */, ); preferredProjectObjectVersion = 77; productRefGroup = E978F91A2F1BBB38004C09B4 /* Products */; @@ -922,23 +915,7 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - E978F9252F1C98A8004C09B4 /* XCRemoteSwiftPackageReference "LaunchServicesPrivate" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MxIris-Reverse-Engineering/LaunchServicesPrivate"; - requirement = { - branch = main; - kind = branch; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ - E978F9262F1C98A8004C09B4 /* LaunchServicesPrivate */ = { - isa = XCSwiftPackageProductDependency; - package = E978F9252F1C98A8004C09B4 /* XCRemoteSwiftPackageReference "LaunchServicesPrivate" */; - productName = LaunchServicesPrivate; - }; E978F9292F1C98F1004C09B4 /* RuntimeViewerCore */ = { isa = XCSwiftPackageProductDependency; productName = RuntimeViewerCore; @@ -947,11 +924,6 @@ isa = XCSwiftPackageProductDependency; productName = RuntimeViewerCore; }; - E994E3C92F3255D1009DD28A /* LaunchServicesPrivate */ = { - isa = XCSwiftPackageProductDependency; - package = E978F9252F1C98A8004C09B4 /* XCRemoteSwiftPackageReference "LaunchServicesPrivate" */; - productName = LaunchServicesPrivate; - }; E9F1A0022F4C0001004C09B4 /* RuntimeViewerUtilities */ = { isa = XCSwiftPackageProductDependency; productName = RuntimeViewerUtilities; diff --git a/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift b/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift index cc8f08b..4ecd67f 100644 --- a/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift +++ b/RuntimeViewerServer/RuntimeViewerServer/RuntimeViewerServer.swift @@ -42,10 +42,7 @@ private enum RuntimeViewerServer { return bundleID } - let processName = ProcessInfo.processInfo.processName - let sanitizedName = processName.components(separatedBy: .whitespacesAndNewlines).joined() - - return "com.RuntimeViewer.UnknownBinary.\(sanitizedName)" + return ProcessInfo.processInfo.processName } fileprivate static func main() { @@ -56,7 +53,7 @@ private enum RuntimeViewerServer { #if os(macOS) || targetEnvironment(macCatalyst) - if LSBundleProxy.forCurrentProcess().isSandboxed { + if LSBundleProxy.forCurrentProcess().isSandbox { runtimeEngine = RuntimeEngine(source: .localSocket(name: processName, identifier: .init(rawValue: identifier), role: .server)) try await runtimeEngine?.connect() } else { @@ -85,13 +82,3 @@ private enum RuntimeViewerServer { } } } - -#if os(macOS) || targetEnvironment(macCatalyst) -extension LSBundleProxy { - fileprivate var isSandboxed: Bool { - guard let entitlements = entitlements else { return false } - guard let isSandboxed = entitlements["com.apple.security.app-sandbox"] as? Bool else { return false } - return isSandboxed - } -} -#endif diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index 75d3e2c..ee8a265 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -79,6 +79,7 @@ E9C9E9DE2C2D169D00C4AA34 /* AppKitPluginImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C9E9DC2C2D169D00C4AA34 /* AppKitPluginImpl.swift */; }; E9C9E9DF2C2D16B000C4AA34 /* AppKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C9E9DB2C2D169D00C4AA34 /* AppKitPlugin.swift */; }; E9C9E9E02C2D172600C4AA34 /* AppKitBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A18BA72C2978DF00689B49 /* AppKitBridge.swift */; }; + E9CB2C542F69B7D4005D2135 /* RuntimeViewerUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = E9CB2C532F69B7D4005D2135 /* RuntimeViewerUtilities */; }; E9CE07AF2C148FA00070A6E8 /* MainToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CE07AE2C148FA00070A6E8 /* MainToolbarController.swift */; }; E9CE07B92C1497B30070A6E8 /* SidebarRootTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CE07B82C1497B30070A6E8 /* SidebarRootTableCellView.swift */; }; E9D470632F12A52A008BF7A9 /* SidebarRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D470622F12A52A008BF7A9 /* SidebarRootViewController.swift */; }; @@ -321,6 +322,7 @@ E9432FDF2C0D614900362862 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( + E9CB2C542F69B7D4005D2135 /* RuntimeViewerUtilities in Frameworks */, E98BF62A2F1D4A750041DB20 /* RuntimeViewerCatalystExtensions in Frameworks */, E9862AD32F377D2B00139991 /* RuntimeViewerMCPBridge in Frameworks */, E942AC882C274DF600A2F3D3 /* RuntimeViewerApplication in Frameworks */, @@ -664,6 +666,7 @@ E942AC872C274DF600A2F3D3 /* RuntimeViewerApplication */, E98BF6292F1D4A750041DB20 /* RuntimeViewerCatalystExtensions */, E9862AD22F377D2B00139991 /* RuntimeViewerMCPBridge */, + E9CB2C532F69B7D4005D2135 /* RuntimeViewerUtilities */, ); productName = RuntimeViewerUsingAppKit; productReference = E9432FE22C0D614900362862 /* RuntimeViewer.app */; @@ -798,7 +801,6 @@ ); mainGroup = E9432FD92C0D614900362862; packageReferences = ( - E995B8992ED9834B0083D9D7 /* XCRemoteSwiftPackageReference "LaunchServicesPrivate" */, ); preferredProjectObjectVersion = 90; productRefGroup = E9432FE32C0D614900362862 /* Products */; @@ -1835,17 +1837,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - E995B8992ED9834B0083D9D7 /* XCRemoteSwiftPackageReference "LaunchServicesPrivate" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MxIris-Reverse-Engineering/LaunchServicesPrivate"; - requirement = { - branch = main; - kind = branch; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ E942AC872C274DF600A2F3D3 /* RuntimeViewerApplication */ = { isa = XCSwiftPackageProductDependency; @@ -1879,6 +1870,10 @@ isa = XCSwiftPackageProductDependency; productName = RuntimeViewerService; }; + E9CB2C532F69B7D4005D2135 /* RuntimeViewerUtilities */ = { + isa = XCSwiftPackageProductDependency; + productName = RuntimeViewerUtilities; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = E9432FDA2C0D614900362862 /* Project object */; diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift index ad2a90e..45953c9 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Attach Process/AttachToProcessViewController.swift @@ -5,15 +5,16 @@ import RuntimeViewerApplication import RuntimeViewerArchitectures final class AttachToProcessViewController: AppKitViewController { - private let pickerViewController: RunningApplicationPickerViewController + private let pickerViewController: RunningPickerTabViewController - private let attachRelay = PublishRelay() + private let attachRelay = PublishRelay() private let cancelRelay = PublishRelay() override init(viewModel: AttachToProcessViewModel? = nil) { - let configuration = RunningApplicationPickerViewController.Configuration(title: "Attach To Process", description: "Select a running application to attach to", cancelButtonTitle: "Cancel", confirmButtonTitle: "Attach") - self.pickerViewController = RunningApplicationPickerViewController(configuration: configuration) + 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) super.init(viewModel: viewModel) } @@ -25,7 +26,7 @@ final class AttachToProcessViewController: AppKitViewController { struct Input { - let attachToProcess: Signal + let attachToProcess: Signal let cancel: Signal } @@ -31,22 +31,38 @@ final class AttachToProcessViewModel: ViewModel { func transform(_ input: Input) -> Output { input.cancel.emit(to: router.rx.trigger(.dismiss)).disposed(by: rx.disposeBag) - input.attachToProcess.emitOnNext { [weak self] application in - guard let self, - let name = application.localizedName, - let bundleIdentifier = application.bundleIdentifier - else { return } + input.attachToProcess.emitOnNext { [weak self] runningItem in + guard let self else { return } + + let name = runningItem.name Task { @MainActor [weak self] in guard let self else { return } do { try await runtimeInjectClient.installServerFrameworkIfNeeded() guard let dylibURL = Bundle(url: runtimeInjectClient.serverFrameworkDestinationURL)?.executableURL else { return } - try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: bundleIdentifier, isSandbox: application.isSandbox) - try await runtimeInjectClient.injectApplication(pid: application.processIdentifier, dylibURL: dylibURL) + + switch runningItem { + case let runningApplication as RunningApplication: + try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: runningApplication.bundleIdentifier ?? name, isSandbox: runningApplication.isSandboxed) + case let runningProcess as RunningProcess: + try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: name, isSandbox: runningProcess.isSandboxed) + default: + return + } + + try await runtimeInjectClient.injectApplication(pid: runningItem.processIdentifier, dylibURL: dylibURL) router.trigger(.dismiss) } catch { - runtimeEngineManager.terminateAttachedRuntimeEngine(name: name, identifier: bundleIdentifier, isSandbox: application.isSandbox) + switch runningItem { + case let runningApplication as RunningApplication: + runtimeEngineManager.terminateAttachedRuntimeEngine(name: name, identifier: runningApplication.bundleIdentifier ?? name, isSandbox: runningApplication.isSandboxed) + case let runningProcess as RunningProcess: + runtimeEngineManager.terminateAttachedRuntimeEngine(name: name, identifier: name, isSandbox: runningProcess.isSandboxed) + default: + return + } + #log(.error, "\(error, privacy: .public)") errorRelay.accept(error) } @@ -56,18 +72,3 @@ final class AttachToProcessViewModel: ViewModel { return Output() } } - -private import LaunchServicesPrivate - -extension NSRunningApplication { - fileprivate var applicationProxy: LSApplicationProxy? { - guard let bundleIdentifier else { return nil } - return LSApplicationProxy(forIdentifier: bundleIdentifier) - } - - fileprivate var isSandbox: Bool { - guard let entitlements = applicationProxy?.entitlements else { return false } - guard let isSandboxed = entitlements["com.apple.security.app-sandbox"] as? Bool else { return false } - return isSandboxed - } -} From da792e2ed51b0215c5ed4698f26d9404196faef0 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 18 Mar 2026 14:05:38 +0800 Subject: [PATCH 06/17] fix: re-establish settings observation after MCP server disabled When scheduleRestart(enabled: false) called stop(), the observeToken was cancelled along with the server. This meant re-enabling MCP from Settings had no effect because no observer was watching for the change. Add observe() after stop() in the disabled path to keep listening. Co-Authored-By: Claude Opus 4.5 Signed-off-by: Mx-Iris --- RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPService.swift b/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPService.swift index 2121cc8..bd30ab6 100644 --- a/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPService.swift +++ b/RuntimeViewerMCP/Sources/RuntimeViewerMCPBridge/MCPService.swift @@ -177,6 +177,7 @@ public final class MCPService { } } else { stop() + observe() } } } From 575487d4bf485e11939f3fca58a6f249296adc04 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 18 Mar 2026 14:14:30 +0800 Subject: [PATCH 07/17] refactor: update dependency configurations and module aliases - Enable local MachOObjCSection, pin MachOSwiftSection and swift-mobile-gestalt to versioned releases - Rename SwiftMobileGestalt module alias to SMobileGestalt - Disable local dependency paths in RuntimeViewerPackages, pin XCoordinator to versioned release Co-Authored-By: Claude Opus 4.5 Signed-off-by: Mx-Iris --- RuntimeViewerCore/Package.swift | 8 ++-- .../DeviceIdentifier.swift | 2 +- RuntimeViewerPackages/Package.swift | 38 +++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 6e52c7e..9e022ec 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -76,7 +76,7 @@ let package = Package( local: .package( path: "../../MachOObjCSection", isRelative: true, - isEnabled: false + isEnabled: true ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", @@ -91,7 +91,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - branch: "main" + from: "0.8.0" ) ), .package( @@ -124,7 +124,7 @@ let package = Package( ), .package( url: "https://github.com/p-x9/swift-mobile-gestalt", - branch: "main" + from: "0.4.0" ), .package( url: "https://github.com/MxIris-Reverse-Engineering/LaunchServicesPrivate", @@ -169,7 +169,7 @@ let package = Package( .target( name: "RuntimeViewerUtilities", dependencies: [ - .product(name: "SwiftMobileGestalt", package: "swift-mobile-gestalt"), + .product(name: "SMobileGestalt", package: "swift-mobile-gestalt", moduleAliases: ["SMobileGestalt": "SwiftMobileGestalt"]), .product(name: "LaunchServicesPrivate", package: "LaunchServicesPrivate"), ] ), diff --git a/RuntimeViewerCore/Sources/RuntimeViewerUtilities/DeviceIdentifier.swift b/RuntimeViewerCore/Sources/RuntimeViewerUtilities/DeviceIdentifier.swift index 3839a03..a383d58 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerUtilities/DeviceIdentifier.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerUtilities/DeviceIdentifier.swift @@ -1,6 +1,6 @@ import Foundation import Security -import SwiftMobileGestalt +import SMobileGestalt public enum DeviceIdentifier { /// Returns the device's UniqueDeviceID (UDID) from MobileGestalt. diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index ac436ea..bd8f1c4 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -139,12 +139,12 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("UIFoundation"), isRelative: true, - isEnabled: true + isEnabled: false ), .package( path: "../../UIFoundation", isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/UIFoundation", @@ -156,16 +156,16 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.forkLibraryDirectory.libraryPath("XCoordinator"), isRelative: true, - isEnabled: true + isEnabled: false ), .package( path: "../../XCoordinator", isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/MxIris-Library-Forks/XCoordinator", - branch: "master" + from: "3.0.0-beta" ) ), @@ -173,7 +173,7 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("CocoaCoordinator"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/CocoaCoordinator", @@ -197,7 +197,7 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("RxSwiftPlus"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/RxSwiftPlus", @@ -208,7 +208,7 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("OpenUXKit"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/OpenUXKit/OpenUXKit", @@ -239,12 +239,12 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("RxAppKit"), isRelative: true, - isEnabled: true + isEnabled: false ), .package( path: "../../RxAppKit", isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/RxAppKit", @@ -255,12 +255,12 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryIOSDirectory.libraryPath("RxUIKit"), isRelative: true, - isEnabled: true + isEnabled: false ), .package( path: "../../RxUIKit", isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/RxUIKit", @@ -271,7 +271,7 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.forkLibraryDirectory.libraryPath("filter-ui"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/MxIris-macOS-Library-Forks/filter-ui", @@ -286,12 +286,12 @@ let package = Package( local: .package( path: "../../MachInjector", isRelative: true, - isEnabled: true + isEnabled: false ), .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("MachInjector"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachInjector", @@ -311,7 +311,7 @@ let package = Package( .package( path: "../../RunningApplicationKit", isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/RunningApplicationKit", @@ -346,12 +346,12 @@ let package = Package( local: .package( path: "../../DSFQuickActionBar", isRelative: true, - isEnabled: true + isEnabled: false ), .package( path: MxIrisStudioWorkspace.forkLibraryDirectory.libraryPath("DSFQuickActionBar"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/MxIris-macOS-Library-Forks/DSFQuickActionBar", @@ -362,7 +362,7 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("SystemHUD"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/SystemHUD", From 5ee48dd46084c78afa8f0650938e0906df3062ad Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 19 Mar 2026 20:03:05 +0800 Subject: [PATCH 08/17] chore: remove unnecessary ASSETCATALOG_COMPILER_APPICON_NAME build setting from release workflow Co-Authored-By: Claude Opus 4.5 Signed-off-by: Mx-Iris --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 287ffd0..30234e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,8 +156,7 @@ jobs: -archivePath $BUILD_PATH/RuntimeViewer.xcarchive \ -skipPackagePluginValidation \ -skipMacroValidation \ - CURRENT_PROJECT_VERSION=$(date +"%Y%m%d.%H.%M") \ - ASSETCATALOG_COMPILER_APPICON_NAME="" + CURRENT_PROJECT_VERSION=$(date +"%Y%m%d.%H.%M") - name: Export macOS app run: | From d4180ab73f40fd19cb13bf0c4b035922691f9a14 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 20 Mar 2026 18:25:48 +0800 Subject: [PATCH 09/17] fix: use PID as identifier and improve arm64e build configuration - Use process identifier (PID) instead of bundle identifier for RuntimeViewerServer identification, fixing issues with processes that lack a bundle ID (e.g. mediaremoted) - Guard LSBundleProxy.forCurrentProcess() which may return nil for non-bundled processes - Pin ide-icons dependency to exact version 0.1.1 - Switch schemes to Debug-arm64e configuration for PAC support - Add ARCHS and ONLY_ACTIVE_ARCH settings for universal builds Co-Authored-By: Claude Opus 4.5 Signed-off-by: Mx-Iris --- RuntimeViewerPackages/Package.swift | 2 +- .../RuntimeViewerServer.xcodeproj/project.pbxproj | 4 ++++ .../xcshareddata/xcschemes/RuntimeViewerServer.xcscheme | 2 +- .../RuntimeViewerServer/RuntimeViewerServer.swift | 8 ++------ .../RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj | 4 ++++ .../xcshareddata/xcschemes/RuntimeViewer macOS.xcscheme | 6 +++--- .../Attach Process/AttachToProcessViewModel.swift | 4 ++-- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index bd8f1c4..2de19d8 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -229,7 +229,7 @@ let package = Package( ), .package( url: "https://github.com/MxIris-Library-Forks/ide-icons", - from: "0.1.0" + exact: "0.1.1" ), .package( url: "https://github.com/gringoireDM/RxEnumKit", diff --git a/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj b/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj index f2ede87..433fc29 100644 --- a/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj +++ b/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj @@ -278,6 +278,7 @@ isa = XCBuildConfiguration; buildSettings = { ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; + ARCHS = "$(ARCHS_STANDARD)"; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -556,6 +557,7 @@ isa = XCBuildConfiguration; buildSettings = { ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; + ARCHS = "$(ARCHS_STANDARD)"; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -777,6 +779,7 @@ isa = XCBuildConfiguration; buildSettings = { ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; + ARCHS = "$(ARCHS_STANDARD)"; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -802,6 +805,7 @@ MARKETING_VERSION = 1.0; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.MxIris.RuntimeViewerServer; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = auto; diff --git a/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/xcshareddata/xcschemes/RuntimeViewerServer.xcscheme b/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/xcshareddata/xcschemes/RuntimeViewerServer.xcscheme index a3a4d0d..0fb6a96 100644 --- a/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/xcshareddata/xcschemes/RuntimeViewerServer.xcscheme +++ b/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/xcshareddata/xcschemes/RuntimeViewerServer.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> @@ -57,7 +57,7 @@ + buildConfiguration = "Debug-arm64e"> { switch runningItem { case let runningApplication as RunningApplication: - try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: runningApplication.bundleIdentifier ?? name, isSandbox: runningApplication.isSandboxed) + try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: runningApplication.processIdentifier.description, isSandbox: runningApplication.isSandboxed) case let runningProcess as RunningProcess: - try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: name, isSandbox: runningProcess.isSandboxed) + try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: runningProcess.processIdentifier.description, isSandbox: runningProcess.isSandboxed) default: return } From a460d887388d97d38ec6ca6616b87c400799ba58 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 21 Mar 2026 00:50:18 +0800 Subject: [PATCH 10/17] fix: differentiate MCP config names between Debug and Release builds Use RuntimeViewer-Debug as the MCP server name in Debug builds to avoid conflicts with Release builds. Also fix command argument format for claude and codex CLI tools. Signed-off-by: Mx-Iris --- .../MCP/MCPStatusPopoverViewModel.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift index 645bb64..0afc3ab 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/MCP/MCPStatusPopoverViewModel.swift @@ -11,11 +11,30 @@ enum MCPConfigType { func configString(port: UInt16) -> String { let url = "http://127.0.0.1:\(port)/mcp" + #if DEBUG switch self { case .claudeCode: - return "claude mcp add RuntimeViewer --transport http --url \(url)" + return "claude mcp add RuntimeViewer-Debug --transport http \(url)" case .codex: - return "codex mcp add RuntimeViewer --transport http --url \(url)" + return "codex mcp add RuntimeViewer-Debug --url \(url)" + case .json: + return """ + { + "mcpServers": { + "RuntimeViewer-Debug": { + "type": "http", + "url": "\(url)" + } + } + } + """ + } + #else + switch self { + case .claudeCode: + return "claude mcp add RuntimeViewer --transport http \(url)" + case .codex: + return "codex mcp add RuntimeViewer --url \(url)" case .json: return """ { @@ -28,6 +47,7 @@ enum MCPConfigType { } """ } + #endif } } From 4a1b04068aff64d2b762124e42efa6362f73cc7e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 21 Mar 2026 23:35:09 +0800 Subject: [PATCH 11/17] docs: add remote engine mirroring design spec Design for mirroring RuntimeEngines across macOS hosts via Bonjour, including management/data connection architecture, generic proxy server, transitive sharing with cycle detection, and UI grouping. --- ...26-03-21-remote-engine-mirroring-design.md | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md diff --git a/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md b/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md new file mode 100644 index 0000000..e9e0d95 --- /dev/null +++ b/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md @@ -0,0 +1,456 @@ +# Remote Engine Mirroring Design + +## Overview + +Enable macOS clients to mirror all RuntimeEngines from remote hosts discovered via Bonjour. When Host A connects to Host B, Host A receives the complete engine list from Host B (including engines Host B mirrored from Host C), with cycle detection via origin chain tracking. The UI groups engines by host using `NSMenuItem.sectionHeaderWithTitle`. + +## Architecture + +``` +Host A (Local) Host B (Remote) +───────────── ───────────── +RuntimeEngineManager RuntimeEngineManager + │ │ + ├─ local engine ├─ local engine ←──────────┐ + ├─ attached engines ├─ attached engine X ←─────┤ + │ ├─ bonjour engine (from C)←┤ + │ Bonjour discovery │ │ + │ │ │ │ + │ ▼ │ │ + ├─ bonjour engine (Host B) ══════════════════►├─ Bonjour Server │ + │ (management + Host B local data) │ (mgmt + local data) │ + │ │ │ + │ Protocol: engineList command │ │ + │ ◄── engine list + directTCP ports ─────────┘ │ + │ │ │ + ├─ directTCP engine (X) ═════════════════════╪══► Proxy Server (X) ──────┘ + ├─ directTCP engine (C) ═════════════════════╪══► Proxy Server (C) ──────┘ +``` + +### Key Design Decisions + +1. **Management connection = Bonjour connection**: The existing Bonjour client engine doubles as the management channel AND a usable engine for browsing the remote host's local runtime data. +2. **Data connections = directTCP**: Each shared remote engine gets its own directTCP connection. The management connection provides host:port info. +3. **Full transitive mirroring**: Engines mirrored from third-party hosts are also shared. +4. **Cycle prevention**: Each engine carries an `originChain` (list of Host IDs, appended at each hop). Receivers skip engines whose chain contains their own `localInstanceID`. +5. **Generic proxy**: `RuntimeEngineProxyServer` can proxy any engine regardless of its underlying connection type (local, XPC, Bonjour, directTCP), and forwards both request-response and server-push data. +6. **Name-based messaging**: All new commands use `RuntimeEngine.CommandNames` + name-based message handlers, consistent with existing engine communication patterns (NOT `RuntimeRequest` protocol). + +## 1. Communication Protocol + +### New Commands + +All management commands are added to `RuntimeEngine.CommandNames` and use the existing name-based message handler mechanism (`connection.sendMessage(name:request:)` / `connection.setMessageHandler(name:handler:)`), consistent with all other engine commands (imageList, imageNodes, reloadData, etc.). + +#### CommandNames Additions + +```swift +extension RuntimeEngine { + fileprivate enum CommandNames: String, CaseIterable { + // ... existing cases ... + case engineList // Request/response: client asks for engine list + case engineListChanged // Push: server notifies engine list changes + } +} +``` + +#### RemoteEngineDescriptor + +Describes a single shareable engine. Used as the payload for both `engineList` response and `engineListChanged` push. + +```swift +public struct RemoteEngineDescriptor: Codable, Hashable { + let engineID: String // Unique identifier for this engine + let displayName: String // Human-readable name (e.g. "Local Runtime", "Attached: Safari") + let engineKind: EngineKind // Simplified source type for UI display + let hostName: String // Human-readable host name + let originChain: [String] // Host ID chain for cycle detection (appended at each hop) + let directTCPHost: String // Proxy server host IP + let directTCPPort: UInt16 // Proxy server port +} + +public enum EngineKind: String, Codable { + case local + case attached // XPC / localSocket injected process + case bonjour // Bonjour-connected remote device + case directTCP // Direct TCP connection + case macCatalyst // Mac Catalyst helper +} +``` + +**Design note**: Uses `EngineKind` instead of `RuntimeSource` to avoid serializing internal connection details (Role, NWEndpoint, etc.) that are meaningless to the receiver. The receiver always connects via `.directTCP`. + +**RuntimeSource → EngineKind mapping**: +- `.local` → `.local` +- `.remote(_, _, _)` → `.attached` (XPC injected process) +- `.localSocket(_, _, _)` → `.attached` (localSocket injected process) +- `.bonjour(_, _, _)` → `.bonjour` +- `.directTCP(_, _, _, _)` → `.directTCP` +- `.macCatalystClient` / `.macCatalystServer` → `.macCatalyst` + +#### Management Flow (within RuntimeEngine) + +**Server side** — In `setupMessageHandlerForServer()`, add handler for `engineList` command. The handler delegates to `RuntimeEngineManager` to build the descriptor list. + +**Implementation note**: The existing `setMessageHandlerBinding` overloads require a `Request` parameter. For `engineList` (no request body, has response), add a new overload: + +```swift +// New setMessageHandlerBinding overload (no request, has response): +private func setMessageHandlerBinding( + forName name: CommandNames, + perform: @escaping (isolated RuntimeEngine) async throws -> Response +) { + guard let connection else { return } + connection.setMessageHandler(name: name.commandName) { [weak self] () -> Response in + guard let self else { throw RequestError.senderConnectionIsLose } + return try await perform(self) + } +} + +// In setupMessageHandlerForServer(), add: +setMessageHandlerBinding(forName: .engineList) { engine -> [RemoteEngineDescriptor] in + await RuntimeEngineManager.shared.buildEngineDescriptors() +} +``` + +**Server push** — When `RuntimeEngineManager` detects engine list changes, it pushes via a new public method on `RuntimeEngine`: + +```swift +// RuntimeEngine public API addition: +public func pushEngineListChanged(_ descriptors: [RemoteEngineDescriptor]) async throws { + guard let connection, source.remoteRole?.isServer == true else { return } + try await connection.sendMessage(name: .engineListChanged, request: descriptors) +} + +// In RuntimeEngineManager, on engine list change: +try await bonjourServerEngine.pushEngineListChanged(buildEngineDescriptors()) +``` + +**Client side** — In `setupMessageHandlerForClient()`, add handler for `engineListChanged`: + +```swift +// In RuntimeEngine.setupMessageHandlerForClient(), add: +setMessageHandlerBinding(forName: .engineListChanged) { engine, descriptors in + await RuntimeEngineManager.shared.handleEngineListChanged(descriptors, from: engine) +} +``` + +**Note**: `RuntimeEngine.connection` is `private`. Management commands go through the engine's own message handler infrastructure or dedicated public methods (`requestEngineList()`, `pushEngineListChanged()`), so no external access to `connection` is needed. + +### Bonjour Server Single-Client Constraint + +The current `RuntimeNetworkServerConnection` supports only one client at a time (accepts one `NWConnection`, then cancels the listener). This design works within this constraint: + +- Each Bonjour server engine serves one management client +- If multiple remote hosts discover this host, only one can connect at a time via Bonjour +- When the connected client disconnects, the server restarts listening for the next client +- This is acceptable for the initial implementation; multi-client support can be added later by refactoring `RuntimeNetworkServerConnection` to accept multiple connections + +## 2. Proxy Server + +### RuntimeEngineProxyServer + +Location: `RuntimeViewerCore/Sources/RuntimeViewerCore/` + +A new actor that wraps any `RuntimeEngine` and exposes it via a directTCP server connection. Incoming requests are forwarded to the underlying engine, and server-push data is relayed to the connected client. + +```swift +public actor RuntimeEngineProxyServer { + let engine: RuntimeEngine + private let connection: RuntimeConnection // directTCP server (port 0, auto-assigned) + private var subscriptions: Set = [] + + init(engine: RuntimeEngine, identifier: String) async throws + + var port: UInt16 { get } + var host: String { get } + + func stop() +} +``` + +**Request-response handlers**: Mirrors `RuntimeEngine.setupMessageHandlerForServer()`, but forwards to `engine` public API: + +```swift +// Register handlers on the directTCP server connection: +connection.setMessageHandler(name: .isImageLoaded) { [engine] (path: String) -> Bool in + try await engine.isImageLoaded(path: path) +} +connection.setMessageHandler(name: .runtimeObjectsInImage) { [engine] (image: String) -> [RuntimeObject] in + try await engine.objects(in: image) +} +// ... same pattern for all RuntimeEngine public methods +``` + +**Server-push relay**: Subscribe to the source engine's Combine publishers and forward to the directTCP client. + +**Implementation note**: `imageList` is currently `public private(set) var` (NOT `@Published`), while `imageNodes` is `@Published` and `reloadDataPublisher` is a `PassthroughSubject`. For the proxy relay: +- `imageNodes`: subscribe via `engine.$imageNodes` +- `reloadDataPublisher`: subscribe via `engine.reloadDataPublisher` +- `imageList`: since `sendRemoteDataIfNeeded()` already pushes `imageList` before `reloadData`, the proxy can fetch `engine.imageList` when relaying `reloadData`. Alternatively, make `imageList` `@Published` during implementation. + +```swift +// Subscribe to source engine's data changes and relay: +engine.$imageNodes.sink { [connection] imageNodes in + Task { try? await connection.sendMessage(name: .imageNodes, request: imageNodes) } +}.store(in: &subscriptions) + +engine.reloadDataPublisher.sink { [weak engine, connection] in + Task { + guard let engine else { return } + let imageList = await engine.imageList + try? await connection.sendMessage(name: .imageList, request: imageList) + try? await connection.sendMessage(name: .reloadData) + } +}.store(in: &subscriptions) +``` + +**Cross-actor calls**: Since `RuntimeEngine` is an actor and all its public methods are `async`, and `setMessageHandler` handler closures are already `@Sendable () async throws -> ...`, cross-actor `await` calls work naturally with no special handling needed. + +## 3. Remote Host — Engine Sharing Management + +### RuntimeEngineManager Additions (Server Side) + +```swift +// New properties +private var proxyServers: [String: RuntimeEngineProxyServer] = [:] // engineID → proxy + +// New methods +func startSharingEngines() +func stopSharingEngines() +func buildEngineDescriptors() -> [RemoteEngineDescriptor] +``` + +**`startSharingEngines()`**: +- Observes `runtimeEngines` changes (via existing `@Published` / Rx binding) +- For each engine (except the Bonjour server engine itself), starts a `RuntimeEngineProxyServer` +- On engine list change: updates proxy servers + pushes `engineListChanged` command via Bonjour server engine's connection to the management client + +**Origin chain construction**: +- Local engines: `originChain = [localInstanceID]` +- Mirrored engines: `originChain = remote engine's originChain + [localInstanceID]` + (Each intermediate host appends its own ID, enabling full cycle detection across N hops) + +**Proxy server cleanup**: +- When a proxied engine disconnects (e.g. attached process exits), the corresponding `RuntimeEngineProxyServer` is stopped, its TCP listener cancelled, and port released +- Push updated engine list to management client + +## 4. Local Host — Engine Mirror Reception + +### RuntimeEngineManager Additions (Client Side) + +```swift +// New properties +private var mirroredEngines: [String: RuntimeEngine] = [:] // engineID → engine + +// New methods +func requestEngineList(from engine: RuntimeEngine) +func handleEngineListChanged(_ descriptors: [RemoteEngineDescriptor], from engine: RuntimeEngine) +``` + +**Flow after Bonjour client engine connects**: +1. Send `engineList` command via the Bonjour client engine, receive `[RemoteEngineDescriptor]` +2. `engineListChanged` handler is already registered by `setupMessageHandlerForClient()` +3. For each `RemoteEngineDescriptor`: + a. **Cycle check**: Skip if `originChain` contains `RuntimeNetworkBonjour.localInstanceID` + b. **Dedup check**: Skip if `engineID` already exists in any engine collection + c. Create `RuntimeEngine(source: .directTCP(name:host:port:role: .client))` with `hostInfo` and `originChain` from descriptor + d. Connect and store in `mirroredEngines` +4. On `engineListChanged` push: diff against current `mirroredEngines`, add/remove accordingly + +**Initial engine list request**: After Bonjour client engine state becomes `.connected`, `RuntimeEngineManager` sends `engineList` command. This requires exposing a public method on `RuntimeEngine` to send the engine list request: + +```swift +// RuntimeEngine public API addition (uses existing request(local:remote:) pattern): +public func requestEngineList() async throws -> [RemoteEngineDescriptor] { + try await request { + [] // Local engines don't have a remote engine list + } remote: { + try await $0.sendMessage(name: .engineList) + } +} +``` + +## 5. Engine Identity Enhancement + +### HostInfo + +Location: `RuntimeViewerCore/Sources/RuntimeViewerCommunication/` (co-located with networking types) + +```swift +public struct HostInfo: Codable, Hashable, Sendable { + public let hostID: String // RuntimeNetworkBonjour.localInstanceID + public let hostName: String // SCDynamicStoreCopyComputerName / UIDevice.name +} +``` + +### RuntimeEngine Additions + +```swift +public actor RuntimeEngine { + // Existing properties... + public nonisolated let hostInfo: HostInfo + public nonisolated let originChain: [String] +} +``` + +- Local engines: `hostInfo = .local` (hostID = localInstanceID, hostName = computerName), `originChain = [localInstanceID]` +- Bonjour client engines: `hostInfo` from Bonjour TXT record (add `rv-host-name` key) +- Mirrored engines: constructed from `RemoteEngineDescriptor` + +### Bonjour TXT Record Enhancement + +Add `rv-host-name` to the Bonjour TXT record alongside existing `rv-instance-id`: + +```swift +// In RuntimeNetworkBonjour.makeService(name:): +txtRecord["rv-instance-id"] = localInstanceID +txtRecord["rv-host-name"] = hostName // NEW +``` + +## 6. UI — Toolbar Source Menu Grouping + +### Data Model + +```swift +public struct RuntimeEngineSection { + public let hostName: String + public let hostID: String + public let engines: [RuntimeEngine] +} +``` + +`RuntimeEngineManager` provides `@Published var runtimeEngineSections: [RuntimeEngineSection]`, computed from all engines grouped by `hostInfo.hostID`. Exposed via Rx as `rx.runtimeEngineSections: Driver<[RuntimeEngineSection]>`. + +### RxAppKit Extension (Local Dependency) + +Add `sectionItems` binding directly in the RxAppKit library source: + +```swift +extension Reactive where Base: NSPopUpButton { + func sectionItems( + _ sections: Driver<[Section]>, + sectionTitle: @escaping (Section) -> String, + items: @escaping (Section) -> [Item], + itemTitle: @escaping (Item) -> String, + itemImage: ((Item) -> NSImage?)? = nil + ) -> Disposable +} +``` + +**Behavior**: +1. On each `sections` emission: `removeAllItems()` +2. For each section: + a. Add `NSMenuItem.sectionHeader(title:)` (disabled, non-selectable) + b. For each item: add normal `NSMenuItem` with `representedObject` set to a stable identifier +3. Selection synchronization: use `representedObject` matching (not index-based) + +### Selection Model Change + +The current `switchSource: Signal` (index-based) will not work with section headers in the menu. Change to identifier-based selection: + +```swift +// MainViewModel.Input — change: +// Before: let switchSource: Signal +// After: +let switchSource: Signal // engine identifier + +// MainWindowController — change: +// Before: switchSourceItem.popUpButton.rx.selectedItemIndex() +// After: switchSourceItem.popUpButton.rx.selectedItemRepresentedObject() +// (new RxAppKit binding that emits representedObject of selected item) +``` + +Each `NSMenuItem` gets a stable engine identifier as `representedObject`. The `MainViewModel` matches this against `runtimeEngines` to find the selected engine. + +**Implementation note**: `RuntimeSource.identifier` is currently `fileprivate` (defined in `RuntimeConnectionNotificationService.swift`). It needs to be promoted to a `public` property on `RuntimeSource` (move from `fileprivate` extension to the main `RuntimeSource` definition or a public extension). + +### Menu Appearance + +``` +┌──────────────────────────┐ +│ 本机 │ ← sectionHeader (disabled) +│ Local Runtime │ +│ Attached: Safari │ +│ MacBook-Pro │ ← sectionHeader (disabled) +│ Local Runtime │ +│ Attached: Xcode │ +│ iPhone 15 (via Host-C) │ +└──────────────────────────┘ +``` + +## 7. Lifecycle Management + +### Management Connection Disconnect + +When a Bonjour management connection to a remote host disconnects: +1. Remove all mirrored engines from that host (`mirroredEngines` entries matching the host) +2. DirectTCP connections naturally close as mirrored engines are terminated +3. **Send `UserNotification`** via existing `RuntimeConnectionNotificationService.notifyDisconnected(source:error:)` — reuse the existing notification infrastructure (supports `Settings.shared.notifications` toggle) + +### Remote Engine Change + +On `engineListChanged` push: +1. Diff current `mirroredEngines` against new descriptor list +2. **Added**: Create directTCP client engine, connect +3. **Removed**: Terminate engine, close connection, clean up proxy server resources +4. **Unchanged**: Keep existing connection + +### Proxy Server Cleanup + +When a proxied engine disconnects on the server side: +1. Stop the `RuntimeEngineProxyServer` (cancel TCP listener, release port) +2. Remove from `proxyServers` dictionary +3. Push updated `engineListChanged` to management client + +### UserNotification + +Reuse existing `RuntimeConnectionNotificationService` (already supports connect/disconnect notifications with `UNUserNotificationCenter`, configurable via `Settings.shared.notifications`): + +- Remote host disconnected: `notifyDisconnected(source:error:)` — already implemented +- Remote host connected: `notifyConnected(source:)` — already implemented +- Mirrored engine added/removed: extend `RuntimeConnectionNotificationService` with new methods if needed + +## 8. Files to Create/Modify + +### New Files +- `RuntimeViewerCore/Sources/RuntimeViewerCommunication/RemoteEngineDescriptor.swift` — `RemoteEngineDescriptor`, `EngineKind` +- `RuntimeViewerCore/Sources/RuntimeViewerCommunication/HostInfo.swift` — `HostInfo` +- `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift` — Proxy server actor +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineSection.swift` — `RuntimeEngineSection` + +### Modified Files +- `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift`: + - Add `hostInfo`, `originChain` nonisolated properties + - Add `engineList`, `engineListChanged` to `CommandNames` + - Add handlers in `setupMessageHandlerForServer/Client()` + - Add `requestEngineList()` and `pushEngineListChanged()` public methods + - Add new `setMessageHandlerBinding` overload (no request, has response) +- `RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeSource.swift`: + - Promote `identifier` from `fileprivate` to `public` + - Add `public var engineKind: EngineKind` computed property +- `RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift`: + - Add `rv-host-name` to Bonjour TXT record +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift`: + - Add `proxyServers`, `mirroredEngines`, `runtimeEngineSections` + - Add sharing management, mirror reception, proxy server lifecycle + - Reuse `RuntimeConnectionNotificationService` for notifications +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift`: + - Switch to section-based source binding + - Change `switchSource` from `Signal` to `Signal` (identifier-based) +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainWindowController.swift`: + - Update popup binding to use `sectionItems` and `representedObject` +- `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainToolbarController.swift`: + - Update `SwitchSourceToolbarItem` if needed for new binding +- RxAppKit (local dependency): + - Add `sectionItems` binding for `NSPopUpButton` + - Add `selectedItemRepresentedObject()` binding + +## 9. Thread Safety + +`RuntimeEngineManager` currently uses `@MainActor` for individual methods. New mutable state (`proxyServers`, `mirroredEngines`) will be accessed from Bonjour callbacks and Task contexts. Options: + +- Mark `RuntimeEngineManager` as `@MainActor` (preferred — it's already UI-adjacent and most mutations happen on main thread) +- Or protect new dictionaries with a lock/actor isolation + +This should be decided during implementation based on existing threading patterns in the class. From 84baaa17dd6c9fc03140a417e8a129da9fbd283d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 21 Mar 2026 23:54:51 +0800 Subject: [PATCH 12/17] docs: revert EngineKind, use RuntimeSource directly in RemoteEngineDescriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeSource is already Codable — no need for a separate EngineKind abstraction. --- ...26-03-21-remote-engine-mirroring-design.md | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md b/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md index e9e0d95..cb53e1d 100644 --- a/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md +++ b/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md @@ -61,33 +61,14 @@ Describes a single shareable engine. Used as the payload for both `engineList` r ```swift public struct RemoteEngineDescriptor: Codable, Hashable { let engineID: String // Unique identifier for this engine - let displayName: String // Human-readable name (e.g. "Local Runtime", "Attached: Safari") - let engineKind: EngineKind // Simplified source type for UI display + let source: RuntimeSource // Original source type (already Codable) let hostName: String // Human-readable host name let originChain: [String] // Host ID chain for cycle detection (appended at each hop) let directTCPHost: String // Proxy server host IP let directTCPPort: UInt16 // Proxy server port } - -public enum EngineKind: String, Codable { - case local - case attached // XPC / localSocket injected process - case bonjour // Bonjour-connected remote device - case directTCP // Direct TCP connection - case macCatalyst // Mac Catalyst helper -} ``` -**Design note**: Uses `EngineKind` instead of `RuntimeSource` to avoid serializing internal connection details (Role, NWEndpoint, etc.) that are meaningless to the receiver. The receiver always connects via `.directTCP`. - -**RuntimeSource → EngineKind mapping**: -- `.local` → `.local` -- `.remote(_, _, _)` → `.attached` (XPC injected process) -- `.localSocket(_, _, _)` → `.attached` (localSocket injected process) -- `.bonjour(_, _, _)` → `.bonjour` -- `.directTCP(_, _, _, _)` → `.directTCP` -- `.macCatalystClient` / `.macCatalystServer` → `.macCatalyst` - #### Management Flow (within RuntimeEngine) **Server side** — In `setupMessageHandlerForServer()`, add handler for `engineList` command. The handler delegates to `RuntimeEngineManager` to build the descriptor list. @@ -414,7 +395,7 @@ Reuse existing `RuntimeConnectionNotificationService` (already supports connect/ ## 8. Files to Create/Modify ### New Files -- `RuntimeViewerCore/Sources/RuntimeViewerCommunication/RemoteEngineDescriptor.swift` — `RemoteEngineDescriptor`, `EngineKind` +- `RuntimeViewerCore/Sources/RuntimeViewerCommunication/RemoteEngineDescriptor.swift` — `RemoteEngineDescriptor` - `RuntimeViewerCore/Sources/RuntimeViewerCommunication/HostInfo.swift` — `HostInfo` - `RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngineProxyServer.swift` — Proxy server actor - `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineSection.swift` — `RuntimeEngineSection` @@ -428,7 +409,6 @@ Reuse existing `RuntimeConnectionNotificationService` (already supports connect/ - Add new `setMessageHandlerBinding` overload (no request, has response) - `RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeSource.swift`: - Promote `identifier` from `fileprivate` to `public` - - Add `public var engineKind: EngineKind` computed property - `RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift`: - Add `rv-host-name` to Bonjour TXT record - `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift`: From 983df86f3435eef9e3446540698086ee65b23606 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 22 Mar 2026 00:29:37 +0800 Subject: [PATCH 13/17] refactor: update dependency versions and module configuration - Bump MachOObjCSection to 0.6.101, MachOSwiftSection to 0.8.1 - UIFoundation to 0.4.0 with AppleInternal trait, remove separate re-export - Support traits in local package dependency resolution - Remove moduleAliases for SMobileGestalt - Rename debug product to RuntimeViewer-Debug Signed-off-by: Mx-Iris --- RuntimeViewerCore/Package.swift | 6 +++--- RuntimeViewerPackages/Package.swift | 16 +++++++++------- .../RuntimeViewerUI/AppKit/Extensions.swift | 1 - .../RuntimeViewerUI/RuntimeViewerUI.swift | 1 - .../project.pbxproj | 8 ++++---- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 9e022ec..c2988cb 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -80,7 +80,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", - from: "0.6.100" + from: "0.6.101" ) ), .package( @@ -91,7 +91,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - from: "0.8.0" + from: "0.8.1" ) ), .package( @@ -169,7 +169,7 @@ let package = Package( .target( name: "RuntimeViewerUtilities", dependencies: [ - .product(name: "SMobileGestalt", package: "swift-mobile-gestalt", moduleAliases: ["SMobileGestalt": "SwiftMobileGestalt"]), + .product(name: "SMobileGestalt", package: "swift-mobile-gestalt"), .product(name: "LaunchServicesPrivate", package: "LaunchServicesPrivate"), ] ), diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 2de19d8..ef1c99b 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -52,7 +52,7 @@ struct MxIrisStudioWorkspace: RawRepresentable, ExpressibleByStringLiteral, Cust extension Package.Dependency { enum LocalSearchPath { - case package(path: String, isRelative: Bool, isEnabled: Bool) + case package(path: String, isRelative: Bool, isEnabled: Bool, traits: Set = [.defaults]) } static func package(local localSearchPaths: LocalSearchPath..., remote: Package.Dependency) -> Package.Dependency { @@ -66,7 +66,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, let resolvedURL = URL(string: path, relativeTo: URL(fileURLWithPath: #filePath)) { resolvedURL @@ -75,7 +75,7 @@ extension Package.Dependency { } if FileManager.default.fileExists(atPath: url.path) { - return .package(path: url.path) + return .package(path: url.path, traits: traits) } } } @@ -139,16 +139,19 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("UIFoundation"), isRelative: true, - isEnabled: false + isEnabled: true, + traits: ["AppleInternal"], ), .package( path: "../../UIFoundation", isRelative: true, - isEnabled: false + isEnabled: true, + traits: ["AppleInternal"], ), remote: .package( url: "https://github.com/Mx-Iris/UIFoundation", - from: "0.3.1" + from: "0.4.0", + traits: ["AppleInternal"], ) ), @@ -420,7 +423,6 @@ let package = Package( .product(name: "FilterUI", package: "filter-ui", condition: .when(platforms: appkitPlatforms)), .product(name: "Rearrange", package: "Rearrange", condition: .when(platforms: appkitPlatforms)), .product(name: "RunningApplicationKit", package: "RunningApplicationKit", condition: .when(platforms: appkitPlatforms)), - .product(name: "UIFoundationAppleInternal", package: "UIFoundation"), .product(name: "LateResponders", package: "LateResponders"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts", condition: .when(platforms: appkitPlatforms)), .product(name: "DSFQuickActionBar", package: "DSFQuickActionBar", condition: .when(platforms: appkitPlatforms)), diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/Extensions.swift b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/Extensions.swift index af9a258..78ae7bd 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/Extensions.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/Extensions.swift @@ -8,5 +8,4 @@ extension NSPopUpButton { } } - #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerUI/RuntimeViewerUI.swift b/RuntimeViewerPackages/Sources/RuntimeViewerUI/RuntimeViewerUI.swift index d437e41..3af7aca 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerUI/RuntimeViewerUI.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerUI/RuntimeViewerUI.swift @@ -4,7 +4,6 @@ @_exported import IDEIcons @_exported import UIFoundation @_exported import UIFoundationToolbox -@_exported import UIFoundationAppleInternal #if canImport(AppKit) && !targetEnvironment(macCatalyst) #if USING_SYSTEM_UXKIT @_exported import UXKit diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj index 9bf7e7f..b0ffd2e 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit.xcodeproj/project.pbxproj @@ -224,7 +224,7 @@ E92124722F447C94007481E4 /* ExportingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportingViewController.swift; sourceTree = ""; }; E927C22D2E9444F300084A7B /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = AppIcon.icon; path = ../Resources/AppIcon.icon; sourceTree = SOURCE_ROOT; }; E942ACBE2C28865100A2F3D3 /* MainRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainRoute.swift; sourceTree = ""; }; - E9432FE22C0D614900362862 /* RuntimeViewer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RuntimeViewer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E9432FE22C0D614900362862 /* RuntimeViewer-Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RuntimeViewer-Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; E9432FE52C0D614900362862 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; E9432FE72C0D614A00362862 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E9432FEA2C0D614A00362862 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -400,7 +400,7 @@ E9432FE32C0D614900362862 /* Products */ = { isa = PBXGroup; children = ( - E9432FE22C0D614900362862 /* RuntimeViewer.app */, + E9432FE22C0D614900362862 /* RuntimeViewer-Debug.app */, E947C3672C2A4D0400296B2E /* RuntimeViewerCatalystHelper.app */, E9E900DC2C2CF9A500FADDCC /* RuntimeViewerCatalystHelperPlugin.bundle */, E9E900E82C2D0D5B00FADDCC /* com.JH.RuntimeViewerService */, @@ -669,7 +669,7 @@ E9CB2C532F69B7D4005D2135 /* RuntimeViewerUtilities */, ); productName = RuntimeViewerUsingAppKit; - productReference = E9432FE22C0D614900362862 /* RuntimeViewer.app */; + productReference = E9432FE22C0D614900362862 /* RuntimeViewer-Debug.app */; productType = "com.apple.product-type.application"; }; E947C3662C2A4D0400296B2E /* RuntimeViewerCatalystHelper */ = { @@ -1212,7 +1212,7 @@ MARKETING_VERSION = 2.0.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = dev.JH.RuntimeViewer; - PRODUCT_NAME = RuntimeViewer; + PRODUCT_NAME = "RuntimeViewer-Debug"; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; From ad439c5d3f8f8e4bebf2710a27e4fd5d5a7521ce Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 22 Mar 2026 00:29:43 +0800 Subject: [PATCH 14/17] fix: use PID instead of bundle identifier for caller tracking Using bundle identifier could match wrong processes when multiple instances share the same bundle ID. PID is unique per process. Signed-off-by: Mx-Iris --- .../Requests/OpenApplicationRequest.swift | 6 +++--- .../RuntimeHelperClient.swift | 4 ++-- .../RuntimeViewerService.swift | 14 +++++--------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Requests/OpenApplicationRequest.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Requests/OpenApplicationRequest.swift index e9e25a6..7eddfd1 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Requests/OpenApplicationRequest.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Requests/OpenApplicationRequest.swift @@ -9,11 +9,11 @@ public struct OpenApplicationRequest: Codable, RuntimeRequest { public let url: URL - public let callerBundleIdentifier: String + public let callerPID: Int32 - public init(url: URL, callerBundleIdentifier: String) { + public init(url: URL, callerPID: Int32) { self.url = url - self.callerBundleIdentifier = callerBundleIdentifier + self.callerPID = callerPID } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerHelperClient/RuntimeHelperClient.swift b/RuntimeViewerPackages/Sources/RuntimeViewerHelperClient/RuntimeHelperClient.swift index 8bf939d..1571ea0 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerHelperClient/RuntimeHelperClient.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerHelperClient/RuntimeHelperClient.swift @@ -89,8 +89,8 @@ public final class RuntimeHelperClient: @unchecked Sendable { } public func launchMacCatalystHelper() async throws { - let callerBundleIdentifier = Bundle.main.bundleIdentifier ?? "" - try await connectionIfNeeded().sendMessage(request: OpenApplicationRequest(url: RuntimeViewerCatalystHelperLauncher.helperURL, callerBundleIdentifier: callerBundleIdentifier)) + let callerPID = ProcessInfo.processInfo.processIdentifier + try await connectionIfNeeded().sendMessage(request: OpenApplicationRequest(url: RuntimeViewerCatalystHelperLauncher.helperURL, callerPID: callerPID)) } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerService/RuntimeViewerService.swift b/RuntimeViewerPackages/Sources/RuntimeViewerService/RuntimeViewerService.swift index c849438..a859884 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerService/RuntimeViewerService.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerService/RuntimeViewerService.swift @@ -9,7 +9,7 @@ import OSLog public final class RuntimeViewerService { private let listener: SwiftyXPC.XPCListener - private var launchedApplicationsByCallerBundleID: [String: [NSRunningApplication]] = [:] + private var launchedApplicationsByCallerPID: [Int32: [NSRunningApplication]] = [:] private var endpointByIdentifier: [String: SwiftyXPC.XPCEndpoint] = [:] @@ -46,7 +46,7 @@ public final class RuntimeViewerService { configuration.addsToRecentItems = false configuration.activates = false let launchedApp = try await NSWorkspace.shared.openApplication(at: request.url, configuration: configuration) - launchedApplicationsByCallerBundleID[request.callerBundleIdentifier, default: []].append(launchedApp) + launchedApplicationsByCallerPID[request.callerPID, default: []].append(launchedApp) return .empty } @@ -89,14 +89,10 @@ public final class RuntimeViewerService { do { try Task.checkCancellation() - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, - let bundleID = app.bundleIdentifier, - let launchedApps = service.launchedApplicationsByCallerBundleID[bundleID] else { continue } + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { continue } - // Check if there's still another instance of the caller app running - if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID && $0 != app }) { continue } - - service.launchedApplicationsByCallerBundleID.removeValue(forKey: bundleID) + let pid = app.processIdentifier + guard let launchedApps = service.launchedApplicationsByCallerPID.removeValue(forKey: pid) else { continue } for launchedApp in launchedApps { if !launchedApp.isTerminated { From 788918862db046e8133d14a5894fe52db9fd83de Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 22 Mar 2026 00:29:48 +0800 Subject: [PATCH 15/17] feat: add printVTableOffset option for Swift interface generation Signed-off-by: Mx-Iris --- .../Common/RuntimeObjectInterface+GenerationOptions.swift | 1 + .../Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift | 3 +++ .../Tests/RuntimeViewerCoreTests/GenerationOptionsTests.swift | 4 ++++ .../Generation Options/GenerationOptionsViewController.swift | 1 + 4 files changed, 9 insertions(+) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift index 5e44c39..628508a 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift @@ -33,6 +33,7 @@ extension RuntimeObjectInterface { printStrippedSymbolicItem: true, emitOffsetComments: true, printMemberAddress: true, + printVTableOffset: true, printTypeLayout: true, printEnumLayout: true, synthesizeOpaqueType: true diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index f0aa617..4e4d3c3 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -21,6 +21,8 @@ public struct SwiftGenerationOptions: Sendable, Equatable { @Default(false) public var printMemberAddress: Bool @Default(false) + public var printVTableOffset: Bool + @Default(false) public var printTypeLayout: Bool @Default(false) public var printEnumLayout: Bool @@ -220,6 +222,7 @@ actor RuntimeSwiftSection { printStrippedSymbolicItem: options.printStrippedSymbolicItem, printFieldOffset: options.emitOffsetComments, printMemberAddress: options.printMemberAddress, + printVTableOffset: options.printVTableOffset, printTypeLayout: options.printTypeLayout, printEnumLayout: options.printEnumLayout, fieldOffsetTransformer: fieldOffsetTransformer, diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/GenerationOptionsTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/GenerationOptionsTests.swift index c38dc7d..ca018d0 100644 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/GenerationOptionsTests.swift +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/GenerationOptionsTests.swift @@ -70,6 +70,7 @@ struct SwiftGenerationOptionsTests { #expect(options.printStrippedSymbolicItem == true) #expect(options.emitOffsetComments == false) #expect(options.printMemberAddress == false) + #expect(options.printVTableOffset == false) #expect(options.printTypeLayout == false) #expect(options.printEnumLayout == false) #expect(options.synthesizeOpaqueType == false) @@ -81,6 +82,7 @@ struct SwiftGenerationOptionsTests { printStrippedSymbolicItem: false, emitOffsetComments: true, printMemberAddress: true, + printVTableOffset: true, printTypeLayout: true, printEnumLayout: true, synthesizeOpaqueType: true @@ -88,6 +90,7 @@ struct SwiftGenerationOptionsTests { #expect(options.printStrippedSymbolicItem == false) #expect(options.emitOffsetComments == true) #expect(options.printMemberAddress == true) + #expect(options.printVTableOffset == true) #expect(options.printTypeLayout == true) #expect(options.printEnumLayout == true) #expect(options.synthesizeOpaqueType == true) @@ -140,6 +143,7 @@ struct GenerationOptionsTests { #expect(mcp.swiftInterfaceOptions.printStrippedSymbolicItem == true) #expect(mcp.swiftInterfaceOptions.emitOffsetComments == true) #expect(mcp.swiftInterfaceOptions.printMemberAddress == true) + #expect(mcp.swiftInterfaceOptions.printVTableOffset == true) #expect(mcp.swiftInterfaceOptions.printTypeLayout == true) #expect(mcp.swiftInterfaceOptions.printEnumLayout == true) #expect(mcp.swiftInterfaceOptions.synthesizeOpaqueType == true) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Generation Options/GenerationOptionsViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Generation Options/GenerationOptionsViewController.swift index cdf042d..4f5e40a 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Generation Options/GenerationOptionsViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Generation Options/GenerationOptionsViewController.swift @@ -31,6 +31,7 @@ final class GenerationOptionsViewController: AppKitViewController Date: Sun, 22 Mar 2026 00:29:54 +0800 Subject: [PATCH 16/17] fix: improve Bonjour discovery reliability - Start Bonjour server before browser to ensure TXT record is registered - Use bonjourWithTXTRecord for browser to receive instance IDs - Log instance IDs in discovery for better debugging - Use explicit RuntimeSource.bonjour initializer Signed-off-by: Mx-Iris --- .../RuntimeNetwork.swift | 3 ++- .../Utils/RuntimeEngineManager.swift | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift index 55620bc..f77e166 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/RuntimeNetwork.swift @@ -36,6 +36,7 @@ struct RuntimeRequestData: Codable { public enum RuntimeNetworkBonjour { public static let type = "_runtimeviewer._tcp" public static let instanceIDKey = "rv-instance-id" + /// Unique identifier for this app process, used to filter out self-discovery in Bonjour browsing. public static let localInstanceID = UUID().uuidString @@ -82,7 +83,7 @@ public class RuntimeNetworkBrowser { let parameters = NWParameters() parameters.includePeerToPeer = true - self.browser = NWBrowser(for: .bonjour(type: RuntimeNetworkBonjour.type, domain: nil), using: parameters) + self.browser = NWBrowser(for: .bonjourWithTXTRecord(type: RuntimeNetworkBonjour.type, domain: nil), using: parameters) } public func start( diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift index b5cac5d..9e8face 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift @@ -42,14 +42,19 @@ public final class RuntimeEngineManager: Loggable { private init() { Self.logger.info("RuntimeEngineManager initializing, local instance ID: \(RuntimeNetworkBonjour.localInstanceID, privacy: .public)") + // Start Bonjour server BEFORE browser so the local service's TXT record + // (containing localInstanceID) is registered with the Bonjour daemon + // by the time the browser discovers it. + startBonjourServer() + browser.start( onAdded: { [weak self] endpoint in guard let self else { return } if endpoint.instanceID == RuntimeNetworkBonjour.localInstanceID { - Self.logger.info("Skipping self Bonjour endpoint: \(endpoint.name, privacy: .public)") + Self.logger.info("Skipping self Bonjour endpoint: \(endpoint.name, privacy: .public), instanceID: \(endpoint.instanceID ?? "nil", privacy: .public)") return } - Self.logger.info("Bonjour endpoint discovered: \(endpoint.name, privacy: .public), attempting connection...") + Self.logger.info("Bonjour endpoint discovered: \(endpoint.name, privacy: .public), instanceID: \(endpoint.instanceID ?? "nil", privacy: .public), attempting connection...") Task { @MainActor in await self.connectToBonjourEndpoint(endpoint) } @@ -63,8 +68,6 @@ public final class RuntimeEngineManager: Loggable { } ) - startBonjourServer() - Task { do { Self.logger.info("Launching system runtime engines...") @@ -78,7 +81,8 @@ public final class RuntimeEngineManager: Loggable { private func startBonjourServer() { let name = SCDynamicStoreCopyComputerName(nil, nil) as? String ?? ProcessInfo.processInfo.hostName - let engine = RuntimeEngine(source: .bonjourServer(name: name, identifier: .init(rawValue: name))) + let source = RuntimeSource.bonjour(name: name, identifier: .init(rawValue: name), role: .server) + let engine = RuntimeEngine(source: source) bonjourServerEngine = engine Self.logger.info("Starting Bonjour server with name: \(name, privacy: .public)") From 1820db0263bec166e80855ce8f76e6e1ad9edd0b Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 22 Mar 2026 00:29:59 +0800 Subject: [PATCH 17/17] fix: prevent tokenizer from destroying existing token attachments Add re-entrancy guard and preserve existing attachment characters when tokenizing new ${...} patterns. Restore cursor position after text replacements. Signed-off-by: Mx-Iris --- .../Components/TransformerSettingsView.swift | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/TransformerSettingsView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/TransformerSettingsView.swift index 8cc9687..5881110 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/TransformerSettingsView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerSettingsUI/Components/TransformerSettingsView.swift @@ -229,6 +229,8 @@ private final class TokenTemplateTextView: NSTextView { var didChangeTextHandler: ((String) -> Void)? var didChangeHeightHandler: ((CGFloat) -> Void)? + private var isTokenizing = false + override var string: String { didSet { tokenize() } } @@ -283,21 +285,38 @@ private final class TokenTemplateTextView: NSTextView { return true } + private static let defaultAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), + .foregroundColor: NSColor.textColor, + ] + + /// Converts new ${...} text patterns into attachment characters. + /// Existing attachments (U+FFFC) are NOT matched by the regex, so they are preserved. private func tokenize() { - guard let textStorage else { return } + guard let textStorage, !isTokenizing else { return } + isTokenizing = true + defer { isTokenizing = false } + + let string = textStorage.string + let fullRange = NSRange(location: 0, length: textStorage.length) + let pattern = #"\$\{([^}]+)\}"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return } - let string = textStorage.string - let range = NSRange(string.startIndex..., in: string) - let matches = regex.matches(in: string, range: range) + let matches = regex.matches(in: string, range: fullRange) - let defaultAttributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), - .foregroundColor: NSColor.textColor, - ] + let sel = selectedRange() textStorage.beginEditing() - textStorage.setAttributes(defaultAttributes, range: range) + + // Apply default attributes only to non-attachment ranges + textStorage.enumerateAttribute(.attachment, in: fullRange, options: []) { value, range, _ in + if value == nil { + textStorage.setAttributes(Self.defaultAttributes, range: range) + } + } + + // Replace ${...} text with attachment characters (reverse order preserves ranges) + var cursorAdjustment = 0 for match in matches.reversed() { let nsString = string as NSString let fullMatch = nsString.substring(with: match.range) @@ -308,8 +327,20 @@ private final class TokenTemplateTextView: NSTextView { let cell = TokenTextAttachmentCell(tokenName: tokenName) attachment.attachmentCell = cell textStorage.replaceCharacters(in: match.range, with: NSAttributedString(attachment: attachment)) + + // Adjust cursor position for replacements before cursor + if match.range.location + match.range.length <= sel.location { + cursorAdjustment -= (match.range.length - 1) + } } + textStorage.endEditing() + + // Restore cursor position accounting for text length changes + if cursorAdjustment != 0 { + let newPos = max(0, min(sel.location + cursorAdjustment, textStorage.length)) + setSelectedRange(NSRange(location: newPos, length: 0)) + } } }