Merge e6f529f72d into da71eaebd2
This commit is contained in:
commit
0f77698a5a
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -156,6 +156,10 @@ jobs:
|
|||||||
pnpm -v
|
pnpm -v
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||||
|
|
||||||
|
- name: Prebuild A2UI bundle (bun tests)
|
||||||
|
if: ${{ matrix.runtime == 'bun' && matrix.task == 'test' }}
|
||||||
|
run: pnpm canvas:a2ui:bundle
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||||
run: ${{ matrix.command }}
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,9 @@ dependencies {
|
|||||||
// Material Components (XML theme + resources)
|
// Material Components (XML theme + resources)
|
||||||
implementation("com.google.android.material:material:1.13.0")
|
implementation("com.google.android.material:material:1.13.0")
|
||||||
|
|
||||||
|
// Crypto provider for Ed25519 on Android (some OEM builds lack reliable support).
|
||||||
|
implementation("org.bouncycastle:bcprov-jdk15to18:1.80")
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
val instanceId: StateFlow<String> = runtime.instanceId
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
|
val deviceIdentityStatusText: StateFlow<String> = runtime.deviceIdentityStatusText
|
||||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||||
@ -51,6 +52,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
||||||
|
|
||||||
|
val gatewayToken: StateFlow<String> = runtime.gatewayToken
|
||||||
|
val gatewayPassword: StateFlow<String> = runtime.gatewayPassword
|
||||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||||
|
|
||||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||||
@ -104,6 +108,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setManualTls(value)
|
runtime.setManualTls(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGatewayToken(value: String) {
|
||||||
|
runtime.setGatewayToken(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGatewayPassword(value: String) {
|
||||||
|
runtime.setGatewayPassword(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||||
runtime.setCanvasDebugStatusEnabled(value)
|
runtime.setCanvasDebugStatusEnabled(value)
|
||||||
}
|
}
|
||||||
@ -140,6 +152,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.disconnect()
|
runtime.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun requestNodePairing() {
|
||||||
|
runtime.requestNodePairing()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetDeviceIdentity() {
|
||||||
|
runtime.resetDeviceIdentity()
|
||||||
|
}
|
||||||
|
|
||||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,23 @@ package ai.openclaw.android
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
|
import java.security.Security
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
|
||||||
class NodeApp : Application() {
|
class NodeApp : Application() {
|
||||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
// Ensure a working provider for Ed25519/EdDSA. Some Android builds ship broken or missing support.
|
||||||
|
try {
|
||||||
|
Security.removeProvider("BC")
|
||||||
|
Security.insertProviderAt(BouncyCastleProvider(), 1)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy.Builder()
|
StrictMode.ThreadPolicy.Builder()
|
||||||
|
|||||||
@ -113,6 +113,15 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
private val identityStore = DeviceIdentityStore(appContext)
|
private val identityStore = DeviceIdentityStore(appContext)
|
||||||
|
|
||||||
|
fun resetDeviceIdentity() {
|
||||||
|
identityStore.resetIdentity()
|
||||||
|
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
|
||||||
|
refreshGatewayConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _deviceIdentityStatusText = MutableStateFlow(computeDeviceIdentityStatusText())
|
||||||
|
val deviceIdentityStatusText: StateFlow<String> = _deviceIdentityStatusText.asStateFlow()
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
private val _isConnected = MutableStateFlow(false)
|
||||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
@ -170,8 +179,11 @@ class NodeRuntime(context: Context) {
|
|||||||
onDisconnected = { message ->
|
onDisconnected = { message ->
|
||||||
operatorConnected = false
|
operatorConnected = false
|
||||||
operatorStatusText = message
|
operatorStatusText = message
|
||||||
_serverName.value = null
|
// If the node session is still connected, keep server/remote around so Settings stays usable.
|
||||||
_remoteAddress.value = null
|
if (!nodeConnected) {
|
||||||
|
_serverName.value = null
|
||||||
|
_remoteAddress.value = null
|
||||||
|
}
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||||
_mainSessionKey.value = "main"
|
_mainSessionKey.value = "main"
|
||||||
@ -192,15 +204,22 @@ class NodeRuntime(context: Context) {
|
|||||||
scope = scope,
|
scope = scope,
|
||||||
identityStore = identityStore,
|
identityStore = identityStore,
|
||||||
deviceAuthStore = deviceAuthStore,
|
deviceAuthStore = deviceAuthStore,
|
||||||
onConnected = { _, _, _ ->
|
onConnected = { name, remote, _ ->
|
||||||
nodeConnected = true
|
nodeConnected = true
|
||||||
nodeStatusText = "Connected"
|
nodeStatusText = "Connected"
|
||||||
|
// When operator auth is failing, node can still connect; keep UI usable.
|
||||||
|
if (_serverName.value == null) _serverName.value = name
|
||||||
|
if (_remoteAddress.value == null) _remoteAddress.value = remote
|
||||||
updateStatus()
|
updateStatus()
|
||||||
maybeNavigateToA2uiOnConnect()
|
maybeNavigateToA2uiOnConnect()
|
||||||
},
|
},
|
||||||
onDisconnected = { message ->
|
onDisconnected = { message ->
|
||||||
nodeConnected = false
|
nodeConnected = false
|
||||||
nodeStatusText = message
|
nodeStatusText = message
|
||||||
|
if (!operatorConnected) {
|
||||||
|
_serverName.value = null
|
||||||
|
_remoteAddress.value = null
|
||||||
|
}
|
||||||
updateStatus()
|
updateStatus()
|
||||||
showLocalCanvasOnDisconnect()
|
showLocalCanvasOnDisconnect()
|
||||||
},
|
},
|
||||||
@ -241,7 +260,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateStatus() {
|
private fun updateStatus() {
|
||||||
_isConnected.value = operatorConnected
|
_isConnected.value = operatorConnected || nodeConnected
|
||||||
_statusText.value =
|
_statusText.value =
|
||||||
when {
|
when {
|
||||||
operatorConnected && nodeConnected -> "Connected"
|
operatorConnected && nodeConnected -> "Connected"
|
||||||
@ -284,6 +303,9 @@ class NodeRuntime(context: Context) {
|
|||||||
val manualHost: StateFlow<String> = prefs.manualHost
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||||
|
|
||||||
|
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
||||||
|
val gatewayPassword: StateFlow<String> = prefs.gatewayPassword
|
||||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||||
|
|
||||||
@ -428,6 +450,14 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setManualTls(value)
|
prefs.setManualTls(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGatewayToken(value: String) {
|
||||||
|
prefs.setGatewayToken(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGatewayPassword(value: String) {
|
||||||
|
prefs.setGatewayPassword(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||||
prefs.setCanvasDebugStatusEnabled(value)
|
prefs.setCanvasDebugStatusEnabled(value)
|
||||||
}
|
}
|
||||||
@ -529,25 +559,52 @@ class NodeRuntime(context: Context) {
|
|||||||
caps = buildCapabilities(),
|
caps = buildCapabilities(),
|
||||||
commands = buildInvokeCommands(),
|
commands = buildInvokeCommands(),
|
||||||
permissions = emptyMap(),
|
permissions = emptyMap(),
|
||||||
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
|
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||||
userAgent = buildUserAgent(),
|
userAgent = buildUserAgent(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun computeDeviceIdentityStatusText(): String {
|
||||||
|
return try {
|
||||||
|
val identity = identityStore.loadOrCreate()
|
||||||
|
val publicKey = identityStore.publicKeyBase64Url(identity)?.trim().orEmpty()
|
||||||
|
val signature = identityStore.signPayload("clawdbot:identity-probe", identity)?.trim().orEmpty()
|
||||||
|
|
||||||
|
// Fallback identity uses a placeholder public key (Base64 of a single 0 byte is "AA==").
|
||||||
|
val placeholder = identity.publicKeyRawBase64.trim() == "AA==" || identity.privateKeyPkcs8Base64.trim() == "AA=="
|
||||||
|
|
||||||
|
when {
|
||||||
|
placeholder -> "Missing (crypto fallback)"
|
||||||
|
publicKey.isEmpty() -> "Missing (no public key)"
|
||||||
|
signature.isEmpty() -> "Missing (cannot sign)"
|
||||||
|
else -> "OK"
|
||||||
|
}
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
"Missing (${err::class.java.simpleName})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
private fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
||||||
|
// IMPORTANT:
|
||||||
|
// Do NOT use the CONTROL_UI client id here. The gateway enforces extra "secure context"
|
||||||
|
// rules for the Control UI (HTTPS/localhost), and our Android operator session is a native
|
||||||
|
// client over WS/WSS.
|
||||||
return GatewayConnectOptions(
|
return GatewayConnectOptions(
|
||||||
role = "operator",
|
role = "operator",
|
||||||
scopes = emptyList(),
|
scopes = emptyList(),
|
||||||
caps = emptyList(),
|
caps = emptyList(),
|
||||||
commands = emptyList(),
|
commands = emptyList(),
|
||||||
permissions = emptyMap(),
|
permissions = emptyMap(),
|
||||||
client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"),
|
// Use a known client id/mode so the gateway accepts the handshake.
|
||||||
|
// Avoid CONTROL_UI (web control panel) which enforces secure-context rules.
|
||||||
|
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "ui"),
|
||||||
userAgent = buildUserAgent(),
|
userAgent = buildUserAgent(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshGatewayConnection() {
|
fun refreshGatewayConnection() {
|
||||||
val endpoint = connectedEndpoint ?: return
|
val endpoint = connectedEndpoint ?: return
|
||||||
|
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
|
||||||
val token = prefs.loadGatewayToken()
|
val token = prefs.loadGatewayToken()
|
||||||
val password = prefs.loadGatewayPassword()
|
val password = prefs.loadGatewayPassword()
|
||||||
val tls = resolveTlsParams(endpoint)
|
val tls = resolveTlsParams(endpoint)
|
||||||
@ -562,6 +619,7 @@ class NodeRuntime(context: Context) {
|
|||||||
operatorStatusText = "Connecting…"
|
operatorStatusText = "Connecting…"
|
||||||
nodeStatusText = "Connecting…"
|
nodeStatusText = "Connecting…"
|
||||||
updateStatus()
|
updateStatus()
|
||||||
|
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
|
||||||
val token = prefs.loadGatewayToken()
|
val token = prefs.loadGatewayToken()
|
||||||
val password = prefs.loadGatewayPassword()
|
val password = prefs.loadGatewayPassword()
|
||||||
val tls = resolveTlsParams(endpoint)
|
val tls = resolveTlsParams(endpoint)
|
||||||
@ -613,6 +671,52 @@ class NodeRuntime(context: Context) {
|
|||||||
nodeSession.disconnect()
|
nodeSession.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun requestNodePairing() {
|
||||||
|
val endpoint = connectedEndpoint
|
||||||
|
if (!_isConnected.value || endpoint == null) {
|
||||||
|
_statusText.value = "Pairing failed: not connected"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val params =
|
||||||
|
buildJsonObject {
|
||||||
|
// IMPORTANT: Pair the actual nodeId used by the gateway for this device.
|
||||||
|
// With device identity enabled, this will be the deviceId (sha256 of public key).
|
||||||
|
val nodeId = identityStore.loadOrCreate().deviceId
|
||||||
|
put("nodeId", JsonPrimitive(nodeId))
|
||||||
|
put("displayName", JsonPrimitive(displayName.value))
|
||||||
|
put("platform", JsonPrimitive("android"))
|
||||||
|
put("version", JsonPrimitive(resolvedVersionName()))
|
||||||
|
put("deviceFamily", JsonPrimitive("Android"))
|
||||||
|
resolveModelIdentifier()?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
put("caps", JsonArray(buildCapabilities().map(::JsonPrimitive)))
|
||||||
|
put("commands", JsonArray(buildInvokeCommands().map(::JsonPrimitive)))
|
||||||
|
put("silent", JsonPrimitive(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
val res = operatorSession.request("node.pair.request", params.toString(), timeoutMs = 15_000)
|
||||||
|
val requestId =
|
||||||
|
try {
|
||||||
|
val obj = json.parseToJsonElement(res).asObjectOrNull()
|
||||||
|
obj?.get("request")?.asObjectOrNull()?.get("requestId")?.asStringOrNull()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
_statusText.value =
|
||||||
|
if (requestId.isNullOrBlank()) {
|
||||||
|
"Pairing requested"
|
||||||
|
} else {
|
||||||
|
"Pairing requested: $requestId"
|
||||||
|
}
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
_statusText.value = "Pairing failed: ${err.message ?: err::class.java.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||||
@ -667,51 +771,123 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||||
|
|
||||||
val surfaceId =
|
// Extract context as key/value map (supports literal* values).
|
||||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
fun contextString(key: String): String? {
|
||||||
val sourceComponentId =
|
val ctx = userActionObj["context"] as? JsonObject ?: return null
|
||||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
val entry = ctx[key] as? JsonPrimitive ?: return null
|
||||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
return entry.content.trim().takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
val sessionKey = resolveMainSessionKey()
|
fun contextNumber(key: String): Double? {
|
||||||
val message =
|
val ctx = userActionObj["context"] as? JsonObject ?: return null
|
||||||
OpenClawCanvasA2UIAction.formatAgentMessage(
|
val entry = ctx[key] as? JsonPrimitive ?: return null
|
||||||
actionName = name,
|
return entry.content.toDoubleOrNull()
|
||||||
sessionKey = sessionKey,
|
}
|
||||||
surfaceId = surfaceId,
|
|
||||||
sourceComponentId = sourceComponentId,
|
|
||||||
host = displayName.value,
|
|
||||||
instanceId = instanceId.value.lowercase(),
|
|
||||||
contextJson = contextJson,
|
|
||||||
)
|
|
||||||
|
|
||||||
val connected = nodeConnected
|
val nodeId = contextString("nodeId")
|
||||||
|
|
||||||
|
var ok = false
|
||||||
var error: String? = null
|
var error: String? = null
|
||||||
if (connected) {
|
|
||||||
try {
|
// If the action name matches a known node command, invoke it directly via the operator session.
|
||||||
nodeSession.sendNodeEvent(
|
// (Do not require operatorConnected here; if it's offline we'll return a visible error.)
|
||||||
event = "agent.request",
|
val (command, params) =
|
||||||
payloadJson =
|
when (name) {
|
||||||
|
"camera.snap" -> "camera.snap" to buildJsonObject { }
|
||||||
|
"camera.clip" -> {
|
||||||
|
val durationSec = (contextNumber("durationSec") ?: 5.0).toLong().coerceIn(1, 60)
|
||||||
|
"camera.clip" to
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("message", JsonPrimitive(message))
|
put("durationMs", JsonPrimitive(durationSec * 1000))
|
||||||
put("sessionKey", JsonPrimitive(sessionKey))
|
put("includeAudio", JsonPrimitive(true))
|
||||||
put("thinking", JsonPrimitive("low"))
|
}
|
||||||
put("deliver", JsonPrimitive(false))
|
}
|
||||||
put("key", JsonPrimitive(actionId))
|
"screen.record" -> {
|
||||||
}.toString(),
|
val durationSec = (contextNumber("durationSec") ?: 15.0).toLong().coerceIn(1, 180)
|
||||||
)
|
"screen.record" to
|
||||||
} catch (e: Throwable) {
|
buildJsonObject {
|
||||||
error = e.message ?: "send failed"
|
put("durationMs", JsonPrimitive(durationSec * 1000))
|
||||||
|
put("includeAudio", JsonPrimitive(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"location.get" ->
|
||||||
|
"location.get" to
|
||||||
|
buildJsonObject {
|
||||||
|
put("timeoutMs", JsonPrimitive(10_000))
|
||||||
|
put("desiredAccuracy", JsonPrimitive("balanced"))
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
} ?: (null to null)
|
||||||
|
|
||||||
|
val isDirectNodeCommand = command != null && params != null
|
||||||
|
if (isDirectNodeCommand) {
|
||||||
|
if (nodeId == null) {
|
||||||
|
error = "missing nodeId in action context"
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
val invokeParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("nodeId", JsonPrimitive(nodeId))
|
||||||
|
put("command", JsonPrimitive(command!!))
|
||||||
|
put("params", params!!)
|
||||||
|
put("timeoutMs", JsonPrimitive(30_000))
|
||||||
|
put("idempotencyKey", JsonPrimitive(actionId))
|
||||||
|
}
|
||||||
|
operatorSession.request("node.invoke", invokeParams.toString(), timeoutMs = 35_000)
|
||||||
|
ok = true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
error = e.message ?: "invoke failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to agent request only for unknown action names.
|
||||||
|
if (!ok && error == null && !isDirectNodeCommand) {
|
||||||
|
val surfaceId =
|
||||||
|
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||||
|
val sourceComponentId =
|
||||||
|
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||||
|
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||||
|
|
||||||
|
val sessionKey = resolveMainSessionKey()
|
||||||
|
val message =
|
||||||
|
OpenClawCanvasA2UIAction.formatAgentMessage(
|
||||||
|
actionName = name,
|
||||||
|
sessionKey = sessionKey,
|
||||||
|
surfaceId = surfaceId,
|
||||||
|
sourceComponentId = sourceComponentId,
|
||||||
|
host = displayName.value,
|
||||||
|
instanceId = instanceId.value.lowercase(),
|
||||||
|
contextJson = contextJson,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (nodeConnected) {
|
||||||
|
try {
|
||||||
|
nodeSession.sendNodeEvent(
|
||||||
|
event = "agent.request",
|
||||||
|
payloadJson =
|
||||||
|
buildJsonObject {
|
||||||
|
put("message", JsonPrimitive(message))
|
||||||
|
put("sessionKey", JsonPrimitive(sessionKey))
|
||||||
|
put("thinking", JsonPrimitive("low"))
|
||||||
|
put("deliver", JsonPrimitive(false))
|
||||||
|
put("key", JsonPrimitive(actionId))
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
ok = true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
error = e.message ?: "send failed"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = "gateway not connected"
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
error = "gateway not connected"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
canvas.eval(
|
canvas.eval(
|
||||||
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||||
actionId = actionId,
|
actionId = actionId,
|
||||||
ok = connected && error == null,
|
ok = ok && error == null,
|
||||||
error = error,
|
error = error,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -1115,7 +1291,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||||
if (raw.isBlank()) return null
|
if (raw.isBlank()) return null
|
||||||
val base = raw.trimEnd('/')
|
val base = raw.trimEnd('/')
|
||||||
return "${base}/__openclaw__/a2ui/?platform=android"
|
return "${base}/__clawdbot__/a2ui/?platform=android"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||||
@ -1207,8 +1383,7 @@ private const val a2uiReadyCheckJS: String =
|
|||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const host = globalThis.openclawA2UI;
|
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
|
||||||
return !!host && typeof host.applyMessages === 'function';
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -1219,9 +1394,8 @@ private const val a2uiResetJS: String =
|
|||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const host = globalThis.openclawA2UI;
|
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
return globalThis.clawdbotA2UI.reset();
|
||||||
return host.reset();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e?.message ?? e) };
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
}
|
}
|
||||||
@ -1232,10 +1406,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
|||||||
return """
|
return """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const host = globalThis.openclawA2UI;
|
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
|
||||||
const messages = $messagesJson;
|
const messages = $messagesJson;
|
||||||
return host.applyMessages(messages);
|
return globalThis.clawdbotA2UI.applyMessages(messages);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e?.message ?? e) };
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,6 +71,16 @@ class SecurePrefs(context: Context) {
|
|||||||
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
||||||
val manualTls: StateFlow<Boolean> = _manualTls
|
val manualTls: StateFlow<Boolean> = _manualTls
|
||||||
|
|
||||||
|
// Gateway auth (used for both discovery + manual connects)
|
||||||
|
private val _gatewayToken = MutableStateFlow(loadGatewayToken().orEmpty())
|
||||||
|
val gatewayToken: StateFlow<String> = _gatewayToken
|
||||||
|
|
||||||
|
private val _gatewayPassword = MutableStateFlow(loadGatewayPassword().orEmpty())
|
||||||
|
val gatewayPassword: StateFlow<String> = _gatewayPassword
|
||||||
|
|
||||||
|
// NOTE: node pairing tokens are not valid gateway connect auth tokens.
|
||||||
|
// Keep node pairing state on the gateway; do not store a separate node token here.
|
||||||
|
|
||||||
private val _lastDiscoveredStableId =
|
private val _lastDiscoveredStableId =
|
||||||
MutableStateFlow(
|
MutableStateFlow(
|
||||||
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||||
@ -143,6 +153,26 @@ class SecurePrefs(context: Context) {
|
|||||||
_manualTls.value = value
|
_manualTls.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGatewayToken(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
val key = "gateway.token.${_instanceId.value}"
|
||||||
|
prefs.edit {
|
||||||
|
if (trimmed.isEmpty()) remove(key) else putString(key, trimmed)
|
||||||
|
}
|
||||||
|
_gatewayToken.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGatewayPassword(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
val key = "gateway.password.${_instanceId.value}"
|
||||||
|
prefs.edit {
|
||||||
|
if (trimmed.isEmpty()) remove(key) else putString(key, trimmed)
|
||||||
|
}
|
||||||
|
_gatewayPassword.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
// node token setter removed (see note above)
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||||
_canvasDebugStatusEnabled.value = value
|
_canvasDebugStatusEnabled.value = value
|
||||||
@ -157,6 +187,7 @@ class SecurePrefs(context: Context) {
|
|||||||
fun saveGatewayToken(token: String) {
|
fun saveGatewayToken(token: String) {
|
||||||
val key = "gateway.token.${_instanceId.value}"
|
val key = "gateway.token.${_instanceId.value}"
|
||||||
prefs.edit { putString(key, token.trim()) }
|
prefs.edit { putString(key, token.trim()) }
|
||||||
|
_gatewayToken.value = token.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadGatewayPassword(): String? {
|
fun loadGatewayPassword(): String? {
|
||||||
@ -168,8 +199,11 @@ class SecurePrefs(context: Context) {
|
|||||||
fun saveGatewayPassword(password: String) {
|
fun saveGatewayPassword(password: String) {
|
||||||
val key = "gateway.password.${_instanceId.value}"
|
val key = "gateway.password.${_instanceId.value}"
|
||||||
prefs.edit { putString(key, password.trim()) }
|
prefs.edit { putString(key, password.trim()) }
|
||||||
|
_gatewayPassword.value = password.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// node token loader removed (see note above)
|
||||||
|
|
||||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||||
val key = "gateway.tls.$stableId"
|
val key = "gateway.tls.$stableId"
|
||||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import java.security.KeyFactory
|
|||||||
import java.security.KeyPairGenerator
|
import java.security.KeyPairGenerator
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.Signature
|
import java.security.Signature
|
||||||
|
import java.security.Security
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.util.UUID
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@ -23,6 +25,14 @@ class DeviceIdentityStore(context: Context) {
|
|||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||||
|
|
||||||
|
fun resetIdentity() {
|
||||||
|
try {
|
||||||
|
if (identityFile.exists()) identityFile.delete()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun loadOrCreate(): DeviceIdentity {
|
fun loadOrCreate(): DeviceIdentity {
|
||||||
val existing = load()
|
val existing = load()
|
||||||
@ -44,9 +54,11 @@ class DeviceIdentityStore(context: Context) {
|
|||||||
return try {
|
return try {
|
||||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||||
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
||||||
val keyFactory = KeyFactory.getInstance("Ed25519")
|
val keyFactory = runCatching { KeyFactory.getInstance("Ed25519", "BC") }.getOrNull()
|
||||||
|
?: KeyFactory.getInstance("Ed25519")
|
||||||
val privateKey = keyFactory.generatePrivate(keySpec)
|
val privateKey = keyFactory.generatePrivate(keySpec)
|
||||||
val signature = Signature.getInstance("Ed25519")
|
val signature = runCatching { Signature.getInstance("Ed25519", "BC") }.getOrNull()
|
||||||
|
?: Signature.getInstance("Ed25519")
|
||||||
signature.initSign(privateKey)
|
signature.initSign(privateKey)
|
||||||
signature.update(payload.toByteArray(Charsets.UTF_8))
|
signature.update(payload.toByteArray(Charsets.UTF_8))
|
||||||
base64UrlEncode(signature.sign())
|
base64UrlEncode(signature.sign())
|
||||||
@ -97,17 +109,33 @@ class DeviceIdentityStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun generate(): DeviceIdentity {
|
private fun generate(): DeviceIdentity {
|
||||||
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
|
// Some Android builds/devices can be missing Ed25519 support at runtime (or have
|
||||||
val spki = keyPair.public.encoded
|
// broken providers). The gateway can still accept a token-only connection, so
|
||||||
val rawPublic = stripSpkiPrefix(spki)
|
// we fall back to a "no-signature" identity rather than crashing.
|
||||||
val deviceId = sha256Hex(rawPublic)
|
return try {
|
||||||
val privateKey = keyPair.private.encoded
|
// Prefer BC provider when available.
|
||||||
return DeviceIdentity(
|
val kpg = runCatching { KeyPairGenerator.getInstance("Ed25519", "BC") }.getOrNull()
|
||||||
deviceId = deviceId,
|
?: KeyPairGenerator.getInstance("Ed25519")
|
||||||
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
val keyPair = kpg.generateKeyPair()
|
||||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
|
val spki = keyPair.public.encoded
|
||||||
createdAtMs = System.currentTimeMillis(),
|
val rawPublic = stripSpkiPrefix(spki)
|
||||||
)
|
val deviceId = sha256Hex(rawPublic)
|
||||||
|
val privateKey = keyPair.private.encoded
|
||||||
|
DeviceIdentity(
|
||||||
|
deviceId = deviceId,
|
||||||
|
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
||||||
|
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
|
||||||
|
createdAtMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
val placeholder = Base64.encodeToString(byteArrayOf(0), Base64.NO_WRAP)
|
||||||
|
DeviceIdentity(
|
||||||
|
deviceId = UUID.randomUUID().toString(),
|
||||||
|
publicKeyRawBase64 = placeholder,
|
||||||
|
privateKeyPkcs8Base64 = placeholder,
|
||||||
|
createdAtMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
|
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
|
||||||
|
|||||||
@ -68,6 +68,7 @@ class CanvasController {
|
|||||||
|
|
||||||
fun onPageFinished() {
|
fun onPageFinished() {
|
||||||
applyDebugStatus()
|
applyDebugStatus()
|
||||||
|
injectA2uiActionBridgeCompat()
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||||
@ -122,6 +123,37 @@ class CanvasController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some WebView/JS-bridge combos don't reliably preserve `handler === globalThis.clawdbotCanvasA2UIAction`,
|
||||||
|
// which makes the A2UI bundle try to post an object instead of a string. Android JS interfaces only
|
||||||
|
// accept primitives, so clicks appear to do nothing. This shim forces string payloads.
|
||||||
|
private fun injectA2uiActionBridgeCompat() {
|
||||||
|
withWebViewOnMain { wv ->
|
||||||
|
val js =
|
||||||
|
"""
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const native = globalThis.clawdbotCanvasA2UIAction;
|
||||||
|
if (!native || typeof native.postMessage !== 'function') return;
|
||||||
|
if (native.__clawdbotWrapped) return;
|
||||||
|
const wrapper = {
|
||||||
|
__clawdbotWrapped: true,
|
||||||
|
postMessage: (payload) => {
|
||||||
|
try {
|
||||||
|
if (typeof payload === 'string') return native.postMessage(payload);
|
||||||
|
return native.postMessage(JSON.stringify(payload));
|
||||||
|
} catch (e) {
|
||||||
|
try { native.postMessage(String(payload)); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
globalThis.clawdbotCanvasA2UIAction = wrapper;
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
wv.evaluateJavascript(js, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun eval(javaScript: String): String =
|
suspend fun eval(javaScript: String): String =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val wv = webView ?: throw IllegalStateException("no webview")
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
|
|||||||
@ -70,6 +70,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val instanceId by viewModel.instanceId.collectAsState()
|
val instanceId by viewModel.instanceId.collectAsState()
|
||||||
val displayName by viewModel.displayName.collectAsState()
|
val displayName by viewModel.displayName.collectAsState()
|
||||||
|
val deviceIdentityStatusText by viewModel.deviceIdentityStatusText.collectAsState()
|
||||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||||
val locationMode by viewModel.locationMode.collectAsState()
|
val locationMode by viewModel.locationMode.collectAsState()
|
||||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||||
@ -82,6 +83,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val manualHost by viewModel.manualHost.collectAsState()
|
val manualHost by viewModel.manualHost.collectAsState()
|
||||||
val manualPort by viewModel.manualPort.collectAsState()
|
val manualPort by viewModel.manualPort.collectAsState()
|
||||||
val manualTls by viewModel.manualTls.collectAsState()
|
val manualTls by viewModel.manualTls.collectAsState()
|
||||||
|
val gatewayToken by viewModel.gatewayToken.collectAsState()
|
||||||
|
val gatewayPassword by viewModel.gatewayPassword.collectAsState()
|
||||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||||
val statusText by viewModel.statusText.collectAsState()
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
val serverName by viewModel.serverName.collectAsState()
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
@ -277,6 +280,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
item { Text("Identity: $deviceIdentityStatusText", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
item { Button(onClick = viewModel::resetDeviceIdentity) { Text("Reset identity") } }
|
||||||
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
|
||||||
@ -292,15 +297,26 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
|
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
// UI sanity: "Disconnect" only when we have an active remote.
|
// Keep Disconnect/Pairing visible even if only the node session is connected.
|
||||||
if (isConnected && remoteAddress != null) {
|
if (isConnected) {
|
||||||
Button(
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
onClick = {
|
Button(
|
||||||
viewModel.disconnect()
|
onClick = {
|
||||||
NodeForegroundService.stop(context)
|
viewModel.disconnect()
|
||||||
},
|
NodeForegroundService.stop(context)
|
||||||
) {
|
},
|
||||||
Text("Disconnect")
|
) {
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
// Triggers a node pairing request (so it appears in `clawdbot nodes pending`).
|
||||||
|
viewModel.requestNodePairing()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Request Pairing")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -403,6 +419,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = manualEnabled,
|
enabled = manualEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = gatewayToken,
|
||||||
|
onValueChange = viewModel::setGatewayToken,
|
||||||
|
label = { Text("Gateway token") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = manualEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = gatewayPassword,
|
||||||
|
onValueChange = viewModel::setGatewayPassword,
|
||||||
|
label = { Text("Gateway password") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = manualEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node pairing token removed: gateway does not accept node pairing tokens as connect auth.
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Require TLS") },
|
headlineContent = { Text("Require TLS") },
|
||||||
supportingContent = { Text("Pin the gateway certificate on first connect.") },
|
supportingContent = { Text("Pin the gateway certificate on first connect.") },
|
||||||
|
|||||||
112
scripts/bundle-a2ui.ts
Normal file
112
scripts/bundle-a2ui.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
const HASH_FILE = path.join(repoRoot, "src", "canvas-host", "a2ui", ".bundle.hash");
|
||||||
|
const OUTPUT_FILE = path.join(repoRoot, "src", "canvas-host", "a2ui", "a2ui.bundle.js");
|
||||||
|
|
||||||
|
const INPUT_PATHS = [
|
||||||
|
path.join(repoRoot, "package.json"),
|
||||||
|
path.join(repoRoot, "pnpm-lock.yaml"),
|
||||||
|
path.join(repoRoot, "vendor", "a2ui", "renderers", "lit"),
|
||||||
|
path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"),
|
||||||
|
];
|
||||||
|
|
||||||
|
async function exists(p: string) {
|
||||||
|
try {
|
||||||
|
await fs.stat(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFiles(root: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
async function walk(dir: string) {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
for (const ent of entries) {
|
||||||
|
const full = path.join(dir, ent.name);
|
||||||
|
if (ent.isDirectory()) {
|
||||||
|
await walk(full);
|
||||||
|
} else if (ent.isFile()) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectInputFiles(): Promise<string[]> {
|
||||||
|
const files: string[] = [];
|
||||||
|
for (const p of INPUT_PATHS) {
|
||||||
|
const st = await fs.stat(p);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
files.push(...(await listFiles(p)));
|
||||||
|
} else {
|
||||||
|
files.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort((a, b) => a.localeCompare(b, "en"));
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256File(filePath: string): Promise<string> {
|
||||||
|
const buf = await fs.readFile(filePath);
|
||||||
|
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeHash(): Promise<string> {
|
||||||
|
const inputs = await collectInputFiles();
|
||||||
|
const h = crypto.createHash("sha256");
|
||||||
|
for (const p of inputs) {
|
||||||
|
// include relative path to avoid collisions and keep stable ordering
|
||||||
|
const rel = path.relative(repoRoot, p).replace(/\\/g, "/");
|
||||||
|
h.update(rel);
|
||||||
|
h.update("\0");
|
||||||
|
h.update(await fs.readFile(p));
|
||||||
|
h.update("\0");
|
||||||
|
}
|
||||||
|
return h.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], label: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { stdio: "inherit", cwd: repoRoot, shell: process.platform === "win32" });
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (code === 0) return resolve();
|
||||||
|
reject(new Error(`${label} failed (code=${code ?? "?"}${signal ? ` signal=${signal}` : ""})`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const currentHash = await computeHash();
|
||||||
|
const previousHash = (await exists(HASH_FILE)) ? (await fs.readFile(HASH_FILE, "utf8")).trim() : null;
|
||||||
|
|
||||||
|
if (previousHash && previousHash === currentHash && (await exists(OUTPUT_FILE))) {
|
||||||
|
console.log("A2UI bundle up to date; skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build vendor A2UI lit renderer TS output, then bundle our bootstrap.
|
||||||
|
await run("pnpm", ["-s", "exec", "tsc", "-p", "vendor/a2ui/renderers/lit/tsconfig.json"], "A2UI lit tsc");
|
||||||
|
await run("pnpm", ["-s", "exec", "rolldown", "-c", "apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs"], "A2UI rolldown");
|
||||||
|
|
||||||
|
await fs.writeFile(HASH_FILE, `${currentHash}\n`, "utf8");
|
||||||
|
|
||||||
|
// sanity
|
||||||
|
const outHash = await sha256File(OUTPUT_FILE);
|
||||||
|
if (!outHash) throw new Error("A2UI bundle not generated");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle");
|
||||||
|
console.error(String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -3,6 +3,16 @@ import os from "node:os";
|
|||||||
|
|
||||||
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||||
|
|
||||||
|
function runOnce(args, label) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(pnpm, args, { stdio: "inherit", shell: process.platform === "win32" });
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (code === 0) return resolve();
|
||||||
|
reject(new Error(`${label} failed (code=${code ?? "?"}${signal ? ` signal=${signal}` : ""})`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const runs = [
|
const runs = [
|
||||||
{
|
{
|
||||||
name: "unit",
|
name: "unit",
|
||||||
@ -44,7 +54,7 @@ const WARNING_SUPPRESSION_FLAGS = [
|
|||||||
"--disable-warning=DEP0060",
|
"--disable-warning=DEP0060",
|
||||||
];
|
];
|
||||||
|
|
||||||
const runOnce = (entry, extraArgs = []) =>
|
const runTestOnce = (entry, extraArgs = []) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
const args = maxWorkers
|
const args = maxWorkers
|
||||||
? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs]
|
? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs]
|
||||||
@ -67,10 +77,10 @@ const runOnce = (entry, extraArgs = []) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const run = async (entry) => {
|
const run = async (entry) => {
|
||||||
if (shardCount <= 1) return runOnce(entry);
|
if (shardCount <= 1) return runTestOnce(entry);
|
||||||
for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) {
|
for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]);
|
const code = await runTestOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]);
|
||||||
if (code !== 0) return code;
|
if (code !== 0) return code;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@ -85,6 +95,10 @@ const shutdown = (signal) => {
|
|||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
// Some unit tests expect the gateway-hosted A2UI scaffold + bundle to be present.
|
||||||
|
// The bundle is a generated artifact (gitignored), so ensure it's built before running Vitest.
|
||||||
|
await runOnce(["canvas:a2ui:bundle"], "A2UI bundle");
|
||||||
|
|
||||||
const parallelCodes = await Promise.all(parallelRuns.map(run));
|
const parallelCodes = await Promise.all(parallelRuns.map(run));
|
||||||
const failedParallel = parallelCodes.find((code) => code !== 0);
|
const failedParallel = parallelCodes.find((code) => code !== 0);
|
||||||
if (failedParallel !== undefined) {
|
if (failedParallel !== undefined) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user