426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
239 lines
8.7 KiB
Swift
239 lines
8.7 KiB
Swift
import Foundation
|
|
import Capacitor
|
|
import WebKit
|
|
|
|
@objc(MobileAgentBridgePlugin)
|
|
public class MobileAgentBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
public let identifier = "MobileAgentBridgePlugin"
|
|
public let jsName = "MobileAgentBridge"
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
CAPPluginMethod(name: "startInboundTunnel", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "stopInboundTunnel", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "getTunnelStatus", returnType: CAPPluginReturnPromise),
|
|
]
|
|
|
|
private var relayUrl: String?
|
|
private var deviceId: String?
|
|
private var pairingToken: String?
|
|
private var state: String = "idle"
|
|
private var lastError: String?
|
|
private var session: URLSession?
|
|
private var task: URLSessionWebSocketTask?
|
|
|
|
@objc func startInboundTunnel(_ call: CAPPluginCall) {
|
|
guard let relay = call.getString("relayUrl")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!relay.isEmpty else {
|
|
call.reject("MobileAgentBridge.startInboundTunnel requires relayUrl")
|
|
return
|
|
}
|
|
guard let id = call.getString("deviceId")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!id.isEmpty else {
|
|
call.reject("MobileAgentBridge.startInboundTunnel requires deviceId")
|
|
return
|
|
}
|
|
|
|
stopTunnel(notify: false)
|
|
relayUrl = relay
|
|
deviceId = id
|
|
pairingToken = call.getString("pairingToken")?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
guard let url = buildRelayUrl(relayUrl: relay, deviceId: id, token: pairingToken) else {
|
|
transition("error", reason: "Invalid relay URL: \(relay)")
|
|
call.resolve(status())
|
|
return
|
|
}
|
|
|
|
transition("connecting", reason: nil)
|
|
let session = URLSession(configuration: .default)
|
|
let task = session.webSocketTask(with: url)
|
|
self.session = session
|
|
self.task = task
|
|
task.resume()
|
|
receiveLoop()
|
|
sendFrame([
|
|
"type": "tunnel.register",
|
|
"role": "phone-agent",
|
|
"deviceId": id,
|
|
"pairingToken": pairingToken ?? NSNull(),
|
|
]) { [weak self] error in
|
|
if let error {
|
|
self?.transition("error", reason: error.localizedDescription)
|
|
} else {
|
|
self?.transition("registered", reason: nil)
|
|
}
|
|
}
|
|
call.resolve(status())
|
|
}
|
|
|
|
@objc func stopInboundTunnel(_ call: CAPPluginCall) {
|
|
stopTunnel(notify: true)
|
|
call.resolve()
|
|
}
|
|
|
|
@objc func getTunnelStatus(_ call: CAPPluginCall) {
|
|
call.resolve(status())
|
|
}
|
|
|
|
private func stopTunnel(notify: Bool) {
|
|
task?.cancel(with: .normalClosure, reason: nil)
|
|
session?.invalidateAndCancel()
|
|
task = nil
|
|
session = nil
|
|
relayUrl = nil
|
|
deviceId = nil
|
|
pairingToken = nil
|
|
lastError = nil
|
|
state = "idle"
|
|
if notify {
|
|
notifyListeners("stateChange", data: ["state": "idle"])
|
|
}
|
|
}
|
|
|
|
private func transition(_ next: String, reason: String?) {
|
|
state = next
|
|
lastError = next == "error" ? reason : nil
|
|
var event: [String: Any] = ["state": next]
|
|
if let reason { event["reason"] = reason }
|
|
notifyListeners("stateChange", data: event)
|
|
}
|
|
|
|
private func status() -> [String: Any] {
|
|
[
|
|
"state": state,
|
|
"relayUrl": relayUrl ?? NSNull(),
|
|
"deviceId": deviceId ?? NSNull(),
|
|
"lastError": lastError ?? NSNull(),
|
|
]
|
|
}
|
|
|
|
private func buildRelayUrl(relayUrl: String, deviceId: String, token: String?) -> URL? {
|
|
guard var components = URLComponents(string: relayUrl) else { return nil }
|
|
if components.scheme == "https" { components.scheme = "wss" }
|
|
if components.scheme == "http" { components.scheme = "ws" }
|
|
var items = components.queryItems ?? []
|
|
items.removeAll { $0.name == "deviceId" || $0.name == "token" }
|
|
items.append(URLQueryItem(name: "deviceId", value: deviceId))
|
|
if let token, !token.isEmpty {
|
|
items.append(URLQueryItem(name: "token", value: token))
|
|
}
|
|
components.queryItems = items
|
|
return components.url
|
|
}
|
|
|
|
private func receiveLoop() {
|
|
task?.receive { [weak self] result in
|
|
guard let self else { return }
|
|
switch result {
|
|
case .success(let message):
|
|
self.handle(message)
|
|
self.receiveLoop()
|
|
case .failure(let error):
|
|
if self.task != nil {
|
|
self.transition("error", reason: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handle(_ message: URLSessionWebSocketTask.Message) {
|
|
let text: String
|
|
switch message {
|
|
case .string(let value):
|
|
text = value
|
|
case .data(let data):
|
|
text = String(data: data, encoding: .utf8) ?? ""
|
|
@unknown default:
|
|
return
|
|
}
|
|
guard let data = text.data(using: .utf8),
|
|
let frame = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return
|
|
}
|
|
let type = frame["type"] as? String
|
|
guard type == "http_request" || type == "tunnel.http_request" || type == "agent.http_request" else {
|
|
return
|
|
}
|
|
proxyHttpRequest(frame)
|
|
}
|
|
|
|
private func proxyHttpRequest(_ frame: [String: Any]) {
|
|
let id = frame["id"] ?? NSNull()
|
|
let path = (frame["path"] as? String) ?? "/api/health"
|
|
guard path.starts(with: "/"), !path.starts(with: "//"), !path.contains("://") else {
|
|
sendFrame(["type": "http_response", "id": id, "status": 400, "headers": [:], "body": "Invalid local path"])
|
|
return
|
|
}
|
|
let method = ((frame["method"] as? String) ?? "GET").uppercased()
|
|
let headers = frame["headers"] as? [String: String] ?? [:]
|
|
let body = frame["body"] is NSNull ? nil : frame["body"] as? String
|
|
let timeoutMs = frame["timeoutMs"] as? Int ?? frame["timeout_ms"] as? Int ?? 30000
|
|
let options: [String: Any] = [
|
|
"method": method,
|
|
"path": path,
|
|
"headers": headers,
|
|
"body": body ?? NSNull(),
|
|
"timeoutMs": timeoutMs,
|
|
]
|
|
dispatchLocalRequest(options) { [weak self] response in
|
|
var out = response
|
|
out["type"] = "http_response"
|
|
out["id"] = id
|
|
self?.sendFrame(out)
|
|
}
|
|
}
|
|
|
|
private func dispatchLocalRequest(_ options: [String: Any], completion: @escaping ([String: Any]) -> Void) {
|
|
guard let webView = bridge?.webView else {
|
|
completion(["status": 0, "headers": [:], "body": "", "error": "WebView unavailable"])
|
|
return
|
|
}
|
|
let body = """
|
|
const handler = window.__ELIZA_BRIDGE__?.iosLocalAgentRequest;
|
|
if (typeof handler !== "function") {
|
|
throw new Error("iOS local agent IPC bridge is unavailable");
|
|
}
|
|
return await handler(options);
|
|
"""
|
|
DispatchQueue.main.async {
|
|
webView.callAsyncJavaScript(body, arguments: ["options": options], in: nil, in: .page) { result in
|
|
switch result {
|
|
case .success(let value):
|
|
if let dict = value as? [String: Any] {
|
|
completion(dict)
|
|
} else {
|
|
completion(["status": 0, "headers": [:], "body": "", "error": "Invalid IPC response"])
|
|
}
|
|
case .failure(let error):
|
|
completion(["status": 0, "headers": [:], "body": "", "error": error.localizedDescription])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendFrame(_ frame: [String: Any], completion: ((Error?) -> Void)? = nil) {
|
|
guard let task else {
|
|
completion?(NSError(
|
|
domain: "MobileAgentBridge",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "WebSocket is not connected"]
|
|
))
|
|
return
|
|
}
|
|
guard let data = try? JSONSerialization.data(withJSONObject: frame),
|
|
let text = String(data: data, encoding: .utf8) else {
|
|
completion?(NSError(
|
|
domain: "MobileAgentBridge",
|
|
code: 2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Failed to encode tunnel frame"]
|
|
))
|
|
return
|
|
}
|
|
task.send(.string(text)) { [weak self] error in
|
|
if let error {
|
|
self?.transition("error", reason: error.localizedDescription)
|
|
}
|
|
completion?(error)
|
|
}
|
|
}
|
|
}
|