From 2f69c200eaad59cda59ead7a762d90d56b798136 Mon Sep 17 00:00:00 2001
From: John Holdsworth <1786033+johnno1962@users.noreply.github.com>
Date: Sat, 16 May 2026 10:39:12 +0200
Subject: [PATCH 1/7] nonisolated(unsafe) for those who celebrate.
---
App/InjectionNext/Info.plist | 2 +-
DLKit | 2 +-
InjectionLite | 2 +-
Package.swift | 3 ++-
Sources/InjectionNext/InjectionNext.swift | 4 +++-
SwiftRegex5 | 2 +-
SwiftTrace | 2 +-
7 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/App/InjectionNext/Info.plist b/App/InjectionNext/Info.plist
index fbd83da5..89ec1116 100644
--- a/App/InjectionNext/Info.plist
+++ b/App/InjectionNext/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
CFBundleVersion
- 14720
+ 14738
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
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..7d11d925 160000
--- a/InjectionLite
+++ b/InjectionLite
@@ -1 +1 @@
-Subproject commit 37497fec752d6cdfb944e4da9608b820dee1a819
+Subproject commit 7d11d92533a3febf83f3d5dd2553cf04773aa292
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/Sources/InjectionNext/InjectionNext.swift b/Sources/InjectionNext/InjectionNext.swift
index 467ddf2e..b4931cd6 100644
--- a/Sources/InjectionNext/InjectionNext.swift
+++ b/Sources/InjectionNext/InjectionNext.swift
@@ -217,7 +217,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),
@@ -308,7 +310,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),
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
From 1a7806646038ab3c95b1cedc78f33469d0fa5c28 Mon Sep 17 00:00:00 2001
From: John Holdsworth <1786033+johnno1962@users.noreply.github.com>
Date: Sat, 16 May 2026 15:35:06 +0200
Subject: [PATCH 2/7] Fix Bazel rules_xcodeproj path rewriting #25
---
App/InjectionNext.xcodeproj/project.pbxproj | 4 ++--
App/InjectionNext/Info.plist | 2 +-
InjectionLite | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/App/InjectionNext.xcodeproj/project.pbxproj b/App/InjectionNext.xcodeproj/project.pbxproj
index 93587924..bb3e55b5 100644
--- a/App/InjectionNext.xcodeproj/project.pbxproj
+++ b/App/InjectionNext.xcodeproj/project.pbxproj
@@ -1122,7 +1122,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 +1144,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/Info.plist b/App/InjectionNext/Info.plist
index 89ec1116..893236ab 100644
--- a/App/InjectionNext/Info.plist
+++ b/App/InjectionNext/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
CFBundleVersion
- 14738
+ 14740
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
diff --git a/InjectionLite b/InjectionLite
index 7d11d925..c63338bd 160000
--- a/InjectionLite
+++ b/InjectionLite
@@ -1 +1 @@
-Subproject commit 7d11d92533a3febf83f3d5dd2553cf04773aa292
+Subproject commit c63338bda8b512b482368c3bd4900dd1cfc1d6f2
From 956d72c10038dffed61a851a283454f1e4dfef4a Mon Sep 17 00:00:00 2001
From: John Holdsworth <1786033+johnno1962@users.noreply.github.com>
Date: Tue, 19 May 2026 20:07:47 +0200
Subject: [PATCH 3/7] Screenshot MCP function.
---
App/InjectionNext.xcodeproj/project.pbxproj | 21 +++++
App/InjectionNext/ControlServer.swift | 17 ++++
App/InjectionNext/Info.plist | 2 +-
App/InjectionNext/InjectionServer.swift | 41 +++++++++
App/InjectionNext/build_bundles.sh | 3 +
InjectionLite | 2 +-
Sources/InjectionNext/InjectionNext.swift | 48 +++++++++++
.../InjectionNextC/include/InjectionClient.h | 2 +
mcp-server/README.md | 12 +++
mcp-server/index.js | 27 +++++-
mcp-server/package.json | 3 +-
mcp-server/take-screenshot.js | 85 +++++++++++++++++++
12 files changed, 258 insertions(+), 5 deletions(-)
create mode 100644 mcp-server/take-screenshot.js
diff --git a/App/InjectionNext.xcodeproj/project.pbxproj b/App/InjectionNext.xcodeproj/project.pbxproj
index bb3e55b5..3a5b09b5 100644
--- a/App/InjectionNext.xcodeproj/project.pbxproj
+++ b/App/InjectionNext.xcodeproj/project.pbxproj
@@ -257,6 +257,12 @@
BB67DBB31FB0CDA8000EAC8A /* SimpleSocket.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = SimpleSocket.mm; path = ../../Sources/InjectionNextC/SimpleSocket.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 +462,7 @@
BBCA01FF1FB0F10300E45F0F /* InjectionNext */,
BBDD84172C4FE79E000F3124 /* InjectionBundle */,
BB85A4252D6B9C5300FA29D0 /* feedcommands */,
+ BB7AC1582FBCEDEF009269AF /* mcp-server */,
BB439B661FABA64300B4F50B /* Products */,
BBB040621FB1798A007DDD0A /* Frameworks */,
);
@@ -519,6 +526,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 = (
diff --git a/App/InjectionNext/ControlServer.swift b/App/InjectionNext/ControlServer.swift
index 845ac053..a174cceb 100644
--- a/App/InjectionNext/ControlServer.swift
+++ b/App/InjectionNext/ControlServer.swift
@@ -164,6 +164,9 @@ class ControlServer {
case "get_last_error":
return getLastError()
+ case "take_screenshot":
+ return takeScreenshot()
+
case "prepare_swiftui_source":
return prepareSwiftUISource()
@@ -268,6 +271,20 @@ 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 prepareSwiftUISource() -> ActionResult {
guard let lastSource = NextCompiler.lastSource else {
return .fail("No source file currently being edited")
diff --git a/App/InjectionNext/Info.plist b/App/InjectionNext/Info.plist
index 893236ab..d82d8622 100644
--- a/App/InjectionNext/Info.plist
+++ b/App/InjectionNext/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
CFBundleVersion
- 14740
+ 14752
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
diff --git a/App/InjectionNext/InjectionServer.swift b/App/InjectionNext/InjectionServer.swift
index b52775b5..7482317a 100644
--- a/App/InjectionNext/InjectionServer.swift
+++ b/App/InjectionNext/InjectionServer.swift
@@ -38,6 +38,12 @@ 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?
@discardableResult
class func alert(_ msg: String, cancel: String? = nil) -> Bool {
@@ -70,6 +76,31 @@ class InjectionServer: SimpleSocket {
}
}
+ func requestScreenshot(timeout: DispatchTimeInterval = .seconds(10))
+ -> (mimeType: String, data: Data)? {
+ let pending = PendingScreenshot()
+ Self.clientQueue.sync {
+ pendingScreenshot = pending
+ _ = writeCommand(InjectionCommand.screenshot.rawValue, with: 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)")
@@ -214,6 +245,16 @@ 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 .detail:
if let detail = readString() {
setenv(INJECTION_DETAIL, detail, 1)
diff --git a/App/InjectionNext/build_bundles.sh b/App/InjectionNext/build_bundles.sh
index ae7cacef..10b95ffc 100755
--- a/App/InjectionNext/build_bundles.sh
+++ b/App/InjectionNext/build_bundles.sh
@@ -51,4 +51,7 @@ if [[ "$ACTION" = "install" ]]; then
build_bundle tvOSDev AppleTVOS appletvos &&
build_bundle xrOSDev XROS xros
fi &&
+
+rsync -au ../mcp-server "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&
+
exit 0
diff --git a/InjectionLite b/InjectionLite
index c63338bd..4babfec7 160000
--- a/InjectionLite
+++ b/InjectionLite
@@ -1 +1 @@
-Subproject commit c63338bda8b512b482368c3bd4900dd1cfc1d6f2
+Subproject commit 4babfec70772f80530a1f930e7a200f10e573f8e
diff --git a/Sources/InjectionNext/InjectionNext.swift b/Sources/InjectionNext/InjectionNext.swift
index b4931cd6..338f751d 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
@@ -34,6 +39,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)
@@ -318,6 +356,16 @@ open class InjectionNext: SimpleSocket {
userInfo: metricsDict
)
}
+ case .screenshot:
+ if let data = screenshotData() {
+ writeCommand(InjectionResponse.screenshotData.rawValue,
+ with: "image/png")
+ write(data)
+ } else {
+ writeCommand(InjectionResponse.screenshotData.rawValue,
+ with: "")
+ write(Data())
+ }
case .setenv:
while let name = readString(), let value = readString() {
setVariable(name: name, to: value, first: false)
diff --git a/Sources/InjectionNextC/include/InjectionClient.h b/Sources/InjectionNextC/include/InjectionClient.h
index 16ea2c76..2542c34a 100644
--- a/Sources/InjectionNextC/include/InjectionClient.h
+++ b/Sources/InjectionNextC/include/InjectionClient.h
@@ -43,6 +43,7 @@ typedef NS_ENUM(int, InjectionCommand) {
InjectionMetrics,
InjectionSetenv,
InjectionEndenv,
+ InjectionScreenshot,
InjectionInvalid = 1000,
@@ -60,6 +61,7 @@ typedef NS_ENUM(int, InjectionResponse) {
InjectionDetail,
InjectionBazelTarget,
InjectionExecutable,
+ InjectionScreenshotData,
InjectionExit = ~0
};
diff --git a/mcp-server/README.md b/mcp-server/README.md
index 1d4fe91e..e03fc631 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -103,6 +103,17 @@ echo '{"action":"get_logs"}' | nc -w 3 localhost 8919
echo '{"action":"stop_watching"}' | nc -w 3 localhost 8919
```
+### 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.
+
### Test from Cursor
After configuring the MCP server, open Cursor and ask the AI:
@@ -129,6 +140,7 @@ 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 |
| `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 |
diff --git a/mcp-server/index.js b/mcp-server/index.js
index a2cd34cb..bfe1462d 100755
--- a/mcp-server/index.js
+++ b/mcp-server/index.js
@@ -8,13 +8,13 @@ import net from "node:net";
const CONTROL_PORT = 8919;
const CONTROL_HOST = "127.0.0.1";
-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, () => {
const payload = JSON.stringify({ action, ...params }) + "\n";
@@ -144,6 +144,29 @@ 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(
"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..e4a1c576 100644
--- a/mcp-server/package.json
+++ b/mcp-server/package.json
@@ -11,7 +11,8 @@
"injection-next-mcp": "./index.js"
},
"scripts": {
- "start": "node index.js"
+ "start": "node index.js",
+ "screenshot": "node take-screenshot.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..594f6c5e
--- /dev/null
+++ b/mcp-server/take-screenshot.js
@@ -0,0 +1,85 @@
+#!/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");
+}
+
+async function main() {
+ await client.connect(transport);
+
+ const status = await client.callTool({
+ name: "get_status",
+ arguments: {},
+ });
+ const statusText = textContent(status);
+ const statusData = statusText ? JSON.parse(statusText) : {};
+ 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();
+}
From 7ee0bc406557bc432abf00db844f5b8fbb513681 Mon Sep 17 00:00:00 2001
From: John Holdsworth <1786033+johnno1962@users.noreply.github.com>
Date: Wed, 20 May 2026 13:17:26 +0200
Subject: [PATCH 4/7] Touch capture/replay.
---
App/InjectionNext.xcodeproj/project.pbxproj | 6 +
App/InjectionNext/ControlServer.swift | 37 +++
App/InjectionNext/Info.plist | 2 +-
App/InjectionNext/InjectionServer.swift | 27 ++
README.md | 4 +-
Sources/InjectionNext/InjectionNext.swift | 63 ++--
.../InjectionNextC/InjectionTouchEvents.mm | 310 ++++++++++++++++++
.../InjectionNextC/include/InjectionClient.h | 5 +
.../include/InjectionTouchEvents.h | 25 ++
mcp-server/README.md | 32 ++
mcp-server/index.js | 22 ++
mcp-server/package.json | 3 +-
mcp-server/touch-events.js | 156 +++++++++
13 files changed, 668 insertions(+), 24 deletions(-)
create mode 100644 Sources/InjectionNextC/InjectionTouchEvents.mm
create mode 100644 Sources/InjectionNextC/include/InjectionTouchEvents.h
create mode 100644 mcp-server/touch-events.js
diff --git a/App/InjectionNext.xcodeproj/project.pbxproj b/App/InjectionNext.xcodeproj/project.pbxproj
index 3a5b09b5..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,6 +256,8 @@
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 = ""; };
@@ -588,6 +591,8 @@
isa = PBXGroup;
children = (
BB42F9302EB63AAF00FDBBCC /* InjectionImplC.h */,
+ BBF00CFF2FBCE00100F0D001 /* InjectionTouchEvents.h */,
+ BBF00D002FBCE00100F0D001 /* InjectionTouchEvents.mm */,
BBDD84592C4FF645000F3124 /* ClientBoot.mm */,
BBDD843A2C4FE892000F3124 /* InjectionBoot.mm */,
BBDD84182C4FE7E6000F3124 /* InjectionNext.swift */,
@@ -914,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 */,
diff --git a/App/InjectionNext/ControlServer.swift b/App/InjectionNext/ControlServer.swift
index a174cceb..f75c2c5a 100644
--- a/App/InjectionNext/ControlServer.swift
+++ b/App/InjectionNext/ControlServer.swift
@@ -167,6 +167,12 @@ class ControlServer {
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()
@@ -285,6 +291,37 @@ class ControlServer {
])
}
+ 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")
diff --git a/App/InjectionNext/Info.plist b/App/InjectionNext/Info.plist
index d82d8622..a4c6c490 100644
--- a/App/InjectionNext/Info.plist
+++ b/App/InjectionNext/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
CFBundleVersion
- 14752
+ 14776
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
diff --git a/App/InjectionNext/InjectionServer.swift b/App/InjectionNext/InjectionServer.swift
index 7482317a..7e6af924 100644
--- a/App/InjectionNext/InjectionServer.swift
+++ b/App/InjectionNext/InjectionServer.swift
@@ -44,6 +44,18 @@ class InjectionServer: SimpleSocket {
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 {
@@ -211,6 +223,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 ****")
@@ -255,6 +270,18 @@ class InjectionServer: SimpleSocket {
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/README.md b/README.md
index ce0cd5b0..903d2839 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+ contain 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 338f751d..ad88679a 100644
--- a/Sources/InjectionNext/InjectionNext.swift
+++ b/Sources/InjectionNext/InjectionNext.swift
@@ -22,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)
@@ -116,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 {
@@ -298,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 {
@@ -356,15 +365,27 @@ 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() {
- writeCommand(InjectionResponse.screenshotData.rawValue,
- with: "image/png")
- write(data)
+ sendResponse(.screenshotData, with: "image/png", data: data)
} else {
- writeCommand(InjectionResponse.screenshotData.rawValue,
- with: "")
- write(Data())
+ sendResponse(.screenshotData, with: "", data: Data())
}
case .setenv:
while let name = readString(), let value = readString() {
diff --git a/Sources/InjectionNextC/InjectionTouchEvents.mm b/Sources/InjectionNextC/InjectionTouchEvents.mm
new file mode 100644
index 00000000..2cfafc91
--- /dev/null
+++ b/Sources/InjectionNextC/InjectionTouchEvents.mm
@@ -0,0 +1,310 @@
+//
+// 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 event = [NSClassFromString(@"UITouchesEvent") alloc];
+ SEL initSelector = NSSelectorFromString(@"_init");
+ injectionReplayEvent =
+ ((id (*)(id, SEL))objc_msgSend)(event, 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 = [payload isKindOfClass:NSArray.class] ? payload : [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 2542c34a..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"
@@ -44,6 +45,8 @@ typedef NS_ENUM(int, InjectionCommand) {
InjectionSetenv,
InjectionEndenv,
InjectionScreenshot,
+ InjectionReplayEvents,
+ InjectionCaptureEvents,
InjectionInvalid = 1000,
@@ -62,6 +65,8 @@ typedef NS_ENUM(int, InjectionResponse) {
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/mcp-server/README.md b/mcp-server/README.md
index e03fc631..2b492b36 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -114,6 +114,31 @@ 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:
@@ -141,6 +166,8 @@ or:
| `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 |
@@ -183,3 +210,8 @@ or:
**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 bfe1462d..e7ddc492 100755
--- a/mcp-server/index.js
+++ b/mcp-server/index.js
@@ -167,6 +167,28 @@ server.tool(
}
);
+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 e4a1c576..6bc2ceea 100644
--- a/mcp-server/package.json
+++ b/mcp-server/package.json
@@ -12,7 +12,8 @@
},
"scripts": {
"start": "node index.js",
- "screenshot": "node take-screenshot.js"
+ "screenshot": "node take-screenshot.js",
+ "touch-events": "node touch-events.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
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();
+}
From 1184d6bba91dbc6d09e572cf0a4f7484fbebedc1 Mon Sep 17 00:00:00 2001
From: John Holdsworth <1786033+johnno1962@users.noreply.github.com>
Date: Wed, 20 May 2026 19:16:44 +0200
Subject: [PATCH 5/7] Post review.
---
App/InjectionNext/Info.plist | 2 +-
App/InjectionNext/InjectionServer.swift | 13 +++++++++++--
App/InjectionNext/build_bundles.sh | 7 ++++++-
README.md | 2 +-
Sources/InjectionNextC/InjectionTouchEvents.mm | 11 ++++++++---
mcp-server/take-screenshot.js | 18 ++++++++++++++++--
6 files changed, 43 insertions(+), 10 deletions(-)
diff --git a/App/InjectionNext/Info.plist b/App/InjectionNext/Info.plist
index a4c6c490..eaea60ed 100644
--- a/App/InjectionNext/Info.plist
+++ b/App/InjectionNext/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
CFBundleVersion
- 14776
+ 14777
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
diff --git a/App/InjectionNext/InjectionServer.swift b/App/InjectionNext/InjectionServer.swift
index 7e6af924..df021932 100644
--- a/App/InjectionNext/InjectionServer.swift
+++ b/App/InjectionNext/InjectionServer.swift
@@ -91,9 +91,18 @@ class InjectionServer: SimpleSocket {
func requestScreenshot(timeout: DispatchTimeInterval = .seconds(10))
-> (mimeType: String, data: Data)? {
let pending = PendingScreenshot()
+ var requestStarted = false
Self.clientQueue.sync {
- pendingScreenshot = pending
- _ = writeCommand(InjectionCommand.screenshot.rawValue, with: nil)
+ 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,
diff --git a/App/InjectionNext/build_bundles.sh b/App/InjectionNext/build_bundles.sh
index 10b95ffc..f2964014 100755
--- a/App/InjectionNext/build_bundles.sh
+++ b/App/InjectionNext/build_bundles.sh
@@ -52,6 +52,11 @@ if [[ "$ACTION" = "install" ]]; then
build_bundle xrOSDev XROS xros
fi &&
-rsync -au ../mcp-server "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&
+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/README.md b/README.md
index 903d2839..2006afec 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ 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.
-The Resources directory of the app package of versions 2.0.1+ contain an MCP
+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.
diff --git a/Sources/InjectionNextC/InjectionTouchEvents.mm b/Sources/InjectionNextC/InjectionTouchEvents.mm
index 2cfafc91..7846bcf6 100644
--- a/Sources/InjectionNextC/InjectionTouchEvents.mm
+++ b/Sources/InjectionNextC/InjectionTouchEvents.mm
@@ -196,10 +196,10 @@ static void InjectionReplayEvent(NSDictionary *event) {
if (!injectionTargets)
injectionTargets = [NSMutableDictionary new];
if (!injectionReplayEvent) {
- id event = [NSClassFromString(@"UITouchesEvent") alloc];
+ id touchesEvent = [NSClassFromString(@"UITouchesEvent") alloc];
SEL initSelector = NSSelectorFromString(@"_init");
injectionReplayEvent =
- ((id (*)(id, SEL))objc_msgSend)(event, initSelector);
+ ((id (*)(id, SEL))objc_msgSend)(touchesEvent, initSelector);
}
((void (*)(id, SEL))objc_msgSend)(injectionReplayEvent,
@@ -264,7 +264,12 @@ void InjectionReplayTouchEventsJSON(const char *json) {
return;
NSData *data = [[NSData alloc] initWithBytes:json length:strlen(json)];
id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
- NSArray *events = [payload isKindOfClass:NSArray.class] ? payload : [payload objectForKey:@"events"];
+ 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;
diff --git a/mcp-server/take-screenshot.js b/mcp-server/take-screenshot.js
index 594f6c5e..984b8e15 100644
--- a/mcp-server/take-screenshot.js
+++ b/mcp-server/take-screenshot.js
@@ -38,6 +38,21 @@ function textContent(result) {
.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);
@@ -45,8 +60,7 @@ async function main() {
name: "get_status",
arguments: {},
});
- const statusText = textContent(status);
- const statusData = statusText ? JSON.parse(statusText) : {};
+ 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 " +
From f7e16753342f217866f59a566633eadf4db6407f Mon Sep 17 00:00:00 2001
From: John Holdsworth <1786033+johnno1962@users.noreply.github.com>
Date: Thu, 21 May 2026 17:08:24 +0200
Subject: [PATCH 6/7] ControlServer indicator.
---
App/InjectionNext/ControlServer.swift | 2 ++
App/InjectionNext/Info.plist | 2 +-
.../Views/NetworkSettingsView.swift | 27 +++++++++++++++++++
3 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/App/InjectionNext/ControlServer.swift b/App/InjectionNext/ControlServer.swift
index f75c2c5a..a0551477 100644
--- a/App/InjectionNext/ControlServer.swift
+++ b/App/InjectionNext/ControlServer.swift
@@ -15,6 +15,7 @@ class ControlServer {
static let port: UInt16 = 8919
static var shared: ControlServer?
+ static var servicedRequest = false
private var serverSocket: Int32 = -1
private let queue = DispatchQueue(label: "ControlServer", attributes: .concurrent)
@@ -106,6 +107,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) {
diff --git a/App/InjectionNext/Info.plist b/App/InjectionNext/Info.plist
index eaea60ed..fff73467 100644
--- a/App/InjectionNext/Info.plist
+++ b/App/InjectionNext/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
CFBundleVersion
- 14777
+ 14793
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
diff --git a/App/InjectionNext/Views/NetworkSettingsView.swift b/App/InjectionNext/Views/NetworkSettingsView.swift
index d5f7feda..cac83817 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 {
@@ -38,6 +40,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 on localhost:\(config.controlPort)")
} header: {
Label("Status", systemImage: "antenna.radiowaves.left.and.right")
}
@@ -53,5 +64,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
}
}
From 3739ebb5af7b3b3feb7b74cb840d0f006424ffe7 Mon Sep 17 00:00:00 2001
From: John Holdsworth <1786033+johnno1962@users.noreply.github.com>
Date: Thu, 21 May 2026 20:19:34 +0200
Subject: [PATCH 7/7] Move to UNIX socket.
---
App/InjectionNext/ConfigStore.swift | 2 -
App/InjectionNext/ControlServer.swift | 73 +++++++++++++------
App/InjectionNext/Info.plist | 2 +-
.../Views/NetworkSettingsView.swift | 9 ++-
mcp-server/README.md | 18 ++---
mcp-server/index.js | 10 +--
6 files changed, 73 insertions(+), 41 deletions(-)
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 a0551477..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,15 +13,17 @@ import Cocoa
class ControlServer {
- static let port: UInt16 = 8919
- static var shared: ControlServer?
+ 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
private let queue = DispatchQueue(label: "ControlServer", attributes: .concurrent)
static func start() {
guard shared == nil else { return }
+ CompatibilityControlServer.startServer(":\(compatibilityPort)")
shared = ControlServer()
shared?.listen()
}
@@ -30,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")
@@ -63,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)
@@ -361,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 fff73467..c1280ef9 100644
--- a/App/InjectionNext/Info.plist
+++ b/App/InjectionNext/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
CFBundleVersion
- 14793
+ 14807
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
diff --git a/App/InjectionNext/Views/NetworkSettingsView.swift b/App/InjectionNext/Views/NetworkSettingsView.swift
index cac83817..b35e6f4c 100644
--- a/App/InjectionNext/Views/NetworkSettingsView.swift
+++ b/App/InjectionNext/Views/NetworkSettingsView.swift
@@ -22,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: {
@@ -48,7 +53,7 @@ struct NetworkSettingsView: View {
Text(controlServerText)
}
}
- .help("Local MCP control server on localhost:\(config.controlPort)")
+ .help("Local MCP control server socket")
} header: {
Label("Status", systemImage: "antenna.radiowaves.left.and.right")
}
diff --git a/mcp-server/README.md b/mcp-server/README.md
index 2b492b36..b1c9e929 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -91,16 +91,16 @@ 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
@@ -177,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 │ │ ┌────────▼────────┐ │
@@ -198,12 +198,12 @@ 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
diff --git a/mcp-server/index.js b/mcp-server/index.js
index e7ddc492..16c7e1c5 100755
--- a/mcp-server/index.js
+++ b/mcp-server/index.js
@@ -5,8 +5,8 @@ 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 = {}, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
@@ -16,7 +16,7 @@ function sendCommand(action, params = {}, timeoutMs = 5000) {
reject(new Error("Connection timed out. Is InjectionNext running with ControlServer?"));
}, 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 = {}, timeoutMs = 5000) {
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 {