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: | 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..cb53e1d --- /dev/null +++ b/Documentations/Plans/2026-03-21-remote-engine-mirroring-design.md @@ -0,0 +1,436 @@ +# 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 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 +} +``` + +#### 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` +- `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` +- `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. diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 05139d3..c2988cb 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -69,18 +69,18 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOKit.git", - branch: "main" + from: "0.46.100" ) ), .package( local: .package( path: "../../MachOObjCSection", isRelative: true, - isEnabled: false + isEnabled: true ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection.git", - from: "0.6.0-patch.1" + from: "0.6.101" ) ), .package( @@ -91,7 +91,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - branch: "main" + from: "0.8.1" ) ), .package( @@ -100,15 +100,15 @@ 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", - branch: "main" + from: "0.4.0" ), .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", @@ -124,7 +124,11 @@ 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", + from: "0.1.0" ), ], targets: [ @@ -165,7 +169,8 @@ let package = Package( .target( name: "RuntimeViewerUtilities", dependencies: [ - .product(name: "SwiftMobileGestalt", package: "swift-mobile-gestalt"), + .product(name: "SMobileGestalt", package: "swift-mobile-gestalt"), + .product(name: "LaunchServicesPrivate", package: "LaunchServicesPrivate"), ] ), .testTarget( diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift index da4600f..a1b1685 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCommunication/Connections/RuntimeNetworkConnection.swift @@ -374,7 +374,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, 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) + } } @Loggable @@ -56,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( @@ -73,13 +100,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/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/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/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/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/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/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() } } } diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index dc2d1be..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) } } } @@ -126,7 +126,6 @@ let package = Package( targets: ["RuntimeViewerCatalystExtensions"] ), - ], dependencies: [ .package( @@ -140,16 +139,19 @@ let package = Package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMuiltplePlatfromDirectory.libraryPath("UIFoundation"), isRelative: true, - isEnabled: true + isEnabled: true, + traits: ["AppleInternal"], ), .package( path: "../../UIFoundation", isRelative: true, - isEnabled: true + isEnabled: true, + traits: ["AppleInternal"], ), remote: .package( url: "https://github.com/Mx-Iris/UIFoundation", - branch: "main" + from: "0.4.0", + traits: ["AppleInternal"], ) ), @@ -157,16 +159,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" ) ), @@ -174,13 +176,17 @@ 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", - 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" @@ -194,18 +200,18 @@ 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", - branch: "main" + from: "0.2.2" ) ), .package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("OpenUXKit"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/OpenUXKit/OpenUXKit", @@ -214,11 +220,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", @@ -226,7 +232,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", @@ -236,43 +242,43 @@ 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", - branch: "main" + from: "0.3.0" ) ), .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", - branch: "main" + from: "0.1.1" ) ), .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", - branch: "main" + from: "0.1.2" ) ), .package( @@ -283,12 +289,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", @@ -297,7 +303,7 @@ let package = Package( ), .package( url: "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", - branch: "main" + from: "0.5.100" ), .package( local: .package( @@ -308,11 +314,11 @@ let package = Package( .package( path: "../../RunningApplicationKit", isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/RunningApplicationKit", - from: "0.1.1" + from: "0.2.0" ) ), .package( @@ -323,25 +329,17 @@ 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", from: "3.0.0" ), .package( - url: "https://github.com/database-utility/fuzzy-search.git", - branch: "main" - ), - .package( - url: "https://github.com/MxIris-macOS-Library-Forks/AppKitUI", - branch: "main" + url: "https://github.com/MxIris-Library-Forks/fuzzy-search", + from: "0.1.0" ), .package( url: "https://github.com/sindresorhus/KeyboardShortcuts", @@ -351,27 +349,27 @@ 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", - branch: "main" + from: "6.2.100" ) ), .package( local: .package( path: MxIrisStudioWorkspace.personalLibraryMacOSDirectory.libraryPath("SystemHUD"), isRelative: true, - isEnabled: true + isEnabled: false ), remote: .package( url: "https://github.com/Mx-Iris/SystemHUD", - branch: "main" + from: "0.1.0" ) ), .package( @@ -379,15 +377,14 @@ 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", from: "26.0.0" ), - ], targets: [ .target( @@ -406,12 +403,9 @@ 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"), -// .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)), ], swiftSettings: sharedSwiftSettings ), @@ -429,12 +423,9 @@ 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: "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 +497,6 @@ let package = Package( ] ), - ], swiftLanguageModes: [.v5] ) 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 { 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)) + } } } 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/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj b/RuntimeViewerServer/RuntimeViewerServer.xcodeproj/project.pbxproj index 9b5a094..433fc29 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 */; @@ -285,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; @@ -563,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; @@ -784,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; @@ -809,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; @@ -922,23 +919,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 +928,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.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"> { - 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.processIdentifier.description, isSandbox: runningApplication.isSandboxed) + case let runningProcess as RunningProcess: + try await runtimeEngineManager.launchAttachedRuntimeEngine(name: name, identifier: runningProcess.processIdentifier.description, 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 - } -} 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 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 } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Utils/RuntimeEngineManager.swift index 229b070..9e8face 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 @@ -27,6 +28,8 @@ public final class RuntimeEngineManager: Loggable { private static let retryBaseDelay: UInt64 = 2_000_000_000 // 2 seconds in nanoseconds + private var bonjourServerEngine: RuntimeEngine? + @Dependency(\.helperServiceManager) private var helperServiceManager @@ -37,11 +40,21 @@ 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)") + + // 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 } - Self.logger.info("Bonjour endpoint discovered: \(endpoint.name, privacy: .public), attempting connection...") + if endpoint.instanceID == RuntimeNetworkBonjour.localInstanceID { + 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), instanceID: \(endpoint.instanceID ?? "nil", privacy: .public), attempting connection...") Task { @MainActor in await self.connectToBonjourEndpoint(endpoint) } @@ -54,6 +67,7 @@ public final class RuntimeEngineManager: Loggable { } } ) + Task { do { Self.logger.info("Launching system runtime engines...") @@ -65,6 +79,24 @@ public final class RuntimeEngineManager: Loggable { } } + private func startBonjourServer() { + let name = SCDynamicStoreCopyComputerName(nil, nil) as? String ?? ProcessInfo.processInfo.hostName + 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)") + + 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 {