@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +306 -53
- package/bin/ai-app-bridge.js +51 -4504
- package/bin/android-permissions.js +152 -0
- package/bin/android-uia-xml.js +74 -0
- package/bin/artifact-paths.js +127 -1
- package/bin/bridge-forward.js +56 -0
- package/bin/command-discovery.js +86 -0
- package/bin/command-errors.js +60 -0
- package/bin/command-registry.js +504 -0
- package/bin/command-request.js +14 -0
- package/bin/command-router.js +80 -0
- package/bin/connection-cache.js +119 -0
- package/bin/device-provider.js +2934 -0
- package/bin/execution-host.js +596 -0
- package/bin/execution-runtime.js +101 -0
- package/bin/fact-codec.js +321 -0
- package/bin/fact-recorder.js +691 -0
- package/bin/fact-store.js +226 -0
- package/bin/feedback-probe.js +285 -0
- package/bin/intent/install-intent.js +267 -0
- package/bin/intent/intent-action-executor.js +129 -0
- package/bin/intent/intent-autonomous-adapter.js +46 -0
- package/bin/intent/intent-capture-port.js +64 -0
- package/bin/intent/intent-entry.js +226 -0
- package/bin/intent/intent-errors.js +28 -0
- package/bin/intent/intent-evidence-store.js +121 -0
- package/bin/intent/intent-lifetime.js +62 -0
- package/bin/intent/intent-observation-target.js +33 -0
- package/bin/intent/intent-observer.js +194 -0
- package/bin/intent/intent-production-adapter.js +334 -0
- package/bin/intent/intent-provider.js +27 -0
- package/bin/intent/intent-runtime.js +63 -0
- package/bin/intent/intent-worker.js +432 -0
- package/bin/intent/ios-intent-adapter.js +90 -0
- package/bin/intent/permission-intent.js +239 -0
- package/bin/intent/web-intent-adapter.js +31 -0
- package/bin/ios-device-outcome.js +47 -0
- package/bin/ios-execution.js +108 -0
- package/bin/ios-provider.js +497 -583
- package/bin/ios-runtime-binding.js +54 -0
- package/bin/ios-wda-execution.js +70 -0
- package/bin/ios-wda-port.js +98 -0
- package/bin/ios-wda-project.js +79 -0
- package/bin/mcp-server.js +83 -1040
- package/bin/mmap-scan-index.js +329 -0
- package/bin/observation-collector.js +862 -0
- package/bin/runtime-client.js +154 -0
- package/bin/runtime-directory.js +107 -0
- package/bin/runtime-protocol.js +36 -0
- package/bin/script/bounded-script-registry.js +103 -0
- package/bin/script/node-runtime-adapter.js +233 -0
- package/bin/script/progress-projector.js +63 -0
- package/bin/script/python-runtime-adapter.js +111 -0
- package/bin/script/rolling-summary.js +134 -0
- package/bin/script/script-agent-port.js +40 -0
- package/bin/script/script-assert.js +95 -0
- package/bin/script/script-capture-port.js +76 -0
- package/bin/script/script-catalog.js +85 -0
- package/bin/script/script-durable-restore.js +195 -0
- package/bin/script/script-entry-code.js +26 -0
- package/bin/script/script-entry-route.js +38 -0
- package/bin/script/script-entry.js +3 -0
- package/bin/script/script-errors.js +29 -0
- package/bin/script/script-evidence-store.js +22 -0
- package/bin/script/script-format-removed.js +26 -0
- package/bin/script/script-host-port.js +397 -0
- package/bin/script/script-ledger.js +64 -0
- package/bin/script/script-result.js +57 -0
- package/bin/script/script-sdk.js +152 -0
- package/bin/script/script-sdk.py +153 -0
- package/bin/script/script-session-channel.js +127 -0
- package/bin/script/script-spec.js +86 -0
- package/bin/script/script-supervisor.js +919 -0
- package/bin/script/templates/checkpoint-reentry.js +13 -0
- package/bin/segment-index.js +481 -0
- package/bin/segmented-fact-store.js +1571 -0
- package/bin/shared-kernel/android-h5-target.js +10 -0
- package/bin/shared-kernel/android-install-execution.js +176 -0
- package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
- package/bin/shared-kernel/android-shell-execution.js +195 -0
- package/bin/shared-kernel/argument-schema.js +117 -0
- package/bin/shared-kernel/canonical-path.js +17 -0
- package/bin/shared-kernel/device-acknowledgements.js +53 -0
- package/bin/shared-kernel/device-completion-history.js +52 -0
- package/bin/shared-kernel/device-mutation-lease.js +219 -0
- package/bin/shared-kernel/device-ownership-recovery.js +95 -0
- package/bin/shared-kernel/device-ownership-store.js +94 -0
- package/bin/shared-kernel/evidence-adapters.js +251 -0
- package/bin/shared-kernel/evidence-archive.js +329 -0
- package/bin/shared-kernel/evidence-recording.js +131 -0
- package/bin/shared-kernel/evidence-schema.js +194 -0
- package/bin/shared-kernel/evidence-store.js +190 -0
- package/bin/shared-kernel/execution-admission.js +22 -0
- package/bin/shared-kernel/execution-contracts.js +171 -0
- package/bin/shared-kernel/execution-io.js +106 -0
- package/bin/shared-kernel/execution-ledger.js +125 -0
- package/bin/shared-kernel/execution-scope.js +87 -0
- package/bin/shared-kernel/execution-target.js +115 -0
- package/bin/shared-kernel/flutter-execution.js +13 -0
- package/bin/shared-kernel/flutter-h5-port.js +60 -0
- package/bin/shared-kernel/flutter-h5-target.js +9 -0
- package/bin/shared-kernel/flutter-target.js +75 -0
- package/bin/shared-kernel/h5-execution.js +11 -0
- package/bin/shared-kernel/h5-target.js +31 -0
- package/bin/shared-kernel/host-fact-store.js +49 -0
- package/bin/shared-kernel/ios-h5-target.js +9 -0
- package/bin/shared-kernel/ios-native-target.js +71 -0
- package/bin/shared-kernel/live-capture-query.js +115 -0
- package/bin/shared-kernel/managed-sdk-execution.js +78 -0
- package/bin/shared-kernel/native-execution.js +13 -0
- package/bin/shared-kernel/native-target.js +156 -0
- package/bin/shared-kernel/provider-command-contracts.js +55 -0
- package/bin/shared-kernel/recorded-payload-archive.js +195 -0
- package/bin/shared-kernel/request-context.js +35 -0
- package/bin/shared-kernel/semantic-node.js +55 -0
- package/bin/shared-kernel/summary-transformer.js +352 -0
- package/bin/shared-kernel/target-lease-protocol.js +47 -0
- package/bin/shared-kernel/text-wait.js +111 -0
- package/bin/shared-kernel/uia-execution.js +96 -0
- package/bin/shared-kernel/uia-protocol.js +214 -0
- package/bin/shared-kernel/uia-runtime-port.js +377 -0
- package/bin/shared-kernel/uia-target.js +39 -0
- package/bin/shared-kernel/web-dom-target.js +44 -0
- package/bin/shared-kernel/xml-attributes.js +25 -0
- package/bin/target-execution.js +275 -0
- package/bin/web/command-schema.js +60 -0
- package/bin/web/session-store.js +157 -0
- package/bin/web-provider.js +334 -553
- package/docs/COMMAND_CONTRACT.md +1563 -0
- package/docs/EVIDENCE_ARCHIVE.md +214 -0
- package/docs/INTENT_FOREGROUND.md +71 -0
- package/docs/INTENT_NATIVE_EDITING.md +79 -0
- package/docs/RELEASE.md +59 -0
- package/docs/SCRIPT_AUTHORING.md +489 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
- package/package.json +60 -5
- package/runtime/ios-wda/AABWDABinding.h +19 -0
- package/runtime/ios-wda/AABWDABinding.m +97 -0
- package/runtime/ios-wda/AABWDAExecution.h +26 -0
- package/runtime/ios-wda/AABWDAExecution.m +172 -0
- package/runtime/ios-wda/AABWDAIntegration.h +71 -0
- package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
- package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
- package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +22 -0
- package/skills/ai-app-bridge-use/SKILL.md +23 -360
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
4
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
5
|
+
const { CommandError } = require('../command-errors');
|
|
6
|
+
const { parseXmlAttributes } = require('./xml-attributes');
|
|
7
|
+
|
|
8
|
+
const schema = 'aab.uia.execution.v1';
|
|
9
|
+
const runtimeSchema = 'aab.uia.runtime.v1';
|
|
10
|
+
const snapshotSchema = 'aab.uia.snapshot.v1';
|
|
11
|
+
const rootDirectory = '/data/local/tmp/ai-app-bridge-uia/v1';
|
|
12
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
13
|
+
const hash = /^[0-9a-f]{64}$/;
|
|
14
|
+
const targetRefSchema = { type: 'object', additionalProperties: false,
|
|
15
|
+
required: ['schemaVersion', 'bootId', 'runtimeEpoch', 'snapshotId', 'nodeRef'],
|
|
16
|
+
properties: { schemaVersion: { const: 'aab.uia.target/v1' },
|
|
17
|
+
...Object.fromEntries(['bootId', 'runtimeEpoch', 'snapshotId', 'nodeRef'].map(key => [key, { type: 'string', pattern: uuid.source }])) } };
|
|
18
|
+
const isUuid = value => typeof value === 'string' && value.length === 36 && uuid.test(value);
|
|
19
|
+
const isHash = value => typeof value === 'string' && value.length === 64 && hash.test(value);
|
|
20
|
+
const isActionId = value => typeof value === 'string' && value.length > 0 && value.length <= 1024 && value.isWellFormed();
|
|
21
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
22
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
const exactKeys = (value, keys) => record(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
24
|
+
const fail = (code, message) => { throw new CommandError(code, message, { dispatched: false, ambiguous: false }); };
|
|
25
|
+
|
|
26
|
+
function validRoot(root) {
|
|
27
|
+
const prefix = '/data/local/tmp/ai-app-bridge-uia-test-';
|
|
28
|
+
return root === rootDirectory || typeof root === 'string' && root.startsWith(prefix) && root.length > prefix.length && !/[^a-z0-9-]/.test(root.slice(prefix.length));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validRequest(request) {
|
|
32
|
+
if (!exactKeys(request, ['schemaVersion', 'bootId', 'runtimeEpoch', 'actionId', 'timeoutMs', 'target', 'clickPolicy'])
|
|
33
|
+
|| request.schemaVersion !== schema || ![request.bootId, request.runtimeEpoch].every(isUuid) || !isActionId(request.actionId)
|
|
34
|
+
|| !Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > 60000
|
|
35
|
+
|| !['exact_node', 'nearest_clickable_ancestor'].includes(request.clickPolicy)
|
|
36
|
+
|| !exactKeys(request.target, ['snapshotId', 'ref', 'selector']) || !isUuid(request.target.snapshotId) || !isUuid(request.target.ref)) return false;
|
|
37
|
+
const selector = request.target.selector;
|
|
38
|
+
return exactKeys(selector, ['kind', 'value', 'exact', 'packageName'])
|
|
39
|
+
&& ['text', 'contentDescription', 'resourceName', 'nodeRef'].includes(selector.kind)
|
|
40
|
+
&& typeof selector.value === 'string' && selector.value.length > 0 && selector.value.length <= 4096
|
|
41
|
+
&& typeof selector.exact === 'boolean' && (selector.packageName === null || typeof selector.packageName === 'string' && selector.packageName.length > 0)
|
|
42
|
+
&& (selector.kind !== 'nodeRef' || selector.exact === true && selector.value === request.target.ref && typeof selector.packageName === 'string');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snapshotIdentity(xml) {
|
|
46
|
+
const opening = typeof xml === 'string' && xml.match(/<hierarchy\b[^>]*>/);
|
|
47
|
+
const metadata = opening && parseXmlAttributes(opening[0]);
|
|
48
|
+
if (!metadata || !metadata['aab-schema']) return null;
|
|
49
|
+
if (metadata['aab-schema'] !== snapshotSchema
|
|
50
|
+
|| !['aab-boot-id', 'aab-runtime-epoch', 'aab-snapshot-id'].every(key => isUuid(metadata[key])))
|
|
51
|
+
fail('uia_bound_observation_required', 'Observe UIA again using the node runtime before selecting an action.');
|
|
52
|
+
return { bootId: metadata['aab-boot-id'], runtimeEpoch: metadata['aab-runtime-epoch'], snapshotId: metadata['aab-snapshot-id'] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nodeTargetRef(snapshot, node) {
|
|
56
|
+
if (!snapshot) return undefined;
|
|
57
|
+
if (!isUuid(node?.['aab-ref'])) fail('uia_bound_observation_required', 'The observed UIA node has no valid runtime reference.');
|
|
58
|
+
return { schemaVersion: 'aab.uia.target/v1', ...snapshot, nodeRef: node['aab-ref'] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bindingFromTargetRef(ref, packageName) {
|
|
62
|
+
if (!exactKeys(ref, targetRefSchema.required) || ref.schemaVersion !== 'aab.uia.target/v1'
|
|
63
|
+
|| !['bootId', 'runtimeEpoch', 'snapshotId', 'nodeRef'].every(key => isUuid(ref[key])))
|
|
64
|
+
fail('uia_bound_observation_required', 'Use the complete targetRef from a fresh UIA observation.');
|
|
65
|
+
return { bootId: ref.bootId, runtimeEpoch: ref.runtimeEpoch,
|
|
66
|
+
target: { snapshotId: ref.snapshotId, ref: ref.nodeRef,
|
|
67
|
+
selector: { kind: 'nodeRef', value: ref.nodeRef, exact: true, packageName } } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function observedTarget(xml, node, selector, packageName, { exact = true } = {}) {
|
|
71
|
+
const ref = nodeTargetRef(snapshotIdentity(xml), node);
|
|
72
|
+
if (!ref) fail('uia_bound_observation_required', 'Observe UIA again using the node runtime before selecting an action.');
|
|
73
|
+
const keys = Object.keys(selector || {});
|
|
74
|
+
if (keys.length !== 1) fail('invalid_uia_selector', 'A UIA action requires exactly one selector field.');
|
|
75
|
+
return { bootId: ref.bootId, runtimeEpoch: ref.runtimeEpoch,
|
|
76
|
+
target: { snapshotId: ref.snapshotId, ref: ref.nodeRef,
|
|
77
|
+
selector: { kind: keys[0], value: selector[keys[0]], exact, packageName } } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function actionRequest(binding, { timeoutMs, actionId = randomUUID(), clickPolicy = 'nearest_clickable_ancestor' } = {}) {
|
|
81
|
+
if (!record(binding)) fail('invalid_uia_execution_request', 'A bound UIA observation is required.');
|
|
82
|
+
const request = { schemaVersion: schema, bootId: binding.bootId, runtimeEpoch: binding.runtimeEpoch,
|
|
83
|
+
actionId, timeoutMs, target: binding.target, clickPolicy };
|
|
84
|
+
if (!validRequest(request)) fail('invalid_uia_execution_request', 'UIA requires a valid observed node, original action identity, selector and millisecond deadline.');
|
|
85
|
+
const requestJson = JSON.stringify(request);
|
|
86
|
+
if (Buffer.byteLength(requestJson) > 32768) fail('uia_request_capacity_exhausted', 'The UIA action request exceeds 32 KiB.');
|
|
87
|
+
return { request, requestJson, requestSha256: digest(requestJson) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validDescriptor(value, root) {
|
|
91
|
+
return record(value) && validRoot(root) && value.schemaVersion === runtimeSchema
|
|
92
|
+
&& isUuid(value.bootId) && isUuid(value.runtimeEpoch) && isHash(value.dexSha256)
|
|
93
|
+
&& Number.isSafeInteger(value.pid) && value.pid > 0 && Number.isSafeInteger(value.apiLevel) && value.apiLevel >= 33
|
|
94
|
+
&& value.socketName === `aab-uia-${value.runtimeEpoch}` && isHash(value.token)
|
|
95
|
+
&& value.sessionPath === `${root}/sessions/${value.runtimeEpoch}` && typeof value.running === 'boolean';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validIdentity(identity) {
|
|
99
|
+
if (!record(identity) || identity.kind !== 'uia-node' || identity.schemaVersion !== schema
|
|
100
|
+
|| ![identity.bootId, identity.runtimeEpoch].every(isUuid) || !isActionId(identity.actionId)
|
|
101
|
+
|| typeof identity.requestJson !== 'string' || !isHash(identity.requestSha256)
|
|
102
|
+
|| digest(identity.requestJson) !== identity.requestSha256 || Buffer.byteLength(identity.requestJson) > 32768
|
|
103
|
+
|| !record(identity.target) || typeof identity.target.serial !== 'string' || !identity.target.serial
|
|
104
|
+
|| typeof identity.target.adb !== 'string' || !identity.target.adb || !validRoot(identity.target.root)
|
|
105
|
+
|| identity.target.sessionPath !== `${identity.target.root}/sessions/${identity.runtimeEpoch}` || !isHash(identity.target.dexSha256)) return false;
|
|
106
|
+
let request;
|
|
107
|
+
try { request = JSON.parse(identity.requestJson); } catch { return false; }
|
|
108
|
+
return validRequest(request) && ['bootId', 'runtimeEpoch', 'actionId'].every(key => request[key] === identity[key]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function originalReceipt(response, identity) {
|
|
112
|
+
if (!validIdentity(identity) || !record(response) || response.schemaVersion !== schema || response.settled !== true
|
|
113
|
+
|| !['bootId', 'runtimeEpoch', 'actionId', 'requestSha256'].every(key => response[key] === identity[key])
|
|
114
|
+
|| typeof response.receiptJson !== 'string' || !isHash(response.receiptSha256)
|
|
115
|
+
|| Buffer.byteLength(response.receiptJson) > 65536 || digest(response.receiptJson) !== response.receiptSha256) return null;
|
|
116
|
+
let receipt;
|
|
117
|
+
try { receipt = JSON.parse(response.receiptJson); } catch { return null; }
|
|
118
|
+
if (!record(receipt) || receipt.schemaVersion !== schema || receipt.settled !== true || receipt.ambiguous !== false
|
|
119
|
+
|| !['bootId', 'runtimeEpoch', 'actionId', 'requestSha256'].every(key => receipt[key] === identity[key])
|
|
120
|
+
|| typeof receipt.ok !== 'boolean' || typeof receipt.dispatched !== 'boolean'
|
|
121
|
+
|| receipt.ok === false && (typeof receipt.error !== 'string' || !receipt.error)
|
|
122
|
+
|| receipt.ok === true && Object.hasOwn(receipt, 'error')) return null;
|
|
123
|
+
const request = JSON.parse(identity.requestJson);
|
|
124
|
+
if (receipt.completion === 'recovered_before_admission') return validRecoveryReceipt(receipt, identity, request) ? receipt : null;
|
|
125
|
+
if (!Number.isSafeInteger(receipt.completedAtElapsedMs) || receipt.completedAtElapsedMs < 0
|
|
126
|
+
|| !['original_callback', 'admission_rejected', 'before_admission'].includes(receipt.completion)
|
|
127
|
+
|| Object.hasOwn(receipt, 'recovery')) return null;
|
|
128
|
+
if (Object.hasOwn(receipt, 'binding') && !matchingBinding(receipt.binding, request)) return null;
|
|
129
|
+
if (receipt.completion === 'original_callback') {
|
|
130
|
+
if (receipt.dispatched !== true || !Number.isSafeInteger(receipt.callback?.interactionId) || receipt.callback.interactionId < 1
|
|
131
|
+
|| receipt.callback.handled !== receipt.ok || !record(receipt.binding)) return null;
|
|
132
|
+
} else if (receipt.dispatched !== false || receipt.ok !== false || Object.hasOwn(receipt, 'callback')
|
|
133
|
+
|| receipt.completion === 'admission_rejected' && !record(receipt.binding)) return null;
|
|
134
|
+
return receipt;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validRecoveryReceipt(receipt, identity, request) {
|
|
138
|
+
if (!exactKeys(receipt, ['schemaVersion', 'bootId', 'runtimeEpoch', 'actionId', 'requestSha256',
|
|
139
|
+
'settled', 'ok', 'dispatched', 'ambiguous', 'completion', 'error', 'recovery'])
|
|
140
|
+
|| receipt.ok !== false || receipt.dispatched !== false || receipt.error !== 'uia_owner_exited_before_admission') return false;
|
|
141
|
+
const value = receipt.recovery;
|
|
142
|
+
return exactKeys(value, ['authority', 'bootId', 'observedAtElapsedMs', 'priorPhase', 'priorRecordSha256',
|
|
143
|
+
'preparedAtElapsedMs', 'originalDexSha256', 'recoveryDexSha256'])
|
|
144
|
+
&& value.authority === 'exclusive_runtime_root_lock' && isUuid(value.bootId)
|
|
145
|
+
&& ['prepared', 'queued'].includes(value.priorPhase) && isHash(value.priorRecordSha256) && isHash(value.recoveryDexSha256)
|
|
146
|
+
&& value.originalDexSha256 === identity.target.dexSha256
|
|
147
|
+
&& Number.isSafeInteger(value.preparedAtElapsedMs) && value.preparedAtElapsedMs >= 0
|
|
148
|
+
&& Number.isSafeInteger(value.preparedAtElapsedMs + request.timeoutMs)
|
|
149
|
+
&& Number.isSafeInteger(value.observedAtElapsedMs) && value.observedAtElapsedMs >= 0
|
|
150
|
+
&& (value.bootId !== identity.bootId || value.observedAtElapsedMs >= value.preparedAtElapsedMs);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function matchingBinding(binding, request) {
|
|
154
|
+
if (!record(binding) || binding.snapshotId !== request.target.snapshotId || binding.ref !== request.target.ref
|
|
155
|
+
|| !isDeepStrictEqual(binding.selector, request.target.selector) || binding.clickPolicy !== request.clickPolicy
|
|
156
|
+
|| binding.identityStrength !== 'same_connection_node_and_reobserved_attributes'
|
|
157
|
+
|| !record(binding.window) || !Number.isSafeInteger(binding.window.id) || binding.window.id < 0
|
|
158
|
+
|| binding.window.displayId !== 0 || binding.window.focused !== true) return false;
|
|
159
|
+
const { target, actionTarget } = binding;
|
|
160
|
+
for (const node of [target, actionTarget]) {
|
|
161
|
+
if (!record(node) || node.windowId !== binding.window.id || typeof node.sourceId !== 'string'
|
|
162
|
+
|| !/^-?[0-9]{1,20}$/.test(node.sourceId) || node.enabled !== true || node.visible !== true) return false;
|
|
163
|
+
}
|
|
164
|
+
const selector = request.target.selector;
|
|
165
|
+
const value = selector.kind === 'nodeRef' ? binding.ref : target[selector.kind];
|
|
166
|
+
if (typeof value !== 'string' || !(selector.exact ? value === selector.value : value.includes(selector.value))
|
|
167
|
+
|| selector.packageName !== null && target.packageName !== selector.packageName || actionTarget.clickable !== true) return false;
|
|
168
|
+
return !(request.clickPolicy === 'exact_node' || target.clickable === true) || isDeepStrictEqual(target, actionTarget);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function recordResponse(value, identity) {
|
|
172
|
+
if (!record(value) || value.schemaVersion !== 'aab.uia.record.v2' || value.phase !== 'terminal'
|
|
173
|
+
|| value.requestJson !== identity.requestJson || !['bootId', 'runtimeEpoch', 'actionId', 'requestSha256'].every(key => value[key] === identity[key])) return null;
|
|
174
|
+
const response = { schemaVersion: schema, bootId: value.bootId, runtimeEpoch: value.runtimeEpoch, actionId: value.actionId,
|
|
175
|
+
requestSha256: value.requestSha256, settled: true, phase: 'terminal', receiptJson: value.receiptJson, receiptSha256: value.receiptSha256 };
|
|
176
|
+
const receipt = originalReceipt(response, identity);
|
|
177
|
+
if (!receipt) return null;
|
|
178
|
+
if (receipt.completion === 'recovered_before_admission'
|
|
179
|
+
&& (value.preparedAtElapsedMs !== receipt.recovery.preparedAtElapsedMs
|
|
180
|
+
|| value.deadlineElapsedMs !== value.preparedAtElapsedMs + JSON.parse(identity.requestJson).timeoutMs
|
|
181
|
+
|| value.interactionId !== 0 || typeof value.acknowledged !== 'boolean')) return null;
|
|
182
|
+
return response;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function settlementProof(response, identity) {
|
|
186
|
+
const receipt = originalReceipt(response, identity);
|
|
187
|
+
return receipt ? { kind: 'uia-node', schemaVersion: schema, settled: true, bootId: identity.bootId,
|
|
188
|
+
runtimeEpoch: identity.runtimeEpoch, actionId: identity.actionId, requestSha256: identity.requestSha256,
|
|
189
|
+
dispatched: receipt.dispatched, ambiguous: false, error: receipt.error ?? null,
|
|
190
|
+
responseSha256: response.receiptSha256, receiptJson: response.receiptJson } : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function acknowledgementIdentity(identity, response) {
|
|
194
|
+
if (!originalReceipt(response, identity)) fail('uia_completion_identity_mismatch', 'Only a validated original UIA receipt may be acknowledged.');
|
|
195
|
+
return { bootId: identity.bootId, runtimeEpoch: identity.runtimeEpoch, actionSha256: digest(identity.actionId),
|
|
196
|
+
requestSha256: identity.requestSha256, receiptSha256: response.receiptSha256, originalDexSha256: identity.target.dexSha256 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function recoveryIdentity(identity) {
|
|
200
|
+
if (!validIdentity(identity)) fail('invalid_uia_execution_identity', 'Recovery requires the complete original UIA request and target identity.');
|
|
201
|
+
return { bootId: identity.bootId, runtimeEpoch: identity.runtimeEpoch, actionSha256: digest(identity.actionId),
|
|
202
|
+
requestSha256: identity.requestSha256, originalDexSha256: identity.target.dexSha256 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function acknowledgementMatches(response, identity) {
|
|
206
|
+
return exactKeys(response, ['ok', 'schemaVersion', 'identity', 'disposition']) && response.ok === true
|
|
207
|
+
&& response.schemaVersion === 'aab.uia.ack.v1' && isDeepStrictEqual(response.identity, identity)
|
|
208
|
+
&& ['acknowledged', 'not_retained'].includes(response.disposition);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { schema, runtimeSchema, snapshotSchema, rootDirectory, digest, validRoot, validRequest,
|
|
212
|
+
targetRefSchema, snapshotIdentity, nodeTargetRef, bindingFromTargetRef,
|
|
213
|
+
actionRequest, observedTarget, validDescriptor, validIdentity, originalReceipt, recordResponse, settlementProof,
|
|
214
|
+
acknowledgementIdentity, acknowledgementMatches, recoveryIdentity };
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { randomUUID } = require('node:crypto');
|
|
6
|
+
const { CommandError } = require('../command-errors');
|
|
7
|
+
const { execFileBounded, httpRequestBounded } = require('./execution-io');
|
|
8
|
+
const { createOwnershipStore, defaultDirectory } = require('./device-ownership-store');
|
|
9
|
+
const { checkExecution, runExecution, executionSleep } = require('./execution-scope');
|
|
10
|
+
const protocol = require('./uia-protocol');
|
|
11
|
+
|
|
12
|
+
const mainClass = 'io.github.mobileaidev.aiappbridge.uia.UiaRuntime';
|
|
13
|
+
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
14
|
+
|
|
15
|
+
function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.rootDirectory,
|
|
16
|
+
bundleDirectory = path.resolve(__dirname, '../../runtime/uia'), run = execFileBounded, http = httpRequestBounded,
|
|
17
|
+
connectionLocks = createOwnershipStore(path.join(defaultDirectory(), 'uia-connections')) } = {}) {
|
|
18
|
+
if (typeof serial !== 'string' || !serial || typeof adb !== 'string' || !adb || !protocol.validRoot(root)) {
|
|
19
|
+
throw new CommandError('invalid_uia_runtime_target', 'UIA runtime requires an explicit Android serial, ADB executable and valid root.', { dispatched: false, ambiguous: false });
|
|
20
|
+
}
|
|
21
|
+
const invoke = (args, budgetMs = timeoutMs) => run(adb, ['-s', serial, ...args], {
|
|
22
|
+
timeoutMs: budgetMs, mutation: false, encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, windowsHide: true,
|
|
23
|
+
});
|
|
24
|
+
const shell = script => invoke(['shell', 'sh', '-c', quote(script)]).then(result => result.stdout.trim());
|
|
25
|
+
const failure = (code, message, details) => new CommandError(code, message, { details });
|
|
26
|
+
|
|
27
|
+
async function readJson(file) {
|
|
28
|
+
const raw = await shell(`if [ -f ${quote(file)} ]; then cat ${quote(file)}; else printf '%s' null; fi`);
|
|
29
|
+
try { return JSON.parse(raw); }
|
|
30
|
+
catch { throw failure('uia_runtime_record_invalid', 'The phone did not return a valid UIA JSON record.', { file }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bundle() {
|
|
34
|
+
let manifest, bytes;
|
|
35
|
+
try {
|
|
36
|
+
manifest = JSON.parse(fs.readFileSync(path.join(bundleDirectory, 'manifest.json'), 'utf8'));
|
|
37
|
+
bytes = fs.readFileSync(path.join(bundleDirectory, 'ai-app-bridge-uia.jar'));
|
|
38
|
+
} catch (error) { throw failure('uia_runtime_bundle_unavailable', 'The installed CLI has no complete UIA runtime bundle.', { cause: error.code }); }
|
|
39
|
+
if (manifest?.schemaVersion !== 'aab.uia.bundle.v1' || manifest.mainClass !== mainClass || manifest.minApi !== 33
|
|
40
|
+
|| manifest.artifact !== 'ai-app-bridge-uia.jar' || bytes.length > 2 * 1024 * 1024 || protocol.digest(bytes) !== manifest.sha256) {
|
|
41
|
+
throw failure('uia_runtime_bundle_invalid', 'The UIA runtime artifact does not match its bundle manifest.');
|
|
42
|
+
}
|
|
43
|
+
return { manifest, file: path.join(bundleDirectory, manifest.artifact) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function descriptor(value) {
|
|
47
|
+
if (!protocol.validDescriptor(value, root)) throw failure('uia_runtime_descriptor_invalid', 'The UIA runtime descriptor has invalid identity or transport fields.');
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function withConnectionLock(action) {
|
|
52
|
+
// Reuse the OS-managed lock implementation, with a separate connection
|
|
53
|
+
// namespace. Discovery, rotation and forward allocation share this lock.
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
let held;
|
|
56
|
+
while (!(held = connectionLocks.lock(`${serial}:${root}`))) {
|
|
57
|
+
checkExecution();
|
|
58
|
+
if (Date.now() - started >= timeoutMs) throw failure('uia_connection_busy', 'Another Host is configuring this UIA connection.');
|
|
59
|
+
await executionSleep(25);
|
|
60
|
+
}
|
|
61
|
+
try { return await action(); } finally { held.close(); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function forwards() {
|
|
65
|
+
const result = await invoke(['forward', '--list']);
|
|
66
|
+
return result.stdout.trim().split(/\r?\n/).filter(Boolean).map(line => {
|
|
67
|
+
const fields = line.trim().split(/\s+/);
|
|
68
|
+
if (fields.length !== 3) throw failure('uia_forward_list_invalid', 'ADB returned an invalid forward inventory.');
|
|
69
|
+
return { serial: fields[0], local: fields[1], remote: fields[2] };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function connect(peer) {
|
|
74
|
+
const target = `localabstract:${peer.socketName}`;
|
|
75
|
+
const existing = (await forwards()).filter(item => item.serial === serial && item.remote === target);
|
|
76
|
+
if (existing.length > 1) throw failure('uia_forward_ambiguous', 'Multiple ADB forwards target this UIA runtime. Inspect the forward inventory before proceeding.');
|
|
77
|
+
let local;
|
|
78
|
+
if (existing.length === 1) local = existing[0].local;
|
|
79
|
+
else {
|
|
80
|
+
const assigned = (await invoke(['forward', 'tcp:0', target])).stdout.trim();
|
|
81
|
+
local = `tcp:${assigned}`;
|
|
82
|
+
const matches = (await forwards()).filter(item => item.local === local);
|
|
83
|
+
if (matches.length !== 1 || matches[0].serial !== serial || matches[0].remote !== target) {
|
|
84
|
+
throw failure('uia_forward_identity_mismatch', 'The allocated ADB forward does not target this exact phone and runtime.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!/^tcp:[1-9][0-9]{0,4}$/.test(local) || Number(local.slice(4)) > 65535) throw failure('uia_forward_invalid', 'UIA requires an assigned TCP forward port.');
|
|
88
|
+
return { peer, port: Number(local.slice(4)) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function post(connection, payload) {
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = await runExecution({ mutation: payload.op === 'start', timeoutMs }, () => http(`http://127.0.0.1:${connection.port}/v1`, {
|
|
95
|
+
method: 'POST', payload, headers: { Authorization: `Bearer ${connection.peer.token}` }, timeoutMs, maxBytes: 1048576,
|
|
96
|
+
}));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.statusCode !== undefined) {
|
|
99
|
+
let response;
|
|
100
|
+
try { response = JSON.parse(error.responseBody); } catch { throw failure('uia_http_response_invalid', 'The UIA HTTP error did not contain valid JSON.'); }
|
|
101
|
+
if (response?.ok === false && typeof response.error === 'string' && response.error) {
|
|
102
|
+
throw failure(response.error, typeof response.message === 'string' ? response.message : 'The UIA runtime rejected this protocol request.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
try { return JSON.parse(raw); }
|
|
108
|
+
catch { throw failure('uia_http_response_invalid', 'The UIA runtime did not return valid JSON.'); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function statusOf(connection) {
|
|
112
|
+
let result;
|
|
113
|
+
try { result = await post(connection, { op: 'status' }); }
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (['ECONNREFUSED', 'ECONNRESET', 'EPIPE'].includes(error.code))
|
|
116
|
+
throw failure('uia_runtime_unreachable', 'The original UIA runtime is unreachable. Use uia-runtime --operation start with this serial for an explicit phone process-lock check; unresolved actions remain retained.',
|
|
117
|
+
{ bootId: connection.peer.bootId, runtimeEpoch: connection.peer.runtimeEpoch, transportError: error.code });
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
const peer = connection.peer;
|
|
121
|
+
if (result?.ok !== true || result.schemaVersion !== protocol.schema || result.bootId !== peer.bootId
|
|
122
|
+
|| result.runtimeEpoch !== peer.runtimeEpoch || result.dexSha256 !== peer.dexSha256
|
|
123
|
+
|| !Number.isSafeInteger(result.capacity) || result.capacity < 1
|
|
124
|
+
|| !Number.isSafeInteger(result.count) || result.count < 0 || result.count > result.capacity
|
|
125
|
+
|| !Number.isSafeInteger(result.pending) || result.pending < 0 || result.pending > result.count
|
|
126
|
+
|| !Number.isSafeInteger(result.acknowledged) || result.acknowledged < 0 || result.acknowledged > result.count - result.pending
|
|
127
|
+
|| typeof result.closing !== 'boolean') {
|
|
128
|
+
throw failure('uia_runtime_identity_mismatch', 'The connected runtime did not confirm the expected boot, epoch and artifact.');
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function installAsset(asset) {
|
|
134
|
+
const level = await shell('getprop ro.build.version.sdk');
|
|
135
|
+
if (!/^[0-9]+$/.test(level) || Number(level) < 33) throw failure('uia_android_api_33_required', 'UIA node execution requires Android API 33 or newer.', { apiLevel: level });
|
|
136
|
+
const destination = `${root}/runtime-${asset.manifest.sha256}.jar`;
|
|
137
|
+
await shell(`umask 077\nmkdir -p ${quote(root)} && chmod 700 ${quote(root)}`);
|
|
138
|
+
const existingHash = await shell(`if [ -f ${quote(destination)} ]; then sha256sum ${quote(destination)}; else printf '%s' null; fi`);
|
|
139
|
+
if (existingHash !== 'null') {
|
|
140
|
+
if (existingHash.split(/\s+/)[0] !== asset.manifest.sha256)
|
|
141
|
+
throw failure('uia_runtime_artifact_mismatch', 'An existing content-addressed UIA runtime artifact has different bytes.');
|
|
142
|
+
return destination;
|
|
143
|
+
}
|
|
144
|
+
const temporary = `${root}/runtime-${asset.manifest.sha256}.${randomUUID()}.tmp`;
|
|
145
|
+
await invoke(['push', asset.file, temporary]);
|
|
146
|
+
const installedHash = (await shell(`sha256sum ${quote(temporary)}`)).split(/\s+/)[0];
|
|
147
|
+
if (installedHash !== asset.manifest.sha256) throw failure('uia_runtime_artifact_mismatch', 'The phone runtime artifact hash differs from the installed CLI bundle.');
|
|
148
|
+
// Never overwrite an executable that an existing runtime may still map.
|
|
149
|
+
await shell(`mv -n ${quote(temporary)} ${quote(destination)}`);
|
|
150
|
+
const publishedHash = (await shell(`sha256sum ${quote(destination)}`)).split(/\s+/)[0];
|
|
151
|
+
if (publishedHash !== asset.manifest.sha256) throw failure('uia_runtime_artifact_mismatch', 'The published UIA runtime artifact has different bytes.');
|
|
152
|
+
await shell(`rm -f ${quote(temporary)}`);
|
|
153
|
+
return destination;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function startRuntime(previous, asset, destination) {
|
|
157
|
+
destination ??= await installAsset(asset);
|
|
158
|
+
await shell(`CLASSPATH=${quote(destination)} setsid nohup app_process /system/bin ${mainClass} ${quote(root)} ${asset.manifest.sha256} >${quote(root + '/startup.log')} 2>&1 </dev/null &`);
|
|
159
|
+
const deadline = Date.now() + Math.min(timeoutMs, 7000);
|
|
160
|
+
while (Date.now() < deadline) {
|
|
161
|
+
checkExecution();
|
|
162
|
+
const current = await readJson(`${root}/runtime.json`);
|
|
163
|
+
if (current !== null && current.runtimeEpoch !== previous?.runtimeEpoch) {
|
|
164
|
+
const peer = descriptor(current);
|
|
165
|
+
if (!peer.running || peer.dexSha256 !== asset.manifest.sha256) throw failure('uia_runtime_identity_mismatch', 'The newly discovered runtime does not match this bundle.');
|
|
166
|
+
if (previous) await removeForwards(previous);
|
|
167
|
+
return peer;
|
|
168
|
+
}
|
|
169
|
+
await executionSleep(100);
|
|
170
|
+
}
|
|
171
|
+
const log = await shell(`tail -c 4096 ${quote(root + '/startup.log')}`);
|
|
172
|
+
let rejection;
|
|
173
|
+
try { rejection = JSON.parse(log.trim().split(/\r?\n/).at(-1)); } catch { /* Non-JSON VM startup diagnostics remain below. */ }
|
|
174
|
+
if (rejection?.ok === false && typeof rejection.error === 'string' && rejection.error.startsWith('uia_'))
|
|
175
|
+
throw failure(rejection.error, 'The phone runtime refused to start. Original session records were retained.', { startupLog: log });
|
|
176
|
+
throw failure('uia_runtime_start_failed', 'The phone did not publish a ready UIA runtime.', { startupLog: log });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function removeForwards(peer) {
|
|
180
|
+
for (const item of await forwards()) if (item.serial === serial && item.remote === `localabstract:${peer.socketName}`)
|
|
181
|
+
await invoke(['forward', '--remove', item.local]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function publicPeer(peer) {
|
|
185
|
+
return { serial, bootId: peer.bootId, runtimeEpoch: peer.runtimeEpoch, dexSha256: peer.dexSha256, apiLevel: peer.apiLevel, running: peer.running };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function stopRuntime(connection) {
|
|
189
|
+
const { peer } = connection;
|
|
190
|
+
const stopped = await post(connection, { op: 'stop' });
|
|
191
|
+
if (stopped?.ok !== true || stopped.runtimeEpoch !== peer.runtimeEpoch || stopped.stopping !== true)
|
|
192
|
+
throw failure('uia_runtime_stop_unconfirmed', 'The original UIA runtime did not accept orderly shutdown.');
|
|
193
|
+
const deadline = Date.now() + Math.min(timeoutMs, 10000);
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
const current = descriptor(await readJson(`${root}/runtime.json`));
|
|
196
|
+
if (current.runtimeEpoch !== peer.runtimeEpoch) throw failure('uia_runtime_identity_mismatch', 'A different UIA runtime appeared during shutdown.');
|
|
197
|
+
if (!current.running) {
|
|
198
|
+
await removeForwards(peer);
|
|
199
|
+
return { ok: true, ...publicPeer(current) };
|
|
200
|
+
}
|
|
201
|
+
await executionSleep(50);
|
|
202
|
+
}
|
|
203
|
+
throw failure('uia_runtime_stop_unconfirmed', 'The original UIA runtime has not published its stopped state.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function ensureLocked(rotate) {
|
|
207
|
+
const asset = bundle();
|
|
208
|
+
const previous = await readJson(`${root}/runtime.json`);
|
|
209
|
+
let peer = previous === null ? null : descriptor(previous);
|
|
210
|
+
if (peer === null || peer.running === false) peer = await startRuntime(peer, asset);
|
|
211
|
+
if (peer.dexSha256 !== asset.manifest.sha256) throw failure('uia_runtime_version_mismatch', 'Stop the existing UIA runtime before starting this installed bundle.');
|
|
212
|
+
let connection = await connect(peer), status = await statusOf(connection);
|
|
213
|
+
if (status.closing) throw failure('uia_runtime_closing', 'The original UIA runtime is closing. Observe again after it exits.');
|
|
214
|
+
// Rotation belongs to NEW observation, before binding a node. A full epoch
|
|
215
|
+
// cannot admit another new action; acknowledgements are monotonic.
|
|
216
|
+
if (rotate && status.count === status.capacity && status.pending === 0 && status.acknowledged === status.count) {
|
|
217
|
+
await stopRuntime(connection);
|
|
218
|
+
peer = await startRuntime(peer, asset);
|
|
219
|
+
connection = await connect(peer); status = await statusOf(connection);
|
|
220
|
+
}
|
|
221
|
+
return { ...connection, status };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const ensure = () => withConnectionLock(() => ensureLocked(false));
|
|
225
|
+
|
|
226
|
+
async function originalConnection(identity) {
|
|
227
|
+
if (!protocol.validIdentity(identity) || identity.target.serial !== serial || identity.target.root !== root || identity.target.adb !== adb) {
|
|
228
|
+
throw failure('invalid_uia_execution_identity', 'Recovery requires this exact UIA action and physical device identity.');
|
|
229
|
+
}
|
|
230
|
+
return withConnectionLock(async () => {
|
|
231
|
+
const value = await readJson(`${identity.target.sessionPath}/runtime.json`);
|
|
232
|
+
if (value === null) return null;
|
|
233
|
+
const peer = descriptor(value);
|
|
234
|
+
if (peer.bootId !== identity.bootId || peer.runtimeEpoch !== identity.runtimeEpoch || peer.dexSha256 !== identity.target.dexSha256) {
|
|
235
|
+
throw failure('uia_runtime_identity_mismatch', 'The original UIA session descriptor does not match the action.');
|
|
236
|
+
}
|
|
237
|
+
if (!peer.running) return null;
|
|
238
|
+
const connection = await connect(peer);
|
|
239
|
+
await statusOf(connection);
|
|
240
|
+
return connection;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function actionPayload(op, identity) { return { op, requestJson: identity.requestJson, requestSha256: identity.requestSha256 }; }
|
|
245
|
+
|
|
246
|
+
async function maintainRecord(operation, identity) {
|
|
247
|
+
// One-shot maintenance must acquire the same phone OS lock as the runtime.
|
|
248
|
+
// It never starts UiAutomation or replays an action.
|
|
249
|
+
const asset = bundle(), destination = await installAsset(asset);
|
|
250
|
+
let raw;
|
|
251
|
+
try {
|
|
252
|
+
raw = await shell(`CLASSPATH=${quote(destination)} app_process /system/bin ${mainClass} ${quote(root)} ${asset.manifest.sha256} ${operation} ${quote(JSON.stringify(identity))}`);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
let rejection;
|
|
255
|
+
try { rejection = JSON.parse(String(error.stderr).trim().split(/\r?\n/).at(-1)); } catch { /* Retain the transport error. */ }
|
|
256
|
+
if (rejection?.ok === false && typeof rejection.error === 'string' && rejection.error.startsWith('uia_'))
|
|
257
|
+
throw failure(rejection.error, 'The phone retained the original action because journal maintenance could not complete safely.');
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
try { return JSON.parse(raw); }
|
|
261
|
+
catch { throw failure('uia_maintenance_response_invalid', 'The phone did not return a valid journal maintenance response.'); }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function recover(identity, { cancel = true } = {}) {
|
|
265
|
+
if (!protocol.validIdentity(identity) || identity.target.serial !== serial || identity.target.root !== root || identity.target.adb !== adb)
|
|
266
|
+
throw failure('invalid_uia_execution_identity', 'An exact original UIA identity is required.');
|
|
267
|
+
let liveError;
|
|
268
|
+
try {
|
|
269
|
+
const connection = await originalConnection(identity);
|
|
270
|
+
if (connection) {
|
|
271
|
+
const response = await post(connection, actionPayload(cancel ? 'cancel' : 'query', identity));
|
|
272
|
+
if (protocol.originalReceipt(response, identity)) return { ...response, completionSource: 'original-runtime' };
|
|
273
|
+
liveError = response?.error || 'uia_action_not_settled';
|
|
274
|
+
}
|
|
275
|
+
} catch (error) { liveError = error.code || 'uia_runtime_unreachable'; }
|
|
276
|
+
// The second completion authority is the SAME action's durable file, not a
|
|
277
|
+
// restarted runtime, a new action, an installed app state or an idle flag.
|
|
278
|
+
const saved = await readJson(`${identity.target.sessionPath}/actions/${protocol.digest(identity.actionId)}.json`);
|
|
279
|
+
const response = protocol.recordResponse(saved, identity);
|
|
280
|
+
if (response) return { ...response, completionSource: 'original-durable-record', liveQueryError: liveError ?? null };
|
|
281
|
+
// Missing records and admitted/unknown phases are never non-dispatch proof.
|
|
282
|
+
// The phone re-reads and fully validates this exact record under owner.lock.
|
|
283
|
+
if (cancel && ['prepared', 'queued'].includes(saved?.phase)) {
|
|
284
|
+
try {
|
|
285
|
+
const record = await withConnectionLock(() => maintainRecord('recover-record', protocol.recoveryIdentity(identity)));
|
|
286
|
+
const recovered = protocol.recordResponse(record, identity);
|
|
287
|
+
if (recovered) return { ...recovered, completionSource: 'phone-journal-maintenance', liveQueryError: liveError ?? null };
|
|
288
|
+
liveError = 'uia_recovery_receipt_invalid';
|
|
289
|
+
} catch (error) { liveError = error.code || 'uia_recovery_failed'; }
|
|
290
|
+
}
|
|
291
|
+
return { ok: false, settled: false, dispatched: null, ambiguous: true, error: liveError || 'uia_original_completion_unavailable' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
ensure, post,
|
|
296
|
+
async observe() {
|
|
297
|
+
const connection = await withConnectionLock(() => ensureLocked(true));
|
|
298
|
+
const result = await post(connection, { op: 'observe' });
|
|
299
|
+
if (result?.ok !== true || result.schemaVersion !== protocol.snapshotSchema || result.bootId !== connection.peer.bootId
|
|
300
|
+
|| result.runtimeEpoch !== connection.peer.runtimeEpoch || typeof result.xml !== 'string') {
|
|
301
|
+
throw failure('uia_snapshot_invalid', 'UIA did not return a snapshot from the verified runtime.');
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
},
|
|
305
|
+
action: (connection, op, identity) => post(connection, actionPayload(op, identity)),
|
|
306
|
+
recover,
|
|
307
|
+
async acknowledge(identity, response) {
|
|
308
|
+
const proof = protocol.acknowledgementIdentity(identity, response);
|
|
309
|
+
if (identity.target.serial !== serial || identity.target.root !== root || identity.target.adb !== adb)
|
|
310
|
+
throw failure('invalid_uia_execution_identity', 'Acknowledgement requires the original physical device and runtime root.');
|
|
311
|
+
return withConnectionLock(async () => {
|
|
312
|
+
const value = await readJson(`${root}/runtime.json`), peer = value === null ? null : descriptor(value);
|
|
313
|
+
let connection, result;
|
|
314
|
+
if (peer?.running) {
|
|
315
|
+
try { connection = await connect(peer); await statusOf(connection); }
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error.code !== 'uia_runtime_unreachable') throw error;
|
|
318
|
+
connection = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (connection) {
|
|
322
|
+
if (peer.runtimeEpoch === identity.runtimeEpoch) {
|
|
323
|
+
if (peer.bootId !== identity.bootId || peer.dexSha256 !== identity.target.dexSha256)
|
|
324
|
+
throw failure('uia_runtime_identity_mismatch', 'The active epoch does not match the original action.');
|
|
325
|
+
result = await post(connection, { ...actionPayload('acknowledge', identity), receiptSha256: response.receiptSha256 });
|
|
326
|
+
if (!protocol.originalReceipt(result, identity) || result.receiptSha256 !== response.receiptSha256 || result.acknowledged !== true)
|
|
327
|
+
throw failure('uia_acknowledgement_invalid', 'The runtime did not confirm this exact terminal receipt.');
|
|
328
|
+
return { ok: true, disposition: 'acknowledged' };
|
|
329
|
+
}
|
|
330
|
+
result = await post(connection, { op: 'acknowledge-record', identity: proof });
|
|
331
|
+
} else {
|
|
332
|
+
result = await maintainRecord('acknowledge-record', proof);
|
|
333
|
+
}
|
|
334
|
+
if (!protocol.acknowledgementMatches(result, proof))
|
|
335
|
+
throw failure('uia_acknowledgement_invalid', 'Maintenance did not confirm this exact original receipt identity.');
|
|
336
|
+
return { ok: true, disposition: result.disposition };
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
async control(operation) {
|
|
340
|
+
if (!['status', 'start', 'stop'].includes(operation)) throw failure('invalid_uia_runtime_operation', 'UIA runtime operation must be status, start or stop.');
|
|
341
|
+
return withConnectionLock(async () => {
|
|
342
|
+
const value = await readJson(`${root}/runtime.json`);
|
|
343
|
+
if (operation === 'start') {
|
|
344
|
+
const asset = bundle(), destination = await installAsset(asset);
|
|
345
|
+
const ownerRaw = await shell(`CLASSPATH=${quote(destination)} app_process /system/bin ${mainClass} ${quote(root)} ${asset.manifest.sha256} owner-status`);
|
|
346
|
+
let owner;
|
|
347
|
+
try { owner = JSON.parse(ownerRaw); }
|
|
348
|
+
catch { throw failure('uia_runtime_owner_unverified', 'The phone did not return valid JSON for its exclusive UIA process lock.'); }
|
|
349
|
+
if (owner?.ok !== true || owner.schemaVersion !== 'aab.uia.owner.v1' || owner.root !== root
|
|
350
|
+
|| owner.dexSha256 !== asset.manifest.sha256 || typeof owner.owned !== 'boolean')
|
|
351
|
+
throw failure('uia_runtime_owner_unverified', 'The phone did not return a valid UIA process-lock observation.');
|
|
352
|
+
let peer = value === null ? null : descriptor(value);
|
|
353
|
+
if (owner.owned) {
|
|
354
|
+
if (peer === null || !peer.running) throw failure('uia_runtime_already_running', 'The original runtime still owns its process lock. Wait for it to finish starting or stopping.');
|
|
355
|
+
if (peer.dexSha256 !== asset.manifest.sha256) throw failure('uia_runtime_version_mismatch', 'Stop the existing UIA runtime before starting this installed bundle.');
|
|
356
|
+
} else {
|
|
357
|
+
// Explicit start may reopen a dead owner. The new process reacquires
|
|
358
|
+
// the OS lock and audits every original record BEFORE UiAutomation.
|
|
359
|
+
peer = await startRuntime(peer, asset, destination);
|
|
360
|
+
}
|
|
361
|
+
const state = await statusOf(await connect(peer));
|
|
362
|
+
if (state.closing) throw failure('uia_runtime_closing', 'The original runtime is still stopping.');
|
|
363
|
+
return { ...state, ...publicPeer(peer) };
|
|
364
|
+
}
|
|
365
|
+
if (value === null) return { ok: true, serial, running: false };
|
|
366
|
+
const peer = descriptor(value);
|
|
367
|
+
if (!peer.running) return { ok: true, ...publicPeer(peer) };
|
|
368
|
+
const connection = await connect(peer);
|
|
369
|
+
const state = await statusOf(connection);
|
|
370
|
+
if (operation === 'status') return { ...state, ...publicPeer(peer) };
|
|
371
|
+
return stopRuntime(connection);
|
|
372
|
+
});
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { createUiaRuntimePort };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseXmlAttributes } = require('./xml-attributes');
|
|
4
|
+
const { visitAndroidUiHierarchyTags, looksLikeAndroidUiHierarchyXml } = require('../android-uia-xml');
|
|
5
|
+
|
|
6
|
+
const selectorSchema = { type: 'object', additionalProperties: false, minProperties: 1, maxProperties: 1,
|
|
7
|
+
properties: Object.fromEntries(['text', 'contentDescription', 'resourceName']
|
|
8
|
+
.map(key => [key, { type: 'string', minLength: 1 }])) };
|
|
9
|
+
const intentSelectorSchema = { ...selectorSchema, properties: { ...selectorSchema.properties,
|
|
10
|
+
nodeRef: require('./uia-protocol').targetRefSchema.properties.nodeRef } };
|
|
11
|
+
|
|
12
|
+
function selectUiaNode(rawTree, spec, packageName) {
|
|
13
|
+
const reject = error => ({ ok: false, error, dispatched: false });
|
|
14
|
+
if (typeof rawTree !== 'string' || !looksLikeAndroidUiHierarchyXml(rawTree)) return reject('observed_uia_xml_required');
|
|
15
|
+
const selector = spec.selector || (typeof spec.text === 'string' ? { text: spec.text } : null);
|
|
16
|
+
const keys = Object.keys(selector || {});
|
|
17
|
+
const attribute = { text: 'text', contentDescription: 'content-desc', resourceName: 'resource-id', nodeRef: 'aab-ref' }[keys[0]];
|
|
18
|
+
if (keys.length !== 1 || !attribute || typeof selector[keys[0]] !== 'string' || !selector[keys[0]]
|
|
19
|
+
|| spec.exact === false || (spec.selector && spec.text != null)) return reject('explicit_exact_uia_selector_required');
|
|
20
|
+
const nodes = [];
|
|
21
|
+
visitAndroidUiHierarchyTags(rawTree, ({ tag, tagName, closing }) => {
|
|
22
|
+
if (!closing) {
|
|
23
|
+
const node = parseXmlAttributes(tag);
|
|
24
|
+
if (!node.class && tagName !== 'node') node.class = tagName;
|
|
25
|
+
nodes.push(node);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const selected = nodes.filter(node => node.package === packageName && node[attribute] === selector[keys[0]]
|
|
29
|
+
&& node['visible-to-user'] !== 'false' && node.displayed !== 'false');
|
|
30
|
+
if (selected.length !== 1) return reject(selected.length ? 'uia_selector_not_unique' : 'uia_selector_not_found');
|
|
31
|
+
const node = selected[0];
|
|
32
|
+
const bounds = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(node.bounds || '');
|
|
33
|
+
if (!bounds || node.enabled !== 'true' || (spec.requireClickable === true && node.clickable !== 'true')) return reject('uia_node_not_operable');
|
|
34
|
+
const [left, top, right, bottom] = bounds.slice(1).map(Number);
|
|
35
|
+
if (left < 0 || top < 0 || right <= left || bottom <= top) return reject('uia_node_not_operable');
|
|
36
|
+
return { ok: true, node, x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { selectUiaNode, selectorSchema, intentSelectorSchema };
|