@elizaos/capacitor-mobile-agent-bridge 2.0.11-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ElizaosCapacitorMobileAgentBridge'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license'] || { :type => 'MIT' }
10
+ s.homepage = 'https://elizaos.ai'
11
+ s.authors = { 'elizaOS' => 'dev@elizaos.ai' }
12
+ s.source = { :git => 'https://github.com/elizaOS/eliza.git', :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '15.0'
15
+ s.dependency 'Capacitor'
16
+ s.swift_version = '5.9'
17
+ end
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shaw Walters and elizaOS Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @elizaos/capacitor-mobile-agent-bridge
2
+
3
+ Outbound tunnel from a phone-hosted Eliza agent so a remote Mac client
4
+ can reach it. Phone-side companion to the Mac-side
5
+ `TunnelToMobileClient` in `@elizaos/app-core`.
6
+
7
+ This package owns the phone-to-relay tunnel for a phone-hosted Eliza
8
+ agent. The JS surface (`startInboundTunnel`, `stopInboundTunnel`,
9
+ `getTunnelStatus`, `stateChange` event) is stable. Native implementations
10
+ hold an outbound WebSocket to the relay and proxy requests into the same
11
+ local agent route surface used by the rest of the mobile app.
12
+
13
+ ## Status
14
+
15
+ | Platform | Status |
16
+ | --- | --- |
17
+ | Web | Fallback. Returns `state: "error"` with an explanatory message. |
18
+ | iOS | Outbound WebSocket tunnel. Proxies path-only requests through the WebView IPC bridge; no listening port is opened. |
19
+ | Android | Outbound WebSocket tunnel. Proxies path-only requests into the registered `ElizaAgentService` via reflection; no listening port is opened. |
20
+
21
+ ## Relay frame protocol
22
+
23
+ Tunnel frames use a path-only HTTP request envelope. The relay never
24
+ sends absolute URLs, and the plugin rejects `//host` and scheme-bearing
25
+ paths before dispatching to the agent. On iOS, dispatch goes through
26
+ `window.__ELIZA_IOS_LOCAL_AGENT_REQUEST__`, which is the same Capacitor
27
+ IPC bridge the UI uses for full-Bun local mode.
28
+
29
+ ## Usage
30
+
31
+ ```ts
32
+ import { MobileAgentBridge } from "@elizaos/capacitor-mobile-agent-bridge";
33
+
34
+ await MobileAgentBridge.startInboundTunnel({
35
+ relayUrl: "wss://relay.elizacloud.ai/v1/agent-tunnel",
36
+ deviceId: "phone-abc123",
37
+ pairingToken: "...",
38
+ });
39
+
40
+ const status = await MobileAgentBridge.getTunnelStatus();
41
+ // { state: "registered" | "error" | ..., relayUrl, deviceId, lastError }
42
+
43
+ await MobileAgentBridge.stopInboundTunnel();
44
+ ```
@@ -0,0 +1,59 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
4
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
5
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
6
+ kotlinVersion = project.hasProperty('kotlinVersion') ? rootProject.ext.kotlinVersion : '1.9.25'
7
+ }
8
+
9
+ apply plugin: 'com.android.library'
10
+ // Explicitly apply the Kotlin Android plugin. The kotlin-gradle-plugin is on
11
+ // the root buildscript classpath, but without applying it here AGP 8.13 falls
12
+ // back to its "built-in Kotlin" compile path (build/intermediates/
13
+ // built_in_kotlinc), which compiles the .kt sources but does NOT bundle the
14
+ // resulting .class files into the *release* library jar. The app's
15
+ // :app:assembleRelease then links a library AAR with zero plugin classes, so
16
+ // the Capacitor plugin (and any manifest-declared component) is absent from
17
+ // the release dex. Applying the standard Kotlin plugin wires Kotlin
18
+ // compilation into both the debug and release jar-bundling tasks.
19
+ apply plugin: 'org.jetbrains.kotlin.android'
20
+ android {
21
+ namespace = "ai.eliza.plugins.mobileagentbridge"
22
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
23
+ defaultConfig {
24
+ minSdk project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
25
+ targetSdk project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
26
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
27
+ }
28
+ buildTypes {
29
+ release {
30
+ minifyEnabled false
31
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
32
+ }
33
+ }
34
+ compileOptions {
35
+ sourceCompatibility JavaVersion.VERSION_21
36
+ targetCompatibility JavaVersion.VERSION_21
37
+ }
38
+
39
+ kotlinOptions {
40
+ jvmTarget = "21"
41
+ }
42
+ }
43
+
44
+ repositories {
45
+ google()
46
+ maven {
47
+ url = uri(rootProject.ext.has('mavenCentralMirrorUrl') ? rootProject.ext.mavenCentralMirrorUrl : 'https://repo.maven.apache.org/maven2')
48
+ }
49
+ mavenCentral()
50
+ }
51
+
52
+ dependencies {
53
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
54
+ implementation project(':capacitor-android')
55
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
56
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
57
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
58
+ testImplementation "junit:junit:$junitVersion"
59
+ }
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <uses-permission android:name="android.permission.INTERNET" />
4
+ </manifest>
@@ -0,0 +1,286 @@
1
+ package ai.eliza.plugins.mobileagentbridge
2
+
3
+ import android.net.Uri
4
+ import android.content.pm.PackageManager
5
+ import com.getcapacitor.JSObject
6
+ import com.getcapacitor.Plugin
7
+ import com.getcapacitor.PluginCall
8
+ import com.getcapacitor.PluginMethod
9
+ import com.getcapacitor.annotation.CapacitorPlugin
10
+ import java.util.Locale
11
+ import java.util.concurrent.TimeUnit
12
+ import okhttp3.OkHttpClient
13
+ import okhttp3.Request
14
+ import okhttp3.Response
15
+ import okhttp3.WebSocket
16
+ import okhttp3.WebSocketListener
17
+ import org.json.JSONObject
18
+
19
+ @CapacitorPlugin(name = "MobileAgentBridge")
20
+ class MobileAgentBridgePlugin : Plugin() {
21
+ private val client = OkHttpClient.Builder()
22
+ .pingInterval(20, TimeUnit.SECONDS)
23
+ .build()
24
+ private var socket: WebSocket? = null
25
+ private var relayUrl: String? = null
26
+ private var deviceId: String? = null
27
+ private var pairingToken: String? = null
28
+ private var localAgentApiBase: String = DEFAULT_LOCAL_AGENT_API_BASE
29
+ private var state: String = "idle"
30
+ private var lastError: String? = null
31
+
32
+ @PluginMethod
33
+ fun startInboundTunnel(call: PluginCall) {
34
+ val relay = call.getString("relayUrl")?.trim()
35
+ val id = call.getString("deviceId")?.trim()
36
+ if (relay.isNullOrEmpty()) {
37
+ call.reject("MobileAgentBridge.startInboundTunnel requires relayUrl")
38
+ return
39
+ }
40
+ if (id.isNullOrEmpty()) {
41
+ call.reject("MobileAgentBridge.startInboundTunnel requires deviceId")
42
+ return
43
+ }
44
+
45
+ stopTunnel(notify = false)
46
+ relayUrl = relay
47
+ deviceId = id
48
+ pairingToken = call.getString("pairingToken")?.trim()?.takeIf { it.isNotEmpty() }
49
+ val localBase = call.getString("localAgentApiBase")?.trim()?.takeIf { it.isNotEmpty() }
50
+ localAgentApiBase = if (localBase == null) {
51
+ DEFAULT_LOCAL_AGENT_API_BASE
52
+ } else {
53
+ normalizeLocalAgentApiBase(localBase) ?: run {
54
+ transition("error", "Invalid localAgentApiBase: $localBase")
55
+ call.resolve(status())
56
+ return
57
+ }
58
+ }
59
+
60
+ val url = buildRelayUrl(relay, id, pairingToken)
61
+ if (url == null) {
62
+ transition("error", "Invalid relay URL: $relay")
63
+ call.resolve(status())
64
+ return
65
+ }
66
+
67
+ transition("connecting", null)
68
+ val request = Request.Builder().url(url).build()
69
+ socket = client.newWebSocket(request, object : WebSocketListener() {
70
+ override fun onOpen(webSocket: WebSocket, response: Response) {
71
+ transition("registered", null)
72
+ sendFrame(JSONObject().apply {
73
+ put("type", "tunnel.register")
74
+ put("role", "phone-agent")
75
+ put("deviceId", id)
76
+ put("pairingToken", pairingToken ?: JSONObject.NULL)
77
+ })
78
+ }
79
+
80
+ override fun onMessage(webSocket: WebSocket, text: String) {
81
+ handleFrame(text)
82
+ }
83
+
84
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
85
+ if (socket === webSocket) {
86
+ socket = null
87
+ transition("disconnected", reason.ifBlank { null })
88
+ }
89
+ }
90
+
91
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
92
+ if (socket === webSocket) {
93
+ transition("error", t.message ?: "WebSocket failure")
94
+ }
95
+ }
96
+ })
97
+ call.resolve(status())
98
+ }
99
+
100
+ @PluginMethod
101
+ fun stopInboundTunnel(call: PluginCall) {
102
+ stopTunnel(notify = true)
103
+ call.resolve()
104
+ }
105
+
106
+ @PluginMethod
107
+ fun getTunnelStatus(call: PluginCall) {
108
+ call.resolve(status())
109
+ }
110
+
111
+ private fun stopTunnel(notify: Boolean) {
112
+ socket?.close(1000, "Client stop")
113
+ socket = null
114
+ relayUrl = null
115
+ deviceId = null
116
+ pairingToken = null
117
+ localAgentApiBase = DEFAULT_LOCAL_AGENT_API_BASE
118
+ state = "idle"
119
+ lastError = null
120
+ if (notify) notifyListeners("stateChange", JSObject().apply { put("state", "idle") })
121
+ }
122
+
123
+ private fun transition(next: String, reason: String?) {
124
+ state = next
125
+ lastError = if (next == "error") reason else null
126
+ notifyListeners("stateChange", JSObject().apply {
127
+ put("state", next)
128
+ if (reason != null) put("reason", reason)
129
+ })
130
+ }
131
+
132
+ private fun status(): JSObject {
133
+ return JSObject().apply {
134
+ put("state", state)
135
+ put("relayUrl", relayUrl)
136
+ put("deviceId", deviceId)
137
+ put("localAgentApiBase", localAgentApiBase)
138
+ put("lastError", lastError)
139
+ }
140
+ }
141
+
142
+ private fun buildRelayUrl(raw: String, id: String, token: String?): String? {
143
+ return try {
144
+ val uri = Uri.parse(raw)
145
+ val scheme = when (uri.scheme) {
146
+ "https" -> "wss"
147
+ "http" -> "ws"
148
+ "wss", "ws" -> uri.scheme
149
+ else -> return null
150
+ }
151
+ val builder = uri.buildUpon().scheme(scheme)
152
+ .clearQuery()
153
+ .appendQueryParameter("deviceId", id)
154
+ for (name in uri.queryParameterNames) {
155
+ if (name != "deviceId" && name != "token") {
156
+ for (value in uri.getQueryParameters(name)) {
157
+ builder.appendQueryParameter(name, value)
158
+ }
159
+ }
160
+ }
161
+ if (token != null) builder.appendQueryParameter("token", token)
162
+ builder.build().toString()
163
+ } catch (_: Exception) {
164
+ null
165
+ }
166
+ }
167
+
168
+ private fun handleFrame(text: String) {
169
+ val frame = try {
170
+ JSONObject(text)
171
+ } catch (_: Exception) {
172
+ return
173
+ }
174
+ val type = frame.optString("type")
175
+ if (type != "http_request" && type != "tunnel.http_request" && type != "agent.http_request") {
176
+ return
177
+ }
178
+ Thread {
179
+ val id = frame.opt("id")
180
+ val response = try {
181
+ proxyHttpRequest(frame)
182
+ } catch (error: Exception) {
183
+ JSONObject().apply {
184
+ put("status", 0)
185
+ put("headers", JSONObject())
186
+ put("body", "")
187
+ put("error", error.message ?: "Local agent proxy failed")
188
+ }
189
+ }
190
+ response.put("type", "http_response")
191
+ response.put("id", id ?: JSONObject.NULL)
192
+ sendFrame(response)
193
+ }.start()
194
+ }
195
+
196
+ private fun proxyHttpRequest(frame: JSONObject): JSONObject {
197
+ val path = frame.optString("path", "/api/health")
198
+ if (!path.startsWith("/") || path.startsWith("//") || path.contains("://")) {
199
+ return JSONObject().apply {
200
+ put("status", 400)
201
+ put("headers", JSONObject())
202
+ put("body", "Invalid local path")
203
+ }
204
+ }
205
+ val method = frame.optString("method", "GET").trim().uppercase(Locale.US)
206
+ if (!method.matches(Regex("^[A-Z]{1,16}$"))) {
207
+ throw IllegalArgumentException("Unsupported HTTP method")
208
+ }
209
+ val timeoutMs = frame.optInt(
210
+ "timeoutMs",
211
+ frame.optInt("timeout_ms", DEFAULT_LOCAL_REQUEST_TIMEOUT_MS),
212
+ ).coerceIn(1_000, MAX_LOCAL_REQUEST_TIMEOUT_MS)
213
+ val body = frame.opt("body")?.takeUnless { it == JSONObject.NULL }?.toString()
214
+ return requestLocalAgent(
215
+ method = method,
216
+ path = path,
217
+ headers = frame.optJSONObject("headers") ?: JSONObject(),
218
+ body = body,
219
+ timeoutMs = timeoutMs,
220
+ )
221
+ }
222
+
223
+ private fun requestLocalAgent(
224
+ method: String,
225
+ path: String,
226
+ headers: JSONObject,
227
+ body: String?,
228
+ timeoutMs: Int,
229
+ ): JSONObject {
230
+ val request = JSONObject().apply {
231
+ put("method", method)
232
+ put("path", path)
233
+ put("headers", headers)
234
+ put("body", body ?: JSONObject.NULL)
235
+ put("timeoutMs", timeoutMs)
236
+ }
237
+ val serviceClass = Class.forName(resolveAgentServiceClassName())
238
+ val bridge = serviceClass.getMethod("requestLocalAgent", String::class.java)
239
+ val raw = bridge.invoke(null, request.toString()) as? String
240
+ ?: throw IllegalStateException("ElizaAgentService.requestLocalAgent returned null")
241
+ return JSONObject(raw)
242
+ }
243
+
244
+ private fun resolveAgentServiceClassName(): String {
245
+ val ctx = context ?: throw IllegalStateException("Android context is unavailable")
246
+ val packageInfo = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
247
+ ctx.packageManager.getPackageInfo(
248
+ ctx.packageName,
249
+ PackageManager.PackageInfoFlags.of(PackageManager.GET_SERVICES.toLong()),
250
+ )
251
+ } else {
252
+ @Suppress("DEPRECATION")
253
+ ctx.packageManager.getPackageInfo(ctx.packageName, PackageManager.GET_SERVICES)
254
+ }
255
+ return packageInfo.services
256
+ ?.firstOrNull {
257
+ it.packageName == ctx.packageName && it.name.endsWith(".ElizaAgentService")
258
+ }
259
+ ?.name
260
+ ?: throw IllegalStateException("ElizaAgentService is not registered")
261
+ }
262
+
263
+ private fun sendFrame(frame: JSONObject) {
264
+ socket?.send(frame.toString())
265
+ }
266
+
267
+ private fun normalizeLocalAgentApiBase(raw: String): String? {
268
+ return try {
269
+ if (raw == DEFAULT_LOCAL_AGENT_API_BASE) return DEFAULT_LOCAL_AGENT_API_BASE
270
+ val uri = Uri.parse(raw)
271
+ val scheme = uri.scheme?.lowercase(Locale.US)
272
+ val host = uri.host?.lowercase(Locale.US)
273
+ if (scheme != "http") return null
274
+ if (host !in setOf("127.0.0.1", "localhost", "10.0.2.2")) return null
275
+ DEFAULT_LOCAL_AGENT_API_BASE
276
+ } catch (_: Exception) {
277
+ null
278
+ }
279
+ }
280
+
281
+ private companion object {
282
+ private const val DEFAULT_LOCAL_AGENT_API_BASE = "eliza-local-agent://ipc"
283
+ private const val DEFAULT_LOCAL_REQUEST_TIMEOUT_MS = 30_000
284
+ private const val MAX_LOCAL_REQUEST_TIMEOUT_MS = 600_000
285
+ }
286
+ }
@@ -0,0 +1,94 @@
1
+ import type { PluginListenerHandle } from "@capacitor/core";
2
+ /**
3
+ * MobileAgentBridge — phone-side half of "Mac connects to an agent
4
+ * running on iOS/Android" via an outbound tunnel.
5
+ *
6
+ * iOS apps cannot bind a publicly reachable listening socket; Android
7
+ * apps mostly cannot either, depending on AOSP variant and network
8
+ * environment. This plugin lets the phone hold an **outbound** WebSocket
9
+ * to a configured relay (default: Eliza Cloud managed gateway relay)
10
+ * and proxy traffic between the relay and the on-device agent API.
11
+ *
12
+ * Wire shape (relay → phone → relay):
13
+ * - Phone connects to `${relayUrl}?deviceId=<id>` and sends a
14
+ * `register` frame with optional pairing token.
15
+ * - Relay forwards JSON frames addressed to this device.
16
+ * - Phone proxies frames into the on-device agent's native local-agent
17
+ * bridge (`eliza-local-agent://ipc` on Android, in-process ITTP/Bun IPC
18
+ * on iOS) and ships responses back over the same WebSocket.
19
+ *
20
+ * Native iOS and Android implementations open the outbound WebSocket and
21
+ * proxy `http_request` frames to the local agent without exposing an
22
+ * inbound listening port.
23
+ */
24
+ export interface MobileAgentBridgeStartOptions {
25
+ /**
26
+ * URL of the relay endpoint to dial. Typically a WebSocket
27
+ * (`wss://...`) but may be `https://...` for long-poll fallbacks.
28
+ * The relay must understand the agent-tunnel frame protocol.
29
+ */
30
+ relayUrl: string;
31
+ /**
32
+ * Stable device identifier. Reused across relaunches so an existing
33
+ * pairing on the Mac side keeps resolving to this device.
34
+ */
35
+ deviceId: string;
36
+ /**
37
+ * Optional pre-shared token for the pairing. The relay uses this to
38
+ * authorize the inbound connection without requiring full cloud
39
+ * credentials per frame.
40
+ */
41
+ pairingToken?: string;
42
+ /**
43
+ * Optional override for the local agent base used to satisfy proxied
44
+ * frames. Defaults to `eliza-local-agent://ipc` on Android and the
45
+ * in-process ITTP/Bun IPC surface on iOS.
46
+ */
47
+ localAgentApiBase?: string;
48
+ }
49
+ export type MobileAgentTunnelState = "idle" | "connecting" | "registered" | "disconnected" | "error";
50
+ export interface MobileAgentTunnelStatus {
51
+ state: MobileAgentTunnelState;
52
+ /** Relay URL the bridge is currently dialing (if any). */
53
+ relayUrl: string | null;
54
+ /** Stable device identifier from the last `startInboundTunnel` call. */
55
+ deviceId: string | null;
56
+ /** Last error message surfaced by the native transport. */
57
+ lastError: string | null;
58
+ }
59
+ export interface MobileAgentTunnelStateEvent {
60
+ state: MobileAgentTunnelState;
61
+ reason?: string;
62
+ }
63
+ /**
64
+ * MobileAgentBridge plugin surface.
65
+ *
66
+ * Implementations:
67
+ * - Web fallback: reports tunnel startup as unavailable.
68
+ * - iOS: URLSessionWebSocketTask + WebView IPC dispatch into the
69
+ * in-process local-agent bridge.
70
+ * - Android: OkHttp WebSocket + foreground-service local-agent request
71
+ * dispatch.
72
+ */
73
+ export interface MobileAgentBridgePlugin {
74
+ /**
75
+ * Start (or restart) the inbound tunnel. Idempotent; calling with
76
+ * the same options while already registered leaves the tunnel unchanged.
77
+ */
78
+ startInboundTunnel(options: MobileAgentBridgeStartOptions): Promise<MobileAgentTunnelStatus>;
79
+ /**
80
+ * Stop the tunnel and release resources. Safe to call when no
81
+ * tunnel is active.
82
+ */
83
+ stopInboundTunnel(): Promise<void>;
84
+ /**
85
+ * Snapshot the current tunnel status.
86
+ */
87
+ getTunnelStatus(): Promise<MobileAgentTunnelStatus>;
88
+ /**
89
+ * Subscribe to tunnel state changes. Returns a listener handle the
90
+ * caller must close to unsubscribe.
91
+ */
92
+ addListener(eventName: "stateChange", listenerFunc: (event: MobileAgentTunnelStateEvent) => void): Promise<PluginListenerHandle>;
93
+ removeAllListeners(): Promise<void>;
94
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { MobileAgentBridgePlugin } from "./definitions";
2
+ export type * from "./definitions";
3
+ /**
4
+ * Capacitor plugin entry point. The native bindings on iOS and Android
5
+ * register themselves under the name `MobileAgentBridge`; the web
6
+ * fallback loads on dev / Electrobun shells where no phone tunnel is
7
+ * possible.
8
+ */
9
+ export declare const MobileAgentBridge: MobileAgentBridgePlugin;
@@ -0,0 +1,11 @@
1
+ import { registerPlugin } from "@capacitor/core";
2
+ const loadWeb = () => import("./web").then((m) => new m.MobileAgentBridgeWeb());
3
+ /**
4
+ * Capacitor plugin entry point. The native bindings on iOS and Android
5
+ * register themselves under the name `MobileAgentBridge`; the web
6
+ * fallback loads on dev / Electrobun shells where no phone tunnel is
7
+ * possible.
8
+ */
9
+ export const MobileAgentBridge = registerPlugin("MobileAgentBridge", {
10
+ web: loadWeb,
11
+ });
@@ -0,0 +1,16 @@
1
+ import { WebPlugin } from "@capacitor/core";
2
+ import type { MobileAgentBridgePlugin, MobileAgentBridgeStartOptions, MobileAgentTunnelStatus } from "./definitions";
3
+ /**
4
+ * Web fallback for the MobileAgentBridge.
5
+ *
6
+ * Browsers and Electrobun shells cannot host the on-device agent that
7
+ * the inbound tunnel proxies traffic into. We surface a stable "idle"
8
+ * status and reject `startInboundTunnel` so callers see an honest
9
+ * failure mode rather than a silent success.
10
+ */
11
+ export declare class MobileAgentBridgeWeb extends WebPlugin implements MobileAgentBridgePlugin {
12
+ private status;
13
+ startInboundTunnel(options: MobileAgentBridgeStartOptions): Promise<MobileAgentTunnelStatus>;
14
+ stopInboundTunnel(): Promise<void>;
15
+ getTunnelStatus(): Promise<MobileAgentTunnelStatus>;
16
+ }
@@ -0,0 +1,81 @@
1
+ import { WebPlugin } from "@capacitor/core";
2
+ function assertRelayUrl(value) {
3
+ if (typeof value !== "string" || value.trim().length === 0) {
4
+ throw new Error("relayUrl must be a non-empty URL");
5
+ }
6
+ let parsed;
7
+ try {
8
+ parsed = new URL(value);
9
+ }
10
+ catch {
11
+ throw new Error("relayUrl must be a valid URL");
12
+ }
13
+ if (parsed.protocol !== "wss:" &&
14
+ parsed.protocol !== "ws:" &&
15
+ parsed.protocol !== "https:" &&
16
+ parsed.protocol !== "http:") {
17
+ throw new Error("relayUrl protocol is not allowed");
18
+ }
19
+ if (parsed.username || parsed.password) {
20
+ throw new Error("relayUrl must not contain embedded credentials");
21
+ }
22
+ return parsed.toString();
23
+ }
24
+ function assertDeviceId(value) {
25
+ if (typeof value !== "string" || value.trim().length === 0) {
26
+ throw new Error("deviceId must be a non-empty string");
27
+ }
28
+ const normalized = value.trim();
29
+ if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(normalized)) {
30
+ throw new Error("deviceId contains invalid characters");
31
+ }
32
+ return normalized;
33
+ }
34
+ /**
35
+ * Web fallback for the MobileAgentBridge.
36
+ *
37
+ * Browsers and Electrobun shells cannot host the on-device agent that
38
+ * the inbound tunnel proxies traffic into. We surface a stable "idle"
39
+ * status and reject `startInboundTunnel` so callers see an honest
40
+ * failure mode rather than a silent success.
41
+ */
42
+ export class MobileAgentBridgeWeb extends WebPlugin {
43
+ constructor() {
44
+ super(...arguments);
45
+ this.status = {
46
+ state: "idle",
47
+ relayUrl: null,
48
+ deviceId: null,
49
+ lastError: null,
50
+ };
51
+ }
52
+ async startInboundTunnel(options) {
53
+ const relayUrl = assertRelayUrl(options.relayUrl);
54
+ const deviceId = assertDeviceId(options.deviceId);
55
+ this.status = {
56
+ state: "error",
57
+ relayUrl,
58
+ deviceId,
59
+ lastError: "MobileAgentBridge.startInboundTunnel is only available on iOS and Android.",
60
+ };
61
+ this.notifyListeners("stateChange", {
62
+ state: "error",
63
+ reason: this.status.lastError ?? undefined,
64
+ });
65
+ return this.status;
66
+ }
67
+ async stopInboundTunnel() {
68
+ if (this.status.state === "idle")
69
+ return;
70
+ this.status = {
71
+ state: "idle",
72
+ relayUrl: null,
73
+ deviceId: null,
74
+ lastError: null,
75
+ };
76
+ this.notifyListeners("stateChange", { state: "idle" });
77
+ }
78
+ async getTunnelStatus() {
79
+ return this.status;
80
+ }
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { MobileAgentBridgeWeb } from "./web";
3
+ describe("MobileAgentBridgeWeb fallback", () => {
4
+ afterEach(() => {
5
+ vi.restoreAllMocks();
6
+ });
7
+ it("starts in an idle state", async () => {
8
+ await expect(new MobileAgentBridgeWeb().getTunnelStatus()).resolves.toEqual({
9
+ state: "idle",
10
+ relayUrl: null,
11
+ deviceId: null,
12
+ lastError: null,
13
+ });
14
+ });
15
+ it.each([
16
+ { relayUrl: "", deviceId: "device-1" },
17
+ { relayUrl: "javascript:alert(1)", deviceId: "device-1" },
18
+ { relayUrl: "file:///tmp/socket", deviceId: "device-1" },
19
+ { relayUrl: "wss://user:pass@example.test/relay", deviceId: "device-1" },
20
+ { relayUrl: "wss://example.test/relay", deviceId: "" },
21
+ { relayUrl: "wss://example.test/relay", deviceId: "../escape" },
22
+ ])("rejects malformed tunnel options %#", async (options) => {
23
+ const plugin = new MobileAgentBridgeWeb();
24
+ const listener = vi.fn();
25
+ await plugin.addListener("stateChange", listener);
26
+ await expect(plugin.startInboundTunnel(options)).rejects.toThrow(/relayUrl|deviceId/);
27
+ await expect(plugin.getTunnelStatus()).resolves.toEqual({
28
+ state: "idle",
29
+ relayUrl: null,
30
+ deviceId: null,
31
+ lastError: null,
32
+ });
33
+ expect(listener).not.toHaveBeenCalled();
34
+ });
35
+ it("normalizes valid options, emits an error state, and returns to idle on stop", async () => {
36
+ const plugin = new MobileAgentBridgeWeb();
37
+ const listener = vi.fn();
38
+ await plugin.addListener("stateChange", listener);
39
+ await expect(plugin.startInboundTunnel({
40
+ relayUrl: "wss://relay.example/tunnel",
41
+ deviceId: " device-1 ",
42
+ pairingToken: "<script>",
43
+ })).resolves.toEqual({
44
+ state: "error",
45
+ relayUrl: "wss://relay.example/tunnel",
46
+ deviceId: "device-1",
47
+ lastError: "MobileAgentBridge.startInboundTunnel is only available on iOS and Android.",
48
+ });
49
+ expect(listener).toHaveBeenCalledWith({
50
+ state: "error",
51
+ reason: "MobileAgentBridge.startInboundTunnel is only available on iOS and Android.",
52
+ });
53
+ await plugin.stopInboundTunnel();
54
+ await expect(plugin.getTunnelStatus()).resolves.toEqual({
55
+ state: "idle",
56
+ relayUrl: null,
57
+ deviceId: null,
58
+ lastError: null,
59
+ });
60
+ expect(listener).toHaveBeenLastCalledWith({ state: "idle" });
61
+ });
62
+ });
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ var core = require('@capacitor/core');
4
+
5
+ const loadWeb = () => Promise.resolve().then(function () { return web; }).then((m) => new m.MobileAgentBridgeWeb());
6
+ /**
7
+ * Capacitor plugin entry point. The native bindings on iOS and Android
8
+ * register themselves under the name `MobileAgentBridge`; the web
9
+ * fallback loads on dev / Electrobun shells where no phone tunnel is
10
+ * possible.
11
+ */
12
+ const MobileAgentBridge = core.registerPlugin("MobileAgentBridge", {
13
+ web: loadWeb,
14
+ });
15
+
16
+ function assertRelayUrl(value) {
17
+ if (typeof value !== "string" || value.trim().length === 0) {
18
+ throw new Error("relayUrl must be a non-empty URL");
19
+ }
20
+ let parsed;
21
+ try {
22
+ parsed = new URL(value);
23
+ }
24
+ catch {
25
+ throw new Error("relayUrl must be a valid URL");
26
+ }
27
+ if (parsed.protocol !== "wss:" &&
28
+ parsed.protocol !== "ws:" &&
29
+ parsed.protocol !== "https:" &&
30
+ parsed.protocol !== "http:") {
31
+ throw new Error("relayUrl protocol is not allowed");
32
+ }
33
+ if (parsed.username || parsed.password) {
34
+ throw new Error("relayUrl must not contain embedded credentials");
35
+ }
36
+ return parsed.toString();
37
+ }
38
+ function assertDeviceId(value) {
39
+ if (typeof value !== "string" || value.trim().length === 0) {
40
+ throw new Error("deviceId must be a non-empty string");
41
+ }
42
+ const normalized = value.trim();
43
+ if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(normalized)) {
44
+ throw new Error("deviceId contains invalid characters");
45
+ }
46
+ return normalized;
47
+ }
48
+ /**
49
+ * Web fallback for the MobileAgentBridge.
50
+ *
51
+ * Browsers and Electrobun shells cannot host the on-device agent that
52
+ * the inbound tunnel proxies traffic into. We surface a stable "idle"
53
+ * status and reject `startInboundTunnel` so callers see an honest
54
+ * failure mode rather than a silent success.
55
+ */
56
+ class MobileAgentBridgeWeb extends core.WebPlugin {
57
+ constructor() {
58
+ super(...arguments);
59
+ this.status = {
60
+ state: "idle",
61
+ relayUrl: null,
62
+ deviceId: null,
63
+ lastError: null,
64
+ };
65
+ }
66
+ async startInboundTunnel(options) {
67
+ const relayUrl = assertRelayUrl(options.relayUrl);
68
+ const deviceId = assertDeviceId(options.deviceId);
69
+ this.status = {
70
+ state: "error",
71
+ relayUrl,
72
+ deviceId,
73
+ lastError: "MobileAgentBridge.startInboundTunnel is only available on iOS and Android.",
74
+ };
75
+ this.notifyListeners("stateChange", {
76
+ state: "error",
77
+ reason: this.status.lastError ?? undefined,
78
+ });
79
+ return this.status;
80
+ }
81
+ async stopInboundTunnel() {
82
+ if (this.status.state === "idle")
83
+ return;
84
+ this.status = {
85
+ state: "idle",
86
+ relayUrl: null,
87
+ deviceId: null,
88
+ lastError: null,
89
+ };
90
+ this.notifyListeners("stateChange", { state: "idle" });
91
+ }
92
+ async getTunnelStatus() {
93
+ return this.status;
94
+ }
95
+ }
96
+
97
+ var web = /*#__PURE__*/Object.freeze({
98
+ __proto__: null,
99
+ MobileAgentBridgeWeb: MobileAgentBridgeWeb
100
+ });
101
+
102
+ exports.MobileAgentBridge = MobileAgentBridge;
103
+ //# sourceMappingURL=plugin.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.cjs.js","sources":["esm/index.js","esm/web.js"],"sourcesContent":["import { registerPlugin } from \"@capacitor/core\";\nconst loadWeb = () => import(\"./web\").then((m) => new m.MobileAgentBridgeWeb());\n/**\n * Capacitor plugin entry point. The native bindings on iOS and Android\n * register themselves under the name `MobileAgentBridge`; the web\n * fallback loads on dev / Electrobun shells where no phone tunnel is\n * possible.\n */\nexport const MobileAgentBridge = registerPlugin(\"MobileAgentBridge\", {\n web: loadWeb,\n});\n","import { WebPlugin } from \"@capacitor/core\";\nfunction assertRelayUrl(value) {\n if (typeof value !== \"string\" || value.trim().length === 0) {\n throw new Error(\"relayUrl must be a non-empty URL\");\n }\n let parsed;\n try {\n parsed = new URL(value);\n }\n catch {\n throw new Error(\"relayUrl must be a valid URL\");\n }\n if (parsed.protocol !== \"wss:\" &&\n parsed.protocol !== \"ws:\" &&\n parsed.protocol !== \"https:\" &&\n parsed.protocol !== \"http:\") {\n throw new Error(\"relayUrl protocol is not allowed\");\n }\n if (parsed.username || parsed.password) {\n throw new Error(\"relayUrl must not contain embedded credentials\");\n }\n return parsed.toString();\n}\nfunction assertDeviceId(value) {\n if (typeof value !== \"string\" || value.trim().length === 0) {\n throw new Error(\"deviceId must be a non-empty string\");\n }\n const normalized = value.trim();\n if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(normalized)) {\n throw new Error(\"deviceId contains invalid characters\");\n }\n return normalized;\n}\n/**\n * Web fallback for the MobileAgentBridge.\n *\n * Browsers and Electrobun shells cannot host the on-device agent that\n * the inbound tunnel proxies traffic into. We surface a stable \"idle\"\n * status and reject `startInboundTunnel` so callers see an honest\n * failure mode rather than a silent success.\n */\nexport class MobileAgentBridgeWeb extends WebPlugin {\n constructor() {\n super(...arguments);\n this.status = {\n state: \"idle\",\n relayUrl: null,\n deviceId: null,\n lastError: null,\n };\n }\n async startInboundTunnel(options) {\n const relayUrl = assertRelayUrl(options.relayUrl);\n const deviceId = assertDeviceId(options.deviceId);\n this.status = {\n state: \"error\",\n relayUrl,\n deviceId,\n lastError: \"MobileAgentBridge.startInboundTunnel is only available on iOS and Android.\",\n };\n this.notifyListeners(\"stateChange\", {\n state: \"error\",\n reason: this.status.lastError ?? undefined,\n });\n return this.status;\n }\n async stopInboundTunnel() {\n if (this.status.state === \"idle\")\n return;\n this.status = {\n state: \"idle\",\n relayUrl: null,\n deviceId: null,\n lastError: null,\n };\n this.notifyListeners(\"stateChange\", { state: \"idle\" });\n }\n async getTunnelStatus() {\n return this.status;\n }\n}\n"],"names":["registerPlugin","WebPlugin"],"mappings":"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"}
package/dist/plugin.js ADDED
@@ -0,0 +1,106 @@
1
+ var capacitorMobileAgentBridge = (function (exports, core) {
2
+ 'use strict';
3
+
4
+ const loadWeb = () => Promise.resolve().then(function () { return web; }).then((m) => new m.MobileAgentBridgeWeb());
5
+ /**
6
+ * Capacitor plugin entry point. The native bindings on iOS and Android
7
+ * register themselves under the name `MobileAgentBridge`; the web
8
+ * fallback loads on dev / Electrobun shells where no phone tunnel is
9
+ * possible.
10
+ */
11
+ const MobileAgentBridge = core.registerPlugin("MobileAgentBridge", {
12
+ web: loadWeb,
13
+ });
14
+
15
+ function assertRelayUrl(value) {
16
+ if (typeof value !== "string" || value.trim().length === 0) {
17
+ throw new Error("relayUrl must be a non-empty URL");
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = new URL(value);
22
+ }
23
+ catch {
24
+ throw new Error("relayUrl must be a valid URL");
25
+ }
26
+ if (parsed.protocol !== "wss:" &&
27
+ parsed.protocol !== "ws:" &&
28
+ parsed.protocol !== "https:" &&
29
+ parsed.protocol !== "http:") {
30
+ throw new Error("relayUrl protocol is not allowed");
31
+ }
32
+ if (parsed.username || parsed.password) {
33
+ throw new Error("relayUrl must not contain embedded credentials");
34
+ }
35
+ return parsed.toString();
36
+ }
37
+ function assertDeviceId(value) {
38
+ if (typeof value !== "string" || value.trim().length === 0) {
39
+ throw new Error("deviceId must be a non-empty string");
40
+ }
41
+ const normalized = value.trim();
42
+ if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(normalized)) {
43
+ throw new Error("deviceId contains invalid characters");
44
+ }
45
+ return normalized;
46
+ }
47
+ /**
48
+ * Web fallback for the MobileAgentBridge.
49
+ *
50
+ * Browsers and Electrobun shells cannot host the on-device agent that
51
+ * the inbound tunnel proxies traffic into. We surface a stable "idle"
52
+ * status and reject `startInboundTunnel` so callers see an honest
53
+ * failure mode rather than a silent success.
54
+ */
55
+ class MobileAgentBridgeWeb extends core.WebPlugin {
56
+ constructor() {
57
+ super(...arguments);
58
+ this.status = {
59
+ state: "idle",
60
+ relayUrl: null,
61
+ deviceId: null,
62
+ lastError: null,
63
+ };
64
+ }
65
+ async startInboundTunnel(options) {
66
+ const relayUrl = assertRelayUrl(options.relayUrl);
67
+ const deviceId = assertDeviceId(options.deviceId);
68
+ this.status = {
69
+ state: "error",
70
+ relayUrl,
71
+ deviceId,
72
+ lastError: "MobileAgentBridge.startInboundTunnel is only available on iOS and Android.",
73
+ };
74
+ this.notifyListeners("stateChange", {
75
+ state: "error",
76
+ reason: this.status.lastError ?? undefined,
77
+ });
78
+ return this.status;
79
+ }
80
+ async stopInboundTunnel() {
81
+ if (this.status.state === "idle")
82
+ return;
83
+ this.status = {
84
+ state: "idle",
85
+ relayUrl: null,
86
+ deviceId: null,
87
+ lastError: null,
88
+ };
89
+ this.notifyListeners("stateChange", { state: "idle" });
90
+ }
91
+ async getTunnelStatus() {
92
+ return this.status;
93
+ }
94
+ }
95
+
96
+ var web = /*#__PURE__*/Object.freeze({
97
+ __proto__: null,
98
+ MobileAgentBridgeWeb: MobileAgentBridgeWeb
99
+ });
100
+
101
+ exports.MobileAgentBridge = MobileAgentBridge;
102
+
103
+ return exports;
104
+
105
+ })({}, capacitorExports);
106
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sources":["esm/index.js","esm/web.js"],"sourcesContent":["import { registerPlugin } from \"@capacitor/core\";\nconst loadWeb = () => import(\"./web\").then((m) => new m.MobileAgentBridgeWeb());\n/**\n * Capacitor plugin entry point. The native bindings on iOS and Android\n * register themselves under the name `MobileAgentBridge`; the web\n * fallback loads on dev / Electrobun shells where no phone tunnel is\n * possible.\n */\nexport const MobileAgentBridge = registerPlugin(\"MobileAgentBridge\", {\n web: loadWeb,\n});\n","import { WebPlugin } from \"@capacitor/core\";\nfunction assertRelayUrl(value) {\n if (typeof value !== \"string\" || value.trim().length === 0) {\n throw new Error(\"relayUrl must be a non-empty URL\");\n }\n let parsed;\n try {\n parsed = new URL(value);\n }\n catch {\n throw new Error(\"relayUrl must be a valid URL\");\n }\n if (parsed.protocol !== \"wss:\" &&\n parsed.protocol !== \"ws:\" &&\n parsed.protocol !== \"https:\" &&\n parsed.protocol !== \"http:\") {\n throw new Error(\"relayUrl protocol is not allowed\");\n }\n if (parsed.username || parsed.password) {\n throw new Error(\"relayUrl must not contain embedded credentials\");\n }\n return parsed.toString();\n}\nfunction assertDeviceId(value) {\n if (typeof value !== \"string\" || value.trim().length === 0) {\n throw new Error(\"deviceId must be a non-empty string\");\n }\n const normalized = value.trim();\n if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(normalized)) {\n throw new Error(\"deviceId contains invalid characters\");\n }\n return normalized;\n}\n/**\n * Web fallback for the MobileAgentBridge.\n *\n * Browsers and Electrobun shells cannot host the on-device agent that\n * the inbound tunnel proxies traffic into. We surface a stable \"idle\"\n * status and reject `startInboundTunnel` so callers see an honest\n * failure mode rather than a silent success.\n */\nexport class MobileAgentBridgeWeb extends WebPlugin {\n constructor() {\n super(...arguments);\n this.status = {\n state: \"idle\",\n relayUrl: null,\n deviceId: null,\n lastError: null,\n };\n }\n async startInboundTunnel(options) {\n const relayUrl = assertRelayUrl(options.relayUrl);\n const deviceId = assertDeviceId(options.deviceId);\n this.status = {\n state: \"error\",\n relayUrl,\n deviceId,\n lastError: \"MobileAgentBridge.startInboundTunnel is only available on iOS and Android.\",\n };\n this.notifyListeners(\"stateChange\", {\n state: \"error\",\n reason: this.status.lastError ?? undefined,\n });\n return this.status;\n }\n async stopInboundTunnel() {\n if (this.status.state === \"idle\")\n return;\n this.status = {\n state: \"idle\",\n relayUrl: null,\n deviceId: null,\n lastError: null,\n };\n this.notifyListeners(\"stateChange\", { state: \"idle\" });\n }\n async getTunnelStatus() {\n return this.status;\n }\n}\n"],"names":["registerPlugin","WebPlugin"],"mappings":"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"}
@@ -0,0 +1,237 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import WebKit
4
+
5
+ @objc(MobileAgentBridgePlugin)
6
+ public class MobileAgentBridgePlugin: CAPPlugin, CAPBridgedPlugin {
7
+ public let identifier = "MobileAgentBridgePlugin"
8
+ public let jsName = "MobileAgentBridge"
9
+ public let pluginMethods: [CAPPluginMethod] = [
10
+ CAPPluginMethod(name: "startInboundTunnel", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "stopInboundTunnel", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "getTunnelStatus", returnType: CAPPluginReturnPromise),
13
+ ]
14
+
15
+ private var relayUrl: String?
16
+ private var deviceId: String?
17
+ private var pairingToken: String?
18
+ private var state: String = "idle"
19
+ private var lastError: String?
20
+ private var session: URLSession?
21
+ private var task: URLSessionWebSocketTask?
22
+
23
+ @objc func startInboundTunnel(_ call: CAPPluginCall) {
24
+ guard let relay = call.getString("relayUrl")?.trimmingCharacters(in: .whitespacesAndNewlines),
25
+ !relay.isEmpty else {
26
+ call.reject("MobileAgentBridge.startInboundTunnel requires relayUrl")
27
+ return
28
+ }
29
+ guard let id = call.getString("deviceId")?.trimmingCharacters(in: .whitespacesAndNewlines),
30
+ !id.isEmpty else {
31
+ call.reject("MobileAgentBridge.startInboundTunnel requires deviceId")
32
+ return
33
+ }
34
+
35
+ stopTunnel(notify: false)
36
+ relayUrl = relay
37
+ deviceId = id
38
+ pairingToken = call.getString("pairingToken")?.trimmingCharacters(in: .whitespacesAndNewlines)
39
+
40
+ guard let url = buildRelayUrl(relayUrl: relay, deviceId: id, token: pairingToken) else {
41
+ transition("error", reason: "Invalid relay URL: \(relay)")
42
+ call.resolve(status())
43
+ return
44
+ }
45
+
46
+ transition("connecting", reason: nil)
47
+ let session = URLSession(configuration: .default)
48
+ let task = session.webSocketTask(with: url)
49
+ self.session = session
50
+ self.task = task
51
+ task.resume()
52
+ receiveLoop()
53
+ sendFrame([
54
+ "type": "tunnel.register",
55
+ "role": "phone-agent",
56
+ "deviceId": id,
57
+ "pairingToken": pairingToken ?? NSNull(),
58
+ ]) { [weak self] error in
59
+ if let error {
60
+ self?.transition("error", reason: error.localizedDescription)
61
+ } else {
62
+ self?.transition("registered", reason: nil)
63
+ }
64
+ }
65
+ call.resolve(status())
66
+ }
67
+
68
+ @objc func stopInboundTunnel(_ call: CAPPluginCall) {
69
+ stopTunnel(notify: true)
70
+ call.resolve()
71
+ }
72
+
73
+ @objc func getTunnelStatus(_ call: CAPPluginCall) {
74
+ call.resolve(status())
75
+ }
76
+
77
+ private func stopTunnel(notify: Bool) {
78
+ task?.cancel(with: .normalClosure, reason: nil)
79
+ session?.invalidateAndCancel()
80
+ task = nil
81
+ session = nil
82
+ relayUrl = nil
83
+ deviceId = nil
84
+ pairingToken = nil
85
+ lastError = nil
86
+ state = "idle"
87
+ if notify {
88
+ notifyListeners("stateChange", data: ["state": "idle"])
89
+ }
90
+ }
91
+
92
+ private func transition(_ next: String, reason: String?) {
93
+ state = next
94
+ lastError = next == "error" ? reason : nil
95
+ var event: [String: Any] = ["state": next]
96
+ if let reason { event["reason"] = reason }
97
+ notifyListeners("stateChange", data: event)
98
+ }
99
+
100
+ private func status() -> [String: Any] {
101
+ [
102
+ "state": state,
103
+ "relayUrl": relayUrl ?? NSNull(),
104
+ "deviceId": deviceId ?? NSNull(),
105
+ "lastError": lastError ?? NSNull(),
106
+ ]
107
+ }
108
+
109
+ private func buildRelayUrl(relayUrl: String, deviceId: String, token: String?) -> URL? {
110
+ guard var components = URLComponents(string: relayUrl) else { return nil }
111
+ if components.scheme == "https" { components.scheme = "wss" }
112
+ if components.scheme == "http" { components.scheme = "ws" }
113
+ var items = components.queryItems ?? []
114
+ items.removeAll { $0.name == "deviceId" || $0.name == "token" }
115
+ items.append(URLQueryItem(name: "deviceId", value: deviceId))
116
+ if let token, !token.isEmpty {
117
+ items.append(URLQueryItem(name: "token", value: token))
118
+ }
119
+ components.queryItems = items
120
+ return components.url
121
+ }
122
+
123
+ private func receiveLoop() {
124
+ task?.receive { [weak self] result in
125
+ guard let self else { return }
126
+ switch result {
127
+ case .success(let message):
128
+ self.handle(message)
129
+ self.receiveLoop()
130
+ case .failure(let error):
131
+ if self.task != nil {
132
+ self.transition("error", reason: error.localizedDescription)
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ private func handle(_ message: URLSessionWebSocketTask.Message) {
139
+ let text: String
140
+ switch message {
141
+ case .string(let value):
142
+ text = value
143
+ case .data(let data):
144
+ text = String(data: data, encoding: .utf8) ?? ""
145
+ @unknown default:
146
+ return
147
+ }
148
+ guard let data = text.data(using: .utf8),
149
+ let frame = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
150
+ return
151
+ }
152
+ let type = frame["type"] as? String
153
+ guard type == "http_request" || type == "tunnel.http_request" || type == "agent.http_request" else {
154
+ return
155
+ }
156
+ proxyHttpRequest(frame)
157
+ }
158
+
159
+ private func proxyHttpRequest(_ frame: [String: Any]) {
160
+ let id = frame["id"] ?? NSNull()
161
+ let path = (frame["path"] as? String) ?? "/api/health"
162
+ guard path.starts(with: "/"), !path.starts(with: "//"), !path.contains("://") else {
163
+ sendFrame(["type": "http_response", "id": id, "status": 400, "headers": [:], "body": "Invalid local path"])
164
+ return
165
+ }
166
+ let method = ((frame["method"] as? String) ?? "GET").uppercased()
167
+ let headers = frame["headers"] as? [String: String] ?? [:]
168
+ let body = frame["body"] is NSNull ? nil : frame["body"] as? String
169
+ let timeoutMs = frame["timeoutMs"] as? Int ?? frame["timeout_ms"] as? Int ?? 30000
170
+ let options: [String: Any] = [
171
+ "method": method,
172
+ "path": path,
173
+ "headers": headers,
174
+ "body": body ?? NSNull(),
175
+ "timeoutMs": timeoutMs,
176
+ ]
177
+ dispatchLocalRequest(options) { [weak self] response in
178
+ var out = response
179
+ out["type"] = "http_response"
180
+ out["id"] = id
181
+ self?.sendFrame(out)
182
+ }
183
+ }
184
+
185
+ private func dispatchLocalRequest(_ options: [String: Any], completion: @escaping ([String: Any]) -> Void) {
186
+ guard let webView = bridge?.webView else {
187
+ completion(["status": 0, "headers": [:], "body": "", "error": "WebView unavailable"])
188
+ return
189
+ }
190
+ let body = """
191
+ if (typeof window.__ELIZA_IOS_LOCAL_AGENT_REQUEST__ !== "function") {
192
+ throw new Error("iOS local agent IPC bridge is unavailable");
193
+ }
194
+ return await window.__ELIZA_IOS_LOCAL_AGENT_REQUEST__(options);
195
+ """
196
+ DispatchQueue.main.async {
197
+ webView.callAsyncJavaScript(body, arguments: ["options": options], in: nil, in: .page) { result in
198
+ switch result {
199
+ case .success(let value):
200
+ if let dict = value as? [String: Any] {
201
+ completion(dict)
202
+ } else {
203
+ completion(["status": 0, "headers": [:], "body": "", "error": "Invalid IPC response"])
204
+ }
205
+ case .failure(let error):
206
+ completion(["status": 0, "headers": [:], "body": "", "error": error.localizedDescription])
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ private func sendFrame(_ frame: [String: Any], completion: ((Error?) -> Void)? = nil) {
213
+ guard let task else {
214
+ completion?(NSError(
215
+ domain: "MobileAgentBridge",
216
+ code: 1,
217
+ userInfo: [NSLocalizedDescriptionKey: "WebSocket is not connected"]
218
+ ))
219
+ return
220
+ }
221
+ guard let data = try? JSONSerialization.data(withJSONObject: frame),
222
+ let text = String(data: data, encoding: .utf8) else {
223
+ completion?(NSError(
224
+ domain: "MobileAgentBridge",
225
+ code: 2,
226
+ userInfo: [NSLocalizedDescriptionKey: "Failed to encode tunnel frame"]
227
+ ))
228
+ return
229
+ }
230
+ task.send(.string(text)) { [weak self] error in
231
+ if let error {
232
+ self?.transition("error", reason: error.localizedDescription)
233
+ }
234
+ completion?(error)
235
+ }
236
+ }
237
+ }
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@elizaos/capacitor-mobile-agent-bridge",
3
+ "version": "2.0.11-beta.7",
4
+ "description": "Outbound tunnel from a phone-hosted Eliza agent so a remote Mac client can reach it. See docs/reverse-direction-tunneling.md.",
5
+ "keywords": [
6
+ "agent",
7
+ "tunnel",
8
+ "relay",
9
+ "mobile",
10
+ "ios",
11
+ "android"
12
+ ],
13
+ "main": "./dist/plugin.cjs.js",
14
+ "module": "./dist/esm/index.js",
15
+ "types": "./dist/esm/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/esm/index.d.ts",
19
+ "bun": "./src/index.ts",
20
+ "development": "./src/index.ts",
21
+ "import": "./dist/esm/index.js",
22
+ "require": "./dist/plugin.cjs.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "unpkg": "dist/plugin.js",
27
+ "files": [
28
+ "android/src/main/",
29
+ "android/build.gradle",
30
+ "ios/Sources/",
31
+ "ios/Plugin.xcodeproj/",
32
+ "*.podspec",
33
+ "dist"
34
+ ],
35
+ "author": "elizaOS",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/elizaOS/eliza"
40
+ },
41
+ "scripts": {
42
+ "lint": "bunx @biomejs/biome check .",
43
+ "lint:check": "bunx @biomejs/biome check .",
44
+ "fmt": "bunx @biomejs/biome check --write --unsafe .",
45
+ "format": "bunx @biomejs/biome format --write .",
46
+ "format:check": "bunx @biomejs/biome format .",
47
+ "build": "node ../../packages/scripts/with-package-build-lock.mjs plugins/plugin-native-mobile-agent-bridge -- bun run build:unlocked",
48
+ "clean": "node ../../packages/scripts/rm-path-recursive.mjs dist",
49
+ "test": "vitest run",
50
+ "watch": "tsc --watch",
51
+ "prepublishOnly": "bun run build",
52
+ "build:unlocked": "bun run clean && tsc && bunx rollup -c rollup.config.mjs"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "^2.4.14",
56
+ "@capacitor/core": "^8.3.1",
57
+ "rollup": "^4.60.2",
58
+ "typescript": "^6.0.3",
59
+ "vitest": "^4.0.0"
60
+ },
61
+ "peerDependencies": {
62
+ "@capacitor/core": "^8.3.1"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ },
67
+ "capacitor": {
68
+ "ios": {
69
+ "src": "ios",
70
+ "podName": "ElizaosCapacitorMobileAgentBridge"
71
+ },
72
+ "android": {
73
+ "src": "android"
74
+ }
75
+ },
76
+ "elizaos": {
77
+ "platforms": [
78
+ "browser"
79
+ ],
80
+ "runtime": "both",
81
+ "platformDetails": {
82
+ "browser": "Fallback for non-native runtimes. Real transport lives on iOS/Android only.",
83
+ "ios": true,
84
+ "android": true
85
+ }
86
+ },
87
+ "gitHead": "cdbc876f793d96073d7eb0d09715a031ce0cd32e"
88
+ }