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 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 }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
|
||||
@ -98,6 +98,9 @@ dependencies {
|
||||
// Material Components (XML theme + resources)
|
||||
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-serialization-json:1.9.0")
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val deviceIdentityStatusText: StateFlow<String> = runtime.deviceIdentityStatusText
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
@ -51,6 +52,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
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 chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
@ -104,6 +108,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
runtime.setGatewayToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayPassword(value: String) {
|
||||
runtime.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
@ -140,6 +152,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun requestNodePairing() {
|
||||
runtime.requestNodePairing()
|
||||
}
|
||||
|
||||
fun resetDeviceIdentity() {
|
||||
runtime.resetDeviceIdentity()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
@ -2,12 +2,23 @@ package ai.openclaw.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import java.security.Security
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
|
||||
override fun 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) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
|
||||
@ -113,6 +113,15 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
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)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
@ -170,8 +179,11 @@ class NodeRuntime(context: Context) {
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
operatorStatusText = message
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
// If the node session is still connected, keep server/remote around so Settings stays usable.
|
||||
if (!nodeConnected) {
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
}
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||
_mainSessionKey.value = "main"
|
||||
@ -192,15 +204,22 @@ class NodeRuntime(context: Context) {
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
onConnected = { name, remote, _ ->
|
||||
nodeConnected = true
|
||||
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()
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
nodeConnected = false
|
||||
nodeStatusText = message
|
||||
if (!operatorConnected) {
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
}
|
||||
updateStatus()
|
||||
showLocalCanvasOnDisconnect()
|
||||
},
|
||||
@ -241,7 +260,7 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
_isConnected.value = operatorConnected
|
||||
_isConnected.value = operatorConnected || nodeConnected
|
||||
_statusText.value =
|
||||
when {
|
||||
operatorConnected && nodeConnected -> "Connected"
|
||||
@ -284,6 +303,9 @@ class NodeRuntime(context: Context) {
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
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 canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
@ -428,6 +450,14 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
prefs.setGatewayToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayPassword(value: String) {
|
||||
prefs.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
@ -529,25 +559,52 @@ class NodeRuntime(context: Context) {
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
|
||||
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||
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 {
|
||||
// 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(
|
||||
role = "operator",
|
||||
scopes = emptyList(),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint = connectedEndpoint ?: return
|
||||
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
@ -562,6 +619,7 @@ class NodeRuntime(context: Context) {
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
@ -613,6 +671,52 @@ class NodeRuntime(context: Context) {
|
||||
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? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
@ -667,51 +771,123 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||
|
||||
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()
|
||||
// Extract context as key/value map (supports literal* values).
|
||||
fun contextString(key: String): String? {
|
||||
val ctx = userActionObj["context"] as? JsonObject ?: return null
|
||||
val entry = ctx[key] as? JsonPrimitive ?: return null
|
||||
return entry.content.trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
val sessionKey = resolveMainSessionKey()
|
||||
val message =
|
||||
OpenClawCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
sessionKey = sessionKey,
|
||||
surfaceId = surfaceId,
|
||||
sourceComponentId = sourceComponentId,
|
||||
host = displayName.value,
|
||||
instanceId = instanceId.value.lowercase(),
|
||||
contextJson = contextJson,
|
||||
)
|
||||
fun contextNumber(key: String): Double? {
|
||||
val ctx = userActionObj["context"] as? JsonObject ?: return null
|
||||
val entry = ctx[key] as? JsonPrimitive ?: return null
|
||||
return entry.content.toDoubleOrNull()
|
||||
}
|
||||
|
||||
val connected = nodeConnected
|
||||
val nodeId = contextString("nodeId")
|
||||
|
||||
var ok = false
|
||||
var error: String? = null
|
||||
if (connected) {
|
||||
try {
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
|
||||
// If the action name matches a known node command, invoke it directly via the operator session.
|
||||
// (Do not require operatorConnected here; if it's offline we'll return a visible error.)
|
||||
val (command, params) =
|
||||
when (name) {
|
||||
"camera.snap" -> "camera.snap" to buildJsonObject { }
|
||||
"camera.clip" -> {
|
||||
val durationSec = (contextNumber("durationSec") ?: 5.0).toLong().coerceIn(1, 60)
|
||||
"camera.clip" to
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(message))
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
put("key", JsonPrimitive(actionId))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
error = e.message ?: "send failed"
|
||||
put("durationMs", JsonPrimitive(durationSec * 1000))
|
||||
put("includeAudio", JsonPrimitive(true))
|
||||
}
|
||||
}
|
||||
"screen.record" -> {
|
||||
val durationSec = (contextNumber("durationSec") ?: 15.0).toLong().coerceIn(1, 180)
|
||||
"screen.record" to
|
||||
buildJsonObject {
|
||||
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 {
|
||||
canvas.eval(
|
||||
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId = actionId,
|
||||
ok = connected && error == null,
|
||||
ok = ok && error == null,
|
||||
error = error,
|
||||
),
|
||||
)
|
||||
@ -1115,7 +1291,7 @@ class NodeRuntime(context: Context) {
|
||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__openclaw__/a2ui/?platform=android"
|
||||
return "${base}/__clawdbot__/a2ui/?platform=android"
|
||||
}
|
||||
|
||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
@ -1207,8 +1383,7 @@ private const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
return !!host && typeof host.applyMessages === 'function';
|
||||
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
@ -1219,9 +1394,8 @@ private const val a2uiResetJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
return host.reset();
|
||||
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||
return globalThis.clawdbotA2UI.reset();
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
@ -1232,10 +1406,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||
return """
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||
const messages = $messagesJson;
|
||||
return host.applyMessages(messages);
|
||||
return globalThis.clawdbotA2UI.applyMessages(messages);
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
|
||||
@ -71,6 +71,16 @@ class SecurePrefs(context: Context) {
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
||||
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 =
|
||||
MutableStateFlow(
|
||||
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
@ -143,6 +153,26 @@ class SecurePrefs(context: Context) {
|
||||
_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) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
@ -157,6 +187,7 @@ class SecurePrefs(context: Context) {
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
_gatewayToken.value = token.trim()
|
||||
}
|
||||
|
||||
fun loadGatewayPassword(): String? {
|
||||
@ -168,8 +199,11 @@ class SecurePrefs(context: Context) {
|
||||
fun saveGatewayPassword(password: String) {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
prefs.edit { putString(key, password.trim()) }
|
||||
_gatewayPassword.value = password.trim()
|
||||
}
|
||||
|
||||
// node token loader removed (see note above)
|
||||
|
||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||
val key = "gateway.tls.$stableId"
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
@ -7,7 +7,9 @@ import java.security.KeyFactory
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.Signature
|
||||
import java.security.Security
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@ -23,6 +25,14 @@ class DeviceIdentityStore(context: Context) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
|
||||
fun resetIdentity() {
|
||||
try {
|
||||
if (identityFile.exists()) identityFile.delete()
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun loadOrCreate(): DeviceIdentity {
|
||||
val existing = load()
|
||||
@ -44,9 +54,11 @@ class DeviceIdentityStore(context: Context) {
|
||||
return try {
|
||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
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 signature = Signature.getInstance("Ed25519")
|
||||
val signature = runCatching { Signature.getInstance("Ed25519", "BC") }.getOrNull()
|
||||
?: Signature.getInstance("Ed25519")
|
||||
signature.initSign(privateKey)
|
||||
signature.update(payload.toByteArray(Charsets.UTF_8))
|
||||
base64UrlEncode(signature.sign())
|
||||
@ -97,17 +109,33 @@ class DeviceIdentityStore(context: Context) {
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
|
||||
val spki = keyPair.public.encoded
|
||||
val rawPublic = stripSpkiPrefix(spki)
|
||||
val deviceId = sha256Hex(rawPublic)
|
||||
val privateKey = keyPair.private.encoded
|
||||
return DeviceIdentity(
|
||||
deviceId = deviceId,
|
||||
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
|
||||
createdAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
// Some Android builds/devices can be missing Ed25519 support at runtime (or have
|
||||
// broken providers). The gateway can still accept a token-only connection, so
|
||||
// we fall back to a "no-signature" identity rather than crashing.
|
||||
return try {
|
||||
// Prefer BC provider when available.
|
||||
val kpg = runCatching { KeyPairGenerator.getInstance("Ed25519", "BC") }.getOrNull()
|
||||
?: KeyPairGenerator.getInstance("Ed25519")
|
||||
val keyPair = kpg.generateKeyPair()
|
||||
val spki = keyPair.public.encoded
|
||||
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? {
|
||||
|
||||
@ -68,6 +68,7 @@ class CanvasController {
|
||||
|
||||
fun onPageFinished() {
|
||||
applyDebugStatus()
|
||||
injectA2uiActionBridgeCompat()
|
||||
}
|
||||
|
||||
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 =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
|
||||
@ -70,6 +70,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val instanceId by viewModel.instanceId.collectAsState()
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
val deviceIdentityStatusText by viewModel.deviceIdentityStatusText.collectAsState()
|
||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||
val locationMode by viewModel.locationMode.collectAsState()
|
||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||
@ -82,6 +83,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.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 statusText by viewModel.statusText.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("Identity: $deviceIdentityStatusText", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
item { Button(onClick = viewModel::resetDeviceIdentity) { Text("Reset identity") } }
|
||||
item { Text("Device: $deviceModel", 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 {
|
||||
// UI sanity: "Disconnect" only when we have an active remote.
|
||||
if (isConnected && remoteAddress != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.disconnect()
|
||||
NodeForegroundService.stop(context)
|
||||
},
|
||||
) {
|
||||
Text("Disconnect")
|
||||
// Keep Disconnect/Pairing visible even if only the node session is connected.
|
||||
if (isConnected) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.disconnect()
|
||||
NodeForegroundService.stop(context)
|
||||
},
|
||||
) {
|
||||
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(),
|
||||
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(
|
||||
headlineContent = { Text("Require TLS") },
|
||||
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";
|
||||
|
||||
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 = [
|
||||
{
|
||||
name: "unit",
|
||||
@ -44,7 +54,7 @@ const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=DEP0060",
|
||||
];
|
||||
|
||||
const runOnce = (entry, extraArgs = []) =>
|
||||
const runTestOnce = (entry, extraArgs = []) =>
|
||||
new Promise((resolve) => {
|
||||
const args = maxWorkers
|
||||
? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs]
|
||||
@ -67,10 +77,10 @@ const runOnce = (entry, extraArgs = []) =>
|
||||
});
|
||||
|
||||
const run = async (entry) => {
|
||||
if (shardCount <= 1) return runOnce(entry);
|
||||
if (shardCount <= 1) return runTestOnce(entry);
|
||||
for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) {
|
||||
// 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;
|
||||
}
|
||||
return 0;
|
||||
@ -85,6 +95,10 @@ const shutdown = (signal) => {
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
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 failedParallel = parallelCodes.find((code) => code !== 0);
|
||||
if (failedParallel !== undefined) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user