diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5aedb5..6f7d5b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: artifact_name: ${{ steps.context.outputs.artifact_name }} source_ref: ${{ steps.context.outputs.source_ref }} injector_ref: ${{ steps.context.outputs.injector_ref }} + mcp_ref: ${{ steps.context.outputs.mcp_ref }} steps: - name: Validate Trigger Actor @@ -40,6 +41,7 @@ jobs: BUILD_NUMBER="${{ github.event.client_payload.build_number }}" SOURCE_REF="${{ github.event.client_payload.source_ref }}" INJECTOR_REF="${{ github.event.client_payload.injector_ref }}" + MCP_REF="${{ github.event.client_payload.mcp_ref }}" RELEASE_REF="${{ github.event.client_payload.release_ref }}" PARENT_REPOSITORY="${{ github.event.client_payload.parent_repository }}" PARENT_REF="${{ github.event.client_payload.parent_ref }}" @@ -62,6 +64,7 @@ jobs: [[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid parent SHA." >&2; exit 1; } [[ "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid Host source SHA." >&2; exit 1; } [[ "$INJECTOR_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid Injector source SHA." >&2; exit 1; } + [[ "$MCP_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid MCP source SHA." >&2; exit 1; } [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid Release source SHA." >&2; exit 1; } [[ "$RELEASE_ID" == "release/app/$VERSION@$PARENT_SHA/host" ]] || { echo "Release id does not identify the parent tag and commit." >&2 @@ -75,6 +78,7 @@ jobs: echo "artifact_name=LookInside-${VERSION}-release" echo "source_ref=${SOURCE_REF}" echo "injector_ref=${INJECTOR_REF}" + echo "mcp_ref=${MCP_REF}" } >>"${GITHUB_OUTPUT}" build_package: @@ -130,6 +134,21 @@ jobs: git -C ../LookInside-Injector checkout --detach "${{ needs.prepare.outputs.injector_ref }}" [[ "$(git -C ../LookInside-Injector rev-parse HEAD)" == "${{ needs.prepare.outputs.injector_ref }}" ]] + - name: Checkout MCP Server + env: + LOOKINSIDE_MCP_READ_TOKEN: ${{ secrets.LOOKINSIDE_PRIVATE_READ_TOKEN || secrets.LOOKINSIDE_WEB_RELEASE_TOKEN || secrets.LOOKINSIDE_INJECTOR_READ_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${LOOKINSIDE_MCP_READ_TOKEN}" ]]; then + echo "LOOKINSIDE_PRIVATE_READ_TOKEN, LOOKINSIDE_WEB_RELEASE_TOKEN, or LOOKINSIDE_INJECTOR_READ_TOKEN is required to checkout LookInside-MCP." >&2 + exit 1 + fi + git clone --no-checkout \ + "https://x-access-token:${LOOKINSIDE_MCP_READ_TOKEN}@github.com/LookInsideApp/LookInside-MCP.git" \ + ../LookInside-MCP + git -C ../LookInside-MCP checkout --detach "${{ needs.prepare.outputs.mcp_ref }}" + [[ "$(git -C ../LookInside-MCP rev-parse HEAD)" == "${{ needs.prepare.outputs.mcp_ref }}" ]] + - name: Configure Private Package Access env: LOOKINSIDE_PRIVATE_READ_TOKEN: ${{ secrets.LOOKINSIDE_PRIVATE_READ_TOKEN || secrets.LOOKINSIDE_WEB_RELEASE_TOKEN || secrets.LOOKINSIDE_INJECTOR_READ_TOKEN }} diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index 45a5e15..866738b 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -173,6 +173,26 @@ Launch/LKLaunchWindowController.swift, Localizable.xcstrings, LookinArchiveIcon.icns, + MCPBridge/LKMCPBridgeAttributeEncoder.swift, + MCPBridge/LKMCPBridgeAttributeValueDecoder.swift, + MCPBridge/LKMCPBridgeConnection.swift, + MCPBridge/LKMCPBridgeDetailsService.swift, + MCPBridge/LKMCPBridgeEventPublisher.swift, + MCPBridge/LKMCPBridgeFrame.swift, + MCPBridge/LKMCPBridgeInspectionModels.swift, + MCPBridge/LKMCPBridgeInspectionService.swift, + MCPBridge/LKMCPBridgeInvocationService.swift, + MCPBridge/LKMCPBridgeListenSocket.swift, + MCPBridge/LKMCPBridgeLiveDocumentLookup.swift, + MCPBridge/LKMCPBridgeModificationService.swift, + MCPBridge/LKMCPBridgeObjectIdentifierList.swift, + MCPBridge/LKMCPBridgeRefreshService.swift, + MCPBridge/LKMCPBridgeScreenshotService.swift, + MCPBridge/LKMCPBridgeSearchQuery.swift, + MCPBridge/LKMCPBridgeSearchService.swift, + MCPBridge/LKMCPBridgeSecureContentDetector.swift, + MCPBridge/LKMCPBridgeSelectorService.swift, + MCPBridge/LKMCPBridgeServer.swift, Manager/LKAppMenuManager.swift, Manager/LKHelper.swift, Manager/LKNavigationManager.swift, @@ -328,6 +348,7 @@ AA48F29821CC0CA50032EC2C /* Frameworks */, AA48F29921CC0CA50032EC2C /* Resources */, 5005F8FE2FA505DF00C0BB07 /* Embed Injector Daemon */, + 5005F8FF2FA505DF00C0BB07 /* Embed MCP CLI */, ); buildRules = ( ); @@ -444,6 +465,34 @@ "", ); }; + 5005F8FF2FA505DF00C0BB07 /* Embed MCP CLI */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + name = "Embed MCP CLI"; + shellPath = /bin/sh; + shellScript = ( + "if [ ! -d \"$SRCROOT/../LookInside-MCP\" ]; then", + " if [ \"$CONFIGURATION\" = \"Release\" ]; then", + " echo \"error: LookInside-MCP not present; Release builds must embed the MCP server binary\"", + " exit 1", + " fi", + " echo \"Skipping Embed MCP CLI: LookInside-MCP not present (not in monorepo checkout)\"", + " exit 0", + "fi", + "PREPARE_SCRIPT=\"$SRCROOT/Scripts/prepare-mcp-cli.sh\"", + "# Run in a fresh login zsh so xcodebuild env from the host build does not leak", + "# into the nested SwiftPM build (would crash XCBBuildService).", + "# Re-export the host's BUILT_PRODUCTS_DIR / CONTENTS_FOLDER_PATH / CONFIGURATION", + "# so the nested zsh can locate the LookInside.app bundle to embed into.", + "env -i HOME=\"$HOME\" USER=\"$USER\" TERM=\"${TERM:-dumb}\" \\", + " GITHUB_TOKEN=\"${GITHUB_TOKEN:-}\" \\", + " CONFIGURATION=\"$CONFIGURATION\" \\", + " BUILT_PRODUCTS_DIR=\"$BUILT_PRODUCTS_DIR\" \\", + " CONTENTS_FOLDER_PATH=\"$CONTENTS_FOLDER_PATH\" \\", + " /bin/zsh -lc \"$PREPARE_SCRIPT\"", + "", + ); + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/LookInside/AppDelegate.swift b/LookInside/AppDelegate.swift index 4b56597..a3a8d40 100644 --- a/LookInside/AppDelegate.swift +++ b/LookInside/AppDelegate.swift @@ -55,6 +55,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_: Notification) { _ = LKConnectionManager.shared + LKMCPBridgeServer.sharedInstance.start() // Documents opened during launch (Finder double-click, Open With…) // are registered with NSDocumentController by now, so "no documents // ⇒ show Launch" covers both cold-start cases. @@ -176,6 +177,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { LKSwiftUISupportGatekeeper.sharedInstance().shutdownRuntime() + LKMCPBridgeServer.sharedInstance.stop() // 清理打开 UIImageView 的图片时创建的临时文件 for path in LKHelper.sharedInstance().tempImageFiles { diff --git a/LookInside/Connection/LookinLiveDocument.swift b/LookInside/Connection/LookinLiveDocument.swift index 517e8a2..870ed70 100644 --- a/LookInside/Connection/LookinLiveDocument.swift +++ b/LookInside/Connection/LookinLiveDocument.swift @@ -13,6 +13,16 @@ import AppKit @objc(LookinLiveDocument) final class LookinLiveDocument: NSDocument { + /// Posted (object: the document) once `LookinLiveDocumentController` + /// added the document and made its window controllers, so observers can + /// read `inspectableApp` and `hierarchyDataSource` at once. + /// `NSDocumentController` has no such notification of its own. + static let didOpenNotification = Notification.Name("LookinLiveDocumentDidOpenNotification") + + /// Posted (object: the document) at the start of `close()`, while the + /// document is still whole. + static let willCloseNotification = Notification.Name("LookinLiveDocumentWillCloseNotification") + /// The app this document inspects. Replaced by a new `LKInspectableApp` /// (new channel and app info) when the app reconnects. Observable with /// KVO. @@ -51,7 +61,7 @@ final class LookinLiveDocument: NSDocument { } /// Nil before `makeWindowControllers()` ran. - private var staticWindowController: LKStaticWindowController? { + var staticWindowController: LKStaticWindowController? { windowControllers.first as? LKStaticWindowController } @@ -127,6 +137,9 @@ final class LookinLiveDocument: NSDocument { } override func close() { + // Before any teardown, so observers can still read the app info and + // the data source. + NotificationCenter.default.post(name: Self.willCloseNotification, object: self) channelEndTask?.cancel() channelEndTask = nil reconnectTask?.cancel() diff --git a/LookInside/Connection/LookinLiveDocumentController.swift b/LookInside/Connection/LookinLiveDocumentController.swift index 5881cc9..2daeecd 100644 --- a/LookInside/Connection/LookinLiveDocumentController.swift +++ b/LookInside/Connection/LookinLiveDocumentController.swift @@ -34,6 +34,9 @@ final class LookinLiveDocumentController: NSObject { NSDocumentController.shared.addDocument(document) document.makeWindowControllers() document.showWindows() + // After makeWindowControllers(), so observers can reach the data + // source, which lives on the window controller. + NotificationCenter.default.post(name: LookinLiveDocument.didOpenNotification, object: document) return (document, false) } diff --git a/LookInside/MCPBridge/LKMCPBridgeAttributeEncoder.swift b/LookInside/MCPBridge/LKMCPBridgeAttributeEncoder.swift new file mode 100644 index 0000000..34ac501 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeAttributeEncoder.swift @@ -0,0 +1,430 @@ +// LKMCPBridgeAttributeEncoder.swift +// +// Encodes a host-side `LookinAttribute` into the wire DTO +// `LKMCPBridgeAttribute` for the `attributes.read` MCPBridge method. +// +// The host represents an attribute's payload as a polymorphic `id value` +// disambiguated by `LookinAttrType`. The wire format flattens that into a +// `kind` discriminator string plus a JSON value whose shape depends on +// kind. v1 of this encoder handles the structurally distinct kinds with +// dedicated projections (numbers, bools, strings, geometry struct types, +// colors, shadows, enums, JSON pass-through); rarer or implementation- +// specific kinds fall back to a `{ "rawDescription": "..." }` payload so +// agents can still read something meaningful instead of seeing a missing +// field. Adding more first-class encoders here is the safest place to +// extend coverage. + +import CoreGraphics +import Foundation + +#if canImport(AppKit) + import AppKit +#endif + +enum LKMCPBridgeAttributeEncoder { + /// Encodes one host-side `LookinAttribute` into the wire DTO. + /// + /// - Parameter redactingSecureContent: when `true`, string-valued kinds + /// (`string`, `selector`, `class`, `enum` string variant) are replaced + /// with a `kind: "redacted"` marker and a nil value before the DTO + /// leaves this encoder. Geometry / numeric / color projections are + /// not affected — they cannot reveal secret user content. The flag is + /// evaluated once per display item by the caller (see + /// `LKMCPBridgeSecureContentDetector`) and applies uniformly to every + /// attribute carried by that item, so a secure text field never leaks + /// either its `text`, its `placeholder`, or any custom-attribute + /// string the host might collect via `lookin_customDebugInfos`. + static func encode( + _ attribute: LookinAttribute, + redactingSecureContent: Bool + ) -> LKMCPBridgeAttribute { + let projection = projectValue(of: attribute) + let finalProjection = redactingSecureContent + ? redactedIfString(projection) + : projection + return LKMCPBridgeAttribute( + identifier: attribute.identifier, + displayTitle: attribute.displayTitle, + isUserCustom: attribute.isUserCustom(), + kind: finalProjection.kind, + value: finalProjection.value, + extraValue: redactingSecureContent ? nil : encodeExtraValue(attribute), + customSetterIdentifier: attribute.customSetterID + ) + } + + /// When the enclosing display item is flagged as secure, swap any + /// string-bearing projection for a redaction marker. Numeric, boolean, + /// geometry, color, shadow, json, and custom-object projections fall + /// through unchanged — they don't carry text values that could leak. + private static func redactedIfString(_ projection: Projection) -> Projection { + switch projection.kind { + case "string", "selector", "class": + return Projection(kind: "redacted", value: nil) + case "enum": + // `enum` covers both EnumInt/EnumLong (numeric) and EnumString + // (textual). Only the latter actually carries a user-visible + // string; the projection layer collapses them into the same + // `kind`, so we conservatively redact both. The numeric variant + // case is degenerate (no leak possible from an integer enum + // value), the cost of redaction is just losing the raw int. + return Projection(kind: "redacted", value: nil) + default: + return projection + } + } + + // MARK: - Per-type projection + + private struct Projection { + let kind: String + let value: LKMCPBridgeJSONValue? + } + + private static func projectValue(of attribute: LookinAttribute) -> Projection { + let rawValue: Any? = attribute.value + switch attribute.attrType { + case .none, .void: + return Projection(kind: "void", value: nil) + + case .char, .int, .short, .long, .longLong, + .unsignedChar, .unsignedInt, .unsignedShort, + .unsignedLong, .unsignedLongLong, + .enumInt, .enumLong: + // EnumInt / EnumLong land here because the actual stored value + // is an NSNumber; the enum case-name table travels in `extraValue`. + let kind: String + switch attribute.attrType { + case .enumInt, .enumLong: + kind = "enum" + default: + kind = "integer" + } + return Projection(kind: kind, value: encodeNumberAsInteger(rawValue)) + + case .float, .double: + return Projection(kind: "double", value: encodeNumberAsDouble(rawValue)) + + case .BOOL: + // ObjC `LookinAttrTypeBOOL` keeps its uppercase initialism after + // the importer strips the type prefix. + return Projection(kind: "bool", value: .bool((rawValue as? NSNumber)?.boolValue ?? false)) + + case .sel: + return Projection(kind: "selector", value: encodeAsString(rawValue)) + + case .class: + return Projection(kind: "class", value: encodeAsString(rawValue)) + + case .nsString: + return Projection(kind: "string", value: encodeAsString(rawValue)) + + case .enumString: + return Projection(kind: "enum", value: encodeAsString(rawValue)) + + case .cgPoint: + return Projection(kind: "point", value: encodePoint(rawValue)) + + case .cgVector: + return Projection(kind: "vector", value: encodeVector(rawValue)) + + case .cgSize: + return Projection(kind: "size", value: encodeSize(rawValue)) + + case .cgRect: + return Projection(kind: "rect", value: encodeRect(rawValue)) + + case .cgAffineTransform: + return Projection(kind: "transform", value: encodeAffineTransform(rawValue)) + + case .uiEdgeInsets: + return Projection(kind: "edgeInsets", value: encodeEdgeInsets(rawValue)) + + case .uiOffset: + return Projection(kind: "offset", value: encodeOffset(rawValue)) + + case .uiColor: + return Projection(kind: "color", value: encodeColor(rawValue)) + + case .shadow: + return Projection(kind: "shadow", value: encodeShadow(rawValue)) + + case .json: + return Projection(kind: "json", value: encodeArbitraryJSON(rawValue)) + + case .customObj: + if let constraints = rawValue as? [LookinAutoLayoutConstraint], constraints.isEmpty == false { + return Projection(kind: "constraints", value: encodeConstraints(constraints)) + } + return Projection(kind: "custom", value: encodeCustomObject(rawValue)) + + @unknown default: + return Projection(kind: "unknown", value: encodeFallbackDescription(rawValue)) + } + } + + // MARK: - extraValue + + private static func encodeExtraValue(_ attribute: LookinAttribute) -> LKMCPBridgeJSONValue? { + guard let extra = attribute.extraValue else { return nil } + // For LookinAttrTypeEnumString, `extraValue` is the array of all + // possible enum case names — surface it directly. Other attribute + // kinds rarely populate `extraValue`; when they do, just pass the + // object through `encodeArbitraryJSON` which falls back to a + // description-string if the type cannot be projected losslessly. + if let strings = extra as? [String] { + return .array(strings.map(LKMCPBridgeJSONValue.string)) + } + return encodeArbitraryJSON(extra) + } + + // MARK: - Number helpers + + private static func encodeNumberAsInteger(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let number = raw as? NSNumber else { return nil } + return .integer(Int64(truncatingIfNeeded: number.int64Value)) + } + + private static func encodeNumberAsDouble(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let number = raw as? NSNumber else { return nil } + return .double(number.doubleValue) + } + + private static func encodeAsString(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let text = raw as? String else { return nil } + return .string(text) + } + + // MARK: - Geometry helpers + + private static func cgFloatValue(_ raw: Any?) -> Double? { + guard let number = raw as? NSNumber else { return nil } + return number.doubleValue + } + + private static func encodePoint(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let nsValue = raw as? NSValue else { return nil } + var point = CGPoint.zero + nsValue.getValue(&point, size: MemoryLayout.size) + return .object([ + "x": .double(Double(point.x)), + "y": .double(Double(point.y)), + ]) + } + + private static func encodeVector(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let nsValue = raw as? NSValue else { return nil } + var vector = CGVector.zero + nsValue.getValue(&vector, size: MemoryLayout.size) + return .object([ + "dx": .double(Double(vector.dx)), + "dy": .double(Double(vector.dy)), + ]) + } + + private static func encodeSize(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let nsValue = raw as? NSValue else { return nil } + var size = CGSize.zero + nsValue.getValue(&size, size: MemoryLayout.size) + return .object([ + "width": .double(Double(size.width)), + "height": .double(Double(size.height)), + ]) + } + + private static func encodeRect(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let nsValue = raw as? NSValue else { return nil } + var rect = CGRect.zero + nsValue.getValue(&rect, size: MemoryLayout.size) + return .object([ + "x": .double(Double(rect.origin.x)), + "y": .double(Double(rect.origin.y)), + "width": .double(Double(rect.size.width)), + "height": .double(Double(rect.size.height)), + ]) + } + + private static func encodeAffineTransform(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let nsValue = raw as? NSValue else { return nil } + var transform = CGAffineTransform.identity + nsValue.getValue(&transform, size: MemoryLayout.size) + return .object([ + "a": .double(Double(transform.a)), + "b": .double(Double(transform.b)), + "c": .double(Double(transform.c)), + "d": .double(Double(transform.d)), + "tx": .double(Double(transform.tx)), + "ty": .double(Double(transform.ty)), + ]) + } + + private static func encodeEdgeInsets(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let nsValue = raw as? NSValue else { return nil } + // UIKit's UIEdgeInsets and AppKit's NSEdgeInsets share the same + // memory layout: top / left / bottom / right CGFloats. + var insets: (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + nsValue.getValue(&insets, size: MemoryLayout.size(ofValue: insets)) + return .object([ + "top": .double(Double(insets.0)), + "left": .double(Double(insets.1)), + "bottom": .double(Double(insets.2)), + "right": .double(Double(insets.3)), + ]) + } + + private static func encodeOffset(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let nsValue = raw as? NSValue else { return nil } + var offset: (CGFloat, CGFloat) = (0, 0) + nsValue.getValue(&offset, size: MemoryLayout.size(ofValue: offset)) + return .object([ + "horizontal": .double(Double(offset.0)), + "vertical": .double(Double(offset.1)), + ]) + } + + // MARK: - Color / Shadow + + private static func encodeColor(_ raw: Any?) -> LKMCPBridgeJSONValue? { + // LookinServer encodes UIColor as @[NSNumber, NSNumber, NSNumber, NSNumber] + // in RGBA, components in 0...1. Surface as a named-key object for + // agent readability while still preserving the underlying numbers. + guard let components = raw as? [NSNumber], components.count >= 4 else { + return nil + } + return .object([ + "red": .double(components[0].doubleValue), + "green": .double(components[1].doubleValue), + "blue": .double(components[2].doubleValue), + "alpha": .double(components[3].doubleValue), + ]) + } + + private static func encodeShadow(_ raw: Any?) -> LKMCPBridgeJSONValue? { + // Server-side shape (LKS_CustomAttrGroupsMaker.m): NSDictionary with + // keys offset (NSValue), opacity (NSNumber), radius + // (NSNumber), color ([NSNumber] RGBA). + guard let dictionary = raw as? [String: Any] else { return nil } + var encoded: [String: LKMCPBridgeJSONValue] = [:] + if let offset = encodeSize(dictionary["offset"]) { + encoded["offset"] = offset + } + if let opacity = dictionary["opacity"] as? NSNumber { + encoded["opacity"] = .double(opacity.doubleValue) + } + if let radius = dictionary["radius"] as? NSNumber { + encoded["radius"] = .double(radius.doubleValue) + } + if let color = encodeColor(dictionary["color"]) { + encoded["color"] = color + } + return .object(encoded) + } + + // MARK: - Custom / JSON / fallback + + private static func encodeArbitraryJSON(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let raw else { return nil } + // Try to round-trip through Foundation's JSONSerialization. This + // handles dictionaries, arrays, NSNumber, NSString, NSNull cleanly + // and lets us reuse the existing JSONValue decoder. + if JSONSerialization.isValidJSONObject(raw) { + do { + let data = try JSONSerialization.data(withJSONObject: raw, options: [.fragmentsAllowed]) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } catch { + // Fall through to description fallback. + } + } + return encodeFallbackDescription(raw) + } + + // MARK: - Constraints + + private static func constraintItemTypeString(_ itemType: LookinConstraintItemType) -> String { + switch itemType { + case .`nil`: return "nil" + case .view: return "view" + case .`self`: return "self" + case .super: return "super" + case .layoutGuide: return "layoutGuide" + case .unknown: return "unknown" + @unknown default: return "unknown" + } + } + + private static func constraintRelationString(_ relation: NSLayoutConstraint.Relation) -> String { + switch relation { + case .lessThanOrEqual: return "lessThanOrEqual" + case .equal: return "equal" + case .greaterThanOrEqual: return "greaterThanOrEqual" + @unknown default: return "unknown" + } + } + + private static func encodeConstraintEndpoint(_ endpointObject: LookinObject?, itemType: LookinConstraintItemType) -> LKMCPBridgeJSONValue { + var fields: [String: LKMCPBridgeJSONValue] = [ + "itemType": .string(constraintItemTypeString(itemType)), + ] + if let endpointObject, endpointObject.oid != 0 { + fields["objectIdentifier"] = .string(String(format: "0x%lx", endpointObject.oid)) + } + if let className = endpointObject?.classChainList?.first { + fields["className"] = .string(className) + } + return .object(fields) + } + + /// Structured projection of an AutoLayout constraints list — previously + /// this fell into the description-string fallback and lost every field. + /// The attribute numbers stay raw NSInteger values: UIKit and AppKit + /// assign different numbers to NSLayoutAttribute cases, and the bridge + /// does not know which platform produced the data. + private static func encodeConstraints(_ constraints: [LookinAutoLayoutConstraint]) -> LKMCPBridgeJSONValue { + let encodedConstraints: [LKMCPBridgeJSONValue] = constraints.map { constraint in + var fields: [String: LKMCPBridgeJSONValue] = [ + "firstItem": encodeConstraintEndpoint(constraint.firstItem, itemType: constraint.firstItemType), + "firstAttributeRawValue": .integer(Int64(constraint.firstAttribute)), + "relation": .string(constraintRelationString(constraint.relation)), + "secondItem": encodeConstraintEndpoint(constraint.secondItem, itemType: constraint.secondItemType), + "secondAttributeRawValue": .integer(Int64(constraint.secondAttribute)), + "multiplier": .double(Double(constraint.multiplier)), + "constant": .double(Double(constraint.constant)), + "priority": .double(Double(constraint.priority)), + "isActive": .bool(constraint.active), + "isEffective": .bool(constraint.effective), + ] + if constraint.constraintOid != 0 { + // The same constraint appears in both endpoints' lists; this + // identifier is what lets an agent deduplicate the copies. + fields["constraintObjectIdentifier"] = .string(String(format: "0x%lx", constraint.constraintOid)) + } + if let identifier = constraint.identifier, identifier.isEmpty == false { + fields["identifier"] = .string(identifier) + } + return .object(fields) + } + return .array(encodedConstraints) + } + + private static func encodeCustomObject(_ raw: Any?) -> LKMCPBridgeJSONValue? { + // Custom objects don't have a fixed shape; surface their class name + // and a description so agents have something to render. Bridge + // consumers that need a typed projection should use the attribute + // identifier to know what they're looking at. + guard let raw else { return nil } + let object = raw as AnyObject + let className = String(describing: type(of: object)) + let descriptionText = String(describing: object) + return .object([ + "className": .string(className), + "description": .string(descriptionText), + ]) + } + + private static func encodeFallbackDescription(_ raw: Any?) -> LKMCPBridgeJSONValue? { + guard let raw else { return nil } + return .object([ + "rawDescription": .string(String(describing: raw)), + ]) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeAttributeValueDecoder.swift b/LookInside/MCPBridge/LKMCPBridgeAttributeValueDecoder.swift new file mode 100644 index 0000000..8babcae --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeAttributeValueDecoder.swift @@ -0,0 +1,391 @@ +// LKMCPBridgeAttributeValueDecoder.swift +// +// Symmetric inverse of LKMCPBridgeAttributeEncoder: turns the wire +// `{ kind, data }` shape that a bridge client uses to express an +// attribute write back into the polymorphic Objective-C value that +// LookinAttributeModification carries to the inspected server +// (RPC 204). +// +// Layout: one entry point (`decode(wireKind:wireData:expectedAttrType:)`) +// switches on `(kind, attrType)` and produces the right NSNumber / +// NSString / NSValue / NSArray. The function is `throws`; the thrown +// errors are mapped to structured `modify.*` wire codes by the +// invocation service so agents see precise reasons. +// +// v0.4 scope: integer / double / bool / string / selector / class / +// point / vector / size / rect / transform / edgeInsets / offset / +// color / enum. The structurally-richer kinds (shadow / json / custom) +// are deliberately rejected with `unsupportedKind`; supporting them +// safely requires a per-attribute schema that the bridge doesn't yet +// have. + +import CoreGraphics +import Foundation + +@MainActor +enum LKMCPBridgeAttributeValueDecoder { + // MARK: - Errors + + enum DecodeError: Error { + /// The wire `kind` is one of the v0.4 unsupported categories + /// (shadow / custom / json) or a string the encoder never + /// produces. + case unsupportedKind(String) + + /// The wire `kind` is valid in isolation but does not match + /// the attribute's declared `LookinAttrType`. E.g. wire says + /// `"color"` but the attribute is `LookinAttrTypeBOOL`. + case kindMismatch(wireKind: String, expectedAttrType: LookinAttrType) + + /// The wire `data` payload is missing required fields or + /// carries the wrong JSON shape for its `kind` (e.g. an `int` + /// where an object is expected). + case shapeInvalid(reason: String) + } + + // MARK: - Entry point + + /// Decodes a `(wireKind, wireData)` pair into the polymorphic id + /// value that `LookinAttributeModification.value` requires, + /// validated against `expectedAttrType`. Throws on any + /// inconsistency; the caller turns the throw into a structured + /// bridge error. + static func decode( + wireKind: String, + wireData: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + switch wireKind { + case "integer": + return try decodeInteger(wireData, expectedAttrType: expectedAttrType) + case "double": + return try decodeDouble(wireData, expectedAttrType: expectedAttrType) + case "bool": + return try decodeBool(wireData, expectedAttrType: expectedAttrType) + case "string": + return try decodeString(wireData, expectedAttrType: expectedAttrType, allowedTypes: [.nsString]) + case "selector": + return try decodeString(wireData, expectedAttrType: expectedAttrType, allowedTypes: [.sel]) + case "class": + return try decodeString(wireData, expectedAttrType: expectedAttrType, allowedTypes: [.class]) + case "enum": + return try decodeEnum(wireData, expectedAttrType: expectedAttrType) + case "point": + return try decodePoint(wireData, expectedAttrType: expectedAttrType) + case "vector": + return try decodeVector(wireData, expectedAttrType: expectedAttrType) + case "size": + return try decodeSize(wireData, expectedAttrType: expectedAttrType) + case "rect": + return try decodeRect(wireData, expectedAttrType: expectedAttrType) + case "transform": + return try decodeAffineTransform(wireData, expectedAttrType: expectedAttrType) + case "edgeInsets": + return try decodeEdgeInsets(wireData, expectedAttrType: expectedAttrType) + case "offset": + return try decodeOffset(wireData, expectedAttrType: expectedAttrType) + case "color": + return try decodeColor(wireData, expectedAttrType: expectedAttrType) + case "shadow", "json", "custom": + throw DecodeError.unsupportedKind(wireKind) + default: + throw DecodeError.unsupportedKind(wireKind) + } + } + + // MARK: - Numeric kinds + + private static let integerCompatibleTypes: Set = [ + .char, .int, .short, .long, .longLong, + .unsignedChar, .unsignedInt, .unsignedShort, + .unsignedLong, .unsignedLongLong, + ] + + private static func decodeInteger( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard integerCompatibleTypes.contains(expectedAttrType) else { + throw DecodeError.kindMismatch(wireKind: "integer", expectedAttrType: expectedAttrType) + } + guard let data else { + throw DecodeError.shapeInvalid(reason: "integer value missing") + } + switch data { + case let .integer(value): + return NSNumber(value: value) + case let .double(value): + // Tolerate Double when JSON decode coerced it; truncate. + return NSNumber(value: Int64(value)) + default: + throw DecodeError.shapeInvalid(reason: "integer expects a JSON number") + } + } + + private static let doubleCompatibleTypes: Set = [.float, .double] + + private static func decodeDouble( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard doubleCompatibleTypes.contains(expectedAttrType) else { + throw DecodeError.kindMismatch(wireKind: "double", expectedAttrType: expectedAttrType) + } + guard let data else { + throw DecodeError.shapeInvalid(reason: "double value missing") + } + switch data { + case let .double(value): + return NSNumber(value: value) + case let .integer(value): + return NSNumber(value: Double(value)) + default: + throw DecodeError.shapeInvalid(reason: "double expects a JSON number") + } + } + + private static func decodeBool( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .BOOL else { + throw DecodeError.kindMismatch(wireKind: "bool", expectedAttrType: expectedAttrType) + } + guard case let .bool(value)? = data else { + throw DecodeError.shapeInvalid(reason: "bool expects a JSON boolean") + } + return NSNumber(value: value) + } + + // MARK: - String-ish kinds + + private static func decodeString( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType, + allowedTypes: Set + ) throws -> Any { + guard allowedTypes.contains(expectedAttrType) else { + let wireKind: String + switch allowedTypes.first { + case .nsString: wireKind = "string" + case .sel: wireKind = "selector" + case .class: wireKind = "class" + default: wireKind = "string" + } + throw DecodeError.kindMismatch(wireKind: wireKind, expectedAttrType: expectedAttrType) + } + guard case let .string(value)? = data else { + throw DecodeError.shapeInvalid(reason: "string-kind value expects a JSON string") + } + return value as NSString + } + + // MARK: - Enums + + private static let enumNumericTypes: Set = [.enumInt, .enumLong] + + private static func decodeEnum( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + switch expectedAttrType { + case .enumInt, .enumLong: + // Numeric enum: wire data is an integer (raw case value). + guard let data else { + throw DecodeError.shapeInvalid(reason: "enum value missing") + } + switch data { + case let .integer(value): + return NSNumber(value: value) + case let .double(value): + return NSNumber(value: Int64(value)) + default: + throw DecodeError.shapeInvalid(reason: "numeric enum expects a JSON integer") + } + case .enumString: + guard case let .string(value)? = data else { + throw DecodeError.shapeInvalid(reason: "string enum expects a JSON string case name") + } + return value as NSString + default: + throw DecodeError.kindMismatch(wireKind: "enum", expectedAttrType: expectedAttrType) + } + } + + // MARK: - Geometry + + private static func decodePoint( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .cgPoint else { + throw DecodeError.kindMismatch(wireKind: "point", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "point") + let x = try requireDouble(object["x"], fieldName: "point.x") + let y = try requireDouble(object["y"], fieldName: "point.y") + return NSValue(point: CGPoint(x: x, y: y)) + } + + private static func decodeVector( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .cgVector else { + throw DecodeError.kindMismatch(wireKind: "vector", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "vector") + let dx = try requireDouble(object["dx"], fieldName: "vector.dx") + let dy = try requireDouble(object["dy"], fieldName: "vector.dy") + return boxStruct(CGVector(dx: dx, dy: dy), objCType: "{CGVector=dd}") + } + + private static func decodeSize( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .cgSize else { + throw DecodeError.kindMismatch(wireKind: "size", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "size") + let width = try requireDouble(object["width"], fieldName: "size.width") + let height = try requireDouble(object["height"], fieldName: "size.height") + return NSValue(size: CGSize(width: width, height: height)) + } + + private static func decodeRect( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .cgRect else { + throw DecodeError.kindMismatch(wireKind: "rect", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "rect") + let x = try requireDouble(object["x"], fieldName: "rect.x") + let y = try requireDouble(object["y"], fieldName: "rect.y") + let width = try requireDouble(object["width"], fieldName: "rect.width") + let height = try requireDouble(object["height"], fieldName: "rect.height") + return NSValue(rect: CGRect(x: x, y: y, width: width, height: height)) + } + + private static func decodeAffineTransform( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .cgAffineTransform else { + throw DecodeError.kindMismatch(wireKind: "transform", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "transform") + let a = try requireDouble(object["a"], fieldName: "transform.a") + let b = try requireDouble(object["b"], fieldName: "transform.b") + let c = try requireDouble(object["c"], fieldName: "transform.c") + let d = try requireDouble(object["d"], fieldName: "transform.d") + let tx = try requireDouble(object["tx"], fieldName: "transform.tx") + let ty = try requireDouble(object["ty"], fieldName: "transform.ty") + let transform = CGAffineTransform(a: CGFloat(a), b: CGFloat(b), c: CGFloat(c), d: CGFloat(d), tx: CGFloat(tx), ty: CGFloat(ty)) + return boxStruct(transform, objCType: "{CGAffineTransform=dddddd}") + } + + private static func decodeEdgeInsets( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .uiEdgeInsets else { + throw DecodeError.kindMismatch(wireKind: "edgeInsets", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "edgeInsets") + let top = try requireDouble(object["top"], fieldName: "edgeInsets.top") + let left = try requireDouble(object["left"], fieldName: "edgeInsets.left") + let bottom = try requireDouble(object["bottom"], fieldName: "edgeInsets.bottom") + let right = try requireDouble(object["right"], fieldName: "edgeInsets.right") + // UIEdgeInsets and NSEdgeInsets share memory layout: top / left + // / bottom / right as four CGFloats. The server-side handler + // reads back via a cross-platform `InsetsValue` helper that + // accepts either objCType string. + var insets: (CGFloat, CGFloat, CGFloat, CGFloat) = (CGFloat(top), CGFloat(left), CGFloat(bottom), CGFloat(right)) + return withUnsafeBytes(of: &insets) { rawBuffer -> NSValue in + NSValue(bytes: rawBuffer.baseAddress!, objCType: "{UIEdgeInsets=dddd}") + } + } + + private static func decodeOffset( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .uiOffset else { + throw DecodeError.kindMismatch(wireKind: "offset", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "offset") + let horizontal = try requireDouble(object["horizontal"], fieldName: "offset.horizontal") + let vertical = try requireDouble(object["vertical"], fieldName: "offset.vertical") + // UIOffset layout: two CGFloats. + var offset: (CGFloat, CGFloat) = (CGFloat(horizontal), CGFloat(vertical)) + return withUnsafeBytes(of: &offset) { rawBuffer -> NSValue in + NSValue(bytes: rawBuffer.baseAddress!, objCType: "{UIOffset=dd}") + } + } + + // MARK: - Color + + private static func decodeColor( + _ data: LKMCPBridgeJSONValue?, + expectedAttrType: LookinAttrType + ) throws -> Any { + guard expectedAttrType == .uiColor else { + throw DecodeError.kindMismatch(wireKind: "color", expectedAttrType: expectedAttrType) + } + let object = try requireObject(data, fieldName: "color") + let red = try requireDouble(object["red"], fieldName: "color.red") + let green = try requireDouble(object["green"], fieldName: "color.green") + let blue = try requireDouble(object["blue"], fieldName: "color.blue") + let alpha = try requireDouble(object["alpha"], fieldName: "color.alpha") + // Server reconstructs the platform-specific color via + // `+[LookinColor lks_colorFromRGBAComponents:]`, which expects + // an NSArray in RGBA order, components in 0.0...1.0. + return [ + NSNumber(value: red), + NSNumber(value: green), + NSNumber(value: blue), + NSNumber(value: alpha), + ] as NSArray + } + + // MARK: - Helpers + + private static func requireObject( + _ data: LKMCPBridgeJSONValue?, + fieldName: String + ) throws -> [String: LKMCPBridgeJSONValue] { + guard case let .object(object)? = data else { + throw DecodeError.shapeInvalid(reason: "\(fieldName) expects a JSON object") + } + return object + } + + private static func requireDouble( + _ data: LKMCPBridgeJSONValue?, + fieldName: String + ) throws -> Double { + guard let data else { + throw DecodeError.shapeInvalid(reason: "\(fieldName) is missing") + } + switch data { + case let .double(value): + return value + case let .integer(value): + return Double(value) + default: + throw DecodeError.shapeInvalid(reason: "\(fieldName) expects a JSON number") + } + } + + private static func boxStruct( + _ value: StructType, + objCType: String + ) -> NSValue { + var copy = value + return withUnsafeBytes(of: ©) { rawBuffer -> NSValue in + NSValue(bytes: rawBuffer.baseAddress!, objCType: objCType) + } + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeConnection.swift b/LookInside/MCPBridge/LKMCPBridgeConnection.swift new file mode 100644 index 0000000..e8a772b --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeConnection.swift @@ -0,0 +1,232 @@ +// LKMCPBridgeConnection.swift +// +// Per-client connection handler for the MCPBridge Unix domain socket. +// +// Each accepted connection becomes one `LKMCPBridgeConnection` instance that +// reads newline-delimited JSON frames from its socket, decodes them as +// `LKMCPBridgeRequest`, dispatches via a request handler closure, and writes +// the resulting `LKMCPBridgeResponse` (or pushed `LKMCPBridgeEvent`) back. +// +// Buffering is line-oriented: bytes accumulate in a buffer until a `\n` +// separator is found; partial frames are tolerated across read events but +// individual frames must fit within the buffer's high-water mark. +// +// Concurrency: every piece of mutable state here — the read buffer, the +// closed flag, the read source — and every `write(2)` on the socket is +// confined to `connectionQueue`, a serial queue private to this +// connection. The serialization is load-bearing rather than defensive: +// requests are dispatched concurrently (each inbound frame spawns its +// own `Task`), so without it two responses could interleave their +// `write(2)` calls mid-frame and hand the peer a corrupt JSON line. +// Large frames — screenshots above all — widen that window enough to +// make it routine. The queue targets the shared I/O queue supplied by +// the server, so distinct connections still proceed in parallel. + +import Darwin +import Dispatch +import Foundation +import os + +final class LKMCPBridgeConnection { + typealias RequestHandler = @Sendable (LKMCPBridgeRequest) async -> LKMCPBridgeResponse + + /// Maximum accumulated bytes for a single frame before the connection is + /// torn down. Inspection responses can carry large screenshot data so the + /// limit is generous; this is purely a safety cap against runaway peers. + private static let maximumFrameBytes = 8 * 1024 * 1024 + + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Connection") + + private let fileDescriptor: Int32 + private let connectionQueue: DispatchQueue + private let requestHandler: RequestHandler + private let onClose: @Sendable (LKMCPBridgeConnection) -> Void + + /// All three are `connectionQueue`-confined; never touch them from + /// another thread. + private var readSource: DispatchSourceRead? + private var readBuffer = Data() + private var isClosed = false + + init( + fileDescriptor: Int32, + queue: DispatchQueue, + requestHandler: @escaping RequestHandler, + onClose: @escaping @Sendable (LKMCPBridgeConnection) -> Void + ) { + self.fileDescriptor = fileDescriptor + connectionQueue = DispatchQueue( + label: "com.lookinside.mcp-bridge.connection.\(fileDescriptor)", + target: queue + ) + self.requestHandler = requestHandler + self.onClose = onClose + } + + func start() { + connectionQueue.async { [weak self] in + guard let self else { return } + let source = DispatchSource.makeReadSource( + fileDescriptor: self.fileDescriptor, + queue: self.connectionQueue + ) + source.setEventHandler { [weak self] in + self?.handleReadable() + } + source.setCancelHandler { [weak self] in + guard let self else { return } + Darwin.close(self.fileDescriptor) + } + self.readSource = source + source.resume() + } + } + + /// Closes the connection. Safe to call from any thread; the actual + /// teardown hops onto `connectionQueue`. + func close(reason: String) { + // Strong capture on purpose. This block is what cancels the read + // source and closes the socket, so it must still run when the caller + // drops its reference first -- which is exactly what + // `LKMCPBridgeServer.stop()` does when it clears `openConnections`. + // Captured weakly, shutdown would leak the descriptor and leave a + // live read source firing on a connection nobody owns. + connectionQueue.async { + self.closeOnConnectionQueue(reason: reason) + } + } + + /// Teardown proper. Callers must already be on `connectionQueue`. + private func closeOnConnectionQueue(reason: String) { + dispatchPrecondition(condition: .onQueue(connectionQueue)) + guard isClosed == false else { return } + isClosed = true + Self.logger.notice("Connection closed: \(reason, privacy: .public)") + readSource?.cancel() + readSource = nil + onClose(self) + } + + // MARK: - Read path + + private func handleReadable() { + dispatchPrecondition(condition: .onQueue(connectionQueue)) + guard isClosed == false else { return } + var buffer = [UInt8](repeating: 0, count: 4096) + let bytesRead = buffer.withUnsafeMutableBufferPointer { pointer -> ssize_t in + read(fileDescriptor, pointer.baseAddress, pointer.count) + } + if bytesRead == 0 { + closeOnConnectionQueue(reason: "peer closed (EOF)") + return + } + if bytesRead < 0 { + let errorNumber = errno + if errorNumber == EAGAIN || errorNumber == EWOULDBLOCK { + return + } + closeOnConnectionQueue(reason: "read failed (errno \(errorNumber))") + return + } + readBuffer.append(buffer, count: bytesRead) + if readBuffer.count > Self.maximumFrameBytes { + closeOnConnectionQueue(reason: "frame buffer exceeded \(Self.maximumFrameBytes) bytes") + return + } + drainFrames() + } + + private func drainFrames() { + while let newlineIndex = readBuffer.firstIndex(of: UInt8(ascii: "\n")) { + let frameSlice = readBuffer[readBuffer.startIndex ..< newlineIndex] + let frameData = Data(frameSlice) + readBuffer.removeSubrange(readBuffer.startIndex ... newlineIndex) + if frameData.isEmpty { + continue + } + dispatchFrame(frameData) + } + } + + private func dispatchFrame(_ frameData: Data) { + do { + let request = try JSONDecoder().decode(LKMCPBridgeRequest.self, from: frameData) + Task { [weak self] in + guard let self else { return } + let response = await self.requestHandler(request) + self.send(response: response) + } + } catch { + Self.logger.error("Failed to decode inbound frame as request: \(error.localizedDescription, privacy: .public)") + // No identifier available — push a generic error event so the peer + // can observe the malformed frame without correlating to a request. + let event = LKMCPBridgeEvent( + topic: "frame.decodeError", + payload: ["message": .string(error.localizedDescription)] + ) + send(event: event) + } + } + + // MARK: - Write path + + func send(response: LKMCPBridgeResponse) { + sendCodable(response) + } + + func send(event: LKMCPBridgeEvent) { + sendCodable(event) + } + + private func sendCodable(_ value: some Encodable) { + // Encoding is pure CPU work over a local value, so it stays on + // the calling thread; only the socket write is serialized. + let data: Data + do { + var encoded = try JSONEncoder().encode(value) + encoded.append(UInt8(ascii: "\n")) + data = encoded + } catch { + Self.logger.error("Failed to encode outbound frame: \(error.localizedDescription, privacy: .public)") + return + } + connectionQueue.async { [weak self] in + guard let self, self.isClosed == false else { return } + self.writeAll(data) + } + } + + private func writeAll(_ data: Data) { + dispatchPrecondition(condition: .onQueue(connectionQueue)) + guard isClosed == false else { return } + var remaining = data + while remaining.isEmpty == false { + let written = remaining.withUnsafeBytes { rawBufferPointer -> ssize_t in + guard let baseAddress = rawBufferPointer.baseAddress else { return 0 } + return write(fileDescriptor, baseAddress, rawBufferPointer.count) + } + if written < 0 { + let errorNumber = errno + if errorNumber == EINTR { + continue + } + if errorNumber == EAGAIN || errorNumber == EWOULDBLOCK { + // Unreachable while the descriptor is blocking, which + // `LKMCPBridgeListenSocket.acceptConnection` guarantees -- + // and that guarantee is load-bearing: on macOS accept(2) + // inherits O_NONBLOCK from the (necessarily non-blocking) + // listener, and a non-blocking descriptor lands here + // partway through any response larger than the socket send + // buffer, tearing the connection down mid-frame. Kept as a + // backstop; if it ever fires, fix the descriptor's mode + // rather than making this branch retry. + closeOnConnectionQueue(reason: "write would block (errno \(errorNumber))") + return + } + closeOnConnectionQueue(reason: "write failed (errno \(errorNumber))") + return + } + remaining.removeFirst(written) + } + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeDetailsService.swift b/LookInside/MCPBridge/LKMCPBridgeDetailsService.swift new file mode 100644 index 0000000..d8b75bb --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeDetailsService.swift @@ -0,0 +1,355 @@ +// LKMCPBridgeDetailsService.swift +// +// Handles the `details.read` bridge route: actively triggers RPC 203 +// `HierarchyDetails` for a batch of object identifiers and returns +// their attribute detail to the agent. Closes the painful "agent +// asked to read / modify a view whose detail the host has not yet +// fetched" loop that v0.4 surfaced as `modify.attributeNotFound`. +// +// Differences from `attributes.read`: +// - `attributes.read` reads the host's cache as-is; if the host has +// not seen the view yet, the agent gets an empty `groups` array +// plus `detailsCached: false`. +// - `details.read` sends RPC 203 to the inspected app, awaits the +// full streamed response (one frame per server-side task package), +// and surfaces every successful view's detail. The same routing +// also writes the response through +// `LKStaticHierarchyDataSource.modifyWithDisplayItemDetail:` so +// subsequent `attributes.read` calls see populated cache and the +// inspector UI reflects the new state. +// +// Scope: v0.5 only fetches attribute groups (no screenshots, no +// frame/bounds/hidden/alpha, no subitems). Screenshots get their +// own dedicated route; visual info is already in `get_hierarchy`; +// subtree changes are out of scope (agents follow up with +// `get_hierarchy` when they need to.) + +import AppKit +import CoreGraphics +import Foundation +import os + +@MainActor +final class LKMCPBridgeDetailsService { + /// Hard cap on per-call batch size. Matches the host inspector's + /// own `packageMaxTasksCount` (see `LKStaticAsyncUpdateManager`). + /// Larger batches are rejected at the wire boundary; agents that + /// need more issue multiple calls. + private static let maximumObjectIdentifiersPerCall = 100 + + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Details") + + init() {} + + // MARK: - Entry point + + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + guard request.method == "details.read" else { + return .failure(identifier: request.identifier, error: .unknownMethod) + } + return await handleDetailsRead( + identifier: request.identifier, + parameters: request.parameters + ) + } + + // MARK: - details.read + + private func handleDetailsRead( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) async -> LKMCPBridgeResponse { + // Parameter extraction + guard let parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"], + case let .array(identifierWireValues)? = parameters["objectIdentifiers"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + // Materialize the identifier list as distinct Swift strings. The + // de-duplication is load-bearing, not tidiness: two equal identifiers + // resolve to the same display item and therefore the same oid, and + // the oid-keyed index built below traps on a duplicate key, which + // would take down the whole host app rather than fail this request. + let requestedIdentifiers: [String] + switch LKMCPBridgeObjectIdentifierList.parse( + wireValues: identifierWireValues, + limit: Self.maximumObjectIdentifiersPerCall + ) { + case let .success(parsed): + requestedIdentifiers = parsed.identifiers + if parsed.droppedDuplicateCount > 0 { + Self.logger.debug( + "details.read collapsed \(parsed.droppedDuplicateCount, privacy: .public) duplicate object identifier(s)" + ) + } + case .failure(.notAllStrings), .failure(.empty): + return .failure(identifier: identifier, error: .invalidParameters) + case let .failure(.tooMany(distinctCount, limit)): + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "dispatch.tooMany", + message: "details.read accepts at most \(limit) object identifiers per call; received \(distinctCount) distinct. Split into multiple calls." + ) + ) + } + + let includeUserCustom: Bool + if case let .bool(raw)? = parameters["includeUserCustom"] { + includeUserCustom = raw + } else { + includeUserCustom = true + } + + // Live-document lookup + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + guard document.hierarchyDataSource != nil else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + + // Resolve each requested identifier to a display item. Missing + // identifiers go into failedIdentifiers and skip the server + // round-trip; the agent learns which oids vanished without the + // request blowing up entirely. + let roots = LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document) + var resolvedItems: [(identifier: String, displayItem: LookinDisplayItem, nativeOid: UInt)] = [] + resolvedItems.reserveCapacity(requestedIdentifiers.count) + var failedIdentifiers: [String] = [] + + for requested in requestedIdentifiers { + guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: roots, + matchingObjectIdentifier: requested + ), + let nativeOid = displayItem.displayingObject()?.oid, + nativeOid != 0 + else { + failedIdentifiers.append(requested) + continue + } + resolvedItems.append((identifier: requested, displayItem: displayItem, nativeOid: nativeOid)) + } + + // If everything is gone, surface a structured error instead of + // an empty success response — the agent likely passed stale oids. + if resolvedItems.isEmpty { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "details.objectNotFound", + message: "None of the requested object identifiers are present in this target's hierarchy. Refresh the hierarchy via get_hierarchy and retry." + ) + ) + } + + // Build the RPC 203 task package. Single package — we already + // cap the batch at 100 entries, so the host inspector's own + // pixel/count packing thresholds never need to come into play. + let clientReadableVersion = LKHelper.lookinReadableVersion() + let tasks = resolvedItems.map { resolved -> LookinStaticAsyncUpdateTask in + let task = LookinStaticAsyncUpdateTask() + task.oid = resolved.nativeOid + task.taskType = .noScreenshot + task.attrRequest = .need + task.needBasisVisualInfo = false + task.needSubitems = false + task.clientReadableVersion = clientReadableVersion + return task + } + let package = LookinStaticAsyncUpdateTasksPackage() + package.tasks = tasks + + // RPC 203 streams one frame per package; with a single package we + // expect exactly one frame. Collect them all anyway so the route + // stays correct if batching here ever splits into packages. A + // stream that ends without a frame leaves every requested + // identifier in `failedIdentifiers` below. + var allDetails: [LookinDisplayItemDetail] = [] + allDetails.reserveCapacity(resolvedItems.count) + do { + for try await frame in document.inspectableApp.hierarchyDetails(packages: [package]) { + allDetails.append(contentsOf: frame) + } + } catch { + return .failure(identifier: identifier, error: mapDetailsError(error as NSError)) + } + + // Index resolved items by native oid so we can match incoming + // details back to the display item (needed for secureContent + // detection and for downstream cache merging). + // + // Keep-first on collision rather than `uniqueKeysWithValues:`. The + // identifier list is already de-duplicated, so a collision here would + // mean two *different* identifier strings resolved to one object -- + // impossible today, since identifiers are generated from the oid and + // matched by exact equality. Trapping on it anyway would turn a future + // change in identifier syntax into a host crash, which is far worse + // than serving the first of two equivalent entries. + let itemsByOid: [UInt: (identifier: String, displayItem: LookinDisplayItem, nativeOid: UInt)] = Dictionary( + resolvedItems.map { ($0.nativeOid, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + // Resolve the concrete data source once. We thread it through + // the per-detail loop rather than walking back from each + // display item, because LookinDisplayItem has no back-reference + // to its owning document. + let staticDataSource = document.hierarchyDataSource + + var emittedDetails: [LKMCPBridgeViewDetail] = [] + emittedDetails.reserveCapacity(allDetails.count) + var seenOids: Set = [] + + for detail in allDetails { + seenOids.insert(detail.displayItemOid) + + // Server-side failure surface: failureCode == -1 means the + // server couldn't resolve the oid (probably deallocated + // mid-request). Push it to failedIdentifiers and skip. + if detail.failureCode == -1 { + if let resolved = itemsByOid[detail.displayItemOid] { + failedIdentifiers.append(resolved.identifier) + } + continue + } + + guard let resolved = itemsByOid[detail.displayItemOid] else { + // The server emitted a detail for an oid we did not + // request. Defensive: ignore it. + continue + } + + // Merge the detail into the host's cache so the inspector + // UI reflects the update and a follow-up attributes.read + // hits the same data without re-fetching. + staticDataSource?.modify(with: detail) + + // Encode the attribute groups carried in this detail + // through the read-side encoder so the wire shape matches + // attributes.read. + let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: resolved.displayItem) + var rawGroups: [LookinAttributesGroup] = [] + if let inbuiltGroups = detail.attributesGroupList { + rawGroups.append(contentsOf: inbuiltGroups) + } + if includeUserCustom, let customGroups = detail.customAttrGroupList { + rawGroups.append(contentsOf: customGroups) + } + let encodedGroups = rawGroups.map { group -> LKMCPBridgeAttributeGroup in + let sections = (group.attrSections ?? []).map { section -> LKMCPBridgeAttributeSection in + let attributes = (section.attributes ?? []).map { attribute in + LKMCPBridgeAttributeEncoder.encode( + attribute, + redactingSecureContent: redactSecureContent + ) + } + return LKMCPBridgeAttributeSection( + identifier: section.identifier ?? "", + attributes: attributes + ) + } + let groupIdentifier = group.userCustomTitle ?? group.identifier ?? "" + return LKMCPBridgeAttributeGroup( + identifier: groupIdentifier, + isUserCustom: group.userCustomTitle != nil, + isSwiftUIGroup: group.isSwiftUIGroup, + sections: sections + ) + } + + emittedDetails.append(LKMCPBridgeViewDetail( + objectIdentifier: resolved.identifier, + groups: encodedGroups, + secureContent: redactSecureContent + )) + } + + // Any resolved oid we never saw a detail for (server omitted it + // from the stream, despite no failureCode) joins failedIdentifiers + // so the agent gets an honest accounting. + for resolved in resolvedItems where seenOids.contains(resolved.nativeOid) == false { + failedIdentifiers.append(resolved.identifier) + } + + return successResponse( + identifier: identifier, + details: emittedDetails, + failedIdentifiers: failedIdentifiers + ) + } + + // MARK: - Helpers + + private func successResponse( + identifier: String, + details: [LKMCPBridgeViewDetail], + failedIdentifiers: [String] + ) -> LKMCPBridgeResponse { + let result = LKMCPBridgeDetailsReadResult( + details: details, + failedIdentifiers: failedIdentifiers + ) + do { + let payload = try encodeAsJSONValue(result) + return .success(identifier: identifier, result: payload) + } catch { + Self.logger.error("details.read encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + private func mapDetailsError(_ error: NSError) -> LKMCPBridgeErrorPayload { + switch error.code { + case LookinErrCode_ObjectNotFound: + return LKMCPBridgeErrorPayload( + code: "details.objectNotFound", + message: "The target app could not find one or more of the requested objects. They may have been deallocated; refresh via get_hierarchy and retry." + ) + case LookinErrCode_Inner: + return LKMCPBridgeErrorPayload( + code: "details.internalError", + message: "The target app rejected the details request with a generic inner error." + ) + case LookinErrCode_LicenseRequired: + return .licenseRequired + case LookinErrCode_NoConnect: + return LKMCPBridgeErrorPayload( + code: "details.disconnected", + message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." + ) + case LookinErrCode_Timeout: + return LKMCPBridgeErrorPayload( + code: "details.timeout", + message: "The target app did not respond within the request timeout. Reduce the batch size or check whether the target is paused in Xcode." + ) + default: + Self.logger.error("details.read received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") + return LKMCPBridgeErrorPayload( + code: "details.internalError", + message: "The target app reported an unexpected error (code \(error.code))." + ) + } + } + + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeEventPublisher.swift b/LookInside/MCPBridge/LKMCPBridgeEventPublisher.swift new file mode 100644 index 0000000..c844f06 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeEventPublisher.swift @@ -0,0 +1,289 @@ +// LKMCPBridgeEventPublisher.swift +// +// Turns host-side state changes into `LKMCPBridgeEvent` frames and hands +// them to `LKMCPBridgeServer.broadcast(event:)`. +// +// What belongs here, and what deliberately does not: +// +// Only things a bridge client cannot learn on its own get published. A +// client that calls a tool already has the tool's return value, so +// echoing the same fact back as an event would be noise at best and, at +// worst, read as independent news about the app. +// +// That rules out the data source's three item-change signals +// (`itemDidChangeAttrGroup`, `itemDidChangeHiddenAlphaValue`, +// `itemsDidChangeFrame`). All three fire only from inside +// `LKStaticHierarchyDataSource.modify(with:)`, which is +// the moment fetched detail lands in the cache -- and the fetch was +// almost always the caller's own `details.read` / `screenshot.read` / +// `attribute.modify`. Publishing them would tell a client "something +// changed" about a change it just made. +// +// `hierarchy.reloaded` is the one case where the client's own action and +// a human's produce the same signal, so it carries an `initiator` field +// instead of being dropped. +// +// What this publisher does NOT and cannot offer: notice that the +// inspected app's UI changed by itself. LookInside is a manual-refresh +// inspector; the host only learns the UI moved when someone asks it to +// look. Every topic here is about the *host's* state, never about the +// target app spontaneously changing. The CLI's resource descriptions say +// so in as many words, because a client that assumes otherwise will +// subscribe and then wait forever. +// +// Subscription filtering is not done here. The host stays ignorant of how +// many bridge clients exist and what each one cares about; it broadcasts +// to every open connection and lets each client filter. That keeps this +// file free of any Model Context Protocol concepts, which the GPL/MIT +// split requires. + +import AppKit +import Foundation +import os + +@MainActor +final class LKMCPBridgeEventPublisher { + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Events") + + /// Where finished events go. Injected rather than reaching for the + /// server singleton so this type can be exercised without a socket. + private let broadcast: @MainActor (LKMCPBridgeEvent) -> Void + + /// The channel-end and push listeners; they live as long as the + /// publisher runs. + private var connectionTasks: [Task] = [] + + /// One reload subscription per open live document. Documents come and + /// go, so these are attached on open and cancelled on close; a stale one + /// would keep publishing reloads for a window nobody can see. + private var documentSubscriptions: [ObjectIdentifier: LKSyncSubscription] = [:] + + private var hasStarted = false + + init(broadcast: @escaping @MainActor (LKMCPBridgeEvent) -> Void) { + self.broadcast = broadcast + } + + // MARK: - Lifecycle + + /// Idempotent. The bridge server starts asynchronously and may be + /// asked to start more than once over a process's life. + func start() { + guard hasStarted == false else { return } + hasStarted = true + + subscribeToDocumentLifecycle() + subscribeToConnectionManager() + subscribeToActivationState() + + // Documents can already exist: a document opened by an auto-connect + // environment variable races the bridge's start. Adopt whatever is + // already open rather than only ever seeing the next one. + for document in LKMCPBridgeLiveDocumentLookup.enumerateLiveDocuments() { + attachReloadSubscription(to: document) + } + } + + func stop() { + guard hasStarted else { return } + hasStarted = false + NotificationCenter.default.removeObserver(self) + for task in connectionTasks { + task.cancel() + } + connectionTasks.removeAll() + for subscription in documentSubscriptions.values { + subscription.cancel() + } + documentSubscriptions.removeAll() + } + + // MARK: - Document lifecycle + + private func subscribeToDocumentLifecycle() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDocumentDidOpen(_:)), + name: LookinLiveDocument.didOpenNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDocumentWillClose(_:)), + name: LookinLiveDocument.willCloseNotification, + object: nil + ) + } + + @objc private func handleDocumentDidOpen(_ notification: Notification) { + guard let document = notification.object as? LookinLiveDocument else { return } + attachReloadSubscription(to: document) + // Carries the whole target record rather than just an identifier: + // a client that learns a target appeared will immediately want to + // know what it is, and the round-trip back to `targets.list` buys + // nothing the host does not already have in hand. + broadcast( + LKMCPBridgeEvent( + topic: "targets.attached", + payload: targetPayload(for: document) + ) + ) + } + + @objc private func handleDocumentWillClose(_ notification: Notification) { + guard let document = notification.object as? LookinLiveDocument else { return } + detachReloadSubscription(from: document) + broadcast( + LKMCPBridgeEvent( + topic: "targets.detached", + payload: targetPayload(for: document) + ) + ) + } + + // MARK: - Per-document reload subscription + + private func attachReloadSubscription(to document: LookinLiveDocument) { + let key = ObjectIdentifier(document) + guard documentSubscriptions[key] == nil else { return } + guard let dataSource = document.hierarchyDataSource else { return } + + // `didReloadHierarchyInfo` is the data source's own announcement + // that it has finished absorbing a new tree, so by the time this + // fires the hierarchy a client would read is already the new one. + // It is sent synchronously on the main thread, so the payload is + // read before a later reload can overwrite `lastReloadInitiator`. + documentSubscriptions[key] = dataSource.didReloadHierarchyInfo.observe { [weak self, weak document] in + MainActor.assumeIsolated { + guard let self, let document else { return } + self.publishHierarchyReloaded(for: document) + } + } + } + + private func detachReloadSubscription(from document: LookinLiveDocument) { + documentSubscriptions.removeValue(forKey: ObjectIdentifier(document))?.cancel() + } + + private func publishHierarchyReloaded(for document: LookinLiveDocument) { + var payload = targetPayload(for: document) + payload["nodeCount"] = .integer(Int64(document.hierarchyDataSource?.rawFlatItems?.count ?? 0)) + // `agent` means "a bridge client asked for this", so a client that + // did the asking can recognize its own echo and ignore it. Only + // `host` is news. + let initiator = document.staticWindowController?.lastReloadInitiator ?? .host + payload["initiator"] = .string(initiator == .agent ? "agent" : "host") + broadcast(LKMCPBridgeEvent(topic: "hierarchy.reloaded", payload: payload)) + } + + // MARK: - Connection manager + + private func subscribeToConnectionManager() { + let connectionManager = LKConnectionManager.shared + + // A channel ending is the one thing here that is genuinely about + // the target app rather than the host: the app was killed, the USB + // cable came out, the simulator shut down. A client holding object + // identifiers for that target needs to stop trusting them. + let endedChannels = connectionManager.channelWillEndEvents() + connectionTasks.append(Task { [weak self] in + for await channel in endedChannels { + self?.publishDisconnected(channel: channel) + } + }) + + let pushes = connectionManager.pushEvents() + connectionTasks.append(Task { [weak self] in + for await push in pushes { + self?.handleServerPush(push) + } + }) + } + + /// Resolves a channel back to the document that owns it, so an event can + /// name a target identifier the client already knows rather than an + /// opaque channel the bridge protocol never exposes. + private func document(forChannel channel: LKChannel) -> LookinLiveDocument? { + LookinLiveDocumentController.shared.liveDocument(for: channel) + } + + private func publishDisconnected(channel: LKChannel) { + guard let document = document(forChannel: channel) else { return } + broadcast( + LKMCPBridgeEvent( + topic: "targets.disconnected", + payload: targetPayload(for: document) + ) + ) + } + + private func handleServerPush(_ push: LKConnectionPush) { + guard push.type == UInt32(LookinPush_SwiftUISupportDetected) else { return } + var payload: [String: LKMCPBridgeJSONValue] = [:] + if let document = document(forChannel: push.channel) { + payload = targetPayload(for: document) + } + payload["capability"] = .string("swiftUI") + broadcast(LKMCPBridgeEvent(topic: "capabilities.swiftUIDetected", payload: payload)) + } + + // MARK: - Activation state + + private func subscribeToActivationState() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleActivationStateDidChange(_:)), + name: LKSwiftUISupportGatekeeper.activationStateDidChangeNotification, + object: nil + ) + } + + @objc private func handleActivationStateDidChange(_: Notification) { + // Read silently: asking for protected-feature access would put an + // activation window on screen, which an event handler must never do. + let state = LKSwiftUISupportGatekeeper.sharedInstance().activationState + broadcast( + LKMCPBridgeEvent( + topic: "license.stateChanged", + payload: ["state": .string(Self.wireName(for: state))] + ) + ) + } + + private static func wireName(for state: LKSwiftUISupportActivationState) -> String { + switch state { + case .activated: + "activated" + case .notActivated: + "notActivated" + case .unknown: + "unknown" + } + } + + // MARK: - Payload helper + + /// The same target description `targets.list` returns, minus the + /// fields that require a round-trip. Kept in one place so every topic + /// names a target the same way. + private func targetPayload(for document: LookinLiveDocument) -> [String: LKMCPBridgeJSONValue] { + var payload: [String: LKMCPBridgeJSONValue] = [:] + guard let appInfo = document.inspectableApp.appInfo else { + return payload + } + payload["targetIdentifier"] = .string(String(appInfo.appInfoIdentifier)) + if let applicationName = appInfo.appName, applicationName.isEmpty == false { + payload["applicationName"] = .string(applicationName) + } + if let bundleIdentifier = appInfo.appBundleIdentifier, bundleIdentifier.isEmpty == false { + payload["bundleIdentifier"] = .string(bundleIdentifier) + } + if let deviceDescription = appInfo.deviceDescription, deviceDescription.isEmpty == false { + payload["deviceDescription"] = .string(deviceDescription) + } + if let operatingSystemDescription = appInfo.osDescription, operatingSystemDescription.isEmpty == false { + payload["operatingSystemDescription"] = .string(operatingSystemDescription) + } + return payload + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeFrame.swift b/LookInside/MCPBridge/LKMCPBridgeFrame.swift new file mode 100644 index 0000000..e20fe7c --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeFrame.swift @@ -0,0 +1,178 @@ +// LKMCPBridgeFrame.swift +// +// Newline-delimited JSON wire frames spoken on the MCPBridge Unix domain socket. +// +// The schema here is intentionally a generic inspection IPC and MUST NOT +// reference Model Context Protocol concepts (no `tools/list`, +// `resources/subscribe`, `notifications/resources/updated` field names or +// method strings). Keeping this layer generic preserves GPL compatibility for +// reuse by other proprietary or open consumers (CI bridge, remote inspector, +// automation runners). + +import Foundation + +// MARK: - Envelope + +/// Discriminates the three frame kinds carried on the wire. +enum LKMCPBridgeFrameKind: String, Sendable, Codable { + case request + case response + case event +} + +// MARK: - Request + +/// A request frame originating from a connected client. +/// +/// `identifier` correlates request and response frames; it is opaque to the +/// server and echoed back verbatim. `method` selects an inspection verb on the +/// server side (for example, `targets.list`, `hierarchy.read`). +struct LKMCPBridgeRequest: Sendable, Codable { + let kind: LKMCPBridgeFrameKind + let identifier: String + let method: String + let parameters: [String: LKMCPBridgeJSONValue]? + + enum CodingKeys: String, CodingKey { + case kind + case identifier = "id" + case method + case parameters = "params" + } +} + +// MARK: - Response + +/// A response frame returned to the client for a previously received request. +/// +/// Exactly one of `result` or `error` is set. The server MUST echo the +/// request's `identifier` verbatim. +struct LKMCPBridgeResponse: Sendable, Codable { + let kind: LKMCPBridgeFrameKind + let identifier: String + let result: LKMCPBridgeJSONValue? + let error: LKMCPBridgeErrorPayload? + + enum CodingKeys: String, CodingKey { + case kind + case identifier = "id" + case result + case error + } + + static func success(identifier: String, result: LKMCPBridgeJSONValue?) -> LKMCPBridgeResponse { + return LKMCPBridgeResponse(kind: .response, identifier: identifier, result: result, error: nil) + } + + static func failure(identifier: String, error: LKMCPBridgeErrorPayload) -> LKMCPBridgeResponse { + return LKMCPBridgeResponse(kind: .response, identifier: identifier, result: nil, error: error) + } +} + +// MARK: - Event + +/// An unsolicited event frame pushed by the server. The `topic` is a dotted +/// path identifying the event category (for example, `hierarchy.invalidated`, +/// `targets.attached`); the `payload` carries topic-specific structure. +struct LKMCPBridgeEvent: Sendable, Codable { + let kind: LKMCPBridgeFrameKind + let topic: String + let payload: [String: LKMCPBridgeJSONValue]? + + init(topic: String, payload: [String: LKMCPBridgeJSONValue]?) { + kind = .event + self.topic = topic + self.payload = payload + } +} + +// MARK: - Error payload + +/// A structured error returned in a response frame. Error codes follow a +/// dotted-namespace convention (for example, `dispatch.unknownMethod`, +/// `license.entitlementRequired`); free-form messages may be present for +/// debuggability but must not be relied on for programmatic dispatch. +struct LKMCPBridgeErrorPayload: Sendable, Codable, Error { + let code: String + let message: String + + static let unknownMethod = LKMCPBridgeErrorPayload( + code: "dispatch.unknownMethod", + message: "The requested method is not implemented by this server." + ) + + static let invalidParameters = LKMCPBridgeErrorPayload( + code: "dispatch.invalidParameters", + message: "The request parameters could not be decoded into the expected shape." + ) + + static let licenseRequired = LKMCPBridgeErrorPayload( + code: "license.entitlementRequired", + message: "The connected LookInside license does not include the required entitlement." + ) + + static let internalError = LKMCPBridgeErrorPayload( + code: "dispatch.internalError", + message: "An unexpected internal error occurred while servicing the request." + ) +} + +// MARK: - JSONValue + +/// A minimal JSON value type that round-trips through Codable without requiring +/// the inspection schema to be statically typed at the wire layer. Used inside +/// request `parameters`, response `result`, and event `payload` containers so +/// the server can route opaque JSON to method-specific handlers. +enum LKMCPBridgeJSONValue: Sendable, Codable { + case null + case bool(Bool) + case integer(Int64) + case double(Double) + case string(String) + case array([LKMCPBridgeJSONValue]) + case object([String: LKMCPBridgeJSONValue]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let boolean = try? container.decode(Bool.self) { + self = .bool(boolean) + } else if let integer = try? container.decode(Int64.self) { + self = .integer(integer) + } else if let floating = try? container.decode(Double.self) { + self = .double(floating) + } else if let text = try? container.decode(String.self) { + self = .string(text) + } else if let elements = try? container.decode([LKMCPBridgeJSONValue].self) { + self = .array(elements) + } else if let object = try? container.decode([String: LKMCPBridgeJSONValue].self) { + self = .object(object) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Encountered a JSON value that is none of null / bool / number / string / array / object." + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(boolean): + try container.encode(boolean) + case let .integer(integer): + try container.encode(integer) + case let .double(floating): + try container.encode(floating) + case let .string(text): + try container.encode(text) + case let .array(elements): + try container.encode(elements) + case let .object(object): + try container.encode(object) + } + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeInspectionModels.swift b/LookInside/MCPBridge/LKMCPBridgeInspectionModels.swift new file mode 100644 index 0000000..bcced23 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeInspectionModels.swift @@ -0,0 +1,522 @@ +// LKMCPBridgeInspectionModels.swift +// +// Codable DTOs returned in MCPBridge response frames. These are deliberately +// flat, JSON-friendly value types so the wire format stays self-describing +// to any consumer (proprietary `lookinside-mcp` shim, future CI bridges, +// remote inspectors). They do not depend on AppKit / UIKit types. +// +// The naming follows the wire vocabulary: a "target" is one inspection +// session bound to a running app (one `LookinLiveDocument`); a "view node" +// is one row in a target's UI hierarchy (one `LookinDisplayItem`). + +import CoreGraphics +import Foundation + +// MARK: - Rect + +/// CGRect represented as JSON-friendly doubles. Origin is the root coordinate +/// space of the inspected hierarchy. +struct LKMCPBridgeRect: Sendable, Codable, Equatable { + let x: Double + let y: Double + let width: Double + let height: Double + + init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(cgRect: CGRect) { + self.init( + x: Double(cgRect.origin.x), + y: Double(cgRect.origin.y), + width: Double(cgRect.size.width), + height: Double(cgRect.size.height) + ) + } +} + +// MARK: - TargetInfo + +/// One row in a `targets.list` response: a single live inspection session +/// (typically corresponding to one inspector window in the host UI). +struct LKMCPBridgeTargetInfo: Sendable, Codable { + /// Stable identifier for the duration of the inspection session. + /// Derived from `LookinAppInfo.appInfoIdentifier` (randomly generated per + /// app launch and reused across reconnects to the same app instance). + let targetIdentifier: String + + /// Human-readable application name, e.g. "WeRead". + let applicationName: String? + + /// Reverse-DNS bundle identifier, e.g. "com.example.weread". + let bundleIdentifier: String? + + /// Human-readable device description, e.g. "iPhone 15 Pro". + let deviceDescription: String? + + /// Human-readable operating-system description, e.g. "17.4". + let operatingSystemDescription: String? + + /// One of `simulator`, `iPad`, `device`, `mac`, `unknown`. + let deviceKind: String + + /// Numeric `LookinServer` version reported by the connected app. + let serverVersion: Int + + /// One of `licensed`, `trial`, `unlicensed`. V0 reports `licensed` for + /// every entry; the entitlement gate that produces real values lands in + /// a follow-up commit. + let licenseState: String +} + +// MARK: - ViewNode + +/// One node in a target's UI hierarchy. Returned by `hierarchy.read`. +/// +/// The shape is intentionally minimal for v1 — frame, identity, visibility, +/// and the immediate parent / child relationship. Per-attribute reads and +/// screenshot URIs live in separate methods to be added in subsequent +/// commits. +struct LKMCPBridgeViewNode: Sendable, Codable { + /// Hex-encoded `LookinObject.oid`, prefixed with `0x` (for example, + /// `0x600000abc123`). Stable within a single connected app instance. + let objectIdentifier: String + + /// Leaf Objective-C class name (head of `LookinObject.classChainList`), + /// e.g. "UIButton". May be empty if the underlying display item is + /// configured by the in-app `lookin_customDebugInfos` hook without + /// touching a real view / layer. + let className: String + + /// What the node is: "view" | "layer" | "window" | "windowScene" | + /// "layoutGuide" | "custom". Mapped from the display item's resolved + /// node kind, so it is present for old servers too (slot derivation). + let nodeKind: String + + /// Frame in the root coordinate space (host calls this `frameToRoot`). + let frame: LKMCPBridgeRect + + /// `true` when the view is hidden via UIKit / AppKit visibility flags. + let isHidden: Bool + + /// Composite alpha in `0.0 ... 1.0`. + let alpha: Double + + /// `true` when this node represents the key `UIWindow` / `NSWindow`. + let representsKeyWindow: Bool + + /// Object identifiers of immediate children, in source order. + let childObjectIdentifiers: [String] + + /// Inlined child nodes when the request asked for a depth greater than + /// one. `nil` means children were not expanded; an empty array means + /// children were expanded and there are none. + let children: [LKMCPBridgeViewNode]? +} + +// MARK: - AttributeGroup / Section / Attribute + +/// One attribute "card" in the host's inspector — a coherent bundle of +/// related attributes (Frame, View, Layer, AutoLayout, UIControl, …). +struct LKMCPBridgeAttributeGroup: Sendable, Codable { + /// Group identifier (`LookinAttrGroupIdentifier`, e.g. `Layout`, + /// `UIScrollView`, `NSWindow`), or the user-supplied title when the + /// group originates from `lookin_customDebugInfos`. + let identifier: String + + /// `true` when this group comes from in-app `lookin_customDebugInfos` + /// rather than LookinServer's built-in introspection. + let isUserCustom: Bool + + /// `true` when this group was produced by the activation-gated SwiftUI + /// extension; agents can use this to flag paid-feature data origin. + let isSwiftUIGroup: Bool + + let sections: [LKMCPBridgeAttributeSection] +} + +/// One sub-row inside an attribute group. +struct LKMCPBridgeAttributeSection: Sendable, Codable { + let identifier: String + let attributes: [LKMCPBridgeAttribute] +} + +/// One inspected attribute value with a type-discriminating `kind`. +struct LKMCPBridgeAttribute: Sendable, Codable { + /// `LookinAttrIdentifier` string (e.g. `BasicViewClass_Frame`, + /// `BasicViewClass_Hidden`). Stable across LookinServer versions. + let identifier: String + + /// Human-readable title set by `lookin_customDebugInfos`; empty for + /// built-in attributes. + let displayTitle: String? + + /// `true` when this attribute originates from `lookin_customDebugInfos`. + let isUserCustom: Bool + + /// Type discriminator for `value`. See `LKMCPBridgeAttributeEncoder` + /// for the full kind → JSON-shape mapping. Common values: + /// `integer`, `double`, `bool`, `string`, `selector`, `class`, + /// `point`, `size`, `rect`, `edgeInsets`, `offset`, `transform`, + /// `color`, `shadow`, `enum`, `json`, `custom`, `void`, + /// `unknown` (when the encoder cannot project the type cleanly and + /// falls back to a `{ "rawDescription": "..." }` payload). + let kind: String + + /// The encoded attribute value, shape-correlated with `kind`. `nil` + /// when the source attribute carries no value (`LookinAttrTypeVoid` + /// or genuinely empty optional fields). + let value: LKMCPBridgeJSONValue? + + /// Auxiliary payload for select kinds. For `enum` types, this is the + /// list of all enum case names the inspected object can hold (the + /// host calls these `extraValue` on `LookinAttribute`). + let extraValue: LKMCPBridgeJSONValue? + + /// Server-side identifier of a custom-attribute setter, present only + /// when the host can write to this attribute through a registered + /// custom setter. Pass-through; the bridge does not interpret it. + let customSetterIdentifier: String? +} + +// MARK: - Invocation + +/// Structural metadata for an `NSObject` returned by an invocation +/// (RPC 206). The receiver of an `invoke.method` response may use +/// `objectIdentifier` as a handle for a follow-up `invoke.method` call on +/// the same object — but the identifier is a fresh server-registered +/// `LookinObject.oid` and is NOT guaranteed to appear in a subsequent +/// `hierarchy.read` (the hierarchy walks the view tree, not the server's +/// general object registry). +struct LKMCPBridgeReturnedObject: Sendable, Codable { + /// Hex-encoded `LookinObject.oid` for the returned object, prefixed + /// with `0x` (matches the form used everywhere else on the bridge). + let objectIdentifier: String + + /// Pointer-formatted memory address of the returned object inside + /// the inspected app's address space, e.g. `0x100abcd00`. + let memoryAddress: String + + /// Full class chain of the returned object (head is the leaf class, + /// tail is `NSObject`). Identical shape to the + /// `LookinObject.classChainList` produced by the inspection routes. + let classChainList: [String] + + /// Optional debug annotation that the in-app `lookin_specialTrace` + /// hook may set. `nil` when the object does not opt in. + let specialTrace: String? +} + +/// Result envelope for `invoke.method`. Always carries `returnedVoid` so +/// callers can disambiguate "method returned `nil` / `0` / empty string" +/// from "method returned void"; the two cases produce the same JSON +/// `description: null` shape on most type-erased clients otherwise. +struct LKMCPBridgeInvocationResult: Sendable, Codable { + /// Stringified return value. `nil` when the method returned `void` + /// or when `secureContent` is `true`. For scalar (non-object, + /// non-void) returns the server fills in a generic `"Method invoked."` + /// placeholder; the bridge surfaces that string verbatim. + let description: String? + + /// `true` when the inspected method's return type is `void` (the + /// server signals this via the `LOOKIN_TAG_RETURN_VALUE_VOID` marker). + let returnedVoid: Bool + + /// Present only when the method returned an `NSObject`. Even when + /// `secureContent` is `true` the structural metadata stays — it + /// carries no user secret, only the object's class chain / address / + /// `oid` — matching the redaction philosophy of `attributes.read` + /// (string-bearing attribute values get redacted; structural + /// metadata does not). + let returnObject: LKMCPBridgeReturnedObject? + + /// `true` when the receiver display item is treated as carrying + /// secure user content (see `LKMCPBridgeSecureContentDetector`). In + /// that case `description` is redacted to `nil` to avoid leaking + /// passwords / OTPs into agent transcripts. `returnObject` is kept; + /// see its doc comment for the redaction rationale. + let secureContent: Bool +} + +// MARK: - Modification + +/// Wire envelope for an attribute value passed across the bridge in a +/// modification context: `kind` is the same string the read-side +/// `LKMCPBridgeAttributeEncoder` produces, `data` is its corresponding +/// JSON-friendly payload. The bridge consumes this in both directions: +/// requests carry it as the value to apply; responses echo it as +/// `requestedValue` so agents can compare against `effectiveAttribute` +/// without having to reconstruct the wire form. +struct LKMCPBridgeAttributeValueWire: Sendable, Codable { + let kind: String + let data: LKMCPBridgeJSONValue? +} + +/// Result envelope for `attribute.modify`. Echoes the requested value, +/// surfaces the post-layout effective attribute, and includes the +/// host-visible side-effect snapshot (frame / bounds / hidden / alpha) +/// that the server captures in `LookinDisplayItemDetail` after the +/// setter has run and a layout pass has completed. +struct LKMCPBridgeModificationResult: Sendable, Codable { + /// Echo of the attribute identifier the agent asked to modify. + let attributeIdentifier: String + + /// Echo of the wire `value` payload the agent supplied. Lets the + /// agent diff against `effectiveAttribute.value` without re-deriving + /// the wire shape from the encoded result. + let requestedValue: LKMCPBridgeAttributeValueWire + + /// Fully-encoded attribute as the host saw it AFTER the setter ran + /// and a layout pass settled. May differ from the request (autolayout + /// adjusts frames; some setters round to pixel boundaries; some + /// setters are no-ops). When `secureContent` is `true` the value + /// here is redacted in the same way `read_attributes` redacts. + let effectiveAttribute: LKMCPBridgeAttribute + + /// `true` when the effective wire value is structurally equal to + /// the requested wire value. Strict equality — no float epsilon. + /// `false` means the inspected app's layout pass / setter / autolayout + /// constraints rejected or adjusted the requested value; agents + /// should surface this to the user as a real signal. + let effectiveMatchesRequested: Bool + + /// Post-modification frame snapshot. Often differs from the previous + /// `get_hierarchy` result when the modification touched layout. + let frame: LKMCPBridgeRect + + /// Post-modification bounds snapshot. + let bounds: LKMCPBridgeRect + + /// Post-modification `isHidden` snapshot. + let isHidden: Bool + + /// Post-modification composite alpha snapshot in `0.0...1.0`. + let alpha: Double + + /// Same secure-content semantics as `read_attributes`: when `true`, + /// any string-bearing `effectiveAttribute.value` fields are redacted. + let secureContent: Bool +} + +// MARK: - Details prefetch + +/// One per-view entry in a `details.read` response. The shape is +/// deliberately a subset of `attributes.read`'s envelope: agents that +/// already consume `attributes.read` can reuse their `groups` parsing +/// code unchanged. The `secureContent` flag is per-item because a +/// single batch may contain both regular and secure-input views. +struct LKMCPBridgeViewDetail: Sendable, Codable { + let objectIdentifier: String + let groups: [LKMCPBridgeAttributeGroup] + let secureContent: Bool +} + +/// Envelope for `details.read`. Successful per-view detail goes in +/// `details`. Object identifiers that were missing from the client +/// hierarchy or returned `failureCode = -1` from the server fall +/// into `failedIdentifiers` — the call as a whole still succeeds so +/// agents can act on whatever did come back. +struct LKMCPBridgeDetailsReadResult: Sendable, Codable { + let details: [LKMCPBridgeViewDetail] + let failedIdentifiers: [String] +} + +// MARK: - Screenshot + +/// Result envelope for `screenshot.read`. Carries the image inline as +/// base64 rather than as a URI: the bridge has no static file server, +/// and the consuming MCP shim needs the bytes anyway to build an image +/// content block. +struct LKMCPBridgeScreenshotResult: Sendable, Codable { + /// The object actually captured. Differs from the request when the + /// caller omitted `objectIdentifier` and the service resolved the + /// key window on their behalf. + let objectIdentifier: String + + /// `solo` (view alone, subviews hidden) or `group` (view plus its + /// whole subtree). Echoed so callers that relied on the default can + /// see which one they got. + let mode: String + + /// Base64-encoded image bytes. + let imageData: String + + /// IANA media type for `imageData`. Always `image/png` today. + let mimeType: String + + /// Pixel dimensions of the returned image, after any downscale. + let pixelWidth: Int + let pixelHeight: Int + + /// Pixel dimensions as captured, before any downscale. Equal to + /// `pixelWidth` / `pixelHeight` when no downscale was applied. + /// Agents can compare the two to know whether fine detail was lost + /// and re-request with a larger `maximumPixelDimension`. + let sourcePixelWidth: Int + let sourcePixelHeight: Int + + /// Decoded (pre-base64) size of `imageData`, in bytes. + let byteCount: Int + + /// Frame of the captured view in the hierarchy's root coordinate + /// space, in points. Lets an agent map pixel coordinates in the + /// image back onto the coordinates `hierarchy.read` reports. + let frame: LKMCPBridgeRect + + /// `true` when the image came from the host's existing cache rather + /// than a fresh render in the target app. Cached images can lag + /// behind the live UI — notably right after `attribute.modify`. + let servedFromCache: Bool + + /// `true` when the captured region includes a view the secure-content + /// detector flags (for `group`, anywhere in the subtree). + /// + /// Screenshots are NOT redacted: unlike attribute strings, there is + /// no way to blank a value without destroying the picture. This flag + /// is the honest signal that the returned pixels may show sensitive + /// user input, so the caller can decide whether to keep it. + let containsSecureContent: Bool +} + +// MARK: - Hierarchy search + +/// One hit in a `hierarchy.find` response. +/// +/// Carries enough context to act on the result without a follow-up +/// `hierarchy.read`: the identifier for subsequent calls, the ancestor +/// chain for orientation, and the fields that matched so the caller can +/// tell a class-name hit apart from an incidental substring in a subtitle. +struct LKMCPBridgeSearchMatch: Sendable, Codable { + let objectIdentifier: String + + /// Leaf Objective-C class name, matching `hierarchy.read`'s field of + /// the same name. + let className: String + + /// The host inspector's row label. `null` when the secure-content + /// detector flagged this view. + let title: String? + + /// The host inspector's secondary label. `null` when the + /// secure-content detector flagged this view. + let subtitle: String? + + /// Frame in the hierarchy's root coordinate space, in points. + let frame: LKMCPBridgeRect + + let isHidden: Bool + let alpha: Double + + /// Nesting level, `0` for a window / scene root. Equal to the number + /// of entries in `ancestorObjectIdentifiers`. + let depth: Int + + /// Ancestor identifiers ordered root first, parent last. Feed any of + /// them to `hierarchy.read`'s `rootObjectIdentifier` to inspect the + /// surrounding subtree. + let ancestorObjectIdentifiers: [String] + + /// Human-readable ancestry, e.g. `NSWindow > NSView > NSButton`, + /// ending with this node. For orientation in a log or a reply; do not + /// parse it — use `ancestorObjectIdentifiers`. + let pathDescription: String + + /// Which searchable fields contained the query, in canonical order. + /// Never empty for a returned match. + let matchedFields: [String] + + /// `true` when the secure-content detector flagged this view, which + /// is also why `title` / `subtitle` may be `null`. + let secureContent: Bool +} + +/// Envelope for `hierarchy.find`. +/// +/// `totalMatchCount` counts every hit in the searched scope, so a caller +/// that receives `truncated: true` knows how much it is not seeing and +/// can narrow the query rather than paginate blindly. +struct LKMCPBridgeSearchResult: Sendable, Codable { + let matches: [LKMCPBridgeSearchMatch] + + /// Hits found before `limit` was applied. + let totalMatchCount: Int + + /// `true` when `matches.count < totalMatchCount`. + let truncated: Bool + + /// Nodes examined. Equals the whole tree unless the caller scoped the + /// search with `rootObjectIdentifier`. + let searchedNodeCount: Int + + /// The fields actually searched, after defaulting. + let searchedFields: [String] +} + +// MARK: - Selector listing + +/// Envelope for `selectors.list`. +/// +/// The server walks the entire class chain up to `NSObject`, so +/// `totalCount` on a UIKit / AppKit view runs into four digits. Callers +/// are expected to narrow with `nameFilter` rather than raise `limit`. +struct LKMCPBridgeSelectorListResult: Sendable, Codable { + /// The class actually queried. Differs from the request when the + /// caller passed `objectIdentifier` and the service resolved the leaf + /// class on their behalf. + let className: String + + /// Selector names in the order the target app produced them: by + /// class, most-derived first. Not sorted alphabetically — position is + /// the only signal about where a method came from. Coarse rather than + /// exact: within a single class the order is arbitrary. + let selectors: [String] + + /// Matching selectors before `limit` was applied. + let totalCount: Int + + /// `true` when `selectors.count < totalCount`. + let truncated: Bool + + /// `true` when selectors taking arguments were included. When + /// `false`, every entry is callable through `invoke.method`. + let includesArguments: Bool + + /// The resolved object's full class chain, present only when the + /// caller passed `objectIdentifier`. Tells the caller which classes + /// the flat `selectors` list spans. + let classChain: [String]? +} + +// MARK: - Hierarchy refresh + +/// Envelope for `hierarchy.refresh`. +/// +/// The interesting field is `previousNodeCount`: LookInside only learns the +/// target's UI changed when someone asks it to look, so a caller has no way +/// to tell a refresh that found a whole new screen from one that found the +/// same screen it already had. Reporting both counts answers that without a +/// follow-up `hierarchy.read`. +struct LKMCPBridgeRefreshResult: Sendable, Codable { + /// Echo of the requested target. + let targetIdentifier: String + + /// Top-level windows / scenes after the reload. Usable directly as + /// `hierarchy.read`'s `rootObjectIdentifier`. + let rootObjectIdentifiers: [String] + + /// Nodes in the tree after the reload. + let nodeCount: Int + + /// Nodes in the tree before it. Equal counts do not prove the UI is + /// unchanged — the same number of views can be an entirely different + /// screen — but a difference does prove it changed. + let previousNodeCount: Int + + /// Wall-clock cost of the round-trip, so callers can judge how + /// expensive repeating this would be. + let durationMilliseconds: Int +} diff --git a/LookInside/MCPBridge/LKMCPBridgeInspectionService.swift b/LookInside/MCPBridge/LKMCPBridgeInspectionService.swift new file mode 100644 index 0000000..7fb7636 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeInspectionService.swift @@ -0,0 +1,356 @@ +// LKMCPBridgeInspectionService.swift +// +// Routes MCPBridge inspection requests to the host's per-window +// `LookinLiveDocument` instances and converts the results to wire DTOs. +// +// All access to `NSDocumentController` and `LookinLiveDocument` happens on +// the main thread; the entry point is `@MainActor` so the bridge +// connection handlers can `await` it from a background queue and get the +// hop for free. + +import AppKit +import CoreGraphics +import Foundation +import os + +@MainActor +final class LKMCPBridgeInspectionService { + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Inspection") + + init() {} + + /// Routes a decoded request frame to the appropriate inspection method. + /// Falls through to `dispatch.unknownMethod` for any verb the host does + /// not implement so the wire layer behaves predictably for new clients. + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + switch request.method { + case "targets.list": + return handleTargetsList(identifier: request.identifier) + case "hierarchy.read": + return handleHierarchyRead(identifier: request.identifier, parameters: request.parameters) + case "attributes.read": + return handleAttributesRead(identifier: request.identifier, parameters: request.parameters) + default: + return .failure(identifier: request.identifier, error: .unknownMethod) + } + } + + // MARK: - targets.list + + private func handleTargetsList(identifier: String) -> LKMCPBridgeResponse { + let documents = LKMCPBridgeLiveDocumentLookup.enumerateLiveDocuments() + let infos = documents.compactMap(makeTargetInfo(for:)) + do { + let payload = try encodeAsJSONValue(infos) + return .success(identifier: identifier, result: .object(["targets": payload])) + } catch { + Self.logger.error("targets.list encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + private func makeTargetInfo(for document: LookinLiveDocument) -> LKMCPBridgeTargetInfo? { + guard let appInfo = document.inspectableApp.appInfo else { return nil } + return LKMCPBridgeTargetInfo( + targetIdentifier: String(appInfo.appInfoIdentifier), + applicationName: appInfo.appName, + bundleIdentifier: appInfo.appBundleIdentifier, + deviceDescription: appInfo.deviceDescription, + operatingSystemDescription: appInfo.osDescription, + deviceKind: deviceKindString(for: appInfo.deviceType), + serverVersion: Int(appInfo.serverVersion), + licenseState: "licensed" + ) + } + + private func deviceKindString(for kind: LookinAppInfoDevice) -> String { + switch kind { + case .simulator: return "simulator" + case .iPad: return "iPad" + case .others: return "device" + case .mac: return "mac" + case .macCatalyst: return "macCatalyst" + @unknown default: return "unknown" + } + } + + // MARK: - hierarchy.read + + private func handleHierarchyRead( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) -> LKMCPBridgeResponse { + guard let parameters = parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + let rootObjectIdentifier: String? + if case let .string(raw)? = parameters["rootObjectIdentifier"] { + rootObjectIdentifier = raw + } else { + rootObjectIdentifier = nil + } + + let depth: Int? + if case let .integer(raw)? = parameters["depth"] { + depth = Int(raw) + } else if case let .double(raw)? = parameters["depth"] { + depth = Int(raw) + } else { + depth = nil + } + + let includeLayoutGuides: Bool + if case let .bool(raw)? = parameters["includeLayoutGuides"] { + includeLayoutGuides = raw + } else { + includeLayoutGuides = false + } + + let includeCells: Bool + if case let .bool(raw)? = parameters["includeCells"] { + includeCells = raw + } else { + includeCells = false + } + + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + guard let dataSource = document.hierarchyDataSource else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + + let rootItems: [LookinDisplayItem] + if let rootObjectIdentifier { + guard let scopedRoot = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: dataSource.rawFlatItems ?? [], + matchingObjectIdentifier: rootObjectIdentifier + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Object identifier \(rootObjectIdentifier) is not present in this target's hierarchy." + ) + ) + } + rootItems = [scopedRoot] + } else { + rootItems = LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document) + } + + let nodes = rootItems.map { makeViewNode(from: $0, remainingDepth: depth, includeLayoutGuides: includeLayoutGuides, includeCells: includeCells) } + do { + let payload = try encodeAsJSONValue(nodes) + return .success(identifier: identifier, result: .object(["roots": payload])) + } catch { + Self.logger.error("hierarchy.read encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + private static func nodeKindString(for item: LookinDisplayItem) -> String { + switch item.resolvedNodeKind() { + // The client's vocabulary has no separate kinds for these two; both + // are layers. + case .layer, .viewOuterLayer, .backingLayer: return "layer" + case .view: return "view" + case .window: return "window" + case .windowScene: return "windowScene" + case .custom: return "custom" + case .layoutGuide: return "layoutGuide" + case .cell: return "cell" + case .unspecified: return "view" + @unknown default: return "view" + } + } + + private func makeViewNode(from item: LookinDisplayItem, remainingDepth: Int?, includeLayoutGuides: Bool, includeCells: Bool) -> LKMCPBridgeViewNode { + let identity = LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: item) + let className = item.displayingObject()?.classChainList?.first ?? "" + let frame = LKMCPBridgeRect(cgRect: LKMCPBridgeLiveDocumentLookup.rootSpaceFrame(for: item)) + var subitems = item.subitems ?? [] + if includeLayoutGuides == false { + // Default-off so existing agents' structural paths and snapshot + // diff baselines do not drift when guide nodes enter the tree. + subitems = subitems.filter { $0.resolvedNodeKind() != .layoutGuide } + } + if includeCells == false { + // Same reasoning as includeLayoutGuides, for cell nodes. + subitems = subitems.filter { $0.resolvedNodeKind() != .cell } + } + let childIdentifiers = subitems.map(LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for:)) + + let inlinedChildren: [LKMCPBridgeViewNode]? + if let remainingDepth, remainingDepth <= 1 { + inlinedChildren = nil + } else { + let nextDepth = remainingDepth.map { $0 - 1 } + inlinedChildren = subitems.map { makeViewNode(from: $0, remainingDepth: nextDepth, includeLayoutGuides: includeLayoutGuides, includeCells: includeCells) } + } + + return LKMCPBridgeViewNode( + objectIdentifier: identity, + className: className, + nodeKind: Self.nodeKindString(for: item), + frame: frame, + isHidden: item.isHidden, + alpha: Double(item.alpha), + representsKeyWindow: item.representedAsKeyWindow, + childObjectIdentifiers: childIdentifiers, + children: inlinedChildren + ) + } + + // MARK: - attributes.read + + private func handleAttributesRead( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) -> LKMCPBridgeResponse { + guard let parameters = parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"], + case let .string(objectIdentifier)? = parameters["objectIdentifier"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + let includeUserCustom: Bool + if case let .bool(raw)? = parameters["includeUserCustom"] { + includeUserCustom = raw + } else { + includeUserCustom = true + } + + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + guard document.hierarchyDataSource != nil else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + + guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), + matchingObjectIdentifier: objectIdentifier + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Object identifier \(objectIdentifier) is not present in this target's hierarchy." + ) + ) + } + + var rawGroups: [LookinAttributesGroup] = [] + if let inbuiltGroups = displayItem.attributesGroupList { + rawGroups.append(contentsOf: inbuiltGroups) + } + if includeUserCustom, let customGroups = displayItem.customAttrGroupList { + rawGroups.append(contentsOf: customGroups) + } + + // Evaluate the secure-content gate once per display item so every + // attribute we emit for this view shares the same redaction + // decision (a UITextField/NSSecureTextField can't have its + // `placeholder` leak when its `text` is redacted, for example). + let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: displayItem) + + let encodedGroups = rawGroups.map { group in + encodeGroup(group, redactingSecureContent: redactSecureContent) + } + + do { + let payload = try encodeAsJSONValue(encodedGroups) + // Annotate whether the host has actually fetched per-item + // details for this view. v2 reads the cache as-is; agents that + // see an empty `groups` array should know to ask the user to + // open this view in the host inspector first. `secureContent` + // tells the agent why textual values may be `null` here. + let hasCachedDetails = displayItem.attributesGroupList?.isEmpty == false + return .success( + identifier: identifier, + result: .object([ + "groups": payload, + "detailsCached": .bool(hasCachedDetails), + "secureContent": .bool(redactSecureContent), + ]) + ) + } catch { + Self.logger.error("attributes.read encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + private func encodeGroup( + _ group: LookinAttributesGroup, + redactingSecureContent: Bool + ) -> LKMCPBridgeAttributeGroup { + let identifier = group.userCustomTitle ?? group.identifier + let sections = (group.attrSections ?? []).map { section in + encodeSection(section, redactingSecureContent: redactingSecureContent) + } + return LKMCPBridgeAttributeGroup( + identifier: identifier ?? "", + isUserCustom: group.userCustomTitle != nil, + isSwiftUIGroup: group.isSwiftUIGroup, + sections: sections + ) + } + + private func encodeSection( + _ section: LookinAttributesSection, + redactingSecureContent: Bool + ) -> LKMCPBridgeAttributeSection { + let attributes = (section.attributes ?? []).map { attribute in + LKMCPBridgeAttributeEncoder.encode( + attribute, + redactingSecureContent: redactingSecureContent + ) + } + return LKMCPBridgeAttributeSection( + identifier: section.identifier ?? "", + attributes: attributes + ) + } + + // MARK: - Encoding helpers + + /// Encodes any `Encodable` value into the loose `LKMCPBridgeJSONValue` + /// tree used inside response frame `result` containers. Round-trips + /// through `JSONEncoder` / `JSONDecoder` so non-trivial nested types + /// (arrays, optionals, etc.) preserve their wire shape. + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeInvocationService.swift b/LookInside/MCPBridge/LKMCPBridgeInvocationService.swift new file mode 100644 index 0000000..3e74935 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeInvocationService.swift @@ -0,0 +1,246 @@ +// LKMCPBridgeInvocationService.swift +// +// Handles the `invoke.method` bridge route: arbitrary zero-argument +// Objective-C selector invocation on a view in an attached target app. +// This is the bridge's first mutating route — it goes over the connection channel +// (RPC 206 `LookinRequestType_InvokeMethod`) and can change the +// inspected app's state. Read-only inspection routes live in +// `LKMCPBridgeInspectionService`; they share the live-document / +// display-item lookup through `LKMCPBridgeLiveDocumentLookup`. +// +// Selector blacklist mirrors `LKConsoleDataSource` (rejects selectors +// containing `:` and `.`). The server enforces a hard zero-argument +// limit on top of that. Anything else — method visibility, naming +// conventions, side-effect ack — is left to the agent and tool-level +// `destructive` hint. + +import AppKit +import Foundation +import os + +@MainActor +final class LKMCPBridgeInvocationService { + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Invocation") + + init() {} + + // MARK: - Entry point + + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + guard request.method == "invoke.method" else { + return .failure(identifier: request.identifier, error: .unknownMethod) + } + return await handleInvokeMethod( + identifier: request.identifier, + parameters: request.parameters + ) + } + + // MARK: - invoke.method + + private func handleInvokeMethod( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) async -> LKMCPBridgeResponse { + guard let parameters = parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"], + case let .string(objectIdentifier)? = parameters["objectIdentifier"], + case let .string(selector)? = parameters["selector"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + if selector.isEmpty { + return .failure(identifier: identifier, error: .invalidParameters) + } + + // Mirror LKConsoleDataSource's selector blacklist (rejects + // anything that wouldn't go through RPC 206's zero-argument + // gate on the server side). Centralizing this here gives a + // single explicit error code with a hint instead of relying on + // the server's generic `LookinErrCode_Inner`. + if selector.contains(":") { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "invoke.unsupportedSelector", + message: "Multi-argument selectors (containing ':') are not supported in this release. Use a zero-argument selector or wait for a future bridge method." + ) + ) + } + if selector.contains(".") { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "invoke.unsupportedSelector", + message: "Dotted access (containing '.') is not supported; pass a single method or property name." + ) + ) + } + + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + guard document.hierarchyDataSource != nil else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + + guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), + matchingObjectIdentifier: objectIdentifier + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Object identifier \(objectIdentifier) is not present in this target's hierarchy." + ) + ) + } + + // The wire oid is hex-encoded; LKInspectableApp accepts the raw + // unsigned-long value. Re-derive it from the display item we + // just resolved so we don't have to parse the wire string — + // the display item already holds the canonical numeric oid. + guard let nativeOid = displayItem.displayingObject()?.oid, nativeOid != 0 else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Display item \(objectIdentifier) does not carry a live object identifier." + ) + ) + } + + let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: displayItem) + + // Sent through `response(type:data:as:)` rather than + // `invokeMethod(oid:text:)`: that one replaces the Server's + // void-return marker with a localized sentence, and the marker is + // what `returnedVoid` is decided from. A missing channel throws + // `LookinErr_NoConnect`, mapped to `invoke.disconnected` below. + let rawDictionary: NSDictionary + do { + rawDictionary = try await document.inspectableApp.response( + type: UInt32(LookinRequestTypeInvokeMethod), + data: LKInspectableApp.invokeMethodParameters(oid: nativeOid, text: selector), + as: NSDictionary.self + ) + } catch { + return .failure(identifier: identifier, error: mapInvocationError(error as NSError)) + } + + let invocationResult = makeInvocationResult( + from: rawDictionary, + redactSecureContent: redactSecureContent + ) + + do { + let payload = try encodeAsJSONValue(invocationResult) + return .success(identifier: identifier, result: payload) + } catch { + Self.logger.error("invoke.method encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + // MARK: - Response shaping + + private func makeInvocationResult( + from dictionary: NSDictionary, + redactSecureContent: Bool + ) -> LKMCPBridgeInvocationResult { + let rawDescription = dictionary["description"] as? String + let returnedVoid = rawDescription == LookinStringFlag_VoidReturn + + let surfacedDescription: String? + if redactSecureContent { + surfacedDescription = nil + } else if returnedVoid { + surfacedDescription = nil + } else { + surfacedDescription = rawDescription + } + + let returnObject = makeReturnedObject(from: dictionary["object"]) + + return LKMCPBridgeInvocationResult( + description: surfacedDescription, + returnedVoid: returnedVoid, + returnObject: returnObject, + secureContent: redactSecureContent + ) + } + + private func makeReturnedObject(from rawObject: Any?) -> LKMCPBridgeReturnedObject? { + guard let lookinObject = rawObject as? LookinObject else { return nil } + let oidString = String(format: "0x%lx", lookinObject.oid) + return LKMCPBridgeReturnedObject( + objectIdentifier: oidString, + memoryAddress: lookinObject.memoryAddress ?? "", + classChainList: lookinObject.classChainList ?? [], + specialTrace: lookinObject.specialTrace + ) + } + + // MARK: - Error mapping + + private func mapInvocationError(_ error: NSError) -> LKMCPBridgeErrorPayload { + switch error.code { + case LookinErrCode_ObjectNotFound: + return LKMCPBridgeErrorPayload( + code: "invoke.objectNotFound", + message: "The target app could not find an object for this identifier or it does not respond to the selector. The object may have been deallocated; try reloading the inspector." + ) + case LookinErrCode_Inner: + return LKMCPBridgeErrorPayload( + code: "invoke.invalidSelector", + message: "The target app rejected the selector — most likely it requires arguments or has no method signature. Only zero-argument selectors are supported." + ) + case LookinErrCode_LicenseRequired: + return .licenseRequired + case LookinErrCode_NoConnect: + return LKMCPBridgeErrorPayload( + code: "invoke.disconnected", + message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." + ) + case LookinErrCode_Timeout: + return LKMCPBridgeErrorPayload( + code: "invoke.timeout", + message: "The target app did not respond within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." + ) + default: + Self.logger.error("invoke.method received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") + return LKMCPBridgeErrorPayload( + code: "invoke.internalError", + message: "The target app reported an unexpected error (code \(error.code))." + ) + } + } + + // MARK: - Encoding helper (duplicated from InspectionService) + + // + // The two services share the same JSON round-trip helper but they + // belong to different actor-isolated types, so we keep one copy per + // service rather than introducing a shared protocol just for two + // identical four-line helpers. + + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift b/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift new file mode 100644 index 0000000..1b313f5 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift @@ -0,0 +1,158 @@ +// LKMCPBridgeListenSocket.swift +// +// Creation of the MCPBridge listening Unix domain socket, split out of +// `LKMCPBridgeServer` so it can be exercised on its own. +// +// The split exists for a reason beyond tidiness: the socket's blocking +// mode is a correctness property that is invisible at the call site and +// impossible to test through the server, which drags in the whole +// inspection stack (AppKit, NSDocumentController, the ObjC bridge). +// Everything here depends on Darwin and Foundation only, so +// `Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift` can compile it +// with a bare `swiftc` invocation the way the other host-side tests do. +// +// Why non-blocking matters: the server drives accepts from a +// `DispatchSourceRead` and drains them in a `while true` loop that +// expects `EAGAIN` to signal "no more pending connections". On a +// blocking descriptor that call never returns `EAGAIN` — it parks the +// thread until the *next* client shows up. Since the accept handler +// runs on the server's serial state queue, the first connected client +// would leave that queue occupied indefinitely, and everything else +// scheduled on it (`stop()`, connection bookkeeping) would simply never +// run. + +import Darwin +import Foundation + +enum LKMCPBridgeListenSocket { + /// File permissions for the socket itself: owner-only, matching the + /// per-user trust model (any process running as this user may + /// connect; nothing else may). + static let socketFilePermissions: mode_t = 0o600 + + /// Binds and listens on a Unix domain socket at `socketPath`, + /// returning a non-blocking descriptor ready for `accept(2)`. + /// + /// Any existing socket file at the path is unlinked first — a stale + /// file from a crashed host would otherwise make `bind` fail with + /// `EADDRINUSE` forever. + /// + /// - Throws: `LKMCPBridgeListenSocketError` when any step fails. The + /// descriptor is closed before throwing, so a failed call leaks + /// nothing. + static func makeListening( + atPath socketPath: String, + backlog: Int32 + ) throws -> Int32 { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw LKMCPBridgeListenSocketError(message: "socket(AF_UNIX) failed (errno \(errno))") + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + let pathCapacity = MemoryLayout.size(ofValue: address.sun_path) + if pathBytes.count >= pathCapacity { + Darwin.close(descriptor) + throw LKMCPBridgeListenSocketError( + message: "Socket path is longer than the kernel's sun_path limit (\(pathCapacity) bytes)." + ) + } + withUnsafeMutableBytes(of: &address.sun_path) { pathBuffer in + for index in 0 ..< pathBytes.count { + pathBuffer[index] = pathBytes[index] + } + pathBuffer[pathBytes.count] = 0 + } + + unlink(socketPath) + + let bindResult = withUnsafePointer(to: &address) { addressPointer -> Int32 in + addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + Darwin.bind(descriptor, rebound, socklen_t(MemoryLayout.size)) + } + } + if bindResult != 0 { + let savedErrorNumber = errno + Darwin.close(descriptor) + throw LKMCPBridgeListenSocketError(message: "bind() failed (errno \(savedErrorNumber))") + } + + // Best-effort: a permissive umask would otherwise leave the + // socket group/world accessible. Not fatal — the parent + // directory is already 0700. + _ = chmod(socketPath, socketFilePermissions) + + if listen(descriptor, backlog) != 0 { + let savedErrorNumber = errno + Darwin.close(descriptor) + unlink(socketPath) + throw LKMCPBridgeListenSocketError(message: "listen() failed (errno \(savedErrorNumber))") + } + + // The accept drain loop's termination condition. See the file + // header for what happens without it. + if fcntl(descriptor, F_SETFL, O_NONBLOCK) != 0 { + let savedErrorNumber = errno + Darwin.close(descriptor) + unlink(socketPath) + throw LKMCPBridgeListenSocketError( + message: "fcntl(F_SETFL, O_NONBLOCK) failed (errno \(savedErrorNumber))" + ) + } + + return descriptor + } + + /// Accepts one pending connection, returning a **blocking** + /// descriptor, or a negative value when there is nothing to accept + /// (`errno` is `EAGAIN` in that case) or the call failed. + /// + /// The blocking mode is the whole reason this wraps `accept(2)`. + /// On BSD-derived systems — macOS included — the accepted socket + /// inherits `O_NONBLOCK` from the listener, which is the opposite + /// of Linux and the opposite of what POSIX requires. Since the + /// listener has to be non-blocking (see `makeListening`), every + /// connection would silently come out non-blocking too. + /// + /// That matters because `LKMCPBridgeConnection` writes responses + /// with a blocking `write(2)` loop and treats `EAGAIN` as fatal. + /// On a non-blocking descriptor, any response larger than the + /// socket send buffer hits `EAGAIN` partway through and the + /// connection is torn down mid-frame. Small responses still work, + /// so the symptom is selective and confusing: hierarchy reads + /// succeed, screenshots drop the connection. + static func acceptConnection(listenDescriptor: Int32) -> Int32 { + var clientAddress = sockaddr_un() + var addressLength = socklen_t(MemoryLayout.size) + let descriptor = withUnsafeMutablePointer(to: &clientAddress) { addressPointer -> Int32 in + addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + accept(listenDescriptor, rebound, &addressLength) + } + } + guard descriptor >= 0 else { return descriptor } + + let flags = fcntl(descriptor, F_GETFL) + if flags >= 0 && (flags & O_NONBLOCK) != 0 { + _ = fcntl(descriptor, F_SETFL, flags & ~O_NONBLOCK) + } + return descriptor + } + + /// Whether `descriptor` is in non-blocking mode. Exists so the + /// property can be asserted directly rather than inferred from + /// timing. + static func isNonBlocking(descriptor: Int32) -> Bool { + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0 else { return false } + return (flags & O_NONBLOCK) != 0 + } +} + +struct LKMCPBridgeListenSocketError: LocalizedError { + let message: String + var errorDescription: String? { + return message + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeLiveDocumentLookup.swift b/LookInside/MCPBridge/LKMCPBridgeLiveDocumentLookup.swift new file mode 100644 index 0000000..3e7906c --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeLiveDocumentLookup.swift @@ -0,0 +1,98 @@ +// LKMCPBridgeLiveDocumentLookup.swift +// +// Shared lookup helpers that translate the bridge wire identifiers +// (`targetIdentifier`, `objectIdentifier`) into the host's live runtime +// objects (`LookinLiveDocument`, `LookinDisplayItem`). Both the inspection +// service (read-only queries) and the invocation service (round-trips to +// the server over the connection channel) share this surface so the wire shape — hex +// `oid` strings, decimal `appInfoIdentifier` strings — stays one +// canonical implementation. +// +// All entry points are `@MainActor`: `NSDocumentController` and +// `LookinLiveDocument` require main-thread isolation, and the bridge +// connection handlers already hop here before invoking lookup. + +import AppKit +import Foundation + +@MainActor +enum LKMCPBridgeLiveDocumentLookup { + /// Enumerates every currently-open `LookinLiveDocument` known to the + /// host. Static (archived `.lookin`) documents are excluded because + /// they have no attached app to inspect / invoke against. + static func enumerateLiveDocuments() -> [LookinLiveDocument] { + let allDocuments = NSDocumentController.shared.documents + return allDocuments.compactMap { $0 as? LookinLiveDocument } + } + + /// Resolves a wire `targetIdentifier` (decimal string form of + /// `LookinAppInfo.appInfoIdentifier`) to the matching + /// `LookinLiveDocument`. Returns `nil` when the string is not a valid + /// unsigned integer or no open live document carries that + /// `appInfoIdentifier`. + static func findLiveDocument(targetIdentifier: String) -> LookinLiveDocument? { + guard let identifierValue = UInt(targetIdentifier) else { return nil } + return enumerateLiveDocuments().first { document in + document.inspectableApp.appInfo?.appInfoIdentifier == identifierValue + } + } + + /// Breadth-first search across the supplied root display items for an + /// item whose wire `objectIdentifier` matches `identifier`. The wire + /// form is the hex-encoded `LookinObject.oid`, prefixed with `0x` + /// (e.g. `0x600000abc123`). + static func findDisplayItem( + amongRoots roots: [LookinDisplayItem], + matchingObjectIdentifier identifier: String + ) -> LookinDisplayItem? { + var queue: [LookinDisplayItem] = roots + while queue.isEmpty == false { + let current = queue.removeFirst() + if objectIdentifierString(for: current) == identifier { + return current + } + if let subitems = current.subitems { + queue.append(contentsOf: subitems) + } + } + return nil + } + + /// Top-level display items (windows / scenes) inside a live document. + /// These are the seed points for `findDisplayItem(amongRoots:matchingObjectIdentifier:)` + /// when the caller didn't pre-narrow the search space. + static func topLevelDisplayItems(in document: LookinLiveDocument) -> [LookinDisplayItem] { + guard let dataSource = document.hierarchyDataSource, + let flatItems = dataSource.rawFlatItems + else { return [] } + // Top-level items (UIWindow / NSWindow) report indentLevel == 0; + // every nested view has indentLevel >= 1. We avoid `superItem` + // here because the Objective-C importer renames it into a Swift + // keyword collision. + return flatItems.filter { $0.indentLevel() == 0 } + } + + /// Canonical hex-encoded `objectIdentifier` string for a display item. + /// Stays in sync with the form returned by `hierarchy.read` so + /// callers can round-trip identifiers across bridge methods. + static func objectIdentifierString(for item: LookinDisplayItem) -> String { + let oid = item.displayingObject()?.oid ?? 0 + return String(format: "0x%lx", oid) + } + + /// The item's rectangle in the root coordinate space. + /// + /// `LookinDisplayItem.frame` is *parent*-relative -- `calculateFrameToRoot` + /// exists precisely because composing it up the tree is non-trivial (it + /// has to subtract each ancestor's `bounds` origin and flip for AppKit + /// superitems). The bridge reports root-space rectangles so an agent can + /// line a node up against a screenshot without walking the tree itself. + /// + /// Falls back to the raw frame when the item has no usable geometry: + /// `hasValidFrameToRoot` is a check on `frame` itself, so in that case + /// both values are equally meaningless and the raw one at least matches + /// what the host inspector displays. + static func rootSpaceFrame(for item: LookinDisplayItem) -> CGRect { + return item.hasValidFrameToRoot() ? item.calculateFrameToRoot() : item.frame + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeModificationService.swift b/LookInside/MCPBridge/LKMCPBridgeModificationService.swift new file mode 100644 index 0000000..88ed8c3 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeModificationService.swift @@ -0,0 +1,344 @@ +// LKMCPBridgeModificationService.swift +// +// Handles the `attribute.modify` bridge route: mutates one inspected +// attribute on a view in the attached target app (RPC 204 +// `InbuiltAttrModification`) and returns the post-layout effective +// value alongside the value the agent requested. +// +// This is the second mutating bridge route (after `invoke.method`). +// It shares the same round-trip plumbing — selector lookup +// happens against `LookinDashboardBlueprint`; the polymorphic wire +// value is decoded into the right ObjC type by +// `LKMCPBridgeAttributeValueDecoder`; the round-trip awaits +// `LKInspectableApp.submit(_:)`; +// `LKMCPBridgeAttributeEncoder` re-encodes the effective post-layout +// attribute so the caller can compare against what they sent. +// +// The bridge does NOT pre-compute settability — it asks +// `LookinDashboardBlueprint setterWithAttrID:` for the setter SEL and +// surfaces `modify.readOnly` when the blueprint returns nil. This +// matches the host inspector's existing modification gate. + +import AppKit +import CoreGraphics +import Foundation +import os + +@MainActor +final class LKMCPBridgeModificationService { + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Modification") + + init() {} + + // MARK: - Entry point + + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + guard request.method == "attribute.modify" else { + return .failure(identifier: request.identifier, error: .unknownMethod) + } + return await handleAttributeModify( + identifier: request.identifier, + parameters: request.parameters + ) + } + + // MARK: - attribute.modify + + private func handleAttributeModify( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) async -> LKMCPBridgeResponse { + // Parameter extraction + guard let parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"], + case let .string(objectIdentifier)? = parameters["objectIdentifier"], + case let .string(attributeIdentifier)? = parameters["attributeIdentifier"], + case let .object(valueObject)? = parameters["value"], + case let .string(wireKind)? = valueObject["kind"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + let wireData = valueObject["data"] + + // Live-document / display-item lookup + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + guard document.hierarchyDataSource != nil else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + + guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), + matchingObjectIdentifier: objectIdentifier + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Object identifier \(objectIdentifier) is not present in this target's hierarchy." + ) + ) + } + + // Find the attribute in the currently-cached detail + guard let attribute = findAttribute( + withIdentifier: attributeIdentifier, + in: displayItem.attributesGroupList ?? [] + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "modify.attributeNotFound", + message: "Attribute \(attributeIdentifier) is not present in this view's cached detail. Open the view in the LookInside inspector (or call read_attributes first) to populate its detail, then retry." + ) + ) + } + + // Setter lookup. `setter(withAttrID:)` returns nil for read-only + // attributes (e.g. computed Relation, class chain, AutoLayout + // constraint summaries). + guard let setterSelector = LookinDashboardBlueprint.setter(withAttrID: attributeIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "modify.readOnly", + message: "Attribute \(attributeIdentifier) is read-only; there is no registered setter for it." + ) + ) + } + + // Native oid + guard let nativeOid = displayItem.displayingObject()?.oid, nativeOid != 0 else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Display item \(objectIdentifier) does not carry a live object identifier." + ) + ) + } + + // Decode wire value into the polymorphic id LookinAttributeModification expects. + let nativeValue: Any + do { + nativeValue = try LKMCPBridgeAttributeValueDecoder.decode( + wireKind: wireKind, + wireData: wireData, + expectedAttrType: attribute.attrType + ) + } catch let LKMCPBridgeAttributeValueDecoder.DecodeError.unsupportedKind(kind) { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "modify.unsupportedKind", + message: "Wire kind '\(kind)' is not supported for modification in this release. Supported kinds: integer, double, bool, string, selector, class, point, vector, size, rect, transform, edgeInsets, offset, color, enum." + ) + ) + } catch let LKMCPBridgeAttributeValueDecoder.DecodeError.kindMismatch(wireKind, expectedAttrType) { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "modify.valueKindMismatch", + message: "Wire kind '\(wireKind)' does not match attribute's declared LookinAttrType (\(expectedAttrType.rawValue))." + ) + ) + } catch let LKMCPBridgeAttributeValueDecoder.DecodeError.shapeInvalid(reason) { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "modify.valueShapeInvalid", + message: reason + ) + ) + } catch { + Self.logger.error("Unexpected decode error: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + + // Build the LookinAttributeModification. + let modification = LookinAttributeModification() + modification.targetOid = nativeOid + modification.setterSelector = setterSelector + modification.attrType = attribute.attrType + modification.value = nativeValue + modification.clientReadableVersion = LKHelper.lookinReadableVersion() + + let detail: LookinDisplayItemDetail + do { + detail = try await document.inspectableApp.submit(modification) + } catch { + return .failure(identifier: identifier, error: mapModificationError(error as NSError)) + } + + // Merge the post-modification detail back into the host's cache, the + // same way `details.read` and `screenshot.read` do. Without this the + // inspector window keeps showing the pre-modification value, and a + // follow-up `attributes.read` serves the stale cached groups -- which + // directly contradicts the `effectiveAttribute` this call is about to + // return, with nothing to tell the agent which one is current. + // + // The host's own modification path additionally re-fetches a + // screenshot (`LKDashboardViewController.m`). That is deliberately + // not mirrored here: screenshots have their own route, and issuing + // one per attribute write would put a render round-trip on the + // critical path of every modification. + document.hierarchyDataSource?.modify(with: detail) + + // Find the effective post-layout attribute in the response. + guard let effectiveAttribute = findAttribute( + withIdentifier: attributeIdentifier, + in: detail.attributesGroupList ?? [] + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "modify.internalError", + message: "Effective attribute disappeared from the server's post-modification detail. This indicates a server-side bug." + ) + ) + } + + // Encode the effective attribute through the same encoder + // the read route uses, so its shape matches read_attributes. + let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: displayItem) + let encodedEffective = LKMCPBridgeAttributeEncoder.encode( + effectiveAttribute, + redactingSecureContent: redactSecureContent + ) + + let requestedWire = LKMCPBridgeAttributeValueWire(kind: wireKind, data: wireData) + let effectiveWire = LKMCPBridgeAttributeValueWire( + kind: encodedEffective.kind, + data: encodedEffective.value + ) + let matches = !redactSecureContent + && requestedWire.kind == effectiveWire.kind + && jsonValuesEqual(requestedWire.data, effectiveWire.data) + + let frame = (detail.frameValue?.rectValue).map { CGRect(x: $0.origin.x, y: $0.origin.y, width: $0.size.width, height: $0.size.height) } ?? .zero + let bounds = (detail.boundsValue?.rectValue).map { CGRect(x: $0.origin.x, y: $0.origin.y, width: $0.size.width, height: $0.size.height) } ?? .zero + let isHidden = detail.hiddenValue?.boolValue ?? false + let alpha = detail.alphaValue?.doubleValue ?? 1.0 + + let result = LKMCPBridgeModificationResult( + attributeIdentifier: attributeIdentifier, + requestedValue: requestedWire, + effectiveAttribute: encodedEffective, + effectiveMatchesRequested: matches, + frame: LKMCPBridgeRect(cgRect: frame), + bounds: LKMCPBridgeRect(cgRect: bounds), + isHidden: isHidden, + alpha: alpha, + secureContent: redactSecureContent + ) + + do { + let payload = try encodeAsJSONValue(result) + return .success(identifier: identifier, result: payload) + } catch { + Self.logger.error("attribute.modify encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + // MARK: - Helpers + + private func findAttribute( + withIdentifier identifier: String, + in groups: [LookinAttributesGroup] + ) -> LookinAttribute? { + for group in groups { + for section in group.attrSections ?? [] { + for attribute in section.attributes ?? [] { + if attribute.identifier == identifier { + return attribute + } + } + } + } + return nil + } + + /// Equality on `LKMCPBridgeJSONValue` by canonical JSON encoding. + /// Uses `.sortedKeys` so object key order does not affect the result. + /// This is intentionally strict — no float epsilon — because layout + /// pass adjustments are a real signal we want to surface to the + /// agent rather than absorb. + private func jsonValuesEqual( + _ lhs: LKMCPBridgeJSONValue?, + _ rhs: LKMCPBridgeJSONValue? + ) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case (nil, _?), (_?, nil): + return false + case let (lhsValue?, rhsValue?): + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let lhsData = (try? encoder.encode(lhsValue)) ?? Data() + let rhsData = (try? encoder.encode(rhsValue)) ?? Data() + return lhsData == rhsData + } + } + + private func mapModificationError(_ error: NSError) -> LKMCPBridgeErrorPayload { + switch error.code { + case LookinErrCode_ObjectNotFound: + return LKMCPBridgeErrorPayload( + code: "modify.objectNotFound", + message: "The target app could not find an object for this identifier. The object may have been deallocated; try reloading the inspector." + ) + case LookinErrCode_Inner: + return LKMCPBridgeErrorPayload( + code: "modify.invalidSetter", + message: "The target app rejected the modification — the setter signature was missing or the value type did not unbox cleanly." + ) + case LookinErrCode_Exception: + // NSException thrown by the setter; the server stuffs the + // exception's reason into the NSLocalizedDescriptionKey. + let reason = (error.userInfo[NSLocalizedDescriptionKey] as? String) ?? "no further detail" + return LKMCPBridgeErrorPayload( + code: "modify.exception", + message: "The setter raised an NSException in the target app: \(reason)" + ) + case LookinErrCode_LicenseRequired: + return .licenseRequired + case LookinErrCode_NoConnect: + return LKMCPBridgeErrorPayload( + code: "modify.disconnected", + message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." + ) + case LookinErrCode_Timeout: + return LKMCPBridgeErrorPayload( + code: "modify.timeout", + message: "The target app did not respond within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." + ) + default: + Self.logger.error("attribute.modify received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") + return LKMCPBridgeErrorPayload( + code: "modify.internalError", + message: "The target app reported an unexpected error (code \(error.code))." + ) + } + } + + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift b/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift new file mode 100644 index 0000000..5351649 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift @@ -0,0 +1,88 @@ +// LKMCPBridgeObjectIdentifierList.swift +// +// Parses and normalizes the `objectIdentifiers` array that `details.read` +// accepts off the wire. +// +// This lives apart from LKMCPBridgeDetailsService for the same reason +// LKMCPBridgeSearchQuery lives apart from LKMCPBridgeSearchService: the +// service imports AppKit and the ObjC bridge, so it cannot be compiled into +// the standalone binaries that Scripts/test-mcp-bridge.sh builds, while the +// list handling itself needs nothing but Foundation. +// +// What makes it worth extracting rather than inlining: a repeated identifier +// used to reach `Dictionary(uniqueKeysWithValues:)` further down the route, +// which traps -- taking the entire host app down rather than failing the one +// request. The caller is an LLM-driven MCP client, so a list that repeats an +// identifier is an ordinary thing to receive, not a malformed request, and +// the cost of getting it wrong is far out of proportion to the mistake. + +import Foundation + +enum LKMCPBridgeObjectIdentifierList { + /// Why a wire payload was rejected. + enum ParseFailure: Error, Equatable { + /// At least one entry was not a JSON string. Loose typing is fine + /// for primitives elsewhere, but an identifier that is not a string + /// cannot address anything, so it is rejected rather than skipped. + case notAllStrings + /// The array carried no entries at all. + case empty + /// More *distinct* identifiers than the route accepts. + case tooMany(distinctCount: Int, limit: Int) + } + + struct Parsed: Equatable { + /// Distinct identifiers, in the order they were first seen. Order is + /// preserved because the response lists details in request order and + /// an agent correlates them positionally. + let identifiers: [String] + + /// How many entries were dropped because an equal string had already + /// been seen. Surfaced so the host can log it; the agent is not told, + /// because repeating an identifier is not an error and the response + /// it gets back is exactly what it asked for. + let droppedDuplicateCount: Int + } + + /// Materializes the wire array into distinct identifiers. + /// + /// - Parameters: + /// - wireValues: The raw `objectIdentifiers` array. + /// - limit: Maximum number of distinct identifiers the route accepts. + static func parse( + wireValues: [LKMCPBridgeJSONValue], + limit: Int + ) -> Result { + var identifiers: [String] = [] + identifiers.reserveCapacity(wireValues.count) + var seenIdentifiers: Set = [] + var droppedDuplicateCount = 0 + + for entry in wireValues { + guard case let .string(value) = entry else { + return .failure(.notAllStrings) + } + if seenIdentifiers.insert(value).inserted { + identifiers.append(value) + } else { + droppedDuplicateCount += 1 + } + } + + guard identifiers.isEmpty == false else { + return .failure(.empty) + } + + // The cap is applied after de-duplication because it exists to bound + // how many objects the host actually fetches from the target app. A + // repeated identifier costs nothing downstream, so rejecting a list + // for a length it does not really have would be a false refusal. + guard identifiers.count <= limit else { + return .failure(.tooMany(distinctCount: identifiers.count, limit: limit)) + } + + return .success( + Parsed(identifiers: identifiers, droppedDuplicateCount: droppedDuplicateCount) + ) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeRefreshService.swift b/LookInside/MCPBridge/LKMCPBridgeRefreshService.swift new file mode 100644 index 0000000..7eba66a --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeRefreshService.swift @@ -0,0 +1,201 @@ +// LKMCPBridgeRefreshService.swift +// +// Handles the `hierarchy.refresh` bridge route: re-fetching the target +// app's whole view tree (RPC 202) and handing it to the host's data +// source, which is exactly what the inspector's toolbar reload button +// does. +// +// Why this needs to exist at all: LookInside is a manual-refresh +// inspector by design. Nothing polls the target and nothing pushes "the +// UI changed" — the host's tree is whatever the last fetch produced. A +// human closes that gap by clicking reload; before this route an agent +// had no equivalent and could only read a snapshot that silently went +// stale the moment the app navigated. +// +// This service is deliberately NOT folded into `LKMCPBridgeInspectionService`. +// That service is pure cached reads with no RPC; refresh round-trips to +// the target and mutates host state, and merging them would quietly +// retire the "the read surface never emits RPC" property. +// +// The actual reload is not reimplemented here. It runs through +// `LKStaticWindowController.reloadHierarchy(initiator:)`, the same method +// the toolbar button calls, so both paths share one re-entrancy gate +// and one fetch chain. Duplicating the chain in Swift would have left two +// copies to drift apart, and writing the window controller's private +// `isFetchingHierarchy` flag from here would have meant this service +// silently driving the host's toolbar enablement. + +import AppKit +import Foundation +import os + +@MainActor +final class LKMCPBridgeRefreshService { + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Refresh") + + init() {} + + // MARK: - Entry point + + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + guard request.method == "hierarchy.refresh" else { + return .failure(identifier: request.identifier, error: .unknownMethod) + } + return await handleHierarchyRefresh( + identifier: request.identifier, + parameters: request.parameters + ) + } + + // MARK: - hierarchy.refresh + + private func handleHierarchyRefresh( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) async -> LKMCPBridgeResponse { + guard let parameters = parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + guard let windowController = document.staticWindowController else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has no inspector window yet." + ) + ) + } + + // Read the old size before starting: the data source is replaced + // in place during the reload, so after the fact there is nothing + // left to compare against. + let previousNodeCount = document.hierarchyDataSource?.rawFlatItems?.count ?? 0 + + let startInstant = ContinuousClock.now + do { + // Stamps `lastReloadInitiator` on the window controller, which + // is what lets the event publisher label the resulting + // `hierarchy.reloaded` event as the caller's own echo rather + // than as news about the app. + // + // The value is discarded: by the time it arrives the window + // controller has already handed it to the data source, and + // everything reported below is read back from there so the + // response describes the host's settled state rather than the + // wire payload. + _ = try await windowController.reloadHierarchy(initiator: .agent) + } catch { + return .failure(identifier: identifier, error: mapRefreshError(error as NSError)) + } + let elapsedComponents = (ContinuousClock.now - startInstant).components + let durationMilliseconds = Int(elapsedComponents.seconds) * 1000 + + Int(elapsedComponents.attoseconds / 1_000_000_000_000_000) + + let rootIdentifiers = LKMCPBridgeLiveDocumentLookup + .topLevelDisplayItems(in: document) + .map { LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: $0) } + + let result = LKMCPBridgeRefreshResult( + targetIdentifier: targetIdentifier, + rootObjectIdentifiers: rootIdentifiers, + nodeCount: document.hierarchyDataSource?.rawFlatItems?.count ?? 0, + previousNodeCount: previousNodeCount, + durationMilliseconds: durationMilliseconds + ) + + do { + let payload = try encodeAsJSONValue(result) + return .success(identifier: identifier, result: payload) + } catch { + Self.logger.error("hierarchy.refresh encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + // MARK: - Error mapping + + private func mapRefreshError(_ error: NSError) -> LKMCPBridgeErrorPayload { + // Two domains reach here. The window controller's own domain means + // the host declined to start; `LookinErrorDomain` means the target + // app failed the fetch. Keeping them apart matters to the caller: + // the first is worth retrying in a moment, the second usually is + // not. + typealias ReloadError = LKStaticWindowController.ReloadError + if error.domain == ReloadError.domain { + switch error.code { + case ReloadError.alreadyInProgress: + return LKMCPBridgeErrorPayload( + code: "refresh.alreadyInProgress", + message: "A hierarchy reload is already running for this target — either a user clicked reload or another refresh call is still in flight. Retry once it settles." + ) + case ReloadError.detailSyncInProgress: + return LKMCPBridgeErrorPayload( + code: "refresh.detailSyncInProgress", + message: "The inspector is still syncing view details. Reloading now would discard that work; wait for it to finish." + ) + case ReloadError.noInspectableApp: + return LKMCPBridgeErrorPayload( + code: "refresh.notAttached", + message: "The inspector window is not attached to an app. Attach a target in LookInside and try again." + ) + case ReloadError.windowClosed: + return LKMCPBridgeErrorPayload( + code: "refresh.windowClosed", + message: "The inspector window closed while the hierarchy was being fetched, so the result was discarded." + ) + case ReloadError.noResponse: + return LKMCPBridgeErrorPayload( + code: "refresh.disconnected", + message: "The target app finished the hierarchy request without returning a hierarchy. The channel may have been closed mid-request." + ) + default: + Self.logger.error("hierarchy.refresh received unmapped host refusal \(error.code, privacy: .public)") + return LKMCPBridgeErrorPayload( + code: "refresh.internalError", + message: "The inspector declined the reload for an unexpected reason (code \(error.code))." + ) + } + } + + switch error.code { + case LookinErrCode_LicenseRequired: + return .licenseRequired + case LookinErrCode_NoConnect: + return LKMCPBridgeErrorPayload( + code: "refresh.disconnected", + message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." + ) + case LookinErrCode_Timeout: + return LKMCPBridgeErrorPayload( + code: "refresh.timeout", + message: "The target app did not return its hierarchy within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." + ) + default: + Self.logger.error("hierarchy.refresh received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") + return LKMCPBridgeErrorPayload( + code: "refresh.internalError", + message: "The target app reported an unexpected error (code \(error.code))." + ) + } + } + + // MARK: - Encoding helper (duplicated from InspectionService) + + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeScreenshotService.swift b/LookInside/MCPBridge/LKMCPBridgeScreenshotService.swift new file mode 100644 index 0000000..054019f --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeScreenshotService.swift @@ -0,0 +1,490 @@ +// LKMCPBridgeScreenshotService.swift +// +// Handles the `screenshot.read` bridge route: renders one display item — +// either on its own (`solo`) or together with its whole subtree +// (`group`) — and returns it as PNG bytes. +// +// Why every mode goes through RPC 203 rather than RPC 201 `App`: +// RPC 201 with `needImages=YES` also carries a whole-app screenshot +// (`LookinAppInfo.screenshot`), but reaching it means driving +// `LKAppsManager.fetchAppInfos(needImages:localInfos:)`, which re-scans +// *every* Server port and rebuilds a fresh `LKInspectableApp` list +// for all connected apps. That is a heavyweight, side-effecting +// operation to perform for one picture, and the resulting screenshot +// is whatever the app looked like when the scan ran. Taking a `group` +// screenshot of the key window instead produces the same picture, +// freshly rendered, over the same code path as every other mode. +// Callers who want "the whole screen" simply omit `objectIdentifier` +// and get the key window's group screenshot. +// +// Caching: the host already holds `soloScreenshot` / `groupScreenshot` +// on `LookinDisplayItem` whenever the inspector UI has rendered that +// view. Those are served directly (`fromCache: true`) so repeat calls +// stay cheap. On a miss the service issues RPC 203 with the matching +// task type, merges the response back through +// `LKStaticHierarchyDataSource.modifyWithDisplayItemDetail:` (same +// side-effect `details.read` relies on) and encodes the fresh image. +// +// Redaction: screenshots are pixels, and the host's secure-content +// redactor only ever operated on attribute *strings*. A screenshot is +// therefore returned unredacted, and the result carries +// `containsSecureContent` so the agent — and the human reading its +// transcript — can see when the frame covered a secure input view. +// Blacking out secure regions is deliberately deferred; see +// docs/monorepo/mcp-design.md §E.3. + +import AppKit +import CoreGraphics +import Foundation +import os + +@MainActor +final class LKMCPBridgeScreenshotService { + // MARK: - Configuration + + /// Default cap on the longest side of the returned image, in pixels. + /// Screenshots land directly in an LLM context window, where a full + /// 3x device screenshot costs a meaningful share of the budget for + /// detail no agent uses. 1024 keeps interface text legible while + /// holding a typical PNG in the low hundreds of kilobytes. + private static let defaultMaximumPixelDimension = 1024 + + /// Hard ceiling for `maximumPixelDimension`. Above this the base64 + /// payload starts to rival the connection's 8 MB frame cap for no + /// practical gain. + private static let hardMaximumPixelDimension = 4096 + + /// Lower bound, purely to reject nonsense input (0, negatives). + private static let minimumPixelDimension = 16 + + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Screenshot") + + init() {} + + // MARK: - Screenshot mode + + /// Which of the two `LookinDisplayItemDetail` screenshot slots the + /// caller wants. Mirrors `LookinStaticAsyncUpdateTaskType` minus its + /// `NoScreenshot` case, which has no meaning on this route. + private enum ScreenshotMode: String { + /// The view alone, with its subviews hidden during capture. + case solo + /// The view together with everything it contains. + case group + + var taskType: LookinStaticAsyncUpdateTaskType { + switch self { + case .solo: return .soloScreenshot + case .group: return .groupScreenshot + } + } + + func cachedImage(on displayItem: LookinDisplayItem) -> NSImage? { + switch self { + case .solo: return displayItem.soloScreenshot + case .group: return displayItem.groupScreenshot + } + } + + func image(in detail: LookinDisplayItemDetail) -> NSImage? { + switch self { + case .solo: return detail.soloScreenshot + case .group: return detail.groupScreenshot + } + } + } + + // MARK: - Entry point + + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + guard request.method == "screenshot.read" else { + return .failure(identifier: request.identifier, error: .unknownMethod) + } + return await handleScreenshotRead( + identifier: request.identifier, + parameters: request.parameters + ) + } + + // MARK: - screenshot.read + + private func handleScreenshotRead( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) async -> LKMCPBridgeResponse { + guard let parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + let requestedObjectIdentifier: String? + if case let .string(raw)? = parameters["objectIdentifier"] { + requestedObjectIdentifier = raw + } else { + requestedObjectIdentifier = nil + } + + let mode: ScreenshotMode + if case let .string(raw)? = parameters["mode"] { + guard let parsed = ScreenshotMode(rawValue: raw) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.invalidMode", + message: "Unsupported screenshot mode \"\(raw)\". Valid modes are \"solo\" and \"group\"." + ) + ) + } + mode = parsed + } else { + mode = .group + } + + let maximumPixelDimension: Int + switch parameters["maximumPixelDimension"] { + case let .integer(raw)?: + maximumPixelDimension = Int(raw) + case let .double(raw)?: + maximumPixelDimension = Int(raw) + case .none, .some(.null): + maximumPixelDimension = Self.defaultMaximumPixelDimension + default: + return .failure(identifier: identifier, error: .invalidParameters) + } + guard maximumPixelDimension >= Self.minimumPixelDimension, + maximumPixelDimension <= Self.hardMaximumPixelDimension + else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.invalidPixelDimension", + message: "maximumPixelDimension must be between \(Self.minimumPixelDimension) and \(Self.hardMaximumPixelDimension); received \(maximumPixelDimension)." + ) + ) + } + + // Live-document lookup + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + guard document.hierarchyDataSource != nil else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + + // Resolve which display item to capture. + let roots = LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document) + let displayItem: LookinDisplayItem + if let requestedObjectIdentifier { + guard let found = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: roots, + matchingObjectIdentifier: requestedObjectIdentifier + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.objectNotFound", + message: "Object identifier \(requestedObjectIdentifier) is not present in this target's hierarchy. Refresh via get_hierarchy and retry." + ) + ) + } + displayItem = found + } else { + // No explicit target: capture the key window, which is what + // "show me the screen" means. Fall back to the first root + // when no window reports itself as key (can happen briefly + // during app launch or when the app is backgrounded). + guard let keyWindowItem = roots.first(where: { $0.representedAsKeyWindow }) ?? roots.first else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.objectNotFound", + message: "This target's hierarchy has no top-level window to capture." + ) + ) + } + displayItem = keyWindowItem + } + + let resolvedObjectIdentifier = LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: displayItem) + let containsSecureContent = Self.subtreeContainsSecureContent(rootDisplayItem: displayItem) + + // Fast path: the inspector UI already rendered this view, so the + // image is sitting on the display item. + if let cachedImage = mode.cachedImage(on: displayItem) { + return encodeResponse( + identifier: identifier, + image: cachedImage, + objectIdentifier: resolvedObjectIdentifier, + mode: mode, + displayItem: displayItem, + maximumPixelDimension: maximumPixelDimension, + containsSecureContent: containsSecureContent, + servedFromCache: true + ) + } + + // Slow path: ask the target app to render it now. + guard let nativeObjectIdentifier = displayItem.displayingObject()?.oid, + nativeObjectIdentifier != 0 + else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.objectNotFound", + message: "The resolved display item has no live object behind it; it was probably deallocated. Refresh via get_hierarchy and retry." + ) + ) + } + + let task = LookinStaticAsyncUpdateTask() + task.oid = nativeObjectIdentifier + task.taskType = mode.taskType + // Attributes are `read_attributes` / `read_view_details` territory; + // asking for them here would make the target app build attribute + // groups it is about to throw away. + task.attrRequest = .notNeed + task.needBasisVisualInfo = false + task.needSubitems = false + task.clientReadableVersion = LKHelper.lookinReadableVersion() + + let package = LookinStaticAsyncUpdateTasksPackage() + package.tasks = [task] + + // We sent exactly one task, so at most one detail can match. + var matchingDetail: LookinDisplayItemDetail? + do { + for try await frame in document.inspectableApp.hierarchyDetails(packages: [package]) where matchingDetail == nil { + matchingDetail = frame.first { $0.displayItemOid == nativeObjectIdentifier } + } + } catch { + return .failure(identifier: identifier, error: mapScreenshotError(error as NSError)) + } + + guard let detail = matchingDetail else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.notAvailable", + message: "The target app did not return a detail for the requested object." + ) + ) + } + if detail.failureCode == -1 { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.objectNotFound", + message: "The target app could not find the requested object; it may have been deallocated. Refresh via get_hierarchy and retry." + ) + ) + } + + // Merge into the host cache so the inspector UI and any repeat + // call see the same image without another round-trip. + document.hierarchyDataSource?.modify(with: detail) + + guard let renderedImage = mode.image(in: detail) else { + // The server has real paths that return a detail with no + // image — notably `solo` on an AppKit view that is not + // layer-backed and has no subviews, where neither capture + // branch fires. Tell the agent to retry in `group` rather + // than leaving it to guess. + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.notAvailable", + message: "The target app returned no \(mode.rawValue) screenshot for this object. Some views cannot be captured in \"solo\" mode; retry with mode \"group\"." + ) + ) + } + + return encodeResponse( + identifier: identifier, + image: renderedImage, + objectIdentifier: resolvedObjectIdentifier, + mode: mode, + displayItem: displayItem, + maximumPixelDimension: maximumPixelDimension, + containsSecureContent: containsSecureContent, + servedFromCache: false + ) + } + + // MARK: - Encoding + + private func encodeResponse( + identifier: String, + image: NSImage, + objectIdentifier: String, + mode: ScreenshotMode, + displayItem: LookinDisplayItem, + maximumPixelDimension: Int, + containsSecureContent: Bool, + servedFromCache: Bool + ) -> LKMCPBridgeResponse { + guard let encoded = Self.encodePNG(from: image, maximumPixelDimension: maximumPixelDimension) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "screenshot.encodingFailed", + message: "The captured image could not be encoded as PNG." + ) + ) + } + + let result = LKMCPBridgeScreenshotResult( + objectIdentifier: objectIdentifier, + mode: mode.rawValue, + imageData: encoded.pngData.base64EncodedString(), + mimeType: "image/png", + pixelWidth: encoded.pixelWidth, + pixelHeight: encoded.pixelHeight, + sourcePixelWidth: encoded.sourcePixelWidth, + sourcePixelHeight: encoded.sourcePixelHeight, + byteCount: encoded.pngData.count, + frame: LKMCPBridgeRect(cgRect: LKMCPBridgeLiveDocumentLookup.rootSpaceFrame(for: displayItem)), + servedFromCache: servedFromCache, + containsSecureContent: containsSecureContent + ) + + do { + let payload = try encodeAsJSONValue(result) + return .success(identifier: identifier, result: payload) + } catch { + Self.logger.error("screenshot.read encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + /// Encodes an `NSImage` as PNG, downscaling so the longest side does + /// not exceed `maximumPixelDimension`. Works in pixels rather than + /// points throughout: the source is a Retina capture whose point size + /// is half (or a third) of what actually matters for payload size. + private static func encodePNG( + from image: NSImage, + maximumPixelDimension: Int + ) -> (pngData: Data, pixelWidth: Int, pixelHeight: Int, sourcePixelWidth: Int, sourcePixelHeight: Int)? { + guard let sourceImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + let sourcePixelWidth = sourceImage.width + let sourcePixelHeight = sourceImage.height + guard sourcePixelWidth > 0, sourcePixelHeight > 0 else { return nil } + + let longestSourceSide = max(sourcePixelWidth, sourcePixelHeight) + let renderedImage: CGImage + let targetPixelWidth: Int + let targetPixelHeight: Int + + if longestSourceSide > maximumPixelDimension { + let scaleFactor = Double(maximumPixelDimension) / Double(longestSourceSide) + targetPixelWidth = max(1, Int((Double(sourcePixelWidth) * scaleFactor).rounded())) + targetPixelHeight = max(1, Int((Double(sourcePixelHeight) * scaleFactor).rounded())) + guard let context = CGContext( + data: nil, + width: targetPixelWidth, + height: targetPixelHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + context.interpolationQuality = .high + context.draw( + sourceImage, + in: CGRect(x: 0, y: 0, width: targetPixelWidth, height: targetPixelHeight) + ) + guard let scaledImage = context.makeImage() else { return nil } + renderedImage = scaledImage + } else { + targetPixelWidth = sourcePixelWidth + targetPixelHeight = sourcePixelHeight + renderedImage = sourceImage + } + + let bitmapRepresentation = NSBitmapImageRep(cgImage: renderedImage) + guard let pngData = bitmapRepresentation.representation(using: .png, properties: [:]) else { + return nil + } + return ( + pngData: pngData, + pixelWidth: targetPixelWidth, + pixelHeight: targetPixelHeight, + sourcePixelWidth: sourcePixelWidth, + sourcePixelHeight: sourcePixelHeight + ) + } + + // MARK: - Helpers + + /// Walks a display item and everything under it looking for a view + /// the secure-content detector flags. A `group` screenshot renders + /// the whole subtree, so a secure field nested three levels down + /// still ends up in the returned pixels. + private static func subtreeContainsSecureContent(rootDisplayItem: LookinDisplayItem) -> Bool { + var pendingItems: [LookinDisplayItem] = [rootDisplayItem] + while pendingItems.isEmpty == false { + let currentItem = pendingItems.removeFirst() + if LKMCPBridgeSecureContentDetector.isSecure(displayItem: currentItem) { + return true + } + if let subitems = currentItem.subitems { + pendingItems.append(contentsOf: subitems) + } + } + return false + } + + private func mapScreenshotError(_ error: NSError) -> LKMCPBridgeErrorPayload { + switch error.code { + case LookinErrCode_ObjectNotFound: + return LKMCPBridgeErrorPayload( + code: "screenshot.objectNotFound", + message: "The target app could not find the requested object. It may have been deallocated; refresh via get_hierarchy and retry." + ) + case LookinErrCode_Inner: + return LKMCPBridgeErrorPayload( + code: "screenshot.internalError", + message: "The target app rejected the render request with a generic inner error." + ) + case LookinErrCode_LicenseRequired: + return .licenseRequired + case LookinErrCode_NoConnect: + return LKMCPBridgeErrorPayload( + code: "screenshot.disconnected", + message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." + ) + case LookinErrCode_Timeout: + return LKMCPBridgeErrorPayload( + code: "screenshot.timeout", + message: "The target app did not render within the request timeout. Check whether the target is paused in Xcode." + ) + default: + Self.logger.error("screenshot.read received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") + return LKMCPBridgeErrorPayload( + code: "screenshot.internalError", + message: "The target app reported an unexpected error (code \(error.code))." + ) + } + } + + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift b/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift new file mode 100644 index 0000000..8ab1c2b --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift @@ -0,0 +1,161 @@ +// LKMCPBridgeSearchQuery.swift +// +// Pure matching core behind the `hierarchy.find` bridge route. +// +// Deliberately kept free of AppKit and of the Objective-C hierarchy model: +// which fields a query looks at, and how a hit is reported back, is the +// part of search whose correctness is invisible at the call site (a field +// silently never matching looks exactly like "nothing in the tree matched"). +// Splitting it out lets `Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift` +// compile it standalone under the host repo's `swiftc`-single-binary test +// pattern, the same reason `LKMCPBridgeListenSocket` was split out. +// +// Matching is case-insensitive substring containment. That is intentionally +// dumber than the host inspector's own search: an agent issues a query it +// composed from a class name it already read out of `hierarchy.read`, so +// fuzzy scoring would trade predictability for a relevance ranking nobody +// asked for. Callers that want precision narrow `fields` instead. + +import Foundation + +struct LKMCPBridgeSearchQuery: Sendable { + // MARK: - Fields + + /// The searchable projections of a hierarchy node. Wire names are the + /// raw values; `hierarchy.find` rejects anything outside this set + /// rather than silently ignoring a misspelled field. + enum Field: String, Sendable, Codable, CaseIterable { + /// Leaf class only — the head of the class chain. Use this to + /// avoid matching every `UIView` subclass on a query of "UIView". + case className + + /// Any entry in the class chain, so a query of "UIControl" + /// also finds `UIButton` instances. + case classChain + + /// The row label the host inspector shows, usually the demangled + /// class name but overridable by the in-app `lookin_customDebugInfos` + /// hook. + case title + + /// The host inspector's secondary label: owning view controller, + /// ivar trace, or a custom subtitle. + case subtitle + + /// Hex memory address of the underlying view / layer / window + /// object, so an address copied out of a crash log or the Xcode + /// debugger can be located in the tree. + case memoryAddress + } + + /// One hierarchy node reduced to the strings this type knows how to + /// match. Built by the service layer so this type never touches + /// `LookinDisplayItem`. + struct Candidate: Sendable { + let className: String + let classChain: [String] + let title: String? + let subtitle: String? + let memoryAddresses: [String] + + init( + className: String, + classChain: [String], + title: String?, + subtitle: String?, + memoryAddresses: [String] + ) { + self.className = className + self.classChain = classChain + self.title = title + self.subtitle = subtitle + self.memoryAddresses = memoryAddresses + } + } + + // MARK: - State + + /// The query exactly as the caller supplied it, kept for echoing back + /// in the response. + let rawQuery: String + + /// Fields to consider, in canonical `Field.allCases` order regardless + /// of the order the caller listed them, so `matchedFields` is stable + /// across requests that ask for the same set. + let fields: [Field] + + private let normalizedQuery: String + + // MARK: - Construction + + /// - Parameters: + /// - rawQuery: caller-supplied needle. Callers are responsible for + /// rejecting the empty string; an empty query here matches + /// everything, which is a legitimate thing for a test to assert + /// but never a useful thing for an agent to ask for. + /// - fields: which projections to search. Duplicates are collapsed; + /// an empty array means every field. + init(rawQuery: String, fields: [Field] = Field.allCases) { + self.rawQuery = rawQuery + normalizedQuery = rawQuery.lowercased() + let requested = Set(fields.isEmpty ? Field.allCases : fields) + self.fields = Field.allCases.filter { requested.contains($0) } + } + + /// Translates wire field names into `Field` values, preserving the + /// canonical order. Returns `nil` for the first unrecognized name so + /// the caller can name it in the error message — silently dropping it + /// would turn a typo into "no results found", which reads as a fact + /// about the app rather than as a mistake in the request. + static func fields(fromWireNames names: [String]) -> [Field]? { + var resolved: Set = [] + for name in names { + guard let field = Field(rawValue: name) else { return nil } + resolved.insert(field) + } + return Field.allCases.filter { resolved.contains($0) } + } + + /// The first wire name in `names` that is not a valid field, for use + /// in the caller's error message. + static func firstUnrecognizedFieldName(in names: [String]) -> String? { + return names.first { Field(rawValue: $0) == nil } + } + + // MARK: - Matching + + /// Every field of `candidate` that contains the query, in canonical + /// order. Empty means no match. + /// + /// The list — rather than a bare `Bool` — is what lets the response + /// tell an agent *why* a node came back. A hit on `memoryAddress` and + /// a hit on `title` warrant very different follow-up actions, and + /// without the reason the agent has to re-derive it by inspecting + /// each result. + func matchedFields(in candidate: Candidate) -> [Field] { + return fields.filter { field in + switch field { + case .className: + return contains(candidate.className) + case .classChain: + return candidate.classChain.contains(where: contains) + case .title: + return contains(candidate.title) + case .subtitle: + return contains(candidate.subtitle) + case .memoryAddress: + return candidate.memoryAddresses.contains(where: contains) + } + } + } + + /// Convenience predicate for callers that only need a yes / no. + func matches(_ candidate: Candidate) -> Bool { + return matchedFields(in: candidate).isEmpty == false + } + + private func contains(_ haystack: String?) -> Bool { + guard let haystack, haystack.isEmpty == false else { return false } + return haystack.lowercased().contains(normalizedQuery) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeSearchService.swift b/LookInside/MCPBridge/LKMCPBridgeSearchService.swift new file mode 100644 index 0000000..f147733 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeSearchService.swift @@ -0,0 +1,317 @@ +// LKMCPBridgeSearchService.swift +// +// Handles the `hierarchy.find` bridge route: locating views in an +// attached target by class name, label, or memory address. +// +// Entirely cache-resident. Unlike `details.read` / `screenshot.read`, +// this route issues no RPC — it walks the flat display-item list +// the host already holds. That makes it the cheapest way for an agent to +// go from "somewhere in this app there is a play button" to an object +// identifier it can hand to `attributes.read` / `invoke.method`, without +// first dumping a few thousand nodes of `hierarchy.read` into its context. +// +// It deliberately does NOT call the host's own +// `-[LKHierarchyDataSource searchWithString:]`. That method drives the +// inspector UI: it flips the data source into its search state, expands +// every ancestor of every hit, clears the user's current selection, and +// republishes the visible row list. Running it for a bridge request would +// reach through the socket and rearrange the window the user is looking +// at. Only the pure per-item predicate is reused in spirit, reimplemented +// over a wider field set in `LKMCPBridgeSearchQuery`. + +import AppKit +import Foundation +import os + +@MainActor +final class LKMCPBridgeSearchService { + /// Default number of matches returned when the caller doesn't ask. + /// Sized so a typical response stays well inside an agent's context + /// budget while still showing enough of a broad query (say, every + /// `UILabel`) for the agent to judge whether to narrow it. + private static let defaultMatchLimit = 50 + + /// Ceiling on `limit`. A caller that wants more than this is really + /// asking for the whole tree and should use `hierarchy.read`. + private static let maximumMatchLimit = 500 + + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Search") + + init() {} + + // MARK: - Entry point + + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + guard request.method == "hierarchy.find" else { + return .failure(identifier: request.identifier, error: .unknownMethod) + } + return handleHierarchyFind( + identifier: request.identifier, + parameters: request.parameters + ) + } + + // MARK: - hierarchy.find + + private func handleHierarchyFind( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) -> LKMCPBridgeResponse { + guard let parameters = parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"], + case let .string(rawQuery)? = parameters["query"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + // An empty needle matches every node, which would silently turn a + // search into a full tree dump — the exact outcome this route + // exists to avoid. + let trimmedQuery = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.isEmpty { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "search.emptyQuery", + message: "`query` must be a non-empty search string. To list the tree instead of searching it, use hierarchy.read." + ) + ) + } + + let fields: [LKMCPBridgeSearchQuery.Field] + if case let .array(rawFields)? = parameters["matchFields"] { + let names: [String] = rawFields.compactMap { value in + if case let .string(name) = value { + return name + } + return nil + } + guard names.count == rawFields.count else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "search.invalidMatchField", + message: "`matchFields` must be an array of strings. Valid values: \(Self.validFieldList)." + ) + ) + } + guard let resolved = LKMCPBridgeSearchQuery.fields(fromWireNames: names) else { + let unrecognized = LKMCPBridgeSearchQuery.firstUnrecognizedFieldName(in: names) ?? "?" + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "search.invalidMatchField", + message: "`\(unrecognized)` is not a searchable field. Valid values: \(Self.validFieldList)." + ) + ) + } + fields = resolved.isEmpty ? LKMCPBridgeSearchQuery.Field.allCases : resolved + } else { + fields = LKMCPBridgeSearchQuery.Field.allCases + } + + let matchLimit: Int + switch parameters["limit"] { + case let .integer(raw)?: + matchLimit = Int(raw) + case let .double(raw)?: + matchLimit = Int(raw) + case nil: + matchLimit = Self.defaultMatchLimit + default: + return .failure(identifier: identifier, error: .invalidParameters) + } + guard matchLimit >= 1, matchLimit <= Self.maximumMatchLimit else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "search.invalidLimit", + message: "`limit` must be between 1 and \(Self.maximumMatchLimit); received \(matchLimit)." + ) + ) + } + + let includeHidden: Bool + if case let .bool(raw)? = parameters["includeHidden"] { + includeHidden = raw + } else { + includeHidden = true + } + + let scopeObjectIdentifier: String? + if case let .string(raw)? = parameters["rootObjectIdentifier"] { + scopeObjectIdentifier = raw + } else { + scopeObjectIdentifier = nil + } + + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + guard let dataSource = document.hierarchyDataSource, + let allItems = dataSource.rawFlatItems + else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + + let searchScope: [LookinDisplayItem] + if let scopeObjectIdentifier { + guard let scopeRoot = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: allItems, + matchingObjectIdentifier: scopeObjectIdentifier + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Object identifier \(scopeObjectIdentifier) is not present in this target's hierarchy." + ) + ) + } + var subtree: [LookinDisplayItem] = [] + scopeRoot.enumerateSelfAndChildren { subtree.append($0) } + searchScope = subtree + } else { + searchScope = allItems + } + + let query = LKMCPBridgeSearchQuery(rawQuery: trimmedQuery, fields: fields) + + // The whole scope is scanned even after `limit` is reached: an + // agent that only learns "here are 50" cannot tell a complete + // answer from the tip of an iceberg, and the scan is a cheap + // in-memory string compare over a list the host already holds. + var matches: [LKMCPBridgeSearchMatch] = [] + var totalMatchCount = 0 + var searchedNodeCount = 0 + + for item in searchScope { + if includeHidden == false, item.isHidden { + continue + } + searchedNodeCount += 1 + let matchedFields = query.matchedFields(in: makeCandidate(from: item)) + if matchedFields.isEmpty { + continue + } + totalMatchCount += 1 + if matches.count < matchLimit { + matches.append(makeMatch(from: item, matchedFields: matchedFields)) + } + } + + let result = LKMCPBridgeSearchResult( + matches: matches, + totalMatchCount: totalMatchCount, + truncated: matches.count < totalMatchCount, + searchedNodeCount: searchedNodeCount, + searchedFields: fields.map(\.rawValue) + ) + + do { + let payload = try encodeAsJSONValue(result) + return .success(identifier: identifier, result: payload) + } catch { + Self.logger.error("hierarchy.find encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + // MARK: - Candidate / match construction + + private func makeCandidate(from item: LookinDisplayItem) -> LKMCPBridgeSearchQuery.Candidate { + let displayingObject = item.displayingObject() + let classChain = displayingObject?.classChainList ?? [] + + // Every object flavour the item can represent contributes its + // address, mirroring the host's own search: an address copied out + // of a debugger may name the layer even when the row represents + // the view, and vice versa. + var memoryAddresses: [String] = [] + for candidateObject in [item.viewObject, item.layerObject, item.windowObject, item.kindObject] { + if let address = candidateObject?.memoryAddress, address.isEmpty == false { + memoryAddresses.append(address) + } + } + + return LKMCPBridgeSearchQuery.Candidate( + className: classChain.first ?? "", + classChain: classChain, + title: item.title(), + subtitle: item.subtitle(), + memoryAddresses: memoryAddresses + ) + } + + private func makeMatch( + from item: LookinDisplayItem, + matchedFields: [LKMCPBridgeSearchQuery.Field] + ) -> LKMCPBridgeSearchMatch { + // Titles and subtitles can carry app-supplied strings through the + // in-app `lookin_customDebugInfos` hook, so they go through the + // same redaction gate as `attributes.read`'s textual values. + let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: item) + let title = redactSecureContent ? nil : item.title() + let subtitle = redactSecureContent ? nil : item.subtitle() + + var ancestorIdentifiers: [String] = [] + var ancestorTitles: [String] = [] + item.enumerateAncestors { ancestor, _ in + ancestorIdentifiers.append(LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: ancestor)) + ancestorTitles.append(Self.pathComponentTitle(for: ancestor)) + } + // `enumerateAncestors` walks upward; the wire order is root first + // so the list reads like a path. + ancestorIdentifiers.reverse() + ancestorTitles.reverse() + + let pathComponents = ancestorTitles + [Self.pathComponentTitle(for: item)] + + return LKMCPBridgeSearchMatch( + objectIdentifier: LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: item), + className: item.displayingObject()?.classChainList?.first ?? "", + title: title, + subtitle: subtitle, + frame: LKMCPBridgeRect(cgRect: LKMCPBridgeLiveDocumentLookup.rootSpaceFrame(for: item)), + isHidden: item.isHidden, + alpha: Double(item.alpha), + depth: ancestorIdentifiers.count, + ancestorObjectIdentifiers: ancestorIdentifiers, + pathDescription: pathComponents.joined(separator: " > "), + matchedFields: matchedFields.map(\.rawValue), + secureContent: redactSecureContent + ) + } + + /// Path components use the class name rather than the display title: + /// the title can be an app-supplied custom string, and a path is for + /// orientation, not for reading back app content. + private static func pathComponentTitle(for item: LookinDisplayItem) -> String { + let className = item.displayingObject()?.classChainList?.first ?? "" + return className.isEmpty ? "?" : className + } + + private static let validFieldList: String = LKMCPBridgeSearchQuery.Field.allCases + .map(\.rawValue) + .joined(separator: ", ") + + // MARK: - Encoding helper (duplicated from InspectionService) + + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeSecureContentDetector.swift b/LookInside/MCPBridge/LKMCPBridgeSecureContentDetector.swift new file mode 100644 index 0000000..c9cbdc4 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeSecureContentDetector.swift @@ -0,0 +1,45 @@ +// LKMCPBridgeSecureContentDetector.swift +// +// Decides whether an inspected display item carries secure user content +// (passwords, OTPs, payment fields) so callers can redact the textual +// values before they cross the wire. +// +// The detector is intentionally conservative: better to over-redact than +// to leak a credential into an MCP transcript that an AI agent or its log +// store will keep indefinitely. The current safety floor matches the +// reference implementation in upstream PR #26 (LookInside repo) by +// catching every AppKit `NSSecureTextField` and its subclasses via the +// inspected object's class chain. +// +// Known gap (tracked for a follow-up server-side change): +// UIKit `UITextField` with `isSecureTextEntry = YES` is NOT detected. +// LookinServer does not currently surface `isSecureTextEntry` as a +// `LookinAttr_*`, so the bridge has no reliable way to read its value. +// PR #26 references the attribute identifier `isSecureTextEntry` but +// LookinServer's identifier table has no matching entry — so that branch +// silently never fires there either. The right fix is to add the +// property to `LookinAttrIdentifiers.h` plus the iOS-side reflection in +// `LookinServer/Server/Category/UITextField+LookinServer.m`, after which +// this detector can opt the UIKit case in. + +import Foundation + +enum LKMCPBridgeSecureContentDetector { + /// Returns `true` when the given display item represents a view that + /// the host should treat as carrying secure user input. When `true`, + /// callers must replace any user-visible text strings on the item's + /// attributes with `null` (or a `kind: "redacted"` marker) before + /// emitting them on the bridge socket. + static func isSecure(displayItem: LookinDisplayItem) -> Bool { + guard let classChain = displayItem.viewObject?.classChainList else { + return false + } + // NSSecureTextField and any subclass: catching the whole chain + // also redacts cases where the inspected view is a private subclass + // (e.g. `_NSSecureTextField_FooBar`) without losing protection. + if classChain.contains("NSSecureTextField") { + return true + } + return false + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeSelectorService.swift b/LookInside/MCPBridge/LKMCPBridgeSelectorService.swift new file mode 100644 index 0000000..2c5b15f --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeSelectorService.swift @@ -0,0 +1,274 @@ +// LKMCPBridgeSelectorService.swift +// +// Handles the `selectors.list` bridge route: enumerating the Objective-C +// methods a class in the target app responds to (RPC 213 +// `LookinRequestTypeAllSelectorNames`). +// +// This route exists to make `invoke.method` usable. Without it an agent +// has to guess selector names from the class name and discover its +// mistakes one `invoke.invalidSelector` at a time; with it the agent can +// read the actual method table first. The two routes are kept in separate +// services because this one is read-only and `invoke.method` is not — the +// tool-level `destructive` hint on the MCP side follows that same split. +// +// Sizing note: the server walks the class chain all the way to `NSObject` +// and returns one flat, de-duplicated array. For a `UIView` subclass that +// is comfortably four digits of selectors, which is why this route +// defaults to a filtered, truncated view and always reports `totalCount`. + +import AppKit +import Foundation +import os + +@MainActor +final class LKMCPBridgeSelectorService { + /// Default cap on returned selectors. Deliberately far below the + /// thousands a framework class yields: the useful answer to "what can + /// I call on this" is nearly always reachable with a `nameFilter`, + /// and an unfiltered dump would cost more context than it is worth. + private static let defaultSelectorLimit = 200 + + /// Ceiling on `limit`. + private static let maximumSelectorLimit = 2000 + + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Selectors") + + init() {} + + // MARK: - Entry point + + func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + guard request.method == "selectors.list" else { + return .failure(identifier: request.identifier, error: .unknownMethod) + } + return await handleSelectorsList( + identifier: request.identifier, + parameters: request.parameters + ) + } + + // MARK: - selectors.list + + private func handleSelectorsList( + identifier: String, + parameters: [String: LKMCPBridgeJSONValue]? + ) async -> LKMCPBridgeResponse { + guard let parameters = parameters, + case let .string(targetIdentifier)? = parameters["targetIdentifier"] + else { + return .failure(identifier: identifier, error: .invalidParameters) + } + + let requestedClassName: String? + if case let .string(raw)? = parameters["className"] { + requestedClassName = raw + } else { + requestedClassName = nil + } + + let requestedObjectIdentifier: String? + if case let .string(raw)? = parameters["objectIdentifier"] { + requestedObjectIdentifier = raw + } else { + requestedObjectIdentifier = nil + } + + if requestedClassName == nil, requestedObjectIdentifier == nil { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "selectors.missingSubject", + message: "Supply either `className` or `objectIdentifier` to name the class whose selectors you want." + ) + ) + } + + let includeArguments: Bool + if case let .bool(raw)? = parameters["includeArguments"] { + includeArguments = raw + } else { + includeArguments = false + } + + let nameFilter: String? + if case let .string(raw)? = parameters["nameFilter"] { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + nameFilter = trimmed.isEmpty ? nil : trimmed + } else { + nameFilter = nil + } + + let selectorLimit: Int + switch parameters["limit"] { + case let .integer(raw)?: + selectorLimit = Int(raw) + case let .double(raw)?: + selectorLimit = Int(raw) + case nil: + selectorLimit = Self.defaultSelectorLimit + default: + return .failure(identifier: identifier, error: .invalidParameters) + } + guard selectorLimit >= 1, selectorLimit <= Self.maximumSelectorLimit else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "selectors.invalidLimit", + message: "`limit` must be between 1 and \(Self.maximumSelectorLimit); received \(selectorLimit)." + ) + ) + } + + guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.targetNotFound", + message: "No live inspection document found for target identifier \(targetIdentifier)." + ) + ) + } + + // Resolve the subject class. `objectIdentifier` wins when both are + // present: it is the more specific request, and it lets the + // response carry the object's whole class chain. + let resolvedClassName: String + let resolvedClassChain: [String]? + if let requestedObjectIdentifier { + guard document.hierarchyDataSource != nil else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.notReady", + message: "Live document has not loaded a hierarchy yet." + ) + ) + } + guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( + amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), + matchingObjectIdentifier: requestedObjectIdentifier + ) else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "hierarchy.objectNotFound", + message: "Object identifier \(requestedObjectIdentifier) is not present in this target's hierarchy." + ) + ) + } + let classChain = displayItem.displayingObject()?.classChainList ?? [] + guard let leafClassName = classChain.first, leafClassName.isEmpty == false else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "selectors.classNotFound", + message: "Display item \(requestedObjectIdentifier) does not report a class name." + ) + ) + } + resolvedClassName = leafClassName + resolvedClassChain = classChain + } else { + let className = requestedClassName ?? "" + guard className.isEmpty == false else { + return .failure( + identifier: identifier, + error: LKMCPBridgeErrorPayload( + code: "selectors.missingSubject", + message: "`className` must be a non-empty class name." + ) + ) + } + resolvedClassName = className + resolvedClassChain = nil + } + + // `hasArg` on the wire means "do not filter out selectors that + // take arguments", so it maps straight onto `includeArguments`. + let rawSelectors: [String] + do { + rawSelectors = try await document.inspectableApp.selectorNames( + className: resolvedClassName, + hasArg: includeArguments + ) + } catch { + return .failure( + identifier: identifier, + error: mapSelectorError(error as NSError, className: resolvedClassName) + ) + } + + // Server order is class-chain order, most-derived first (it walks + // `superclass` upward and de-duplicates on first sight). Preserved + // rather than sorted: position is the only hint about which class + // a selector came from, and alphabetizing would bury the app's own + // overrides among NSObject's. Measured on NSClipView (1554 + // zero-argument selectors): its own land in the first 4%, + // NSObject's in the last 20%. Within one class the order is + // whatever `class_copyMethodList` returns, so this is a coarse + // ranking rather than an attribution. + var selectorNames = rawSelectors + if let nameFilter { + let normalizedFilter = nameFilter.lowercased() + selectorNames = selectorNames.filter { $0.lowercased().contains(normalizedFilter) } + } + let totalCount = selectorNames.count + let truncatedSelectors = Array(selectorNames.prefix(selectorLimit)) + + let result = LKMCPBridgeSelectorListResult( + className: resolvedClassName, + selectors: truncatedSelectors, + totalCount: totalCount, + truncated: truncatedSelectors.count < totalCount, + includesArguments: includeArguments, + classChain: resolvedClassChain + ) + + do { + let payload = try encodeAsJSONValue(result) + return .success(identifier: identifier, result: payload) + } catch { + Self.logger.error("selectors.list encode failed: \(error.localizedDescription, privacy: .public)") + return .failure(identifier: identifier, error: .internalError) + } + } + + // MARK: - Error mapping + + private func mapSelectorError(_ error: NSError, className: String) -> LKMCPBridgeErrorPayload { + switch error.code { + case LookinErrCode_Inner: + // RPC 213's only `Inner` path is a failed NSClassFromString, + // so this is specific enough to name the cause outright. + return LKMCPBridgeErrorPayload( + code: "selectors.classNotFound", + message: "The target app has no class named `\(className)`. Swift types need their module prefix (for example `MyApp.ContentView`); check the `classChain` reported by hierarchy.read." + ) + case LookinErrCode_LicenseRequired: + return .licenseRequired + case LookinErrCode_NoConnect: + return LKMCPBridgeErrorPayload( + code: "selectors.disconnected", + message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." + ) + case LookinErrCode_Timeout: + return LKMCPBridgeErrorPayload( + code: "selectors.timeout", + message: "The target app did not respond within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." + ) + default: + Self.logger.error("selectors.list received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") + return LKMCPBridgeErrorPayload( + code: "selectors.internalError", + message: "The target app reported an unexpected error (code \(error.code))." + ) + } + } + + // MARK: - Encoding helper (duplicated from InspectionService) + + private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) + } +} diff --git a/LookInside/MCPBridge/LKMCPBridgeServer.swift b/LookInside/MCPBridge/LKMCPBridgeServer.swift new file mode 100644 index 0000000..8439c18 --- /dev/null +++ b/LookInside/MCPBridge/LKMCPBridgeServer.swift @@ -0,0 +1,451 @@ +// LKMCPBridgeServer.swift +// +// The MCPBridge server is the GPL-side surface that exposes LookInside's +// inspection state over a local Unix domain socket. Proprietary consumers +// (currently only the `lookinside-mcp` MCP shim, but the surface is designed +// to also serve future CI bridges, remote inspectors, and automation runners) +// connect to this socket and exchange newline-delimited JSON frames. +// +// Process model: the server runs inside the LookInside.app host process. +// Activation is keyed off `applicationDidFinishLaunching:` and shutdown is +// keyed off `applicationWillTerminate:`. The server is intentionally a +// process-global singleton (the host has exactly one inspection state). +// +// Socket location: a single per-user UNIX socket inside the user's +// Application Support directory, with `0o600` permissions and a `0o700` +// parent directory. The path is stable across host restarts. + +import Darwin +import Dispatch +import Foundation +import os + +@objc(LKMCPBridgeServer) +final class LKMCPBridgeServer: NSObject { + // MARK: - Singleton + + @objc static let sharedInstance = LKMCPBridgeServer() + + // MARK: - Configuration + + /// Maximum number of pending connections in the kernel accept queue. + private static let listenBacklog: Int32 = 16 + + private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Server") + + // MARK: - State + + private let stateQueue = DispatchQueue(label: "com.lookinside.mcp-bridge.state") + + /// Shared parent for every connection's private serial queue. Kept + /// concurrent so separate connections make progress independently; + /// each `LKMCPBridgeConnection` targets its own serial queue at this + /// one, which is what keeps a single connection's frames from + /// interleaving. Do not hand this queue to a connection as its + /// working queue directly. + private let ioQueue = DispatchQueue( + label: "com.lookinside.mcp-bridge.io", + qos: .userInitiated, + attributes: .concurrent + ) + + private var listenFileDescriptor: Int32 = -1 + private var acceptSource: DispatchSourceRead? + private var activeConnections: Set = [] + private var openConnections: [ObjectIdentifier: LKMCPBridgeConnection] = [:] + private var isRunning = false + + /// Routes inspection-method requests (`targets.list`, `hierarchy.read`, + /// …) to the host's per-window inspection state. Lazily constructed on + /// the main actor on first use because `NSDocumentController` access + /// requires main-thread isolation. + private var inspectionService: LKMCPBridgeInspectionService? + + /// Routes mutating / RPC-emitting methods (`invoke.method`). Kept + /// separate from `inspectionService` so the inspection surface stays + /// read-only even as new mutating verbs land; lazily constructed on + /// the main actor on first use. + private var invocationService: LKMCPBridgeInvocationService? + + /// Routes `attribute.modify` (RPC 204 InbuiltAttrModification). + /// Kept distinct from `invocationService` so each route's setter + /// lookup / value decoding code stays self-contained. + private var modificationService: LKMCPBridgeModificationService? + + /// Routes `details.read` (batch RPC 203 HierarchyDetails prefetch). + /// Kept distinct from the read-only inspection service because + /// details.read actively pumps the connection channel rather than + /// reading cached state. + private var detailsService: LKMCPBridgeDetailsService? + + /// Routes `screenshot.read` (RPC 203 with a Solo/Group task type). + /// Separate from `detailsService` because the two ask the same RPC + /// for entirely different payloads — attribute groups versus image + /// bytes — and their error vocabularies differ accordingly. + private var screenshotService: LKMCPBridgeScreenshotService? + + /// Routes `hierarchy.find`. Kept apart from `inspectionService` + /// even though both only read cached state, because search owns a + /// parameter vocabulary of its own (match fields, limits) and its + /// own error namespace. + private var searchService: LKMCPBridgeSearchService? + + /// Routes `selectors.list` (RPC 213 AllSelectorNames). Read-only, + /// so kept out of `invocationService` even though the two serve the + /// same workflow — discovering a method, then calling it. + private var selectorService: LKMCPBridgeSelectorService? + + /// Routes `hierarchy.refresh` (RPC 202 Hierarchy). Kept out of the + /// inspection service because refreshing round-trips to the target + /// and replaces host state, which would break that service's + /// "cached reads only, never emits RPC" property. + private var refreshService: LKMCPBridgeRefreshService? + + /// Translates host state changes into pushed event frames. Main-actor + /// isolated (every signal it watches is published there) and owned + /// here so its lifetime matches the socket's. + @MainActor private static var eventPublisher: LKMCPBridgeEventPublisher? + + // MARK: - Lifecycle + + @objc func start() { + stateQueue.async { [weak self] in + guard let self else { return } + guard self.isRunning == false else { return } + do { + try self.bindAndListen() + self.isRunning = true + Self.logger.notice("MCPBridge started at \(LKMCPBridgeServer.socketURL.path, privacy: .public)") + self.startEventPublisher() + } catch { + Self.logger.error("MCPBridge failed to start: \(error.localizedDescription, privacy: .public)") + self.cleanUpListenSocket() + } + } + } + + @objc func stop() { + stateQueue.async { [weak self] in + guard let self else { return } + guard self.isRunning else { return } + self.isRunning = false + + // Cancelling hands the descriptor to the source's cancel handler, + // which closes it. That handler is scheduled on this very queue, + // so it cannot run until this block returns -- closing here as + // well would be a double close, and by the time the second one + // lands the number may have been recycled onto an unrelated file. + self.acceptSource?.cancel() + self.acceptSource = nil + self.listenFileDescriptor = -1 + unlink(Self.socketURL.path) + + for connection in self.openConnections.values { + connection.close(reason: "server shutdown") + } + self.openConnections.removeAll() + self.activeConnections.removeAll() + Self.logger.notice("MCPBridge stopped") + Task { @MainActor in + Self.eventPublisher?.stop() + Self.eventPublisher = nil + } + } + } + + // MARK: - Event push + + /// Pushes an unsolicited frame to every open connection. + /// + /// No subscription filtering happens here on purpose. The host has no + /// idea how many bridge clients are attached or what any of them care + /// about, and teaching it would drag Model Context Protocol concepts + /// into this GPL layer. Clients filter; the host broadcasts. + func broadcast(event: LKMCPBridgeEvent) { + stateQueue.async { [weak self] in + guard let self, self.isRunning else { return } + for connection in self.openConnections.values { + connection.send(event: event) + } + } + } + + private func startEventPublisher() { + Task { @MainActor in + if Self.eventPublisher == nil { + Self.eventPublisher = LKMCPBridgeEventPublisher(broadcast: { event in + LKMCPBridgeServer.sharedInstance.broadcast(event: event) + }) + } + Self.eventPublisher?.start() + } + } + + // MARK: - Bind / Listen / Accept + + private func bindAndListen() throws { + let socketURL = Self.socketURL + try ensureRuntimeDirectory(at: socketURL.deletingLastPathComponent()) + + // Socket creation lives in LKMCPBridgeListenSocket so its + // blocking mode — which `acceptPendingConnections()` depends on + // for loop termination — can be asserted by a test that does not + // need the whole inspection stack. See that file's header. + let descriptor = try LKMCPBridgeListenSocket.makeListening( + atPath: socketURL.path, + backlog: Self.listenBacklog + ) + listenFileDescriptor = descriptor + + let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: stateQueue) + source.setEventHandler { [weak self] in + self?.acceptPendingConnections() + } + source.setCancelHandler { + close(descriptor) + } + acceptSource = source + source.resume() + } + + private func acceptPendingConnections() { + while true { + // Goes through LKMCPBridgeListenSocket because the accepted + // descriptor has to be put back into blocking mode; see that + // method's documentation. + let clientDescriptor = LKMCPBridgeListenSocket.acceptConnection( + listenDescriptor: listenFileDescriptor + ) + if clientDescriptor < 0 { + let errorNumber = errno + if errorNumber == EAGAIN || errorNumber == EWOULDBLOCK { + return + } + Self.logger.error("accept() failed (errno \(errorNumber))") + return + } + let connection = LKMCPBridgeConnection( + fileDescriptor: clientDescriptor, + queue: ioQueue, + requestHandler: { [weak self] request in + await self?.handle(request: request) ?? .failure( + identifier: request.identifier, + error: .internalError + ) + }, + onClose: { [weak self] closingConnection in + self?.removeConnection(closingConnection) + } + ) + let key = ObjectIdentifier(connection) + openConnections[key] = connection + activeConnections.insert(key) + Self.logger.notice("Accepted connection (fd=\(clientDescriptor))") + connection.start() + } + } + + private func removeConnection(_ connection: LKMCPBridgeConnection) { + stateQueue.async { [weak self] in + guard let self else { return } + let key = ObjectIdentifier(connection) + self.openConnections.removeValue(forKey: key) + self.activeConnections.remove(key) + } + } + + // MARK: - Request dispatch + + private func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + // `ping` stays as a transport-level smoke test that doesn't require + // the inspection state to be initialized; mutating verbs route to + // the invocation service; everything else routes to the (read-only) + // inspection service. The split keeps the read surface free of + // any round-trip work so cached reads stay snappy. + switch request.method { + case "ping": + return .success( + identifier: request.identifier, + result: .object([ + "pong": .bool(true), + "serverVersion": .string(currentHostMarketingVersion()), + ]) + ) + case "invoke.method": + return await invocationDispatch(request: request) + case "attribute.modify": + return await modificationDispatch(request: request) + case "details.read": + return await detailsDispatch(request: request) + case "screenshot.read": + return await screenshotDispatch(request: request) + case "hierarchy.find": + return await searchDispatch(request: request) + case "selectors.list": + return await selectorDispatch(request: request) + case "hierarchy.refresh": + return await refreshDispatch(request: request) + default: + return await inspectionDispatch(request: request) + } + } + + private func inspectionDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeInspectionService in + if let existing = self.inspectionService { + return existing + } + let created = LKMCPBridgeInspectionService() + self.inspectionService = created + return created + } + return await service.handle(request: request) + } + + private func invocationDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeInvocationService in + if let existing = self.invocationService { + return existing + } + let created = LKMCPBridgeInvocationService() + self.invocationService = created + return created + } + return await service.handle(request: request) + } + + private func modificationDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeModificationService in + if let existing = self.modificationService { + return existing + } + let created = LKMCPBridgeModificationService() + self.modificationService = created + return created + } + return await service.handle(request: request) + } + + private func detailsDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeDetailsService in + if let existing = self.detailsService { + return existing + } + let created = LKMCPBridgeDetailsService() + self.detailsService = created + return created + } + return await service.handle(request: request) + } + + private func screenshotDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeScreenshotService in + if let existing = self.screenshotService { + return existing + } + let created = LKMCPBridgeScreenshotService() + self.screenshotService = created + return created + } + return await service.handle(request: request) + } + + private func searchDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeSearchService in + if let existing = self.searchService { + return existing + } + let created = LKMCPBridgeSearchService() + self.searchService = created + return created + } + return await service.handle(request: request) + } + + private func selectorDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeSelectorService in + if let existing = self.selectorService { + return existing + } + let created = LKMCPBridgeSelectorService() + self.selectorService = created + return created + } + return await service.handle(request: request) + } + + private func refreshDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { + let service = await MainActor.run { () -> LKMCPBridgeRefreshService in + if let existing = self.refreshService { + return existing + } + let created = LKMCPBridgeRefreshService() + self.refreshService = created + return created + } + return await service.handle(request: request) + } + + // MARK: - Helpers + + /// Failed-start cleanup only. + /// + /// This is the one path where no accept source exists to own the + /// descriptor, so closing it here is correct. `stop()` deliberately does + /// not call this: once the source exists, its cancel handler is the only + /// thing allowed to close the descriptor. + private func cleanUpListenSocket() { + dispatchPrecondition(condition: .onQueue(stateQueue)) + if acceptSource == nil, listenFileDescriptor >= 0 { + close(listenFileDescriptor) + } + listenFileDescriptor = -1 + unlink(Self.socketURL.path) + } + + private func ensureRuntimeDirectory(at directoryURL: URL) throws { + let fileManager = FileManager.default + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + // createDirectory ignores posixPermissions on pre-existing dirs; + // re-apply 0o700 to lock down any inherited permissive state. + _ = chmod(directoryURL.path, 0o700) + } + + private func currentHostMarketingVersion() -> String { + return (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "unknown" + } + + // MARK: - Socket location + + /// Canonical socket location: `~/Library/Application Support/LookInside/Host/run/lookinside-host-mcp.sock`. + /// DEBUG builds honor `LOOKINSIDE_MCP_SOCKET_PATH` (clients honor the + /// same variable), so a development build next to an installed one can + /// serve its own socket. Release builds never do: binding unlinks + /// whatever is at the path. + static let socketURL: URL = { + #if DEBUG + if let overridePath = ProcessInfo.processInfo.environment["LOOKINSIDE_MCP_SOCKET_PATH"], + overridePath.isEmpty == false + { + return URL(fileURLWithPath: overridePath) + } + #endif + let fileManager = FileManager.default + let applicationSupportURL = (try? fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + )) ?? fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return applicationSupportURL + .appendingPathComponent("LookInside", isDirectory: true) + .appendingPathComponent("Host", isDirectory: true) + .appendingPathComponent("run", isDirectory: true) + .appendingPathComponent("lookinside-host-mcp.sock", isDirectory: false) + }() +} diff --git a/LookInside/Static/LKStaticWindowController.swift b/LookInside/Static/LKStaticWindowController.swift index 958ec1a..6fc7915 100644 --- a/LookInside/Static/LKStaticWindowController.swift +++ b/LookInside/Static/LKStaticWindowController.swift @@ -18,10 +18,22 @@ import UniformTypeIdentifiers @objc(LKStaticWindowController) @MainActor final class LKStaticWindowController: LKWindowController, NSToolbarDelegate, @preconcurrency LKStaticAsyncUpdateManagerDelegate { + /// Who asked for a hierarchy reload. Recorded when a reload actually + /// starts, so an observer of the data source's `didReloadHierarchyInfo` + /// can attribute the reload it just saw. Reloads cannot overlap, so the + /// value only changes between them. + enum ReloadInitiator { + /// A person acted in the inspector, or the Host decided to reload. + case host + /// A client of the local bridge socket asked for it + /// (`hierarchy.refresh`); clients use this to ignore their own echo. + case agent + } + /// The refusals and failures of `reloadHierarchy(completion:)` that this /// window raises itself. Errors from the request keep their /// `LookinErrorDomain` code. - private enum ReloadError { + enum ReloadError { static let domain = "LKStaticWindowControllerReloadErrorDomain" /// A hierarchy fetch is already in flight on this window. @@ -69,6 +81,9 @@ final class LKStaticWindowController: LKWindowController, NSToolbarDelegate, @pr } } + /// Who asked for the most recent reload that actually started. + private(set) var lastReloadInitiator: ReloadInitiator = .host + private var toolbarItemsMap: [NSToolbarItem.Identifier: NSToolbarItem] = [:] private var gestureDebugWindowController: LKGestureDebugWindowController? /// The reload item as it was when the window was set up; the fetching @@ -344,7 +359,10 @@ final class LKStaticWindowController: LKWindowController, NSToolbarDelegate, @pr /// the reload domain (this window refused to start or lost the result) /// or `LookinErrorDomain` (the app failed the request); a refusal is /// reported before this returns. - private func reloadHierarchy(completion: @escaping (Result) -> Void) { + private func reloadHierarchy( + initiator: ReloadInitiator = .host, + completion: @escaping (Result) -> Void + ) { if isFetchingHierarchy { completion(.failure(ReloadError.make( ReloadError.alreadyInProgress, @@ -367,6 +385,9 @@ final class LKStaticWindowController: LKWindowController, NSToolbarDelegate, @pr return } + // Recorded only once the reload starts, so a refused request never + // relabels the reload that refused it. + lastReloadInitiator = initiator isFetchingHierarchy = true viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) LKPerformanceReporter.sharedInstance().willStartReload() @@ -411,6 +432,16 @@ final class LKStaticWindowController: LKWindowController, NSToolbarDelegate, @pr } } + /// `reloadHierarchy(completion:)` for callers outside the UI: no error + /// sheet, and the error is thrown instead. + func reloadHierarchy(initiator: ReloadInitiator) async throws -> LookinHierarchyInfo? { + try await withCheckedThrowingContinuation { continuation in + reloadHierarchy(initiator: initiator) { result in + continuation.resume(with: result) + } + } + } + // MARK: - NSToolbarDelegate func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { diff --git a/README.md b/README.md index c42f089..bfc2f7a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ LookInside continues the work of [Lookin](https://lookin.work/), the original iO 3. Run your app. 4. Open LookInside on your Mac and pick the running app. +## Use it with AI agents + +LookInside 2.3.11 and later include a local MCP server for Codex, Claude, Cursor, Windsurf, VS Code, and other MCP clients. Agents can inspect hierarchies, attributes, screenshots, and UI changes through the app you already opened in LookInside. + +See the [MCP configuration and usage guide](docs/mcp.md). + ## What you can inspect - UIKit, AppKit, and SwiftUI view trees diff --git a/Scripts/build-release-from-tag.sh b/Scripts/build-release-from-tag.sh index d7ccf93..2d07983 100755 --- a/Scripts/build-release-from-tag.sh +++ b/Scripts/build-release-from-tag.sh @@ -456,6 +456,19 @@ verify_injector_bundle_layout() { fail "Injector binary SMAuthorizedClients does not authorize $HOST_BUNDLE_IDENTIFIER for team $INJECTOR_AUTHORIZED_TEAM_ID" } +# The MCP server rides the host's release so it can never drift out of step +# with the MCPBridge protocol the host speaks. A release that quietly shipped +# without it would present as "the MCP integration disappeared in this +# version" to every user who configured their agent against the bundled path. +verify_mcp_cli_bundle_layout() { + local app_path="$1" + local mcp_binary="$app_path/Contents/Resources/lookinside-mcp" + + [[ -f "$mcp_binary" ]] || fail "MCP server binary is missing: $mcp_binary" + [[ -x "$mcp_binary" ]] || fail "MCP server binary is not executable: $mcp_binary" + is_mach_o_file "$mcp_binary" || fail "MCP server binary is not Mach-O: $mcp_binary" +} + package_cli() { local cli_binary="$1" local cli_zip="$2" @@ -549,6 +562,9 @@ sign_app_bundle() { log "Verifying bundled injector daemon" verify_injector_bundle_layout "$app_path" + log "Verifying bundled MCP server" + verify_mcp_cli_bundle_layout "$app_path" + remove_legacy_code_resources "$app_path" log "Signing nested app code" diff --git a/Scripts/prepare-mcp-cli.sh b/Scripts/prepare-mcp-cli.sh new file mode 100755 index 0000000..8d6b721 --- /dev/null +++ b/Scripts/prepare-mcp-cli.sh @@ -0,0 +1,114 @@ +#!/bin/zsh +set -euo pipefail + +# Builds the lookinside-mcp stdio server out of the sibling LookInside-MCP +# repo (when present) and embeds the binary into the LookInside.app bundle +# currently being built. +# +# Layout written: +# $BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Resources/lookinside-mcp +# +# Resources/ rather than MacOS/, because the host never launches this binary. +# MCP clients (Claude Desktop, Claude Code, Codex CLI, …) spawn it themselves +# and speak Model Context Protocol to it over stdio; the host only ever sees +# it as one more connection on its Unix socket. Shipping it inside the bundle +# is a distribution convenience -- it inherits the host's notarization and +# rides the same Sparkle channel, so the binary can never drift out of step +# with the MCPBridge protocol version the host speaks. +# +# GPL boundary: lookinside-mcp is proprietary and stays a separate process. It +# is never linked into the GPL host -- process isolation is the license +# boundary, and placing an independent executable in the bundle is +# aggregation, not linking. See docs/monorepo/mcp-design.md §0.2-0.3. +# +# Signing is deliberately not done here. The release flow's `sign_nested_code` +# walks every file under Contents/, detects Mach-O binaries and signs each one +# before sealing the outer bundle, so this binary is picked up automatically. +# +# Public/contributor builds without the sibling repo are tolerated: the script +# warns and exits 0, exactly like prepare-injector-daemon.sh. Those builds +# simply ship without the MCP server; nothing else about the app changes. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +MONOREPO_DIR="$(cd "$PROJECT_DIR/.." && pwd)" +MCP_REPO="$MONOREPO_DIR/LookInside-MCP" + +if [ ! -d "$MCP_REPO" ]; then + if [ "${CONFIGURATION:-}" = "Release" ]; then + echo "error: LookInside-MCP repo not found at $MCP_REPO" >&2 + echo " Release builds must embed the MCP server binary." >&2 + exit 1 + fi + echo "warning: LookInside-MCP repo not found at $MCP_REPO" >&2 + echo " (private/source-available; public contributors can ignore)" >&2 + exit 0 +fi + +if [ -z "${CONFIGURATION:-}" ]; then + echo "error: CONFIGURATION is unset (not running under Xcode?)" >&2 + exit 1 +fi + +if [ -z "${BUILT_PRODUCTS_DIR:-}" ] || [ -z "${CONTENTS_FOLDER_PATH:-}" ]; then + echo "error: BUILT_PRODUCTS_DIR / CONTENTS_FOLDER_PATH are unset (not running under Xcode?)" >&2 + exit 1 +fi + +case "$CONFIGURATION" in + Debug) + swift_configuration="debug" + ;; + Release) + swift_configuration="release" + ;; + *) + echo "warning: unknown CONFIGURATION=$CONFIGURATION, skipping MCP CLI embed" >&2 + exit 0 + ;; +esac + +# A scratch path of our own, never the package's default .build. A developer +# very likely has `swift build` running in this same package from a terminal, +# and SwiftPM takes an exclusive lock on its scratch directory -- sharing one +# would make the two builds block each other. +SCRATCH_PATH="$MCP_REPO/build/host-derived" +LOG="$SCRATCH_PATH/swift-build-$swift_configuration.log" + +mkdir -p "$SCRATCH_PATH" + +set +e +swift build \ + --package-path "$MCP_REPO" \ + --scratch-path "$SCRATCH_PATH" \ + --configuration "$swift_configuration" \ + --product lookinside-mcp >"$LOG" 2>&1 +build_status=$? +set -e + +if [ "$build_status" -ne 0 ]; then + echo "lookinside-mcp $swift_configuration build failed. Log: $LOG" >&2 + tail -80 "$LOG" >&2 || true + exit "$build_status" +fi + +BIN_PATH="$(swift build \ + --package-path "$MCP_REPO" \ + --scratch-path "$SCRATCH_PATH" \ + --configuration "$swift_configuration" \ + --show-bin-path 2>/dev/null)" + +built_binary="$BIN_PATH/lookinside-mcp" +if [ ! -x "$built_binary" ]; then + echo "error: lookinside-mcp binary not found at $built_binary" >&2 + exit 1 +fi + +APP_CONTENTS_DIR="$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH" +DEST_DIR="$APP_CONTENTS_DIR/Resources" +mkdir -p "$DEST_DIR" + +cp -f "$built_binary" "$DEST_DIR/lookinside-mcp" +chmod +x "$DEST_DIR/lookinside-mcp" + +echo "Embedded lookinside-mcp ($swift_configuration) into $DEST_DIR" diff --git a/Scripts/test-mcp-bridge.sh b/Scripts/test-mcp-bridge.sh new file mode 100755 index 0000000..619a3fd --- /dev/null +++ b/Scripts/test-mcp-bridge.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +# Host-side MCPBridge tests. +# +# Same shape as test-security-gates.sh: compile the unit under test +# together with its test file into a standalone binary and run it. Only +# units that depend on Darwin / Foundation alone can be covered this +# way -- the routing services pull in AppKit, NSDocumentController and +# the inspector's documents, so they are exercised end to end against a +# running Host instead (see docs/mcp.md). + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMPDIR="${TMPDIR:-/tmp}/lookinside-mcp-bridge-tests.$$" +mkdir -p "$TMPDIR" +trap 'rm -rf "$TMPDIR"' EXIT + +LISTEN_SOCKET="$ROOT/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift" +LISTEN_SOCKET_TEST="$ROOT/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift" + +swiftc -parse-as-library "$LISTEN_SOCKET" "$LISTEN_SOCKET_TEST" -o "$TMPDIR/listen-socket-test" +"$TMPDIR/listen-socket-test" + +SEARCH_QUERY="$ROOT/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift" +SEARCH_QUERY_TEST="$ROOT/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift" + +swiftc -parse-as-library "$SEARCH_QUERY" "$SEARCH_QUERY_TEST" -o "$TMPDIR/search-query-test" +"$TMPDIR/search-query-test" + +# The identifier list needs LKMCPBridgeFrame.swift alongside it for +# LKMCPBridgeJSONValue; that file is Foundation-only, so it compiles here. +FRAME="$ROOT/LookInside/MCPBridge/LKMCPBridgeFrame.swift" +OBJECT_IDENTIFIER_LIST="$ROOT/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift" +OBJECT_IDENTIFIER_LIST_TEST="$ROOT/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift" + +swiftc -parse-as-library "$FRAME" "$OBJECT_IDENTIFIER_LIST" "$OBJECT_IDENTIFIER_LIST_TEST" -o "$TMPDIR/object-identifier-list-test" +"$TMPDIR/object-identifier-list-test" + +echo "MCPBridge tests passed" diff --git a/Scripts/test-release-workflow.sh b/Scripts/test-release-workflow.sh index 82ca75f..a6ee788 100755 --- a/Scripts/test-release-workflow.sh +++ b/Scripts/test-release-workflow.sh @@ -32,7 +32,8 @@ assert_contains 'INJECTOR_REF="${{ github.event.client_payload.injector_ref }}"' assert_contains 'RELEASE_REF="${{ github.event.client_payload.release_ref }}"' assert_contains 'ref: ${{ needs.prepare.outputs.source_ref }}' assert_contains 'checkout --detach "${{ needs.prepare.outputs.injector_ref }}"' -assert_absent 'LookInside-MCP' +assert_contains 'MCP_REF="${{ github.event.client_payload.mcp_ref }}"' +assert_contains 'checkout --detach "${{ needs.prepare.outputs.mcp_ref }}"' assert_contains 'token: ${{ secrets.LOOKINSIDE_WEB_RELEASE_TOKEN }}' assert_contains 'target_commitish: ${{ needs.prepare.outputs.source_ref }}' assert_contains 'if existing_target="$(gh api' diff --git a/Scripts/test.sh b/Scripts/test.sh index a6d4a36..27c5209 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -59,6 +59,7 @@ run_xcode_build() { run_command "sync derived source" bash Scripts/sync-derived-source.sh run_command "gesture capture model tests" bash Scripts/test-gesture-models.sh +run_command "MCPBridge tests" bash Scripts/test-mcp-bridge.sh run_swift_build "swift build" build run_xcode_build "LookInside" "Debug" diff --git a/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift b/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift new file mode 100644 index 0000000..0433779 --- /dev/null +++ b/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift @@ -0,0 +1,257 @@ +import Darwin +import Foundation + +/// Regression coverage for the MCPBridge listening socket. +/// +/// The socket used to be created blocking. `LKMCPBridgeServer` drains +/// accepts in a `while true` loop that treats `EAGAIN` as "no more +/// pending connections", so on a blocking descriptor the second +/// `accept(2)` of each drain parked the server's serial state queue +/// until another client happened to connect. One connected client was +/// enough to wedge `stop()` and all connection bookkeeping behind it. +/// +/// `testSecondAcceptReturnsPromptly` is the one that reproduces it: it +/// fails (rather than hanging) if the descriptor ever goes back to +/// blocking mode. +@main +struct LKMCPBridgeListenSocketTests { + static func main() { + testDescriptorIsNonBlocking() + testAcceptWithNoPendingConnectionsReturnsImmediately() + testSecondAcceptReturnsPromptly() + testAcceptedConnectionIsBlocking() + testSocketFileIsOwnerOnly() + testStaleSocketFileIsReplaced() + print("MCPBridge listen socket tests passed") + } + + // MARK: - Tests + + private static func testDescriptorIsNonBlocking() { + let socketPath = makeTemporarySocketPath() + defer { unlink(socketPath) } + + guard let descriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { + fail("makeListening should succeed on a fresh path") + } + defer { Darwin.close(descriptor) } + + expect( + LKMCPBridgeListenSocket.isNonBlocking(descriptor: descriptor), + "listening descriptor must be non-blocking so the accept drain loop can terminate" + ) + } + + private static func testAcceptWithNoPendingConnectionsReturnsImmediately() { + let socketPath = makeTemporarySocketPath() + defer { unlink(socketPath) } + + guard let descriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { + fail("makeListening should succeed on a fresh path") + } + defer { Darwin.close(descriptor) } + + let accepted = accept(descriptor, nil, nil) + let savedErrorNumber = errno + expect(accepted < 0, "accept with no pending connection must not produce a descriptor") + expect( + savedErrorNumber == EAGAIN || savedErrorNumber == EWOULDBLOCK, + "accept with no pending connection must report EAGAIN, got errno \(savedErrorNumber)" + ) + } + + /// The actual wedge. Connect one client, accept it, then ask for + /// another the way the drain loop does. On a blocking descriptor + /// that second call never returns. + private static func testSecondAcceptReturnsPromptly() { + let socketPath = makeTemporarySocketPath() + defer { unlink(socketPath) } + + guard let listenDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { + fail("makeListening should succeed on a fresh path") + } + defer { Darwin.close(listenDescriptor) } + + guard let clientDescriptor = connectClient(toPath: socketPath) else { + fail("test client should connect to the listening socket") + } + defer { Darwin.close(clientDescriptor) } + + let firstAccepted = acceptWithRetry(listenDescriptor: listenDescriptor) + expect(firstAccepted >= 0, "the pending client connection must be accepted") + defer { + if firstAccepted >= 0 { + Darwin.close(firstAccepted) + } + } + + // Run the second accept off the main thread with a watchdog, so + // a regression reports a failure instead of hanging the suite. + let completion = DispatchSemaphore(value: 0) + let resultLock = NSLock() + var secondAcceptErrorNumber: Int32 = 0 + var secondAcceptDescriptor: Int32 = -1 + + DispatchQueue.global().async { + let accepted = accept(listenDescriptor, nil, nil) + let savedErrorNumber = errno + resultLock.lock() + secondAcceptDescriptor = accepted + secondAcceptErrorNumber = savedErrorNumber + resultLock.unlock() + completion.signal() + } + + let waitResult = completion.wait(timeout: .now() + 2) + expect( + waitResult == .success, + "the second accept blocked instead of returning EAGAIN — the drain loop would wedge the server's state queue" + ) + guard waitResult == .success else { return } + + resultLock.lock() + let accepted = secondAcceptDescriptor + let savedErrorNumber = secondAcceptErrorNumber + resultLock.unlock() + + expect(accepted < 0, "the second accept must not produce a descriptor") + expect( + savedErrorNumber == EAGAIN || savedErrorNumber == EWOULDBLOCK, + "the second accept must report EAGAIN, got errno \(savedErrorNumber)" + ) + } + + /// The listening socket must be non-blocking, but the connections + /// it hands out must not be. + /// + /// On BSD-derived systems -- macOS included -- accept(2) inherits + /// O_NONBLOCK from the listener, the opposite of Linux. Connection + /// writes are blocking by design (`LKMCPBridgeConnection.writeAll` + /// treats EAGAIN as fatal and closes), so an inherited flag turns + /// any response larger than the socket send buffer into a dropped + /// connection. Small frames still work, which is what makes this + /// fail so selectively: hierarchy reads are fine and screenshots + /// kill the connection. + private static func testAcceptedConnectionIsBlocking() { + let socketPath = makeTemporarySocketPath() + defer { unlink(socketPath) } + + guard let listenDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { + fail("makeListening should succeed on a fresh path") + } + defer { Darwin.close(listenDescriptor) } + + guard let clientDescriptor = connectClient(toPath: socketPath) else { + fail("test client should connect to the listening socket") + } + defer { Darwin.close(clientDescriptor) } + + let accepted = LKMCPBridgeListenSocket.acceptConnection(listenDescriptor: listenDescriptor) + expect(accepted >= 0, "the pending client connection must be accepted") + defer { Darwin.close(accepted) } + + expect( + LKMCPBridgeListenSocket.isNonBlocking(descriptor: accepted) == false, + "accepted connections must be blocking; a non-blocking one drops any frame larger than the send buffer" + ) + } + + private static func testSocketFileIsOwnerOnly() { + let socketPath = makeTemporarySocketPath() + defer { unlink(socketPath) } + + guard let descriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { + fail("makeListening should succeed on a fresh path") + } + defer { Darwin.close(descriptor) } + + var fileStatus = stat() + guard stat(socketPath, &fileStatus) == 0 else { + fail("the socket file should exist after makeListening") + } + let permissionBits = fileStatus.st_mode & 0o777 + expect( + permissionBits == LKMCPBridgeListenSocket.socketFilePermissions, + "socket must be owner-only, got \(String(permissionBits, radix: 8))" + ) + } + + /// A host that crashed leaves its socket file behind. Without the + /// unlink, every subsequent launch would fail to bind. + private static func testStaleSocketFileIsReplaced() { + let socketPath = makeTemporarySocketPath() + defer { unlink(socketPath) } + + guard let firstDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { + fail("makeListening should succeed on a fresh path") + } + // Close without unlinking: exactly what a crash leaves behind. + Darwin.close(firstDescriptor) + + guard let secondDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { + fail("makeListening should replace a stale socket file rather than failing to bind") + } + Darwin.close(secondDescriptor) + } + + // MARK: - Helpers + + /// Socket paths are capped at 104 bytes by the kernel, so this stays + /// directly under /tmp rather than in a nested temporary directory. + private static func makeTemporarySocketPath() -> String { + let uniqueSuffix = UUID().uuidString.prefix(8) + return "/tmp/lkmcp-listen-\(uniqueSuffix).sock" + } + + private static func connectClient(toPath socketPath: String) -> Int32? { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { pathBuffer in + for index in 0 ..< pathBytes.count { + pathBuffer[index] = pathBytes[index] + } + pathBuffer[pathBytes.count] = 0 + } + let connectResult = withUnsafePointer(to: &address) { addressPointer -> Int32 in + addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + Darwin.connect(descriptor, rebound, socklen_t(MemoryLayout.size)) + } + } + if connectResult != 0 { + Darwin.close(descriptor) + return nil + } + return descriptor + } + + /// `connect(2)` returning does not guarantee the kernel has already + /// queued the connection on the listener, so give it a moment before + /// concluding there is nothing to accept. + private static func acceptWithRetry(listenDescriptor: Int32) -> Int32 { + for _ in 0 ..< 100 { + let accepted = accept(listenDescriptor, nil, nil) + if accepted >= 0 { + return accepted + } + if errno != EAGAIN && errno != EWOULDBLOCK { + return accepted + } + usleep(10000) + } + return -1 + } + + private static func expect(_ condition: Bool, _ message: String) { + guard condition else { + fail(message) + } + } + + private static func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + Foundation.exit(1) + } +} diff --git a/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift b/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift new file mode 100644 index 0000000..1042ad7 --- /dev/null +++ b/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Coverage for the `details.read` object-identifier list. +/// +/// The failure this guards against is not a wrong answer but a dead host: +/// a repeated identifier used to resolve twice to the same display item and +/// then reach `Dictionary(uniqueKeysWithValues:)`, which traps. The caller is +/// an LLM-driven MCP client, so a list carrying the same identifier twice is +/// routine input. Every de-duplication test here stands in for that crash. +@main +struct LKMCPBridgeObjectIdentifierListTests { + static func main() { + testRepeatedIdentifierIsCollapsed() + testEveryEntryIdenticalCollapsesToOne() + testFirstSeenOrderIsPreserved() + testDistinctIdentifiersAreAllKept() + testDroppedDuplicateCountIsReported() + testNonStringEntryIsRejected() + testEmptyArrayIsRejected() + testCapAppliesToDistinctCountNotRawCount() + testTooManyDistinctIdentifiersIsRejected() + print("MCPBridge object identifier list tests passed") + } + + // MARK: - De-duplication (the crash this file exists for) + + /// The direct reproduction: the exact payload shape that used to take + /// down LookInside.app. + private static func testRepeatedIdentifierIsCollapsed() { + let parsed = expectSuccess( + parse(["0x600000abc123", "0x600000abc123", "0x600000def456"]) + ) + expect( + parsed.identifiers == ["0x600000abc123", "0x600000def456"], + "a repeated identifier must appear exactly once, got \(parsed.identifiers)" + ) + } + + private static func testEveryEntryIdenticalCollapsesToOne() { + let parsed = expectSuccess( + parse(["0xabc", "0xabc", "0xabc", "0xabc"]) + ) + expect( + parsed.identifiers == ["0xabc"], + "four copies of one identifier must collapse to one, got \(parsed.identifiers)" + ) + } + + /// Responses list details in request order and agents correlate them + /// positionally, so de-duplication must not reshuffle the survivors. + private static func testFirstSeenOrderIsPreserved() { + let parsed = expectSuccess( + parse(["0xcc", "0xaa", "0xcc", "0xbb", "0xaa"]) + ) + expect( + parsed.identifiers == ["0xcc", "0xaa", "0xbb"], + "survivors must stay in first-seen order, got \(parsed.identifiers)" + ) + } + + private static func testDistinctIdentifiersAreAllKept() { + let parsed = expectSuccess(parse(["0xaa", "0xbb", "0xcc"])) + expect( + parsed.identifiers == ["0xaa", "0xbb", "0xcc"], + "a list with no repeats must pass through unchanged, got \(parsed.identifiers)" + ) + expect( + parsed.droppedDuplicateCount == 0, + "nothing should be reported dropped, got \(parsed.droppedDuplicateCount)" + ) + } + + private static func testDroppedDuplicateCountIsReported() { + let parsed = expectSuccess(parse(["0xaa", "0xaa", "0xaa", "0xbb"])) + expect( + parsed.droppedDuplicateCount == 2, + "two entries were duplicates, got \(parsed.droppedDuplicateCount)" + ) + } + + // MARK: - Rejection + + private static func testNonStringEntryIsRejected() { + let result = LKMCPBridgeObjectIdentifierList.parse( + wireValues: [.string("0xaa"), .integer(7)], + limit: 100 + ) + expect( + failure(of: result) == .notAllStrings, + "an identifier that is not a string cannot address anything and must be rejected" + ) + } + + private static func testEmptyArrayIsRejected() { + let result = LKMCPBridgeObjectIdentifierList.parse(wireValues: [], limit: 100) + expect( + failure(of: result) == .empty, + "an empty identifier array must be rejected" + ) + } + + // MARK: - Cap + + /// The cap bounds how many objects the host fetches from the target app. + /// Duplicates cost nothing downstream, so a list that only *looks* too + /// long must not be refused. + private static func testCapAppliesToDistinctCountNotRawCount() { + let wireValues = (0 ..< 6).map { _ in LKMCPBridgeJSONValue.string("0xaa") } + + [LKMCPBridgeJSONValue.string("0xbb")] + let result = LKMCPBridgeObjectIdentifierList.parse(wireValues: wireValues, limit: 3) + let parsed = expectSuccess(result) + expect( + parsed.identifiers == ["0xaa", "0xbb"], + "seven raw entries holding two distinct ids must pass a cap of three, got \(parsed.identifiers)" + ) + } + + private static func testTooManyDistinctIdentifiersIsRejected() { + let wireValues = (0 ..< 5).map { LKMCPBridgeJSONValue.string("0x\($0)") } + let result = LKMCPBridgeObjectIdentifierList.parse(wireValues: wireValues, limit: 3) + expect( + failure(of: result) == .tooMany(distinctCount: 5, limit: 3), + "five distinct identifiers must be rejected against a cap of three" + ) + } + + // MARK: - Helpers + + private static func parse( + _ identifiers: [String] + ) -> Result { + return LKMCPBridgeObjectIdentifierList.parse( + wireValues: identifiers.map(LKMCPBridgeJSONValue.string), + limit: 100 + ) + } + + private static func expectSuccess( + _ result: Result + ) -> LKMCPBridgeObjectIdentifierList.Parsed { + switch result { + case let .success(parsed): + return parsed + case let .failure(parseFailure): + fail("expected success, got failure \(parseFailure)") + } + } + + private static func failure( + of result: Result + ) -> LKMCPBridgeObjectIdentifierList.ParseFailure? { + switch result { + case .success: + return nil + case let .failure(parseFailure): + return parseFailure + } + } + + private static func expect(_ condition: Bool, _ message: String) { + guard condition else { + fail(message) + } + } + + private static func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + Foundation.exit(1) + } +} diff --git a/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift b/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift new file mode 100644 index 0000000..6084e44 --- /dev/null +++ b/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift @@ -0,0 +1,225 @@ +import Foundation + +/// Coverage for the `hierarchy.find` matching core. +/// +/// Search is the one bridge route whose failure mode is silent: a field +/// that never matches, or a field name that is quietly dropped, both +/// present to the caller as "nothing in this app matched" — a statement +/// about the inspected app rather than about the request. Every test here +/// exists to make one of those silent outcomes loud. +@main +struct LKMCPBridgeSearchQueryTests { + static func main() { + testMatchesLeafClassName() + testClassNameDoesNotMatchAncestorClass() + testClassChainMatchesAncestorClass() + testMatchingIsCaseInsensitive() + testMatchesTitleAndSubtitle() + testMatchesPartialMemoryAddress() + testNarrowingFieldsExcludesOtherHits() + testMatchedFieldsReportEveryHitInCanonicalOrder() + testEmptyFieldListMeansEveryField() + testDuplicateFieldsAreCollapsed() + testNoMatchReturnsEmpty() + testAbsentStringsNeverMatch() + testUnknownWireFieldNameIsRejected() + testWireFieldNamesResolveInCanonicalOrder() + print("MCPBridge search query tests passed") + } + + // MARK: - Field semantics + + private static func testMatchesLeafClassName() { + let query = LKMCPBridgeSearchQuery(rawQuery: "UIButton") + expect( + query.matches(button()), + "a query naming the leaf class must match that node" + ) + } + + /// `className` is the narrow field: it is what lets an agent ask for + /// `UIView` without getting back every view in the app. + private static func testClassNameDoesNotMatchAncestorClass() { + let query = LKMCPBridgeSearchQuery(rawQuery: "UIControl", fields: [.className]) + expect( + query.matchedFields(in: button()).isEmpty, + "className must consider only the leaf class, not the rest of the chain" + ) + } + + private static func testClassChainMatchesAncestorClass() { + let query = LKMCPBridgeSearchQuery(rawQuery: "UIControl", fields: [.classChain]) + expect( + query.matchedFields(in: button()) == [.classChain], + "classChain must match any class in the chain so subclasses are findable by their base class" + ) + } + + private static func testMatchingIsCaseInsensitive() { + let query = LKMCPBridgeSearchQuery(rawQuery: "uibutton", fields: [.className]) + expect( + query.matches(button()), + "an agent should not have to reproduce the exact casing of a class name" + ) + } + + private static func testMatchesTitleAndSubtitle() { + let titleQuery = LKMCPBridgeSearchQuery(rawQuery: "Play", fields: [.title]) + expect(titleQuery.matches(button()), "title must be searchable") + + let subtitleQuery = LKMCPBridgeSearchQuery(rawQuery: "PlayerViewController", fields: [.subtitle]) + expect(subtitleQuery.matches(button()), "subtitle must be searchable") + } + + /// Addresses arrive from a debugger or a crash log, where the caller + /// often has only the tail of one. + private static func testMatchesPartialMemoryAddress() { + let query = LKMCPBridgeSearchQuery(rawQuery: "abc123", fields: [.memoryAddress]) + expect( + query.matches(button()), + "a substring of a memory address must match, not just the whole address" + ) + } + + // MARK: - Field selection + + private static func testNarrowingFieldsExcludesOtherHits() { + // "Play" appears in the title and in the subtitle, nowhere else. + let query = LKMCPBridgeSearchQuery(rawQuery: "Play", fields: [.className, .classChain]) + expect( + query.matchedFields(in: button()).isEmpty, + "restricting matchFields must actually exclude the fields left out" + ) + } + + private static func testMatchedFieldsReportEveryHitInCanonicalOrder() { + // "UIButton" is the leaf class, is in the chain, and is the title. + let query = LKMCPBridgeSearchQuery(rawQuery: "UIButton") + let matched = query.matchedFields(in: plainButton()) + expect( + matched == [.className, .classChain, .title], + "matchedFields must list every hit in canonical order; got \(matched)" + ) + } + + private static func testEmptyFieldListMeansEveryField() { + let query = LKMCPBridgeSearchQuery(rawQuery: "Play", fields: []) + expect( + query.fields == LKMCPBridgeSearchQuery.Field.allCases, + "an empty field list must widen to every field rather than matching nothing" + ) + } + + private static func testDuplicateFieldsAreCollapsed() { + let query = LKMCPBridgeSearchQuery(rawQuery: "UIButton", fields: [.title, .className, .title]) + expect( + query.fields == [.className, .title], + "duplicates must collapse and order must be canonical; got \(query.fields)" + ) + expect( + query.matchedFields(in: plainButton()) == [.className, .title], + "a repeated field must not be reported twice in matchedFields" + ) + } + + // MARK: - Negative paths + + private static func testNoMatchReturnsEmpty() { + let query = LKMCPBridgeSearchQuery(rawQuery: "NSTableView") + expect( + query.matchedFields(in: button()).isEmpty, + "a query matching nothing must report no matched fields" + ) + } + + /// Nodes configured through the in-app custom-debug-info hook can be + /// missing a title, a subtitle, or any backing object at all. + private static func testAbsentStringsNeverMatch() { + let bare = LKMCPBridgeSearchQuery.Candidate( + className: "", + classChain: [], + title: nil, + subtitle: nil, + memoryAddresses: [] + ) + let query = LKMCPBridgeSearchQuery(rawQuery: "anything") + expect( + query.matchedFields(in: bare).isEmpty, + "a candidate with no strings at all must not match" + ) + + // An empty query would otherwise match every empty string via + // `contains("")`, turning a search into a full tree dump. + let emptyQuery = LKMCPBridgeSearchQuery(rawQuery: "") + expect( + emptyQuery.matchedFields(in: bare).isEmpty, + "empty strings must not be considered matchable content" + ) + } + + // MARK: - Wire field names + + private static func testUnknownWireFieldNameIsRejected() { + expect( + LKMCPBridgeSearchQuery.fields(fromWireNames: ["className", "classname"]) == nil, + "a misspelled field must be rejected, not silently dropped into a no-results answer" + ) + expect( + LKMCPBridgeSearchQuery.firstUnrecognizedFieldName(in: ["className", "classname"]) == "classname", + "the rejected name must be reported so the error message can quote it" + ) + expect( + LKMCPBridgeSearchQuery.firstUnrecognizedFieldName(in: ["className", "title"]) == nil, + "a fully valid list must report no unrecognized name" + ) + } + + private static func testWireFieldNamesResolveInCanonicalOrder() { + let resolved = LKMCPBridgeSearchQuery.fields(fromWireNames: ["subtitle", "className"]) + expect( + resolved == [.className, .subtitle], + "wire names must resolve into canonical order; got \(String(describing: resolved))" + ) + } + + // MARK: - Fixtures + + /// A button whose title and subtitle carry app-supplied text, so + /// field-narrowing tests have something that matches on exactly one + /// field at a time. + private static func button() -> LKMCPBridgeSearchQuery.Candidate { + return LKMCPBridgeSearchQuery.Candidate( + className: "UIButton", + classChain: ["UIButton", "UIControl", "UIView", "UIResponder", "NSObject"], + title: "Play", + subtitle: "PlayerViewController.view", + memoryAddresses: ["0x600000abc123", "0x600000def456"] + ) + } + + /// The default shape: title is just the demangled class name, which + /// is what the host inspector shows for a view with no custom debug + /// info. + private static func plainButton() -> LKMCPBridgeSearchQuery.Candidate { + return LKMCPBridgeSearchQuery.Candidate( + className: "UIButton", + classChain: ["UIButton", "UIControl", "UIView"], + title: "UIButton", + subtitle: "SettingsViewController.view", + memoryAddresses: ["0x600000abc123"] + ) + } + + // MARK: - Helpers + + private static func expect(_ condition: Bool, _ message: String) { + guard condition else { + fail(message) + } + } + + private static func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + Foundation.exit(1) + } +} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..45f3063 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,181 @@ +# Use LookInside with MCP clients + +LookInside 2.3.11 and later include a local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server. It lets MCP-compatible AI clients inspect the iOS or macOS app that is open in LookInside. + +The server is bundled inside the signed LookInside app. There is no separate package to install and no API key or network endpoint to configure. + +## Before you configure a client + +Make sure all of the following are true: + +1. Install an official LookInside 2.3.11 or later release as `/Applications/LookInside.app`. +2. Add the [LookInside server package](https://github.com/LookInsideApp/LookInside-Release) to the Debug build of the app you want to inspect. +3. Run the target app. +4. Open LookInside and connect to the running app from the launch window. +5. Keep LookInside running while the MCP client uses its tools. + +Verify that the bundled executable is present: + +```sh +test -x /Applications/LookInside.app/Contents/Resources/lookinside-mcp \ + && echo "LookInside MCP server is installed" +``` + +If LookInside is installed somewhere else, replace the absolute path in the examples below. Use the executable inside the same LookInside app that you run so the host and MCP server stay on matching versions. + +## Configure an MCP client + +LookInside uses the local `stdio` MCP transport. The command is: + +```text +/Applications/LookInside.app/Contents/Resources/lookinside-mcp +``` + +It does not need arguments, environment variables, credentials, or OAuth. + +### Codex + +Add the server with the Codex CLI: + +```sh +codex mcp add lookinside -- /Applications/LookInside.app/Contents/Resources/lookinside-mcp +codex mcp get lookinside +``` + +Start a new Codex session after adding the server. In the Codex terminal UI, `/mcp` shows the active servers. The Codex app, CLI, and IDE extension share MCP configuration for the same Codex host. + +The equivalent `~/.codex/config.toml` entry is: + +```toml +[mcp_servers.lookinside] +command = "/Applications/LookInside.app/Contents/Resources/lookinside-mcp" +``` + +See the [official Codex MCP documentation](https://learn.chatgpt.com/docs/extend/mcp?surface=cli) for more client-side options. + +### Claude Code + +Add LookInside as a user-scoped local server: + +```sh +claude mcp add --transport stdio --scope user lookinside \ + -- /Applications/LookInside.app/Contents/Resources/lookinside-mcp +claude mcp get lookinside +``` + +Use `/mcp` inside Claude Code to inspect or reconnect the server. See the [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) for scope and approval options. + +### Claude Desktop + +Open **Settings > Developer > Edit Config**, then add this entry to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "lookinside": { + "command": "/Applications/LookInside.app/Contents/Resources/lookinside-mcp", + "args": [] + } + } +} +``` + +Merge the `lookinside` entry into the existing `mcpServers` object if the file already contains other servers. Quit and reopen Claude Desktop after saving it. + +See the [MCP guide for connecting local servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers) for configuration and log locations. + +### Other clients + +Cursor, Windsurf, VS Code, and other MCP clients can use the same executable. Add a local `stdio` server named `lookinside` with: + +- **Command:** `/Applications/LookInside.app/Contents/Resources/lookinside-mcp` +- **Arguments:** none +- **Environment:** none + +The surrounding JSON or settings UI differs by client, so use that client's local `stdio` server format. + +## Verify the connection + +After restarting or opening a new client session, ask the agent: + +> Use LookInside's `ping_host` tool and report the connected host version. Do not use a shell command. + +A successful response looks like: + +```text +Host MCPBridge alive (LookInside 3.0.0). +``` + +Then verify that the current inspection session is visible: + +> Use LookInside to list the attached targets. Report the app name, device, OS, and license state. + +`list_targets` returns an empty list when LookInside is running but has not opened a target. Return to the LookInside launch window, connect to the app, and retry. + +## Recommended inspection workflow + +Agents get the most reliable results when they follow this order: + +1. Call `ping_host` to verify that LookInside is running. +2. Call `list_targets` and choose a target whose `licenseState` allows inspection. +3. Call `refresh_hierarchy` if the user navigated, presented a sheet, or otherwise changed the target app since the last read. +4. Use `take_screenshot` without an object identifier for a visual overview. +5. Use `find_views` when looking for a specific class, label, or address. Prefer it over loading a large complete tree. +6. Use `get_hierarchy` with a bounded `depth`, or scope it to a `rootObjectIdentifier` returned by an earlier call. +7. Call `read_view_details` before `read_attributes` when inspecting newly discovered object identifiers. +8. Re-find views after a hierarchy refresh. Object identifiers from the previous tree may no longer resolve. + +For example: + +> Use LookInside to inspect the first attached target. Refresh its hierarchy, take a window screenshot, find visible button views, and summarize each button's frame and title. Do not mutate the app. + +## Available tools + +| Area | Tools | Purpose | +| --- | --- | --- | +| Connection | `ping_host`, `list_targets` | Check the host and discover attached apps. | +| Hierarchy | `get_hierarchy`, `find_views` | Read or search the current view tree. | +| Details | `read_attributes`, `read_view_details`, `list_class_methods` | Inspect properties and available Objective-C selectors. | +| Visuals | `take_screenshot` | Capture the key window or one view as PNG. | +| Refresh | `refresh_hierarchy` | Reload the tree after the target UI changes. | +| Snapshots | `capture_snapshot`, `list_snapshots`, `drop_snapshot`, `diff_snapshots` | Compare structure, frames, and visibility across two points in time. | +| Mutation | `invoke_method`, `modify_attribute` | Call a zero-argument selector or change a supported attribute in the Debug app. | + +The MCP client receives each tool's complete input schema and description when it connects. Let the client use those schemas instead of guessing argument names or value shapes. + +## Compare UI changes + +Snapshots are useful when you want to understand the effect of a navigation or interaction without comparing two large trees manually: + +1. Call `capture_snapshot` with a label such as `before`. +2. Change the target app, or ask the user to perform the interaction. +3. Call `capture_snapshot` again with a label such as `after`. +4. Call `diff_snapshots` with the two returned snapshot identifiers. + +Example prompt: + +> Capture a LookInside hierarchy snapshot named `before`. Ask me to perform the interaction, then capture `after` and diff the two snapshots. Summarize added, removed, moved, resized, and visibility-changed views. + +Snapshots live only for the lifetime of that MCP server process. Capture both snapshots in the same client session. + +## Safety and privacy + +- Treat the target app as a development or debugging environment. `invoke_method` and `modify_attribute` can change its state immediately. +- Ask before using mutation tools unless the user explicitly requested the change. +- Prefer `list_class_methods` before `invoke_method`; method invocation currently supports zero-argument Objective-C selectors. +- Secure text attributes are redacted. Screenshot capture refuses regions containing detected secure input unless the caller explicitly acknowledges the risk. +- Do not acknowledge secure-content screenshots unless the user understands what is visible and has asked for that capture. +- The MCP server is local and does not configure credentials. Your AI client still applies its own data-handling and tool-approval policies to any hierarchy, attributes, or screenshots it reads. + +## Troubleshooting + +| Symptom | What to check | +| --- | --- | +| The client cannot start `lookinside` | Confirm the command is an absolute path and the bundled executable passes the `test -x` check above. Official releases include it; a local source build may not. | +| `ping_host` says LookInside is not running | Launch the same LookInside app bundle referenced by the MCP command, then retry. | +| `list_targets` returns `[]` | Run the target app and connect to it from the LookInside launch window. Opening LookInside alone is not enough. | +| A target reports a blocked license state | Resolve activation in the LookInside app, reconnect the target, and run `list_targets` again. | +| Results describe the previous screen | Call `refresh_hierarchy`, then locate the views again instead of reusing old object identifiers. | +| `read_attributes` reports uncached details | Call `read_view_details` for that object identifier, then retry. | +| A screenshot is blank | Capture a descendant that performs the drawing; upper window, theme-frame, or hosting views can render no pixels by themselves. | +| The server was added but does not appear | Restart or open a new MCP client session and use the client's MCP status command or panel. |