@elizaos/capacitor-mobile-agent-bridge 2.0.3-beta.2
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.
- package/ElizaosCapacitorMobileAgentBridge.podspec +17 -0
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/android/build.gradle +59 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/ai/eliza/plugins/mobileagentbridge/MobileAgentBridgePlugin.kt +286 -0
- package/dist/esm/definitions.d.ts +94 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/web.d.ts +16 -0
- package/dist/esm/web.js +81 -0
- package/dist/esm/web.test.d.ts +1 -0
- package/dist/esm/web.test.js +62 -0
- package/dist/plugin.cjs.js +103 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +106 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/MobileAgentBridgePlugin/MobileAgentBridgePlugin.swift +237 -0
- package/package.json +88 -0
|
@@ -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,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
|
+
}
|
package/dist/esm/web.js
ADDED
|
@@ -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.3-beta.2",
|
|
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": "82fe0f44215954c2417328203f5bd6510985c1fc"
|
|
88
|
+
}
|