diff --git a/App/InjectionNext.xcodeproj/project.pbxproj b/App/InjectionNext.xcodeproj/project.pbxproj index 93587924..4cfc50a7 100644 --- a/App/InjectionNext.xcodeproj/project.pbxproj +++ b/App/InjectionNext.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ BBDD84542C4FEB16000F3124 /* TupleRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDD84532C4FEB16000F3124 /* TupleRegex.swift */; }; BBDD84552C4FEC98000F3124 /* SimpleSocket.mm in Sources */ = {isa = PBXBuildFile; fileRef = BB67DBB31FB0CDA8000EAC8A /* SimpleSocket.mm */; }; BBDD845A2C4FF646000F3124 /* ClientBoot.mm in Sources */ = {isa = PBXBuildFile; fileRef = BBDD84592C4FF645000F3124 /* ClientBoot.mm */; }; + BBF00D012FBCE00100F0D001 /* InjectionTouchEvents.mm in Sources */ = {isa = PBXBuildFile; fileRef = BBF00D002FBCE00100F0D001 /* InjectionTouchEvents.mm */; }; BBEB87122C74FD930044578A /* Quick in Frameworks */ = {isa = PBXBuildFile; productRef = BBEB87112C74FD930044578A /* Quick */; }; BBF6572E2DAFF00200638170 /* NIMBLE.md in Resources */ = {isa = PBXBuildFile; fileRef = BBF6572C2DAFF00200638170 /* NIMBLE.md */; }; BBF6572F2DAFF00200638170 /* QUICK.md in Resources */ = {isa = PBXBuildFile; fileRef = BBF6572D2DAFF00200638170 /* QUICK.md */; }; @@ -255,8 +256,16 @@ BB5C8E8E2FA503D500646A0C /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.swift; sourceTree = ""; }; BB67DBB21FB0CDA8000EAC8A /* SimpleSocket.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SimpleSocket.h; path = ../../Sources/InjectionNextC/include/SimpleSocket.h; sourceTree = ""; }; BB67DBB31FB0CDA8000EAC8A /* SimpleSocket.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = SimpleSocket.mm; path = ../../Sources/InjectionNextC/SimpleSocket.mm; sourceTree = ""; }; + BBF00CFF2FBCE00100F0D001 /* InjectionTouchEvents.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = InjectionTouchEvents.h; path = ../../Sources/InjectionNextC/include/InjectionTouchEvents.h; sourceTree = ""; }; + BBF00D002FBCE00100F0D001 /* InjectionTouchEvents.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = InjectionTouchEvents.mm; path = ../../Sources/InjectionNextC/InjectionTouchEvents.mm; sourceTree = ""; }; BB6A56EF2C50E73600C92112 /* ConfigStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigStore.swift; sourceTree = ""; }; BB6A56F22C51154F00C92112 /* InjectionBundle-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "InjectionBundle-Bridging-Header.h"; sourceTree = ""; }; + BB7AC1522FBCEDEF009269AF /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; + BB7AC1532FBCEDEF009269AF /* index.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = index.js; sourceTree = ""; }; + BB7AC1542FBCEDEF009269AF /* package.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = package.json; sourceTree = ""; }; + BB7AC1552FBCEDEF009269AF /* package-lock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "package-lock.json"; sourceTree = ""; }; + BB7AC1562FBCEDEF009269AF /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + BB7AC1572FBCEDEF009269AF /* take-screenshot.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "take-screenshot.js"; sourceTree = ""; }; BB85A4242D6B9C5300FA29D0 /* feedcommands */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = feedcommands; sourceTree = BUILT_PRODUCTS_DIR; }; BBA082352D6BC32D00FFFB2F /* FrontendServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontendServer.swift; sourceTree = ""; }; BBA082372D6BD6CB00FFFB2F /* swift-frontend.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "swift-frontend.sh"; sourceTree = ""; }; @@ -456,6 +465,7 @@ BBCA01FF1FB0F10300E45F0F /* InjectionNext */, BBDD84172C4FE79E000F3124 /* InjectionBundle */, BB85A4252D6B9C5300FA29D0 /* feedcommands */, + BB7AC1582FBCEDEF009269AF /* mcp-server */, BB439B661FABA64300B4F50B /* Products */, BBB040621FB1798A007DDD0A /* Frameworks */, ); @@ -519,6 +529,20 @@ path = ../SwiftTrace/SwiftTraceGuts; sourceTree = SOURCE_ROOT; }; + BB7AC1582FBCEDEF009269AF /* mcp-server */ = { + isa = PBXGroup; + children = ( + BB7AC1522FBCEDEF009269AF /* .gitignore */, + BB7AC1532FBCEDEF009269AF /* index.js */, + BB7AC1542FBCEDEF009269AF /* package.json */, + BB7AC1552FBCEDEF009269AF /* package-lock.json */, + BB7AC1562FBCEDEF009269AF /* README.md */, + BB7AC1572FBCEDEF009269AF /* take-screenshot.js */, + ); + name = "mcp-server"; + path = "../mcp-server"; + sourceTree = SOURCE_ROOT; + }; BBB040621FB1798A007DDD0A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -567,6 +591,8 @@ isa = PBXGroup; children = ( BB42F9302EB63AAF00FDBBCC /* InjectionImplC.h */, + BBF00CFF2FBCE00100F0D001 /* InjectionTouchEvents.h */, + BBF00D002FBCE00100F0D001 /* InjectionTouchEvents.mm */, BBDD84592C4FF645000F3124 /* ClientBoot.mm */, BBDD843A2C4FE892000F3124 /* InjectionBoot.mm */, BBDD84182C4FE7E6000F3124 /* InjectionNext.swift */, @@ -893,6 +919,7 @@ BB42F8EC2EB626F100FDBBCC /* SwiftMeta.swift in Sources */, BB4788802EB6B6E700464AB4 /* InjectionBoot.mm in Sources */, BBDD845A2C4FF646000F3124 /* ClientBoot.mm in Sources */, + BBF00D012FBCE00100F0D001 /* InjectionTouchEvents.mm in Sources */, BB42F92D2EB639E500FDBBCC /* DLKitC.c in Sources */, BB42F92E2EB639E500FDBBCC /* trie_dladdr.mm in Sources */, BB42F92F2EB639E500FDBBCC /* trie_dlops.mm in Sources */, @@ -1122,7 +1149,7 @@ INFOPLIST_FILE = InjectionNext/Info.plist; LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.johnholdsworth.InjectionNext; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1144,7 +1171,7 @@ INFOPLIST_FILE = InjectionNext/Info.plist; LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.johnholdsworth.InjectionNext; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/App/InjectionNext/ConfigStore.swift b/App/InjectionNext/ConfigStore.swift index cf2a8610..a1a02ee0 100644 --- a/App/InjectionNext/ConfigStore.swift +++ b/App/InjectionNext/ConfigStore.swift @@ -475,8 +475,6 @@ final class ConfigStore: ObservableObject { hostChangeWarned = true } } let injectionPort: String = HOTRELOADING_PORT - let controlPort: UInt16 = 8919 - // MARK: - Advanced @Published var verboseLogging: Bool { diff --git a/App/InjectionNext/ControlServer.swift b/App/InjectionNext/ControlServer.swift index 845ac053..d89b713f 100644 --- a/App/InjectionNext/ControlServer.swift +++ b/App/InjectionNext/ControlServer.swift @@ -2,8 +2,8 @@ // ControlServer.swift // InjectionNext // -// Local TCP control server for MCP integration. -// Listens on localhost:8919 for JSON commands and +// Local Unix domain socket control server for MCP integration. +// Listens for JSON commands from the bundled MCP server and // maps them to existing AppDelegate actions. // @@ -13,7 +13,9 @@ import Cocoa class ControlServer { - static let port: UInt16 = 8919 + static let compatibilityPort: UInt16 = 8919 + static let socketPath = "/tmp/InjectionNext-control.sock" + static var servicedRequest = false static var shared: ControlServer? private var serverSocket: Int32 = -1 @@ -21,6 +23,7 @@ class ControlServer { static func start() { guard shared == nil else { return } + CompatibilityControlServer.startServer(":\(compatibilityPort)") shared = ControlServer() shared?.listen() } @@ -29,32 +32,46 @@ class ControlServer { queue.async { [weak self] in guard let self = self else { return } - self.serverSocket = socket(AF_INET, SOCK_STREAM, 0) + self.serverSocket = socket(AF_UNIX, SOCK_STREAM, 0) guard self.serverSocket >= 0 else { NSLog("\(APP_PREFIX)ControlServer: socket() failed") return } - var reuse: Int32 = 1 - setsockopt(self.serverSocket, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + let socketPath = Self.socketPath + guard socketPath.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path) else { + NSLog("\(APP_PREFIX)ControlServer: socket path too long: \(socketPath)") + close(self.serverSocket) + return + } - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = Self.port.bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") + unlink(socketPath) + + var addr = sockaddr_un() + addr.sun_len = UInt8(MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + let sunPathSize = MemoryLayout.size(ofValue: sockaddr_un().sun_path) + withUnsafeMutableBytes(of: &addr.sun_path) { buffer in + socketPath.withCString { source in + if let baseAddress = buffer.baseAddress { + strncpy(baseAddress.assumingMemoryBound(to: CChar.self), + source, sunPathSize - 1) + } + } + } let bindResult = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(self.serverSocket, $0, socklen_t(MemoryLayout.size)) + bind(self.serverSocket, $0, socklen_t(MemoryLayout.size)) } } guard bindResult == 0 else { - NSLog("\(APP_PREFIX)ControlServer: bind() failed on port \(Self.port): \(String(cString: strerror(errno)))") + NSLog("\(APP_PREFIX)ControlServer: bind() failed at \(socketPath): \(String(cString: strerror(errno)))") close(self.serverSocket) return } + chmod(socketPath, S_IRUSR | S_IWUSR) guard Darwin.listen(self.serverSocket, 5) == 0 else { NSLog("\(APP_PREFIX)ControlServer: listen() failed") @@ -62,16 +79,10 @@ class ControlServer { return } - NSLog("\(APP_PREFIX)ControlServer: listening on localhost:\(Self.port)") + NSLog("\(APP_PREFIX)ControlServer: listening at \(socketPath)") while true { - var clientAddr = sockaddr_in() - var clientLen = socklen_t(MemoryLayout.size) - let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - accept(self.serverSocket, $0, &clientLen) - } - } + let clientSocket = accept(self.serverSocket, nil, nil) guard clientSocket >= 0 else { continue } self.queue.async { self.handleClient(clientSocket) @@ -106,6 +117,7 @@ class ControlServer { let result = executeAction(action, params: json) sendResponse(sock, success: result.success, data: result.data, error: result.error) + Self.servicedRequest = true } private func sendResponse(_ sock: Int32, success: Bool, data: [String: Any]? = nil, error: String? = nil) { @@ -164,6 +176,15 @@ class ControlServer { case "get_last_error": return getLastError() + case "take_screenshot": + return takeScreenshot() + + case "get_touch_events": + return getTouchEvents() + + case "replay_touch_events": + return replayTouchEvents(params: params) + case "prepare_swiftui_source": return prepareSwiftUISource() @@ -268,6 +289,51 @@ class ControlServer { return .ok(["error": error]) } + private func takeScreenshot() -> ActionResult { + guard let client = InjectionServer.currentClient else { + return .fail("No connected client app") + } + guard let screenshot = client.requestScreenshot() else { + return .fail("Unable to capture screenshot from client app") + } + return .ok([ + "mimeType": screenshot.mimeType, + "data": screenshot.data.base64EncodedString(), + "bytes": screenshot.data.count + ]) + } + + private func getTouchEvents() -> ActionResult { + guard let client = InjectionServer.currentClient else { + return .fail("No connected client app") + } + let events = client.drainTouchEvents().compactMap { json -> Any? in + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) + } + return .ok(["events": events]) + } + + private func replayTouchEvents(params: [String: Any]) -> ActionResult { + guard let client = InjectionServer.currentClient else { + return .fail("No connected client app") + } + let payload: Any + if let events = params["events"] as? [Any] { + payload = ["events": events] + } else { + payload = params + } + guard JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + return .fail("Invalid touch event JSON payload") + } + client.replayTouchEvents(json) + let count = (payload as? [String: Any])?["events"] as? [Any] + return .ok(["events": count?.count ?? 0]) + } + private func prepareSwiftUISource() -> ActionResult { guard let lastSource = NextCompiler.lastSource else { return .fail("No source file currently being edited") @@ -305,4 +371,23 @@ class ControlServer { LogBuffer.shared.clear() return .ok() } + + private final class CompatibilityControlServer: SimpleSocket { + override func run() { + let response: [String: Any] = [ + "success": false, + "error": "InjectionNext now uses a Unix domain socket for MCP control. " + + "Upgrade your mcp-server or use the version bundled inside InjectionNext.app." + ] + guard let data = try? JSONSerialization.data(withJSONObject: response), + let json = String(data: data, encoding: .utf8), + let stream = fdopen(forMode: "w") else { + return + } + defer { fclose(stream) } + (json + "\n").withCString { + _ = fputs($0, stream) + } + } + } } diff --git a/App/InjectionNext/Info.plist b/App/InjectionNext/Info.plist index fbd83da5..c1280ef9 100644 --- a/App/InjectionNext/Info.plist +++ b/App/InjectionNext/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 14720 + 14807 LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion diff --git a/App/InjectionNext/InjectionServer.swift b/App/InjectionNext/InjectionServer.swift index b52775b5..df021932 100644 --- a/App/InjectionNext/InjectionServer.swift +++ b/App/InjectionNext/InjectionServer.swift @@ -38,6 +38,24 @@ class InjectionServer: SimpleSocket { var platform = "iPhoneSimulator" var arch = "arm64" var tmpPath = "/unset" + private final class PendingScreenshot { + let semaphore = DispatchSemaphore(value: 0) + var mimeType: String? + var data: Data? + } + private var pendingScreenshot: PendingScreenshot? + private var touchEvents = [String]() + + func drainTouchEvents() -> [String] { + Self.clientQueue.sync { + defer { touchEvents.removeAll() } + return touchEvents + } + } + + func replayTouchEvents(_ json: String) { + sendCommand(.replayEvents, with: json) + } @discardableResult class func alert(_ msg: String, cancel: String? = nil) -> Bool { @@ -70,6 +88,40 @@ class InjectionServer: SimpleSocket { } } + func requestScreenshot(timeout: DispatchTimeInterval = .seconds(10)) + -> (mimeType: String, data: Data)? { + let pending = PendingScreenshot() + var requestStarted = false + Self.clientQueue.sync { + if pendingScreenshot == nil { + pendingScreenshot = pending + requestStarted = writeCommand(InjectionCommand.screenshot.rawValue, with: nil) + if !requestStarted { + pendingScreenshot = nil + } + } + } + guard requestStarted else { + return nil + } + guard pending.semaphore.wait(timeout: .now() + timeout) == .success, + let mimeType = pending.mimeType, !mimeType.isEmpty, + let data = pending.data, !data.isEmpty else { + Self.clientQueue.sync { + if pendingScreenshot === pending { + pendingScreenshot = nil + } + } + return nil + } + Self.clientQueue.sync { + if pendingScreenshot === pending { + pendingScreenshot = nil + } + } + return (mimeType, data) + } + // Write message into Xcode console of client app. open func log(_ msg: String) { NSLog("\(APP_PREFIX)\(APP_NAME) \(msg)") @@ -180,6 +232,9 @@ class InjectionServer: SimpleSocket { Self.connected.append(self) AppDelegate.ui.setMenuIcon(.ok) ConfigStore.shared.sendEnvVars(to: self) + if Defaults.mcpServer { + self.sendCommand(.captureEvents, with: nil) + } } } else { error("**** Bad tmp ****") @@ -214,6 +269,28 @@ class InjectionServer: SimpleSocket { Reloader.appName = URL(fileURLWithPath: executable).lastPathComponent } + case .screenshotData: + guard let mimeType = readString(), let data = readData() else { + error("**** Bad screenshot ****") + return + } + Self.clientQueue.sync { + pendingScreenshot?.mimeType = mimeType + pendingScreenshot?.data = data + pendingScreenshot?.semaphore.signal() + } + case .touchEvent: + if let json = readString() { + Self.clientQueue.sync { + touchEvents.append(json) + } + } else { + error("**** Bad touch event ****") + } + case .replayComplete: + if readString() == nil { + error("**** Bad replay completion ****") + } case .detail: if let detail = readString() { setenv(INJECTION_DETAIL, detail, 1) diff --git a/App/InjectionNext/Views/NetworkSettingsView.swift b/App/InjectionNext/Views/NetworkSettingsView.swift index d5f7feda..b35e6f4c 100644 --- a/App/InjectionNext/Views/NetworkSettingsView.swift +++ b/App/InjectionNext/Views/NetworkSettingsView.swift @@ -9,6 +9,8 @@ import SwiftUI struct NetworkSettingsView: View { @ObservedObject var config: ConfigStore + @State private var controlServerRunning = ControlServer.shared != nil + @State private var controlServerActive = ControlServer.servicedRequest var body: some View { Form { @@ -20,7 +22,12 @@ struct NetworkSettingsView: View { } LabeledContent("Injection Port", value: config.injectionPort) - LabeledContent("Control Server Port", value: "\(config.controlPort)") + LabeledContent("Control Socket") { + Text(ControlServer.socketPath) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } } header: { Label("Connection", systemImage: "network") } footer: { @@ -38,6 +45,15 @@ struct NetworkSettingsView: View { Text(config.isClientConnected ? "Connected" : "Not Connected") } } + LabeledContent("Control Server") { + HStack(spacing: 6) { + Circle() + .fill(controlServerColor) + .frame(width: 8, height: 8) + Text(controlServerText) + } + } + .help("Local MCP control server socket") } header: { Label("Status", systemImage: "antenna.radiowaves.left.and.right") } @@ -53,5 +69,21 @@ struct NetworkSettingsView: View { } } .formStyle(.grouped) + .onAppear(perform: refreshControlServerStatus) + } + + private func refreshControlServerStatus() { + controlServerRunning = ControlServer.shared != nil + controlServerActive = ControlServer.servicedRequest + } + + private var controlServerText: String { + return !controlServerRunning ? "Stopped" : + controlServerActive ? "Active" : "Running" + } + + private var controlServerColor: Color { + return !controlServerRunning ? .gray : + controlServerActive ? .orange : .green } } diff --git a/App/InjectionNext/build_bundles.sh b/App/InjectionNext/build_bundles.sh index ae7cacef..f2964014 100755 --- a/App/InjectionNext/build_bundles.sh +++ b/App/InjectionNext/build_bundles.sh @@ -51,4 +51,12 @@ if [[ "$ACTION" = "install" ]]; then build_bundle tvOSDev AppleTVOS appletvos && build_bundle xrOSDev XROS xros fi && + +rsync -au \ + --exclude node_modules \ + --exclude .npm \ + --exclude 'npm-debug.log*' \ + --exclude .DS_Store \ + ../mcp-server "$CODESIGNING_FOLDER_PATH/Contents/Resources" && + exit 0 diff --git a/DLKit b/DLKit index 966a6ae1..19e7333d 160000 --- a/DLKit +++ b/DLKit @@ -1 +1 @@ -Subproject commit 966a6ae1d242a9932faf848aaa0291037f316446 +Subproject commit 19e7333d1bdbbf250411b9c257fc413f1866fb50 diff --git a/InjectionLite b/InjectionLite index 37497fec..4babfec7 160000 --- a/InjectionLite +++ b/InjectionLite @@ -1 +1 @@ -Subproject commit 37497fec752d6cdfb944e4da9608b820dee1a819 +Subproject commit 4babfec70772f80530a1f930e7a200f10e573f8e diff --git a/Package.swift b/Package.swift index 8cee02be..cf4e9513 100644 --- a/Package.swift +++ b/Package.swift @@ -43,5 +43,6 @@ let package = Package( .testTarget( name: "InjectionNextTests", dependencies: ["InjectionBundle"]), - ] + ], + swiftLanguageVersions: [.v5] ) diff --git a/README.md b/README.md index ce0cd5b0..2006afec 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ If an option is marked with an asterisk﹡ you need to be using copy_bundle.sh script for them to work as you might expect. Otherwise, if you are launching Xcode from within the app along with the InjectionNext Swift Package you need to re-launch Xcode when you change these settings and perform a clean build. -Versions 1.6.0+ contain an MCP server. Consult the file mcp-server/README.md. +The Resources directory of the app package of versions 2.0.1+ contains an MCP +server which is able to take snapshots of the client app's screen as well as +record and replay touch events. Consult the file mcp-server/README.md. If your app contains local Swift packages, add the following to their targets in Package.swift to be able to inject functions inside the package. diff --git a/Sources/InjectionNext/InjectionNext.swift b/Sources/InjectionNext/InjectionNext.swift index 467ddf2e..ad88679a 100644 --- a/Sources/InjectionNext/InjectionNext.swift +++ b/Sources/InjectionNext/InjectionNext.swift @@ -8,6 +8,11 @@ // #if DEBUG || !SWIFT_PACKAGE import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif #if canImport(InjectionImpl) import InjectionImpl #endif @@ -17,7 +22,20 @@ import InjectionImpl @objc(InjectionNext) open class InjectionNext: SimpleSocket { - + private let responseQueue = DispatchQueue(label: "InjectionNextResponses") + private func sendResponse(_ response: InjectionResponse, + with string: String? = nil, + data: Data? = nil, + extra: (() -> Void)? = nil) { + responseQueue.async { + _ = self.writeCommand(response.rawValue, with: string) + if let data = data { + self.write(data) + } + extra?() + } + } + override class open func error(_ message: String) -> Int32 { let msg = String(format: message, strerror(errno)) print(APP_PREFIX+APP_NAME+": "+msg) @@ -34,6 +52,39 @@ open class InjectionNext: SimpleSocket { log("⚠️ "+msg) } + private func screenshotData() -> Data? { + var data: Data? + let capture = { + #if canImport(UIKit) && !os(watchOS) + guard let window = UIApplication.shared.windows.first(where: { + $0.isKeyWindow + }) ?? UIApplication.shared.windows.first(where: { + !$0.isHidden && $0.alpha > 0 + }) else { return } + let renderer = UIGraphicsImageRenderer(bounds: window.bounds) + data = renderer.image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + }.pngData() + #elseif canImport(AppKit) + guard let window = NSApplication.shared.keyWindow ?? + NSApplication.shared.mainWindow ?? + NSApplication.shared.windows.first(where: { + $0.isVisible + }) else { return } + window.displayIfNeeded() + let bounds = window.contentView?.bounds ?? window.frame + guard let view = window.contentView, + let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { + return + } + view.cacheDisplay(in: bounds, to: rep) + data = rep.representation(using: .png, properties: [:]) + #endif + } + Thread.isMainThread ? capture() : DispatchQueue.main.sync(execute: capture) + return data + } + /// Connection from client app opened in ClientBoot.mm arrives here open override func runInBackground() { super.write(INJECTION_VERSION) @@ -78,25 +129,22 @@ open class InjectionNext: SimpleSocket { #endif // Let server side know the platform and architecture - writeCommand(InjectionResponse.platform.rawValue, with: platform) - super.write(arch) + sendResponse(.platform, with: platform) { + self.write(arch) + } if let projectRoot = getenv(INJECTION_PROJECT_ROOT) ?? getenv(BUILD_WORKSPACE_DIRECTORY) { - writeCommand(InjectionResponse.projectRoot.rawValue, - with: String(cString: projectRoot)) + sendResponse(.projectRoot, with: String(cString: projectRoot)) } - writeCommand(InjectionResponse.tmpPath.rawValue, with: NSTemporaryDirectory()) + sendResponse(.tmpPath, with: NSTemporaryDirectory()) if let detail = getenv(INJECTION_DETAIL) { - writeCommand(InjectionResponse.detail.rawValue, - with: String(cString: detail)) + sendResponse(.detail, with: String(cString: detail)) } if let bazelTarget = getenv(INJECTION_BAZEL_TARGET) { - writeCommand(InjectionResponse.bazelTarget.rawValue, - with: String(cString: bazelTarget)) + sendResponse(.bazelTarget, with: String(cString: bazelTarget)) } if let executable = Bundle.main.executablePath { - writeCommand(InjectionResponse.executable.rawValue, - with: executable) + sendResponse(.executable, with: executable) } Reloader.injectionQueue.sync { @@ -217,7 +265,9 @@ open class InjectionNext: SimpleSocket { } } + nonisolated(unsafe) static var target: ImageSymbols? + nonisolated(unsafe) static var tracer: STTracer = { existing, symname in var traced = existing if SwiftTrace.injectableSymbol(symname), @@ -258,10 +308,9 @@ open class InjectionNext: SimpleSocket { """) } } else { - writeCommand(InjectionResponse.unhide.rawValue, with: nil) + sendResponse(.unhide) } - writeCommand(succeeded ? InjectionResponse.injected.rawValue : - InjectionResponse.failed.rawValue, with: nil) + sendResponse(succeeded ? .injected : .failed) } while true { @@ -308,7 +357,7 @@ open class InjectionNext: SimpleSocket { return error("Unable to read metrics JSON") } if let data = metricsJSON.data(using: .utf8), - let metricsDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let metricsDict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let notificationName = metricsDict["notification_name"] as? String { NotificationCenter.default.post( name: NSNotification.Name(notificationName), @@ -316,6 +365,28 @@ open class InjectionNext: SimpleSocket { userInfo: metricsDict ) } + case .replayEvents: + guard let eventsJSON = readString() else { + return error("Unable to read touch events JSON") + } + #if canImport(UIKit) && !os(watchOS) + eventsJSON.withCString { + InjectionReplayTouchEventsJSON($0) + } + #endif + sendResponse(.replayComplete, with: eventsJSON) + case .captureEvents: + #if canImport(UIKit) && !os(watchOS) + InjectionInstallTouchEventCapture { [weak self] json in + self?.sendResponse(.touchEvent, with: String(cString: json)) + } + #endif + case .screenshot: + if let data = screenshotData() { + sendResponse(.screenshotData, with: "image/png", data: data) + } else { + sendResponse(.screenshotData, with: "", data: Data()) + } case .setenv: while let name = readString(), let value = readString() { setVariable(name: name, to: value, first: false) diff --git a/Sources/InjectionNextC/InjectionTouchEvents.mm b/Sources/InjectionNextC/InjectionTouchEvents.mm new file mode 100644 index 00000000..7846bcf6 --- /dev/null +++ b/Sources/InjectionNextC/InjectionTouchEvents.mm @@ -0,0 +1,315 @@ +// +// InjectionTouchEvents.mm +// InjectionNext +// +// Captures real UIKit touches as JSON and replays them using the same +// UITouch/UITouchesEvent strategy Remote used for interactive mirroring. +// + +#import "include/InjectionTouchEvents.h" +#import +#import +#import + +#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION +#import + +@interface UITouch (InjectionReplay) +- (void)setTimestamp:(NSTimeInterval)timestamp; +- (void)setInitialTouchTimestamp:(NSTimeInterval)timestamp; +- (void)setPhase:(NSInteger)phase; +- (void)setWindow:(UIWindow *)window; +- (void)setView:(UIView *)view; +- (void)setTapCount:(NSUInteger)tapCount; +- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)reset; +- (void)_setPathIndex:(NSInteger)index; +- (void)_setPathIdentity:(unsigned char)identity; +- (void)_setType:(NSInteger)type; +- (void)_setSenderID:(NSUInteger)senderID; +- (void)_setZGradient:(float)gradient; +- (void)_setEdgeType:(NSInteger)type; +- (void)_setEdgeAim:(NSUInteger)aim; +@end + +static InjectionTouchEventCallback injectionTouchEventCallback; +static BOOL injectionCaptureInstalled; +static BOOL injectionIsReplaying; +static NSMutableDictionary *injectionTouches; +static NSMutableDictionary *injectionTargets; +static UIEvent *injectionReplayEvent; + +static NSString *InjectionPhaseName(UITouchPhase phase) { + switch (phase) { + case UITouchPhaseBegan: return @"began"; + case UITouchPhaseMoved: return @"moved"; + case UITouchPhaseStationary: return @"stationary"; + case UITouchPhaseEnded: return @"ended"; + case UITouchPhaseCancelled: return @"cancelled"; + } + return @"cancelled"; +} + +static UITouchPhase InjectionPhaseFromName(NSString *name) { + if ([name isEqualToString:@"began"]) return UITouchPhaseBegan; + if ([name isEqualToString:@"moved"]) return UITouchPhaseMoved; + if ([name isEqualToString:@"stationary"]) return UITouchPhaseStationary; + if ([name isEqualToString:@"ended"]) return UITouchPhaseEnded; + if ([name isEqualToString:@"cancelled"]) return UITouchPhaseCancelled; + return UITouchPhaseCancelled; +} + +static UIWindow *InjectionKeyWindow(void) { + if (@available(iOS 13.0, tvOS 13.0, *)) { + for (UIWindowScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (![scene isKindOfClass:UIWindowScene.class] || + scene.activationState != UISceneActivationStateForegroundActive) + continue; + for (UIWindow *window in scene.windows) + if (window.isKeyWindow) + return window; + for (UIWindow *window in scene.windows) + if (!window.isHidden && window.alpha > 0.0) + return window; + } + } + for (UIWindow *window in UIApplication.sharedApplication.windows) + if (window.isKeyWindow) + return window; + return UIApplication.sharedApplication.windows.firstObject; +} + +static NSNumber *InjectionTouchID(UITouch *touch) { + return @((NSUInteger)touch.hash); +} + +static void InjectionSendTouchJSON(UIEvent *event) { + if (!injectionTouchEventCallback || injectionIsReplaying) + return; + + NSSet *touches = event.allTouches; + if (!touches.count) + return; + + UIWindow *window = touches.anyObject.window ?: InjectionKeyWindow(); + CGRect screenBounds = window.bounds; + CGFloat screenScale = 1.0; +#if !TARGET_OS_VISION + UIScreen *screen = window.screen ?: UIScreen.mainScreen; + screenBounds = screen.bounds; + screenScale = screen.scale; +#endif + NSMutableArray *encodedTouches = [NSMutableArray arrayWithCapacity:touches.count]; + UITouchPhase phase = touches.anyObject.phase; + + for (UITouch *touch in touches) { + CGPoint location = [touch locationInView:touch.window ?: window]; + phase = touch.phase; + [encodedTouches addObject:@{ + @"id": InjectionTouchID(touch), + @"x": @(location.x), + @"y": @(location.y), + @"phase": InjectionPhaseName(touch.phase), + @"tapCount": @(touch.tapCount) + }]; + } + + NSDictionary *payload = @{ + @"time": @(event.timestamp), + @"phase": InjectionPhaseName(phase), + @"touches": encodedTouches, + @"screen": @{ + @"width": @(screenBounds.size.width), + @"height": @(screenBounds.size.height), + @"scale": @(screenScale) + } + }; + NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; + if (!data) + return; + NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (json) + injectionTouchEventCallback(json.UTF8String); +} + +@implementation UIApplication (InjectionTouchEvents) +- (void)injection_sendEvent:(UIEvent *)event { + [self injection_sendEvent:event]; + InjectionSendTouchJSON(event); +} +@end + +static void InjectionConfigureTouch(UITouch *touch, UIView *target, + CGPoint location, UITouchPhase phase, + NSTimeInterval timestamp, NSUInteger tapCount, + NSUInteger identity) { + [touch setTimestamp:timestamp]; + if (phase == UITouchPhaseBegan) + [touch setInitialTouchTimestamp:timestamp]; + [touch setPhase:phase]; + [touch setWindow:target.window]; + [touch setView:target]; + [touch setTapCount:MAX(tapCount, 1)]; + [touch _setPathIndex:1]; + [touch _setPathIdentity:(unsigned char)(identity & 0xff)]; + [touch _setType:0]; + [touch _setSenderID:778835616971358211]; + [touch _setZGradient:0.0]; + [touch _setEdgeType:0]; + [touch _setEdgeAim:0]; + [touch _setLocationInWindow:location resetPrevious:phase == UITouchPhaseBegan]; +} + +static void InjectionShowTapIndicator(UIWindow *window, CGPoint location) { + static const CGFloat size = 44.0; + UIView *indicator = [[UIView alloc] initWithFrame:CGRectMake( + location.x - size / 2.0, location.y - size / 2.0, size, size)]; + indicator.userInteractionEnabled = NO; + indicator.layer.cornerRadius = size / 2.0; + indicator.layer.borderWidth = 2.0; + indicator.layer.borderColor = UIColor.systemBlueColor.CGColor; + indicator.backgroundColor = + [UIColor.systemBlueColor colorWithAlphaComponent:0.18]; + indicator.alpha = 0.9; + + [window addSubview:indicator]; + [UIView animateWithDuration:0.25 delay:0.15 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + indicator.alpha = 0.0; + indicator.transform = CGAffineTransformMakeScale(1.4, 1.4); + } completion:^(__unused BOOL finished) { + [indicator removeFromSuperview]; + }]; +} + +static void InjectionReplayEvent(NSDictionary *event) { + NSArray *touches = event[@"touches"]; + if (![touches isKindOfClass:NSArray.class] || touches.count == 0) + return; + + UIWindow *window = InjectionKeyWindow(); + if (!window) + return; + + if (!injectionTouches) + injectionTouches = [NSMutableDictionary new]; + if (!injectionTargets) + injectionTargets = [NSMutableDictionary new]; + if (!injectionReplayEvent) { + id touchesEvent = [NSClassFromString(@"UITouchesEvent") alloc]; + SEL initSelector = NSSelectorFromString(@"_init"); + injectionReplayEvent = + ((id (*)(id, SEL))objc_msgSend)(touchesEvent, initSelector); + } + + ((void (*)(id, SEL))objc_msgSend)(injectionReplayEvent, + NSSelectorFromString(@"_clearTouches")); + NSMutableSet *currentTouches = [NSMutableSet set]; + NSTimeInterval timestamp = [event[@"time"] doubleValue] ?: NSDate.timeIntervalSinceReferenceDate; + + for (NSDictionary *touchInfo in touches) { + if (![touchInfo isKindOfClass:NSDictionary.class]) + continue; + + NSNumber *touchID = touchInfo[@"id"] ?: @1; + CGPoint location = CGPointMake([touchInfo[@"x"] doubleValue], + [touchInfo[@"y"] doubleValue]); + UITouchPhase phase = InjectionPhaseFromName(touchInfo[@"phase"] ?: event[@"phase"]); + UIView *target = injectionTargets[touchID]; + + if (phase == UITouchPhaseBegan || !target) { + target = [window hitTest:location withEvent:nil] ?: window; + injectionTargets[touchID] = target; + } + + UITouch *touch = injectionTouches[touchID]; + if (!touch) { + touch = [UITouch new]; + injectionTouches[touchID] = touch; + } + + InjectionConfigureTouch(touch, target, location, phase, timestamp, + [touchInfo[@"tapCount"] unsignedIntegerValue], + touchID.unsignedIntegerValue); + if (phase == UITouchPhaseBegan) + InjectionShowTapIndicator(window, location); + ((void (*)(id, SEL, id, BOOL))objc_msgSend)( + injectionReplayEvent, + NSSelectorFromString(@"_addTouch:forDelayedDelivery:"), + touch, NO); + [currentTouches addObject:touch]; + + if (phase == UITouchPhaseEnded || phase == UITouchPhaseCancelled) { + [injectionTouches removeObjectForKey:touchID]; + [injectionTargets removeObjectForKey:touchID]; + } + } + + [UIApplication.sharedApplication sendEvent:injectionReplayEvent]; +} + +void InjectionInstallTouchEventCapture(InjectionTouchEventCallback callback) { + injectionTouchEventCallback = [callback copy]; + if (injectionCaptureInstalled) + return; + injectionCaptureInstalled = YES; + Method original = class_getInstanceMethod(UIApplication.class, @selector(sendEvent:)); + Method replacement = class_getInstanceMethod(UIApplication.class, @selector(injection_sendEvent:)); + if (original && replacement) + method_exchangeImplementations(original, replacement); +} + +void InjectionReplayTouchEventsJSON(const char *json) { + if (!json) + return; + NSData *data = [[NSData alloc] initWithBytes:json length:strlen(json)]; + id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + NSArray *events = nil; + if ([payload isKindOfClass:NSArray.class]) { + events = payload; + } else if ([payload isKindOfClass:NSDictionary.class]) { + events = [(NSDictionary *)payload objectForKey:@"events"]; + } + if (![events isKindOfClass:NSArray.class]) + return; + + NSTimeInterval firstTime = 0; + BOOL foundFirstTime = NO; + for (NSDictionary *event in events) { + if ([event isKindOfClass:NSDictionary.class]) { + firstTime = [event[@"time"] doubleValue]; + foundFirstTime = YES; + break; + } + } + + injectionIsReplaying = YES; + NSTimeInterval replayStart = NSDate.timeIntervalSinceReferenceDate; + NSTimeInterval lastDelay = 0; + for (NSDictionary *event in events) { + if (![event isKindOfClass:NSDictionary.class]) + continue; + NSTimeInterval eventTime = [event[@"time"] doubleValue]; + NSTimeInterval delay = foundFirstTime ? MAX(0, eventTime - firstTime) : lastDelay; + lastDelay = delay; + NSMutableDictionary *copy = [event mutableCopy]; + copy[@"time"] = @(replayStart + delay); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + InjectionReplayEvent(copy); + }); + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((lastDelay + 0.05) * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + injectionIsReplaying = NO; + }); + if (!events.count) + injectionIsReplaying = NO; +} + +#else + +void InjectionInstallTouchEventCapture(InjectionTouchEventCallback callback) {} +void InjectionReplayTouchEventsJSON(const char *json) {} + +#endif diff --git a/Sources/InjectionNextC/include/InjectionClient.h b/Sources/InjectionNextC/include/InjectionClient.h index 16ea2c76..587ce887 100644 --- a/Sources/InjectionNextC/include/InjectionClient.h +++ b/Sources/InjectionNextC/include/InjectionClient.h @@ -11,6 +11,7 @@ // #import +#import "InjectionTouchEvents.h" #define HOTRELOADING_PORT ":8887" #define HOTRELOADING_MULTICAST "239.255.255.239" @@ -43,6 +44,9 @@ typedef NS_ENUM(int, InjectionCommand) { InjectionMetrics, InjectionSetenv, InjectionEndenv, + InjectionScreenshot, + InjectionReplayEvents, + InjectionCaptureEvents, InjectionInvalid = 1000, @@ -60,6 +64,9 @@ typedef NS_ENUM(int, InjectionResponse) { InjectionDetail, InjectionBazelTarget, InjectionExecutable, + InjectionScreenshotData, + InjectionTouchEvent, + InjectionReplayComplete, InjectionExit = ~0 }; diff --git a/Sources/InjectionNextC/include/InjectionTouchEvents.h b/Sources/InjectionNextC/include/InjectionTouchEvents.h new file mode 100644 index 00000000..2fa9d737 --- /dev/null +++ b/Sources/InjectionNextC/include/InjectionTouchEvents.h @@ -0,0 +1,25 @@ +// +// InjectionTouchEvents.h +// InjectionNext +// +// Lightweight touch capture/replay hooks for MCP event macros. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^InjectionTouchEventCallback)(const char *_Nonnull json); + +#ifdef __cplusplus +extern "C" { +#endif + +void InjectionInstallTouchEventCapture(InjectionTouchEventCallback _Nonnull callback); +void InjectionReplayTouchEventsJSON(const char *_Nonnull json); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/SwiftRegex5 b/SwiftRegex5 index 84c3fa9a..c8d5d849 160000 --- a/SwiftRegex5 +++ b/SwiftRegex5 @@ -1 +1 @@ -Subproject commit 84c3fa9a180b2c02e65e9a61287445b0482a5efe +Subproject commit c8d5d849da2f79b955e41d32da372e3e894a890c diff --git a/SwiftTrace b/SwiftTrace index 3dd80b3a..4a86b290 160000 --- a/SwiftTrace +++ b/SwiftTrace @@ -1 +1 @@ -Subproject commit 3dd80b3aee1cbdd1ed503ef57145021a5d594d30 +Subproject commit 4a86b290c6b6d97af6487842d67f086d36093334 diff --git a/mcp-server/README.md b/mcp-server/README.md index 1d4fe91e..b1c9e929 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -91,18 +91,54 @@ You should see the InjectionNext icon in the menu bar. ```bash # Check status -echo '{"action":"status"}' | nc -w 3 localhost 8919 +echo '{"action":"status"}' | nc -U /tmp/InjectionNext-control.sock # Start watching a project -echo '{"action":"watch_project","path":"/path/to/your/project"}' | nc -w 3 localhost 8919 +echo '{"action":"watch_project","path":"/path/to/your/project"}' | nc -U /tmp/InjectionNext-control.sock # Read debug logs -echo '{"action":"get_logs"}' | nc -w 3 localhost 8919 +echo '{"action":"get_logs"}' | nc -U /tmp/InjectionNext-control.sock # Stop watching -echo '{"action":"stop_watching"}' | nc -w 3 localhost 8919 +echo '{"action":"stop_watching"}' | nc -U /tmp/InjectionNext-control.sock ``` +### Screenshot test from terminal + +This starts the MCP server over stdio, calls its `take_screenshot` tool, and writes the returned PNG: + +```bash +cd mcp-server +npm run screenshot -- /tmp/injection-screenshot.png +``` + +`take_screenshot` captures the connected client app, not the InjectionNext menu-bar app. Launch a target app that has the InjectionNext package/bundle loaded before running this command. + +### Touch event test from terminal + +Touch capture is enabled only when the ControlServer/MCP option is enabled and the client app connects. After changing the `mcpServer` default or rebuilding InjectionNext, restart InjectionNext and relaunch the target app so the client receives the capture command during connection. + +Fetch accumulated touch events, print them, save them, and clear the app buffer: + +```bash +cd mcp-server +npm run touch-events -- get /tmp/injection-events.json +``` + +Replay saved events into the connected client app: + +```bash +npm run touch-events -- replay /tmp/injection-events.json +``` + +Or fetch and immediately replay the same events: + +```bash +npm run touch-events -- roundtrip /tmp/injection-events.json +``` + +Events are JSON and preserve the recorded timing interval between touch events. Replayed touches show a temporary translucent circle in the client app. + ### Test from Cursor After configuring the MCP server, open Cursor and ask the AI: @@ -129,6 +165,9 @@ or: | `enable_devices` | Toggle device/simulator injection support | | `unhide_symbols` | Fix default-argument symbol visibility issues | | `get_last_error` | Get last compilation error | +| `take_screenshot` | Capture a PNG screenshot from the connected client app | +| `get_touch_events` | Fetch and clear accumulated touch event JSON from the connected client app | +| `replay_touch_events` | Replay captured touch event JSON in the connected client app | | `prepare_swiftui_source` | Add injection annotations to current SwiftUI file | | `prepare_swiftui_project` | Prepare all SwiftUI files in target | | `set_xcode_path` | Point to a different Xcode.app | @@ -138,11 +177,11 @@ or: ## Architecture ``` -┌─────────────┐ TCP :8919 ┌──────────────────────┐ +┌─────────────┐ Unix domain socket ┌──────────────────────┐ │ MCP Server │◄───────────────────────►│ InjectionNext.app │ │ (Node.js) │ JSON commands/resp │ ┌─────────────────┐ │ │ │ │ │ ControlServer │ │ -│ stdio ↕ │ │ │ (TCP listener) │ │ +│ stdio ↕ │ │ │ (UDS listener) │ │ │ │ │ └────────┬────────┘ │ │ Cursor / │ │ │ │ │ AI Agent │ │ ┌────────▼────────┐ │ @@ -159,15 +198,20 @@ or: ## Troubleshooting -**"Cannot connect to InjectionNext on port 8919"** +**"Cannot connect to InjectionNext control socket"** - Make sure InjectionNext.app is running (check menu bar icon) - Make sure you built the version with ControlServer support (from this repo) -- Check: `lsof -i :8919` should show InjectionNext listening +- Check: `ls -l /tmp/InjectionNext-control.sock` should show the socket -**Port already in use** +**Stale socket file** - Kill any stale InjectionNext processes: `pkill -f InjectionNext` - Re-launch the app **Logs are empty** - Logs only capture events that happen while the app is running - Try `watch_project` to generate some activity, then `get_logs` + +**Touch events are empty** +- Make sure `mcpServer` was enabled before launching InjectionNext +- Restart InjectionNext and relaunch the target app so it reconnects +- Touch capture starts only after the client receives the MCP capture command during connection diff --git a/mcp-server/index.js b/mcp-server/index.js index a2cd34cb..16c7e1c5 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -5,18 +5,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod"; import net from "node:net"; -const CONTROL_PORT = 8919; -const CONTROL_HOST = "127.0.0.1"; +const CONTROL_SOCKET = process.env.INJECTION_CONTROL_SOCKET || + "/tmp/InjectionNext-control.sock"; -function sendCommand(action, params = {}) { +function sendCommand(action, params = {}, timeoutMs = 5000) { return new Promise((resolve, reject) => { const client = new net.Socket(); const timeout = setTimeout(() => { client.destroy(); reject(new Error("Connection timed out. Is InjectionNext running with ControlServer?")); - }, 5000); + }, timeoutMs); - client.connect(CONTROL_PORT, CONTROL_HOST, () => { + client.connect(CONTROL_SOCKET, () => { const payload = JSON.stringify({ action, ...params }) + "\n"; client.write(payload); }); @@ -37,9 +37,9 @@ function sendCommand(action, params = {}) { client.on("error", (err) => { clearTimeout(timeout); - if (err.code === "ECONNREFUSED") { + if (["ENOENT", "ECONNREFUSED"].includes(err.code)) { reject(new Error( - "Cannot connect to InjectionNext on port 8919. " + + `Cannot connect to InjectionNext control socket at ${CONTROL_SOCKET}. ` + "Make sure InjectionNext.app is running (build with ControlServer support)." )); } else { @@ -144,6 +144,51 @@ server.tool( } ); +server.tool( + "take_screenshot", + "Capture a screenshot from the currently connected client app and return it as PNG image data", + {}, + async () => { + const result = await sendCommand("take_screenshot", {}, 15000); + if (!result.success) { + return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true }; + } + const data = result.data?.data; + const mimeType = result.data?.mimeType ?? "image/png"; + if (!data) { + return { content: [{ type: "text", text: "Error: Screenshot response did not include image data" }], isError: true }; + } + return { + content: [ + { type: "image", data, mimeType }, + { type: "text", text: `Captured ${result.data?.bytes ?? 0} bytes` }, + ], + }; + } +); + +server.tool( + "get_touch_events", + "Fetch accumulated touch events from the connected client app. The InjectionNext app clears the event buffer after returning it.", + {}, + async () => { + const result = await sendCommand("get_touch_events"); + return formatResponse(result); + } +); + +server.tool( + "replay_touch_events", + "Replay previously captured touch events in the connected client app", + { + events: z.array(z.any()).describe("Touch event JSON array returned by get_touch_events"), + }, + async ({ events }) => { + const result = await sendCommand("replay_touch_events", { events }, 15000); + return formatResponse(result); + } +); + server.tool( "prepare_swiftui_source", "Automatically add .enableInjection() and @ObserveInjection to the currently edited SwiftUI source file", diff --git a/mcp-server/package.json b/mcp-server/package.json index f5a52ebd..6bc2ceea 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -11,7 +11,9 @@ "injection-next-mcp": "./index.js" }, "scripts": { - "start": "node index.js" + "start": "node index.js", + "screenshot": "node take-screenshot.js", + "touch-events": "node touch-events.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/mcp-server/take-screenshot.js b/mcp-server/take-screenshot.js new file mode 100644 index 00000000..984b8e15 --- /dev/null +++ b/mcp-server/take-screenshot.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function outputPathFromArgs() { + const arg = process.argv[2]; + if (arg) return path.resolve(arg); + + const stamp = new Date().toISOString() + .replace(/[:.]/g, "-") + .replace("T", "_") + .replace("Z", ""); + return path.resolve(`injection-screenshot-${stamp}.png`); +} + +const client = new Client({ + name: "injection-next-screenshot-cli", + version: "1.0.0", +}); + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(__dirname, "index.js")], + cwd: __dirname, + stderr: "inherit", +}); + +function textContent(result) { + return (result.content || []) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +function parseToolJSON(result, toolName) { + const text = textContent(result); + if (result.isError) { + throw new Error(text || `${toolName} MCP tool returned an error`); + } + if (!text) { + return {}; + } + try { + return JSON.parse(text); + } catch { + throw new Error(`${toolName} returned non-JSON text:\n${text}`); + } +} + +async function main() { + await client.connect(transport); + + const status = await client.callTool({ + name: "get_status", + arguments: {}, + }); + const statusData = parseToolJSON(status, "get_status"); + if (!statusData.has_connected_client) { + throw new Error( + "No connected client app. Launch an app that has the InjectionNext " + + "package/bundle loaded, then retry once InjectionNext reports a " + + "connected client." + ); + } + + const result = await client.callTool({ + name: "take_screenshot", + arguments: {}, + }); + + if (result.isError) { + const message = textContent(result) || + "Screenshot MCP tool returned an error"; + throw new Error(message); + } + + const image = (result.content || []).find((item) => item.type === "image"); + if (!image || !image.data) { + throw new Error("Screenshot MCP tool did not return image data"); + } + + const output = outputPathFromArgs(); + await fs.writeFile(output, Buffer.from(image.data, "base64")); + + const bytes = Buffer.byteLength(image.data, "base64"); + console.log(`Wrote ${bytes} bytes (${image.mimeType || "image/png"}) to ${output}`); +} + +try { + await main(); +} finally { + await client.close(); +} diff --git a/mcp-server/touch-events.js b/mcp-server/touch-events.js new file mode 100644 index 00000000..b292fd64 --- /dev/null +++ b/mcp-server/touch-events.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function usage() { + return `Usage: + node touch-events.js get [events.json] + node touch-events.js replay + node touch-events.js roundtrip [events.json] + +Commands: + get Fetch accumulated touch events, print them, and clear the app buffer. + replay Replay touch events from a JSON file. + roundtrip Fetch events, optionally save them, then immediately replay them.`; +} + +function textContent(result) { + return (result.content || []) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +function parseToolJSON(result, toolName) { + const text = textContent(result); + if (result.isError) { + throw new Error(text || `${toolName} MCP tool returned an error`); + } + if (!text) { + return {}; + } + try { + return JSON.parse(text); + } catch { + throw new Error(`${toolName} returned non-JSON text:\n${text}`); + } +} + +function normalizeEvents(data) { + if (Array.isArray(data)) { + return { events: data }; + } + if (Array.isArray(data.events)) { + return data; + } + throw new Error("Touch event payload must be an array or an object with an events array"); +} + +async function readEventsFile(file) { + const text = await fs.readFile(path.resolve(file), "utf8"); + return normalizeEvents(JSON.parse(text)); +} + +async function writeEventsFile(file, events) { + if (!file) return; + await fs.writeFile(path.resolve(file), JSON.stringify(events, null, 2) + "\n"); +} + +const command = process.argv[2] || "roundtrip"; +const file = process.argv[3]; + +if (!["get", "replay", "roundtrip", "-h", "--help", "help"].includes(command)) { + console.error(usage()); + process.exit(2); +} + +if (["-h", "--help", "help"].includes(command)) { + console.log(usage()); + process.exit(0); +} + +if (command === "replay" && !file) { + console.error(usage()); + process.exit(2); +} + +let client; + +async function ensureClientApp() { + const status = await client.callTool({ + name: "get_status", + arguments: {}, + }); + const statusData = parseToolJSON(status, "get_status"); + if (!statusData.has_connected_client) { + throw new Error( + "No connected client app. Launch an app that has the InjectionNext " + + "package/bundle loaded, then retry once InjectionNext reports a " + + "connected client." + ); + } +} + +async function getEvents() { + const result = await client.callTool({ + name: "get_touch_events", + arguments: {}, + }); + return normalizeEvents(parseToolJSON(result, "get_touch_events")); +} + +async function replayEvents(events) { + const result = await client.callTool({ + name: "replay_touch_events", + arguments: events, + }); + return parseToolJSON(result, "replay_touch_events"); +} + +async function main() { + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { StdioClientTransport } = + await import("@modelcontextprotocol/sdk/client/stdio.js"); + + client = new Client({ + name: "injection-next-touch-events-cli", + version: "1.0.0", + }); + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(__dirname, "index.js")], + cwd: __dirname, + stderr: "inherit", + }); + + await client.connect(transport); + await ensureClientApp(); + + if (command === "replay") { + const events = await readEventsFile(file); + console.log(JSON.stringify(events, null, 2)); + const result = await replayEvents(events); + console.log(JSON.stringify(result, null, 2)); + return; + } + + const events = await getEvents(); + console.log(JSON.stringify(events, null, 2)); + await writeEventsFile(file, events); + + if (command === "roundtrip") { + const result = await replayEvents(events); + console.log(JSON.stringify(result, null, 2)); + } +} + +try { + await main(); +} finally { + await client?.close(); +}