@mobileaidev/ai-app-bridge 0.3.4 → 0.3.5
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/README.md +2 -2
- package/bin/android-permissions.js +35 -3
- package/bin/command-registry.js +8 -7
- package/bin/ios-device-outcome.js +17 -0
- package/bin/ios-execution.js +13 -1
- package/bin/ios-provider.js +66 -21
- package/bin/ios-wda-startup.js +68 -0
- package/bin/shared-kernel/device-ownership-recovery.js +5 -0
- package/bin/shared-kernel/uia-protocol.js +1 -1
- package/bin/shared-kernel/uia-runtime-port.js +2 -2
- package/docs/COMMAND_CONTRACT.md +35 -1
- package/docs/INTENT_FOREGROUND.md +1 -1
- package/docs/RELEASE.md +18 -14
- package/docs/SCRIPT_AUTHORING.md +7 -0
- package/package.json +2 -1
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +9 -8
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ discovery. Every request checks the mapping before dispatch. Mutating requests
|
|
|
7
7
|
are never replayed after a missing route or uncertain result. For manual cleanup,
|
|
8
8
|
pass the exact serial and returned Host port to `remove-forward`.
|
|
9
9
|
|
|
10
|
-
This release is `0.3.
|
|
10
|
+
This release is `0.3.5`, distributed through the npm `latest` dist-tag.
|
|
11
11
|
The default installation includes the Script/Intent and capture contracts below.
|
|
12
12
|
The `next` dist-tag also points to this release until a newer candidate is published.
|
|
13
13
|
The supported Node range is `>=26.3.0 <27`; this release was checked on 26.3.0.
|
|
@@ -56,7 +56,7 @@ domains, commands, and options, then call `run` with the selected command.
|
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
# Install the current stable release; see docs/RELEASE.md for packaging.
|
|
59
|
-
npm install -g @mobileaidev/ai-app-bridge@0.3.
|
|
59
|
+
npm install -g @mobileaidev/ai-app-bridge@0.3.5
|
|
60
60
|
|
|
61
61
|
ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
|
|
62
62
|
ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
|
|
@@ -42,7 +42,7 @@ function uniqueBlock(lines, predicate, error) {
|
|
|
42
42
|
return { header: lines[indexes[0]].trim(), lines: block(lines, indexes[0]) };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function parsePermissionState(text, { packageName, permission, userId }) {
|
|
45
|
+
function parsePermissionState(text, { packageName, permission, userId }, definitions) {
|
|
46
46
|
const pkg = uniqueBlock(text.split(/\r?\n/), line => line.startsWith(`Package [${packageName}] (`), 'permission_package_not_found');
|
|
47
47
|
const appId = pkg.lines.map(line => /^\s+(?:appId|userId)=(\d+)\s*$/.exec(line)).filter(Boolean);
|
|
48
48
|
if (appId.length !== 1 || Number(appId[0][1]) >= 100000) throw new CommandError('permission_state_unsupported', 'Android package appId is unavailable or ambiguous.');
|
|
@@ -50,6 +50,21 @@ function parsePermissionState(text, { packageName, permission, userId }) {
|
|
|
50
50
|
if (!/\binstalled=true\b/.test(user.header)) throw new CommandError('permission_package_not_installed', 'The package is not installed for the requested Android user.');
|
|
51
51
|
const runtime = uniqueBlock(user.lines, line => line === 'runtime permissions:', 'runtime_permission_not_found');
|
|
52
52
|
const matches = runtime.lines.filter(line => line.trim().startsWith(`${permission}:`));
|
|
53
|
+
if (!matches.length && definitions !== undefined) {
|
|
54
|
+
// Android 7 omits untouched runtime permissions. Establish that the app
|
|
55
|
+
// requests a runtime permission and has no install grant before interpreting
|
|
56
|
+
// the absent PackageManager state as denied with no flags.
|
|
57
|
+
const requested = uniqueBlock(pkg.lines, line => line === 'requested permissions:', 'runtime_permission_not_found');
|
|
58
|
+
const targetSdk = pkg.lines.flatMap(line => [...line.matchAll(/\btargetSdk=(\d+)\b/g)]);
|
|
59
|
+
const definition = uniqueBlock(definitions.split(/\r?\n/), line => line.startsWith(`Permission [${permission}] (`), 'runtime_permission_not_found');
|
|
60
|
+
const protection = definition.lines.flatMap(line => [...line.matchAll(/\bprot=([^\s]+)/g)]);
|
|
61
|
+
if (targetSdk.length === 1 && Number(targetSdk[0][1]) >= 23
|
|
62
|
+
&& requested.lines.some(line => line.trim() === permission)
|
|
63
|
+
&& protection.length === 1 && protection[0][1].split('|')[0] === 'dangerous'
|
|
64
|
+
&& !pkg.lines.some(line => line.trim().startsWith(`${permission}:`))) {
|
|
65
|
+
return { packageName, permission, userId, uid: userId * 100000 + Number(appId[0][1]), granted: false, flags: [] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
53
68
|
const match = matches.length === 1 && /^\s*[^:]+:\s+granted=(true|false),\s*flags=\[([^\]]*)\]\s*$/.exec(matches[0]);
|
|
54
69
|
if (!matches.length) throw new CommandError('runtime_permission_not_found', 'The requested permission has no runtime permission record for this package and user.');
|
|
55
70
|
if (!match) throw new CommandError('permission_state_unsupported', 'Android returned an unrecognized runtime permission record.');
|
|
@@ -70,8 +85,15 @@ async function readPermissionState(args, run = execute) {
|
|
|
70
85
|
userId = Number(current);
|
|
71
86
|
}
|
|
72
87
|
const dump = await androidCommand(args, ['shell', 'dumpsys', 'package', args.packageName], run);
|
|
88
|
+
let state;
|
|
89
|
+
try { state = parsePermissionState(dump.stdout, { ...args, userId }); }
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code !== 'runtime_permission_not_found') throw error;
|
|
92
|
+
const definitions = await androidCommand(args, ['shell', 'dumpsys', 'package', 'permissions'], run);
|
|
93
|
+
state = parsePermissionState(dump.stdout, { ...args, userId }, definitions.stdout);
|
|
94
|
+
}
|
|
73
95
|
return { ok: true, source: 'android-package-manager', serial: args.serial,
|
|
74
|
-
...
|
|
96
|
+
...state, capturedAtMs: Date.now() };
|
|
75
97
|
} catch (error) {
|
|
76
98
|
if (error instanceof CommandError) throw error;
|
|
77
99
|
throw new CommandError('permission_query_failed', 'Android permission state could not be read.', { details: { cause: error.code || null } });
|
|
@@ -125,7 +147,17 @@ function activityIdentity(value) {
|
|
|
125
147
|
// the actual dialog, and PackageManager independently verifies the named permission.
|
|
126
148
|
function parsePermissionRequest(text) {
|
|
127
149
|
const lines = text.split(/\r?\n/);
|
|
128
|
-
|
|
150
|
+
let tops = lines.filter(line => /^\s*topResumedActivity=/.test(line)).map(activityIdentity);
|
|
151
|
+
if (!tops.length) {
|
|
152
|
+
// Before multi-resume, ActivityManager records one focused Activity and
|
|
153
|
+
// each stack's resumed Activity. Both identities must agree.
|
|
154
|
+
tops = lines.filter(line => /^\s*mFocusedActivity:/.test(line)).map(activityIdentity);
|
|
155
|
+
const resumed = lines.filter(line => /^\s*mResumedActivity:/.test(line)).map(activityIdentity);
|
|
156
|
+
if (tops.length !== 1 || !tops[0] || !resumed.some(value => value && value.token === tops[0].token
|
|
157
|
+
&& value.component === tops[0].component && value.userId === tops[0].userId)) {
|
|
158
|
+
throw new CommandError('permission_request_unsupported', 'The focused and resumed Android Activity identities did not agree.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
129
161
|
if (tops.length !== 1 || !tops[0]) throw new CommandError('permission_request_unsupported', 'One top-resumed Android Activity could not be identified.');
|
|
130
162
|
const records = lines.flatMap((line, i) => /^\s*\* Hist\s+#\d+: ActivityRecord\{/.test(line) ? [{ ...activityIdentity(line), lines: block(lines, i) }] : []);
|
|
131
163
|
const requests = records.filter(record => record.lines.some(line => line.trim().startsWith('Intent {') && line.includes(`act=${REQUEST_PERMISSIONS} `)));
|
package/bin/command-registry.js
CHANGED
|
@@ -10,7 +10,7 @@ const { flutterActionSchema, webCommandSchema } = require('./shared-kernel/provi
|
|
|
10
10
|
const commandDefinitions = [
|
|
11
11
|
{ command: 'runtime', domain: 'execution', summary: 'Inspect, start or orderly stop the shared local execution runtime. CLI exit and MCP disconnect leave operations running; stop cancels and drains them.', targetKind: 'host-runtime', options: ['operation'] },
|
|
12
12
|
{ command: 'device-ownership', domain: 'execution', summary: 'Read ownership, reconcile original completion, explicitly cancel a retained install by actionId, or read a retained UIA receipt by serial/runtimeEpoch/actionId. Installation cancellation abandons its original PM session; it does not roll back an installed APK.', targetKind: 'android-device', options: ['operation', 'serial', 'timeoutMs', 'runtimeEpoch', 'actionId'] },
|
|
13
|
-
{ command: 'uia-runtime', domain: 'advanced', summary: 'Read, start or orderly stop the Android API
|
|
13
|
+
{ command: 'uia-runtime', domain: 'advanced', summary: 'Read, start or orderly stop the Android API 25+ UIA node runtime. Start checks the phone process lock; unacknowledged original receipts are retained.', targetKind: 'android-device', options: ['operation', 'serial', 'adb', 'timeoutMs'] },
|
|
14
14
|
{ command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
|
|
15
15
|
{ command: 'tree', domain: 'core', summary: 'Read Android View tree from the in-app bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
|
|
16
16
|
{ command: 'uia-tree', domain: 'core', summary: 'Read UIAutomator XML for the current foreground window.', options: ['serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
|
|
@@ -70,7 +70,7 @@ const commandDefinitions = [
|
|
|
70
70
|
{ command: 'ios-devices', domain: 'ios', summary: 'List iOS devices known to xcrun devicectl.', targetKind: 'ios-device', options: ['deviceId'] },
|
|
71
71
|
{ command: 'ios-install-app', domain: 'ios', summary: 'Install an iOS .app bundle through devicectl.', targetKind: 'ios-app', options: ['deviceId', 'appPath'] },
|
|
72
72
|
{ command: 'ios-launch-app', domain: 'ios', summary: 'Launch an iOS app by bundle identifier through devicectl.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'terminateExisting'] },
|
|
73
|
-
{ command: 'ios-execution', domain: 'ios', summary: 'Read SDK or WDA execution/physical ownership, cancel an original action, read its durable completion, or reconcile unknown ownership without replay.', targetKind: 'ios-app', options: ['operation', 'deviceId', 'bundleId', 'kind', 'actionId', 'runtimeEpoch', 'runtimeUrl', 'iosHost', 'iosPort', 'wdaRunnerBundleId', 'wdaUrl', 'devicectl', 'timeoutMs'] },
|
|
73
|
+
{ command: 'ios-execution', domain: 'ios', summary: 'Read SDK or WDA execution/physical ownership, cancel an original action, read its durable completion, or reconcile unknown ownership without replay.', targetKind: 'ios-app', options: ['operation', 'deviceId', 'bundleId', 'kind', 'actionId', 'runtimeEpoch', 'runtimeUrl', 'iosHost', 'iosPort', 'wdaRunnerBundleId', 'wdaUrl', 'devicectl', 'timeoutMs', 'setupResultPath'] },
|
|
74
74
|
{ command: 'ios-status', domain: 'ios', summary: 'Read AiAppBridgeIOS runtime status.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
|
|
75
75
|
{ command: 'ios-tree', domain: 'ios', summary: 'Read UIKit tree from the AiAppBridgeIOS runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
|
|
76
76
|
{ command: 'ios-logs', domain: 'ios', summary: 'Read in-app iOS log records.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'sinceId', 'sinceMs', 'limit'] },
|
|
@@ -202,7 +202,7 @@ define('orientation', require('./shared-kernel/ios-native-target').orientationSc
|
|
|
202
202
|
define('selector', { type: 'string', minLength: 1 });
|
|
203
203
|
define('spec', { type: 'object', additionalProperties: true });
|
|
204
204
|
define('targetRef', require('./shared-kernel/uia-protocol').targetRefSchema);
|
|
205
|
-
define('adb serial packageName artifactDir deviceId bundleId iosHost runtimeUrl wdaUrl wdaSessionId devicectl xcodebuild host path token appPath apkPath activity component action data initialRoute outFile permission op mode targetText requireText absentText requireActivity resourceId textFilter resourceIdFilter classFilter urlFilter method pageUrlFilter socketName targetId sessionId accessibilityId elementId wdaProjectPath wdaBundleId teamId displayUniqueId tag level grep since factCursor cursor runtimeEpoch afterActionId mobileFactId targetKey requestId deviceLogScope logcatFormat format agentModule pythonPath goal intent operationId recordingDir namespace outputDir archiveDir manifestSha256 name reason provider adapter', { type: 'string', minLength: 1 });
|
|
205
|
+
define('adb serial packageName artifactDir deviceId bundleId iosHost runtimeUrl wdaUrl wdaSessionId devicectl xcodebuild host path token setupResultPath appPath apkPath activity component action data initialRoute outFile permission op mode targetText requireText absentText requireActivity resourceId textFilter resourceIdFilter classFilter urlFilter method pageUrlFilter socketName targetId sessionId accessibilityId elementId wdaProjectPath wdaBundleId teamId displayUniqueId tag level grep since factCursor cursor runtimeEpoch afterActionId mobileFactId targetKey requestId deviceLogScope logcatFormat format agentModule pythonPath goal intent operationId recordingDir namespace outputDir archiveDir manifestSha256 name reason provider adapter', { type: 'string', minLength: 1 });
|
|
206
206
|
define('text value script payload', { type: 'string' });
|
|
207
207
|
define('aaptPath apksignerPath', { type: 'string', minLength: 1 });
|
|
208
208
|
define('full compact visibleOnly clear follow appPid force hideKeyboard noAutoHideKeyboard allowDowngrade clearTask exact skipFlutterLaunch includeResponseBody keepForward noBodies startWda terminateExisting clearFirst refresh includeActions history includeRecordedPayloads grepCaseSensitive includeCatalog', { type: 'boolean' });
|
|
@@ -355,14 +355,15 @@ function commandSchema(command) {
|
|
|
355
355
|
actionId: { type: 'string', minLength: 1, maxLength: 256 }, runtimeEpoch: optionTypes.runtimeEpoch,
|
|
356
356
|
wdaRunnerBundleId: optionTypes.wdaRunnerBundleId, wdaUrl: optionTypes.wdaUrl,
|
|
357
357
|
runtimeUrl: optionTypes.runtimeUrl, iosHost: optionTypes.iosHost, iosPort: optionTypes.iosPort,
|
|
358
|
-
devicectl: optionTypes.devicectl, timeoutMs: optionTypes.timeoutMs },
|
|
358
|
+
devicectl: optionTypes.devicectl, timeoutMs: optionTypes.timeoutMs, setupResultPath: optionTypes.setupResultPath },
|
|
359
359
|
required: ['operation', 'deviceId'], oneOf: [
|
|
360
360
|
{ properties: { kind: { enum: ['h5', 'flutter'] }, wdaRunnerBundleId: false, wdaUrl: false },
|
|
361
361
|
anyOf: [
|
|
362
|
-
{ properties: { operation: {
|
|
363
|
-
{ properties: { operation: {
|
|
362
|
+
{ properties: { operation: { const: 'status' }, kind: false, actionId: false, runtimeEpoch: false, setupResultPath: false } },
|
|
363
|
+
{ properties: { operation: { const: 'reconcile' }, kind: false, actionId: false, runtimeEpoch: false } },
|
|
364
|
+
{ properties: { operation: { enum: ['result', 'cancel'] }, setupResultPath: false }, required: ['bundleId', 'kind', 'actionId', 'runtimeEpoch'] },
|
|
364
365
|
] },
|
|
365
|
-
{ properties: { kind: { const: 'wda' }, bundleId: false, runtimeUrl: false, iosHost: false, iosPort: false },
|
|
366
|
+
{ properties: { kind: { const: 'wda' }, bundleId: false, runtimeUrl: false, iosHost: false, iosPort: false, setupResultPath: false },
|
|
366
367
|
required: ['kind', 'wdaRunnerBundleId'], anyOf: [
|
|
367
368
|
{ properties: { operation: { enum: ['status', 'reconcile'] }, actionId: false, runtimeEpoch: false } },
|
|
368
369
|
{ properties: { operation: { enum: ['result', 'cancel'] } }, required: ['actionId', 'runtimeEpoch'] },
|
|
@@ -23,6 +23,23 @@ function deviceCommandRejection(reply, args, error) {
|
|
|
23
23
|
function originalDeviceRejection(reply, args) {
|
|
24
24
|
const command = args.slice(0, 3).join(' ');
|
|
25
25
|
if (!matchesInvocation(reply, args) || reply.info.outcome !== 'failed') return null;
|
|
26
|
+
// CoreDevice rejects this launch while acquiring its prerequisites, before
|
|
27
|
+
// contacting the application service. Match the original structured failure,
|
|
28
|
+
// not stderr text or a general tunnel/timeout error.
|
|
29
|
+
const usage = reply.error;
|
|
30
|
+
const requested = usage?.userInfo?.RequestedDeviceStates?.array;
|
|
31
|
+
const available = usage?.userInfo?.CurrentlyAssertableStates?.array;
|
|
32
|
+
const prerequisites = ['com.apple.coredevice.remoteServiceDiscoveryTrustedConnectivityAvailable',
|
|
33
|
+
'com.apple.coredevice.coreDeviceServicesLoaded', 'com.apple.coredevice.powerAssertionTaken'];
|
|
34
|
+
if (command === 'device process launch' && !reply.result
|
|
35
|
+
&& usage?.domain === 'com.apple.dt.CoreDeviceError' && usage.code === 4016
|
|
36
|
+
&& Array.isArray(available) && available.length === 0
|
|
37
|
+
&& Array.isArray(requested) && requested.length === prerequisites.length
|
|
38
|
+
&& prerequisites.every(state => requested.some(value => value?.string === state))) {
|
|
39
|
+
return { ok: false, error: 'ios_device_unavailable',
|
|
40
|
+
message: 'CoreDevice rejected the launch before dispatch because the device connection and services were unavailable. Restore the device connection before launching again.',
|
|
41
|
+
settled: true, dispatched: false, ambiguous: false, deviceOutcome: reply };
|
|
42
|
+
}
|
|
26
43
|
const errors = [];
|
|
27
44
|
function visit(value, depth) {
|
|
28
45
|
if (!value || typeof value !== 'object' || depth > 16 || errors.length > 32) return;
|
package/bin/ios-execution.js
CHANGED
|
@@ -4,6 +4,7 @@ const { randomUUID } = require('node:crypto');
|
|
|
4
4
|
const { CommandError } = require('./command-errors');
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const { originalDeviceOutcome, deviceCommandProof } = require('./ios-device-outcome');
|
|
7
|
+
const { initializationProof, recoverLegacySetup } = require('./ios-wda-startup');
|
|
7
8
|
const managed = require('./shared-kernel/managed-sdk-execution');
|
|
8
9
|
const { runDeviceEffect } = require('./shared-kernel/device-mutation-lease');
|
|
9
10
|
const { checkExecution, runExecution } = require('./shared-kernel/execution-scope');
|
|
@@ -75,12 +76,23 @@ async function executeIOSAction({ port, kind, payload, status, target, timeoutMs
|
|
|
75
76
|
}, result => result.executionReceipt);
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
async function reconcileIOS({ lease, device, args, createPort }) {
|
|
79
|
+
async function reconcileIOS({ lease, device, args, createPort, readWdaTestSummary }) {
|
|
80
|
+
const original = args.setupResultPath ? lease.status(iosDeviceKey(device)).ownership : null;
|
|
79
81
|
const result = await lease.reconcile(iosDeviceKey(device), async pending => {
|
|
80
82
|
if (pending.target?.deviceId !== device.udid
|
|
81
83
|
|| args.bundleId && pending.target?.bundleId && pending.target.bundleId !== args.bundleId) {
|
|
82
84
|
return { settled: false, error: 'ios_original_completion_identity_required' };
|
|
83
85
|
}
|
|
86
|
+
if (pending.kind === 'ios-wda-start' || pending.kind === 'ios-command' && pending.command === 'ios-setup') {
|
|
87
|
+
try {
|
|
88
|
+
const proof = pending.kind === 'ios-wda-start'
|
|
89
|
+
? initializationProof(await readWdaTestSummary(pending.invocation.resultBundlePath), pending.invocation, device.udid)
|
|
90
|
+
: original?.pending?.id === pending.id && await recoverLegacySetup({ pending, owner: original.owner,
|
|
91
|
+
resultPath: args.setupResultPath, device, readSummary: readWdaTestSummary });
|
|
92
|
+
return proof || { settled: false, error: 'ios_original_wda_startup_outcome_unresolved' };
|
|
93
|
+
} catch (error) { return { settled: false, error: 'ios_wda_startup_completion_unavailable', cause: error.code || 'invalid_result' }; }
|
|
94
|
+
}
|
|
95
|
+
if (args.setupResultPath) return { settled: false, error: 'ios_original_completion_identity_required' };
|
|
84
96
|
if (pending.kind === 'ios-command') {
|
|
85
97
|
const invocation = pending.invocation;
|
|
86
98
|
const index = Array.isArray(invocation?.arguments) ? invocation.arguments.indexOf('--json-output') : -1;
|
package/bin/ios-provider.js
CHANGED
|
@@ -17,6 +17,7 @@ const { openWdaPort, target: wdaTarget } = require('./ios-wda-port');
|
|
|
17
17
|
const { prepareWdaProject, wdaBuildEnvironment } = require('./ios-wda-project');
|
|
18
18
|
const { executeWDAAction, reconcileWDA, completionPort } = require('./ios-wda-execution');
|
|
19
19
|
const { deviceCommandRejection, deviceCommandProof } = require('./ios-device-outcome');
|
|
20
|
+
const { initializationProof } = require('./ios-wda-startup');
|
|
20
21
|
const { bindFlutterAction } = require('./shared-kernel/flutter-target');
|
|
21
22
|
const nativeTarget = require('./shared-kernel/ios-native-target');
|
|
22
23
|
const h5Target = require('./shared-kernel/ios-h5-target');
|
|
@@ -152,6 +153,22 @@ class IOSBridgeProvider {
|
|
|
152
153
|
const ctx = this.context(args);
|
|
153
154
|
const raw = await this.devicectlJson(ctx, ['list', 'devices']);
|
|
154
155
|
const devices = parseDevicectlDevices(raw).map(shapeDevice);
|
|
156
|
+
const selected = selectDeviceFromList(devices, args).device;
|
|
157
|
+
if (selected && (selected.tunnelState !== 'connected' || selected.ddiServicesAvailable !== true)) {
|
|
158
|
+
// list devices can describe a sleeping tunnel. A targeted, read-only
|
|
159
|
+
// details request asks CoreDevice to establish its lazy connection.
|
|
160
|
+
try {
|
|
161
|
+
const reply = await this.devicectlJson(ctx, ['device', 'info', 'details', '--device', selected.identifier]);
|
|
162
|
+
const current = shapeDevice(reply?.result);
|
|
163
|
+
if (current.identifier !== selected.identifier || !current.udid || current.udid !== selected.udid) {
|
|
164
|
+
throw bindingFailure('ios_device_identity_mismatch', 'Device details did not match the selected device identifier and UDID.');
|
|
165
|
+
}
|
|
166
|
+
devices[devices.indexOf(selected)] = { ...current, connectionProbe: { ok: true, source: 'devicectl.device.info.details' } };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
selected.connectionProbe = { ok: false, source: 'devicectl.device.info.details',
|
|
169
|
+
error: error.code || 'ios_device_probe_failed', message: error.message };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
155
172
|
return {
|
|
156
173
|
ok: true,
|
|
157
174
|
devices,
|
|
@@ -273,7 +290,9 @@ class IOSBridgeProvider {
|
|
|
273
290
|
if (start.ok === true) {
|
|
274
291
|
wda = start.status || await this.wdaStatus(args);
|
|
275
292
|
} else {
|
|
276
|
-
return { ok: false, error: start.error, message: start.message, device, steps
|
|
293
|
+
return { ok: false, error: start.error, message: start.message, device, steps,
|
|
294
|
+
...(start.executionReceipt ? { settled: start.settled, dispatched: start.dispatched,
|
|
295
|
+
ambiguous: start.ambiguous, executionReceipt: start.executionReceipt } : {}) };
|
|
277
296
|
}
|
|
278
297
|
}
|
|
279
298
|
steps.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
|
|
@@ -477,7 +496,7 @@ class IOSBridgeProvider {
|
|
|
477
496
|
return lookupCompletion(completionPort(port), 'wda', identity, cancelled);
|
|
478
497
|
}
|
|
479
498
|
if (args.operation === 'reconcile') return reconcileIOS({ lease, device, args,
|
|
480
|
-
createPort: target => this.runtimePort(target, { device }) });
|
|
499
|
+
createPort: target => this.runtimePort(target, { device }), readWdaTestSummary: file => this.readWdaTestSummary(file) });
|
|
481
500
|
if (args.operation === 'status') return {
|
|
482
501
|
ok: true, device, ownership: lease.status(key),
|
|
483
502
|
runtime: args.bundleId ? await this.runtimeGet(args, '/v1/execution/status', { device, allowUnavailable: true }) : null,
|
|
@@ -562,7 +581,9 @@ class IOSBridgeProvider {
|
|
|
562
581
|
throw bindingFailure('ios_device_not_found', 'deviceId must match the selected devicectl identifier or UDID.');
|
|
563
582
|
}
|
|
564
583
|
if (device.developerModeStatus !== 'enabled' || device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') {
|
|
565
|
-
throw bindingFailure('ios_tunnel_unavailable',
|
|
584
|
+
throw bindingFailure('ios_tunnel_unavailable', device.connectionProbe?.ok === false
|
|
585
|
+
? `The selected iPhone did not become ready after a targeted device details request: ${device.connectionProbe.message}`
|
|
586
|
+
: 'The selected iPhone does not expose ready developer services after the device details request. Inspect ios-doctor for the observed device state.');
|
|
566
587
|
}
|
|
567
588
|
const runtimeBinding = await this.readRuntimePortFile(args, device);
|
|
568
589
|
const host = target.iosHost ?? device.tunnelIPAddress;
|
|
@@ -768,25 +789,47 @@ class IOSBridgeProvider {
|
|
|
768
789
|
};
|
|
769
790
|
} finally { await build.stop(); }
|
|
770
791
|
const logFile = path.join(directory, 'xcodebuild.log');
|
|
771
|
-
const
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
792
|
+
const resultBundlePath = path.join(directory, 'result.xcresult');
|
|
793
|
+
const invocation = { arguments: [...xcodeArgs, '-resultBundlePath', resultBundlePath, 'test-without-building'],
|
|
794
|
+
resultBundlePath, startedAtMs: Date.now() };
|
|
795
|
+
return runDeviceEffect({ kind: 'ios-wda-start', command: 'ios-setup', invocation,
|
|
796
|
+
target: { deviceId: device.udid, bundleId: args.bundleId ?? null } }, async () => {
|
|
797
|
+
const runtime = spawnWdaProcess(ctx.xcodebuild, invocation.arguments, logFile, true);
|
|
798
|
+
const child = runtime.child;
|
|
799
|
+
let ready = false;
|
|
800
|
+
try {
|
|
801
|
+
while (!runtime.terminal) {
|
|
802
|
+
checkExecution();
|
|
803
|
+
const status = await this.wdaStatus({ ...args, deviceId: device.udid, wdaRunnerBundleId });
|
|
804
|
+
if (status.ok) {
|
|
805
|
+
ready = true; child.unref();
|
|
806
|
+
return { ok: true, device, wdaTestBundleId, wdaRunnerBundleId, pid: child.pid, logFile, buildLogFile, prepared, status,
|
|
807
|
+
executionReceipt: { kind: 'ios-wda-start', settled: true, dispatched: true, ambiguous: false, invocation,
|
|
808
|
+
runtimeBinding: status.runtimeBinding } };
|
|
809
|
+
}
|
|
810
|
+
await executionSleep(1000);
|
|
811
|
+
}
|
|
812
|
+
let proof;
|
|
813
|
+
if (!runtime.spawnError && runtime.exitCode === 65) {
|
|
814
|
+
try { proof = initializationProof(await this.readWdaTestSummary(resultBundlePath), invocation, device.udid); }
|
|
815
|
+
catch { /* An absent or unreadable original XCTest result remains unresolved. */ }
|
|
781
816
|
}
|
|
782
|
-
|
|
817
|
+
return { ok: false, error: runtime.spawnError ? 'ios_wda_xcodebuild_spawn_failed' : 'ios_wda_xcodebuild_exited',
|
|
818
|
+
message: runtime.spawnError?.message ?? 'xcodebuild closed before the selected Runner published a bound endpoint.',
|
|
819
|
+
phase: 'device-test', exitCode: runtime.exitCode, logFile, buildLogFile, prepared, resultBundlePath,
|
|
820
|
+
...(runtime.spawnError ? { dispatched: false, ambiguous: false,
|
|
821
|
+
executionReceipt: { kind: 'ios-wda-start', settled: true, dispatched: false, ambiguous: false, invocation } } : {}),
|
|
822
|
+
...(proof ? { ...proof.outcome, executionReceipt: proof } : {}) };
|
|
823
|
+
} finally {
|
|
824
|
+
if (!ready) await runtime.stop();
|
|
783
825
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
826
|
+
}, result => result.executionReceipt);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async readWdaTestSummary(resultBundlePath) {
|
|
830
|
+
const reply = await execFileText(this.execFile, 'xcrun', ['xcresulttool', 'get', 'test-results', 'summary',
|
|
831
|
+
'--path', resultBundlePath, '--format', 'json'], { timeoutMs: 10000 });
|
|
832
|
+
return JSON.parse(reply.stdout);
|
|
790
833
|
}
|
|
791
834
|
|
|
792
835
|
async requireDevice(args = {}) {
|
|
@@ -987,7 +1030,9 @@ function withQuery(endpointPath, query) {
|
|
|
987
1030
|
function iosSetupSuggestion(device, runtime, wda) {
|
|
988
1031
|
if (!device) return 'Connect one iPhone, trust this Mac on the device, then rerun ios-doctor.';
|
|
989
1032
|
if (device.developerModeStatus !== 'enabled') return 'Enable Developer Mode on the iPhone and rerun ios-setup.';
|
|
990
|
-
if (device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') return
|
|
1033
|
+
if (device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') return device.connectionProbe?.ok === false
|
|
1034
|
+
? `The device details probe failed: ${device.connectionProbe.message}`
|
|
1035
|
+
: 'The device details probe did not establish ready developer services. Check the selected device connection and Xcode preparation state.';
|
|
991
1036
|
if (wda?.ok !== true) return 'Start the prepared Runner with ios-setup --start-wda --team-id, or supply its exact wdaRunnerBundleId. An optional wdaUrl still requires container binding.';
|
|
992
1037
|
if (runtime?.ok !== true) return 'Launch a debug App with AiAppBridgeIOS and supply its exact deviceId and bundleId.';
|
|
993
1038
|
return 'Rerun ios-setup after resolving the failing check.';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { getHostFactStore } = require('./shared-kernel/host-fact-store');
|
|
7
|
+
|
|
8
|
+
const initializationFailure = 'The test runner failed to initialize for UI testing. (Underlying Error: Timed out while enabling automation mode.)';
|
|
9
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
|
|
11
|
+
function initializationProof(summary, invocation, deviceId, completedAtMs = Date.now()) {
|
|
12
|
+
const args = invocation?.arguments;
|
|
13
|
+
if (!Array.isArray(args) || args[args.indexOf('-destination') + 1] !== `id=${deviceId}`
|
|
14
|
+
|| args[args.indexOf('-scheme') + 1] !== 'WebDriverAgentRunner' || !args.includes('test-without-building')
|
|
15
|
+
|| summary?.result !== 'Failed' || summary.totalTestCount !== 1 || summary.failedTests !== 1
|
|
16
|
+
|| summary.passedTests !== 0 || summary.skippedTests !== 0 || summary.expectedFailures !== 0
|
|
17
|
+
|| summary.devicesAndConfigurations?.length !== 1 || summary.devicesAndConfigurations[0].device?.deviceId !== deviceId
|
|
18
|
+
|| summary.testFailures?.length !== 1 || summary.testFailures[0].targetName !== 'WebDriverAgentRunner'
|
|
19
|
+
|| summary.testFailures[0].failureText !== initializationFailure
|
|
20
|
+
|| !Number.isFinite(summary.startTime) || !Number.isFinite(summary.finishTime)
|
|
21
|
+
|| summary.startTime * 1000 < invocation.startedAtMs || summary.finishTime < summary.startTime
|
|
22
|
+
|| summary.finishTime * 1000 > completedAtMs) return null;
|
|
23
|
+
return { kind: 'ios-wda-start', settled: true, dispatched: true, ambiguous: false, invocation,
|
|
24
|
+
outcome: { ok: false, error: 'ios_wda_automation_confirmation_required', settled: true, dispatched: true, ambiguous: false,
|
|
25
|
+
message: 'XCTest ended before UI test initialization completed. Confirm Enable UI Automation on the iPhone, then explicitly run ios-setup again.' },
|
|
26
|
+
summary, summarySha256: digest(JSON.stringify(summary)) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Old setup markers lack a test invocation. Recovery requires the original
|
|
30
|
+
// public response AND its retained Host action, not a newly supplied exit code.
|
|
31
|
+
async function recoverLegacySetup({ pending, owner, resultPath, device, readSummary, readAction }) {
|
|
32
|
+
if (pending.command !== 'ios-setup' || pending.invocation || !resultPath) return null;
|
|
33
|
+
const bytes = await fs.promises.readFile(resultPath);
|
|
34
|
+
if (bytes.length > 4 * 1024 * 1024) return null;
|
|
35
|
+
const envelope = JSON.parse(bytes.toString('utf8'));
|
|
36
|
+
const result = envelope.kind === 'json' ? envelope.value : envelope;
|
|
37
|
+
const feedback = result?._feedback, times = feedback?.timings;
|
|
38
|
+
const evidence = feedback?.evidence?.filter(item => item.partition === 'action' && item.stored === true);
|
|
39
|
+
const steps = result?.steps?.filter(item => item.name === 'start-wda');
|
|
40
|
+
if (result?.error !== 'ios_wda_xcodebuild_exited' || result.device?.udid !== device.udid
|
|
41
|
+
|| feedback?.dispatch?.command !== 'ios-setup' || feedback.target?.bundleId !== pending.target.bundleId
|
|
42
|
+
|| evidence?.length !== 1 || !evidence[0].actionId?.startsWith(`host-action-${owner?.pid}-`)
|
|
43
|
+
|| !Number.isSafeInteger(times?.startedAtMs) || !Number.isSafeInteger(times?.completedAtMs)
|
|
44
|
+
|| times.startedAtMs > owner.acquiredAtMs || owner.acquiredAtMs > pending.preparedAtMs
|
|
45
|
+
|| pending.preparedAtMs > times.completedAtMs || steps?.length !== 1
|
|
46
|
+
|| steps[0].phase !== 'device-test' || steps[0].exitCode !== 65) return null;
|
|
47
|
+
const page = (readAction || (actionId => getHostFactStore().read({ partitions: ['action'], actionId, limit: 2 })))(evidence[0].actionId);
|
|
48
|
+
const fact = page.items?.[0];
|
|
49
|
+
if (page.ok !== true || page.items?.length !== 1 || fact.globalSeq !== evidence[0].globalSeq
|
|
50
|
+
|| fact.payload?.command !== 'ios-setup' || fact.payload.status !== 'failed' || fact.payload.args?.startWda !== true
|
|
51
|
+
|| fact.payload.args.deviceId !== result.device.identifier || fact.payload.args.bundleId !== pending.target.bundleId
|
|
52
|
+
|| fact.payload.result?.error !== result.error || fact.timestamps?.occurredAtMs !== times.startedAtMs
|
|
53
|
+
|| fact.timestamps.observedAtMs !== times.completedAtMs) return null;
|
|
54
|
+
const project = steps[0].prepared?.projectPath;
|
|
55
|
+
if (typeof project !== 'string' || path.basename(project) !== 'WebDriverAgent.xcodeproj') return null;
|
|
56
|
+
const directory = path.dirname(path.dirname(project));
|
|
57
|
+
if (steps[0].logFile !== path.join(directory, 'xcodebuild.log')) return null;
|
|
58
|
+
const resultsDirectory = path.join(directory, 'build', 'Logs', 'Test');
|
|
59
|
+
const bundles = (await fs.promises.readdir(resultsDirectory)).filter(name => name.endsWith('.xcresult'));
|
|
60
|
+
if (bundles.length !== 1) return null;
|
|
61
|
+
const invocation = { arguments: ['-project', project, '-scheme', 'WebDriverAgentRunner', '-destination', `id=${device.udid}`, 'test-without-building'],
|
|
62
|
+
resultBundlePath: path.join(resultsDirectory, bundles[0]), startedAtMs: pending.preparedAtMs };
|
|
63
|
+
const proof = initializationProof(await readSummary(invocation.resultBundlePath), invocation, device.udid, times.completedAtMs);
|
|
64
|
+
return proof && { ...proof, originalSetup: { pendingId: pending.id, actionId: fact.actionId,
|
|
65
|
+
globalSeq: fact.globalSeq, resultPath: path.resolve(resultPath), responseSha256: digest(bytes) } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { initializationProof, recoverLegacySetup };
|
|
@@ -100,6 +100,11 @@ async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(),
|
|
|
100
100
|
if (cleanup.errors.length) (recovered.cleanupErrors ||= []).push(...cleanup.errors);
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
if (args.operation === 'cancel-install' && recovered.ok && recovered.recovered === false) {
|
|
104
|
+
return { ...recovered, ok: false, error: 'install_action_not_pending', actionId: args.actionId,
|
|
105
|
+
message: 'No pending installation matches this action. Nothing was cancelled; read the original installation result to determine its outcome.',
|
|
106
|
+
dispatched: false, ambiguous: false };
|
|
107
|
+
}
|
|
103
108
|
return recovered;
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -90,7 +90,7 @@ function actionRequest(binding, { timeoutMs, actionId = randomUUID(), clickPolic
|
|
|
90
90
|
function validDescriptor(value, root) {
|
|
91
91
|
return record(value) && validRoot(root) && value.schemaVersion === runtimeSchema
|
|
92
92
|
&& isUuid(value.bootId) && isUuid(value.runtimeEpoch) && isHash(value.dexSha256)
|
|
93
|
-
&& Number.isSafeInteger(value.pid) && value.pid > 0 && Number.isSafeInteger(value.apiLevel) && value.apiLevel >=
|
|
93
|
+
&& Number.isSafeInteger(value.pid) && value.pid > 0 && Number.isSafeInteger(value.apiLevel) && value.apiLevel >= 25
|
|
94
94
|
&& value.socketName === `aab-uia-${value.runtimeEpoch}` && isHash(value.token)
|
|
95
95
|
&& value.sessionPath === `${root}/sessions/${value.runtimeEpoch}` && typeof value.running === 'boolean';
|
|
96
96
|
}
|
|
@@ -37,7 +37,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
37
37
|
manifest = JSON.parse(fs.readFileSync(path.join(bundleDirectory, 'manifest.json'), 'utf8'));
|
|
38
38
|
bytes = fs.readFileSync(path.join(bundleDirectory, 'ai-app-bridge-uia.jar'));
|
|
39
39
|
} catch (error) { throw failure('uia_runtime_bundle_unavailable', 'The installed CLI has no complete UIA runtime bundle.', { cause: error.code }); }
|
|
40
|
-
if (manifest?.schemaVersion !== 'aab.uia.bundle.v1' || manifest.mainClass !== mainClass || manifest.minApi !==
|
|
40
|
+
if (manifest?.schemaVersion !== 'aab.uia.bundle.v1' || manifest.mainClass !== mainClass || manifest.minApi !== 25
|
|
41
41
|
|| manifest.artifact !== 'ai-app-bridge-uia.jar' || bytes.length > 2 * 1024 * 1024 || protocol.digest(bytes) !== manifest.sha256) {
|
|
42
42
|
throw failure('uia_runtime_bundle_invalid', 'The UIA runtime artifact does not match its bundle manifest.');
|
|
43
43
|
}
|
|
@@ -133,7 +133,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
133
133
|
|
|
134
134
|
async function installAsset(asset) {
|
|
135
135
|
const level = await shell('getprop ro.build.version.sdk');
|
|
136
|
-
if (!/^[0-9]+$/.test(level) || Number(level) <
|
|
136
|
+
if (!/^[0-9]+$/.test(level) || Number(level) < 25) throw failure('uia_android_api_25_required', 'UIA node execution requires Android API 25 or newer.', { apiLevel: level });
|
|
137
137
|
const destination = `${root}/runtime-${asset.manifest.sha256}.jar`;
|
|
138
138
|
await shell(`umask 077\nmkdir -p ${quote(root)} && chmod 700 ${quote(root)}`);
|
|
139
139
|
const existingHash = await shell(`if [ -f ${quote(destination)} ]; then ${sha256FileScript(destination)}; else printf '%s' null; fi`);
|
package/docs/COMMAND_CONTRACT.md
CHANGED
|
@@ -930,7 +930,7 @@ a mutation already running at timeout returns an ambiguous outcome. These checks
|
|
|
930
930
|
do not make app touch handlers or business outcomes transactional.
|
|
931
931
|
Explicit coordinate commands retain their primitive role.
|
|
932
932
|
|
|
933
|
-
UIA observation and semantic clicks require Android API
|
|
933
|
+
UIA observation and semantic clicks require Android API 25 or newer and the
|
|
934
934
|
bundled, hash-verified `runtime/uia` module. `uia-tree` opens or reuses one
|
|
935
935
|
UiAutomation connection on the explicit device. XML snapshots carry boot,
|
|
936
936
|
runtime and snapshot IDs; each node has an opaque reference. Ordinary UIA text
|
|
@@ -938,6 +938,12 @@ commands, Intent (including installer and permission choices), and JavaScript /
|
|
|
938
938
|
Python Script calls send that reference and their original action ID to the
|
|
939
939
|
same runtime. They do not convert text matches into physical coordinates.
|
|
940
940
|
|
|
941
|
+
API 25–32 use the process accessibility cache; API 33+ uses its connection
|
|
942
|
+
cache. Before API 30, Android exposes only the default-display window inventory.
|
|
943
|
+
All versions retain durable file and directory synchronization and original
|
|
944
|
+
Binder callbacks. Device validation covers API 25, 30 and 36; this is not a
|
|
945
|
+
claim that every vendor framework implementation has been tested.
|
|
946
|
+
|
|
941
947
|
The runtime checks the focused default-display window, unique selector match,
|
|
942
948
|
node attributes and clickable ancestor before one node action. Its guarantee
|
|
943
949
|
is `same_connection_node_and_reobserved_attributes`, not a transaction across
|
|
@@ -966,6 +972,10 @@ ownership with actions. Stop requires committed terminal actions. Explicit start
|
|
|
966
972
|
checks the phone's OS-managed process lock. A dead process can be reopened only
|
|
967
973
|
after the new process acquires that lock and audits all original action records;
|
|
968
974
|
any nonterminal or corrupt record blocks reopening, including after a reboot.
|
|
975
|
+
The 0.3.5 audit compares a nodeRef selector with the receipt's `binding.ref`;
|
|
976
|
+
the target attributes do not contain a `nodeRef` field. A successful audit may
|
|
977
|
+
retire acknowledged sessions and publish a new epoch. It does not resurrect
|
|
978
|
+
the old process or authorize replay of old actions.
|
|
969
979
|
An HTTP timeout or a stale `running` descriptor does not authorize replacement.
|
|
970
980
|
Authentication tokens
|
|
971
981
|
stay in private phone descriptors and are absent from public status and Host
|
|
@@ -1600,9 +1610,33 @@ explicit state assertions, or request Agent help through `ctx.askAgent`.
|
|
|
1600
1610
|
|
|
1601
1611
|
### Android 安装超时后的显式取消
|
|
1602
1612
|
|
|
1613
|
+
`freeze-app` 使用 SIGSTOP 暂停整个 App 进程,包括 SDK HTTP 服务;冻结期间的
|
|
1614
|
+
`tree`、`status` 超时是预期行为。它不是只暂停业务 UI。先完成需要的读取,再冻结;
|
|
1615
|
+
后续 SDK 验证前必须 `thaw-app`,并重新确认可读。
|
|
1616
|
+
|
|
1603
1617
|
`device-ownership status` 返回原安装的 `actionId`。若原 commit 回执因 OEM 确认页丢失,
|
|
1604
1618
|
可用 `device-ownership --operation cancel-install --serial SERIAL --action-id ORIGINAL_ACTION_ID`。
|
|
1605
1619
|
该命令只对保留的原 PM session 执行 abandon,先持久保存取消任务身份,再派发;
|
|
1606
1620
|
重连后 `reconcile` 可读取同一任务的原回执。明确成功才解除该安装的设备占用。
|
|
1607
1621
|
`phase: session-abandoned` 的 `requestSucceeded: null` 表示原安装结果未被推断,
|
|
1608
1622
|
已经安装的 APK 不会回滚。取消按钮、等待超时或 Host 进程退出本身仍不是完成证据。
|
|
1623
|
+
没有待处理安装时返回 `install_action_not_pending`、`recovered:false` 和
|
|
1624
|
+
`dispatched:false`。这同时适用于未知 actionId 和已经结算的安装;是否安装成功应读取
|
|
1625
|
+
原操作结果。未抓住安装中途的取消不能当作 PM abandon 验证。
|
|
1626
|
+
|
|
1627
|
+
### iOS 设备连接与原始失败对账
|
|
1628
|
+
|
|
1629
|
+
`devicectl list devices` 可以返回休眠隧道快照。`ios-devices` 对唯一或显式选择的未就绪
|
|
1630
|
+
设备执行只读 `device info details`,核对 identifier/UDID 后使用当前隧道和 DDI 状态;
|
|
1631
|
+
`connectionProbe` 保留结果。多设备未选择时不会唤醒任意一台。`ios-doctor` 的
|
|
1632
|
+
`deviceConnected` 指开发者隧道可用,USB 配对与传输方式另见 selectedDevice。
|
|
1633
|
+
|
|
1634
|
+
`ios-execution reconcile` 可以识别原 launch 的 CoreDevice 4016 回执:要求精确调用、
|
|
1635
|
+
失败 outcome、完整请求状态以及空的可用状态,且没有启动 result。它是已结算的启动前
|
|
1636
|
+
拒绝;一般超时、丢连接和不匹配的 JSON 仍不能解除所有权。
|
|
1637
|
+
|
|
1638
|
+
### WDA startup outcome recovery
|
|
1639
|
+
|
|
1640
|
+
`ios-setup --start-wda true` records the exact XCTest invocation and result bundle before starting the device test. A completed XCTest failure that explicitly says UI test initialization timed out while enabling automation is a settled failed startup (`ios_wda_automation_confirmation_required`). The Runner launch was dispatched; this does not undo its installation. Confirm Enable UI Automation on the device, then explicitly run setup again.
|
|
1641
|
+
|
|
1642
|
+
`ios-execution --operation reconcile --device-id DEVICE` reads the original result bundle after an interrupted Host. For a legacy generic `ios-setup` marker, supply `--setup-result-path ORIGINAL_RESPONSE.json`: recovery requires the original CLI JSON response (or its unwrapped value), its matching retained Host action in the current FactStore, the same device and startup interval, and the original XCTest bundle identified by that response. Missing, mismatched, or generic test failures remain unresolved. This option is accepted only for SDK/command reconciliation, never action cancellation or WDA session recovery. Lock files and original evidence are retained.
|
|
@@ -29,7 +29,7 @@ choosing any action. The Host does not retry the read or substitute a provider.
|
|
|
29
29
|
|
|
30
30
|
The summary contains `provider` and `foreground` (`packageName`, `activity`, `component`, probe source and timestamps). Observation, decision, dispatch marker and receipt records preserve that route. The original `target` continues to identify the business operation and its app capture streams. System UIA observations do not imply that system-app network/state/event capture is available.
|
|
31
31
|
|
|
32
|
-
Actions inherit the provider of their committed observation. An explicit conflicting provider is rejected. Before dispatch, the adapter checks the foreground component again; a changed component returns `reobserve_required` without dispatching a tap. Exact UIA taps use the API
|
|
32
|
+
Actions inherit the provider of their committed observation. An explicit conflicting provider is rejected. Before dispatch, the adapter checks the foreground component again; a changed component returns `reobserve_required` without dispatching a tap. Exact UIA taps use the API 25+ phone node runtime. Its `uia-node` execution receipt binds the Intent action ID, original request hash, runtime epoch, node/window attributes and original callback. The selected node is revalidated on the same automation connection before dispatch. A callback still requires a fresh observation and business checks; it does not create system-app SDK events.
|
|
33
33
|
|
|
34
34
|
Device-scoped physical taps remain managed Android shell input. Cancellation
|
|
35
35
|
and recovery follow the selected executor's admission and completion contract.
|
package/docs/RELEASE.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
# 0.3.
|
|
1
|
+
# 0.3.5 发行与接入交接
|
|
2
2
|
|
|
3
3
|
本文件记录正式版的依赖关系和出仓库交付入口。封版要求是同一提交的源码、发行包与公开接入合同一致;单个样本的测试进度不改变包版本或发布状态。推送 Git、创建远端标签及发布 npm/pub 包由维护者执行。
|
|
4
4
|
|
|
5
|
-
## 0.3.
|
|
5
|
+
## 0.3.5 变更
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
7
|
+
- 修正 UIA nodeRef 原回执对账,正常重启和死进程后的显式启动可完成原 session 审计;未知回执和失效引用仍受保护。
|
|
8
|
+
- UIA node runtime 支持 Android API 25+,保留 POSIX 原子重命名、fsync、进程锁与原始 Binder 回调;不使用 dump 或坐标回退。Android 7 权限观察支持未初始化的权限状态以及旧 ActivityManager 的身份字段。
|
|
9
|
+
- iOS 对未就绪的 list 快照先执行所选设备的 details 探测;原始 4016 使用断言拒绝可按精确调用公开对账,不删除占用记录。
|
|
10
|
+
- WDA 启动前持久保存 XCTest 调用身份。原始启用自动化超时能结算;旧 ios-setup 记录用原公开响应、Host action 与 XCTest 结果公开对账,未知结果继续保留。
|
|
11
|
+
- 没有待处理安装时,cancel-install 明确返回 install_action_not_pending,不把空操作报告成安装取消成功。
|
|
12
|
+
- 各 SDK 同步版本;原生 SDK 设备执行逻辑沿用 0.3.4。真实业务验收与渠道发布状态单独记录。
|
|
9
13
|
|
|
10
14
|
## 随本版包含的 0.3.3 修复
|
|
11
15
|
|
|
@@ -20,12 +24,12 @@
|
|
|
20
24
|
|
|
21
25
|
| 交付物 | 发行版本 | 独立消费入口 | 发布依赖 |
|
|
22
26
|
| --- | --- | --- | --- |
|
|
23
|
-
| Android SDK | `0.3.
|
|
24
|
-
| Android Gradle 插件 | `0.3.
|
|
25
|
-
| 原生 iOS SDK | Git tag `0.3.
|
|
26
|
-
| Flutter 插件 | `0.3.
|
|
27
|
-
| Desktop CLI/MCP | `0.3.
|
|
28
|
-
| Web SDK | `0.3.
|
|
27
|
+
| Android SDK | `0.3.5` | JitPack `com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-android:0.3.5` | 同名 Git tag,JitPack 对该提交成功构建 |
|
|
28
|
+
| Android Gradle 插件 | `0.3.5` | JitPack `ai-app-bridge-gradle-plugin` 模块及插件 ID `io.github.mobileaidev.aiappbridge.android` | 与 SDK 相同的 Git tag;不再使用旧默认 `0.2.8` |
|
|
29
|
+
| 原生 iOS SDK | Git tag `0.3.5` | Git URL 的仓库根 `Package.swift`,产品 `AiAppBridgeIOS` | 根清单包含 Swift runtime、C adapter 和 segmented C store,无外部 C 包路径 |
|
|
30
|
+
| Flutter 插件 | `0.3.5` | pub `ai_app_bridge_flutter` | Android 固定依赖上述 SDK;iOS Swift/C 源码随插件分发 |
|
|
31
|
+
| Desktop CLI/MCP | `0.3.5` | npm `@mobileaidev/ai-app-bridge` | 包含 UIA bundle、WDA 模板和 native store 源码;WDA 上游固定 `14.1.1` |
|
|
32
|
+
| Web SDK | `0.3.5` | npm `@mobileaidev/ai-app-bridge-web` | 独立浏览器源码包,无 npm 对 CLI 的安装依赖 |
|
|
29
33
|
| Native store | `0.1.0` | 随 CLI 的 bundled dependency 安装 | 不要求另行发布到 npm;`file:../../native/segmented-fact-store` 是工作区构建入口,最终 tarball 必须包含该依赖源码 |
|
|
30
34
|
|
|
31
35
|
Flutter 的 podspec 是随 pub 插件消费的本地 podspec,不是独立 CocoaPods trunk 发布包;原生 iOS 使用根 Swift package。Flutter SwiftPM 的 `../FlutterFramework` 由 Flutter 的集成生成,不能当作本仓库的外部私有依赖,也不应将本机 Flutter framework 打包进插件。
|
|
@@ -34,11 +38,11 @@ Host 支持范围声明为 Node `>=26.3.0 <27`,本轮实际验证基线是 **2
|
|
|
34
38
|
|
|
35
39
|
## 发布顺序
|
|
36
40
|
|
|
37
|
-
1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.
|
|
38
|
-
2. 维护者推送提交与 `0.3.
|
|
41
|
+
1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.5`,若需要改版本,先同时更新上表涉及的 manifest 与固定依赖。
|
|
42
|
+
2. 维护者推送提交与 `0.3.5` 标签,让 JitPack 构建 Android SDK/插件。确认两条公开坐标可解析后,再发布依赖它们的 Flutter 包。本地 Gradle project/path/AAR 替换不能证明 JitPack 坐标可消费。
|
|
39
43
|
3. 原生 iOS 消费相同 Git tag 的根 package;完成根 package 的 iOS 构建,不仅构建 `ios/ai-app-bridge-ios/Package.swift`。Flutter iOS 则检查实际 pub 包内 Swift/C 源码与声明相符。
|
|
40
44
|
4. CLI 与 Web SDK 可分别发布到 npm 的 `latest` dist-tag。CLI 的 native store 已打包随行,不等待一个不存在的单独 registry 依赖。Flutter 包发布以第 2 步完成为前提。
|
|
41
|
-
5. 同步 npm `next` 指向 `0.3.
|
|
45
|
+
5. 同步 npm `next` 指向 `0.3.5`,让已有候选入口也使用本次正式版。将 GitHub `main` 与发行提交同步,并创建非预发布的 GitHub Release。
|
|
42
46
|
6. 从 registry/tag 安装刚发布的确切版本,读取 `capabilities` 和版本,核对来源及支持范围,确认默认安装入口指向本次发行版本。正式发布不自动等于全平台生产验收完成。
|
|
43
47
|
|
|
44
48
|
正式发布命令需在对应目录由维护者执行,例如 npm 使用 `npm publish --tag latest`;pub 使用 `flutter pub publish`。这些命令属于发布动作,不能混入本地验证脚本。
|
|
@@ -79,7 +83,7 @@ npm 升级不会替换已连接的 MCP 进程。用 `ai-app-bridge --version`
|
|
|
79
83
|
已有工作结束后显式停止旧 Runtime,并在 Cursor 等客户端重连 MCP,核对 initialize
|
|
80
84
|
中的 `serverInfo.version`。工具描述仍有旧 `batch`/`smoke` 时刷新客户端缓存。
|
|
81
85
|
|
|
82
|
-
实际发布状态与验证边界见仓库 `docs/
|
|
86
|
+
实际发布状态与验证边界见仓库 `docs/RELEASE_HANDOFF_0.3.5_2026-09-14.md`。
|
|
83
87
|
Android Gradle 插件的 `webSocketCaptureEnabled`、`logInstrumentationEnabled`、
|
|
84
88
|
`webViewDebuggingEnabled` 没有对应插桩实现,现明确弃用并在显式设置时输出提示;
|
|
85
89
|
旧配置仍可构建。当前有效开关是 `enabled`、`okHttpCaptureEnabled`,以及可选
|
package/docs/SCRIPT_AUTHORING.md
CHANGED
|
@@ -298,6 +298,13 @@ as `status` and `keyboard-state` whose `evidenceRefs` can be empty. References
|
|
|
298
298
|
are preserved unchanged; these metadata fields do not create a capture ref or
|
|
299
299
|
make incomplete or missing evidence valid for a device assertion.
|
|
300
300
|
|
|
301
|
+
Python uses `ctx.assert_({...})` with one dictionary argument; `assert` is a
|
|
302
|
+
Python keyword. JavaScript uses `ctx.assert({...})`. Both return a verdict that
|
|
303
|
+
the script must check. A `status` response alone supplies no UI tree evidence,
|
|
304
|
+
and `capture_page_limit` means that capture is incomplete. Drain the documented
|
|
305
|
+
capture cursor before making a completeness assertion; do not relabel a partial
|
|
306
|
+
capture as passed.
|
|
307
|
+
|
|
301
308
|
`ctx.assert` returns `{verdict, name, scope, reason?}`. Verdict is `passed`,
|
|
302
309
|
`failed`, or `inconclusive`; it does not throw or stop the program. Code must
|
|
303
310
|
check the verdict and implement the intended stopping behavior. `throw` alone
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobileaidev/ai-app-bridge",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Desktop CLI and MCP server for AI App Bridge across Android, iOS, Flutter, WebView, and Web targets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"bin/ios-wda-port.js",
|
|
67
67
|
"bin/ios-wda-project.js",
|
|
68
68
|
"bin/ios-wda-execution.js",
|
|
69
|
+
"bin/ios-wda-startup.js",
|
|
69
70
|
"runtime/ios-wda",
|
|
70
71
|
"LICENSE",
|
|
71
72
|
"NOTICE",
|
|
Binary file
|
|
@@ -2,21 +2,22 @@
|
|
|
2
2
|
"schemaVersion": "aab.uia.bundle.v1",
|
|
3
3
|
"mainClass": "io.github.mobileaidev.aiappbridge.uia.UiaRuntime",
|
|
4
4
|
"artifact": "ai-app-bridge-uia.jar",
|
|
5
|
-
"sha256": "
|
|
6
|
-
"minApi":
|
|
5
|
+
"sha256": "54a78985b47adf05e2db95a1c4deb25a093bbf3f2b158c75d1fb96aaaddd2c80",
|
|
6
|
+
"minApi": 25,
|
|
7
7
|
"compileApi": 35,
|
|
8
8
|
"buildTools": "36.0.0",
|
|
9
9
|
"androidJarSha256": "4566663c3876e022b4fa4ced8c8697c4ab1688267f090114fd92d027b32e619b",
|
|
10
10
|
"d8JarSha256": "4097ff9c46c185c6e7214da7fe9b1befb5adeea5cc9ca349270e0249904f9240",
|
|
11
11
|
"sources": {
|
|
12
|
-
"android/ai-app-bridge-uia/build.gradle.kts": "
|
|
13
|
-
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/
|
|
12
|
+
"android/ai-app-bridge-uia/build.gradle.kts": "f43bc522ff813c1aa47619aab10310cdebb3388db40b344855966084bd7cc4b6",
|
|
13
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/AndroidPosix.java": "d1bf8163108ef2948b298831fc7e26a244822d122c07d47160ca182dbed609c6",
|
|
14
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/DurableFiles.java": "481723be04b95a94d3b71b86f155dae4e964f750e479d64541eaf12326d4dea2",
|
|
14
15
|
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaActionEngine.java": "e7cb3ba5b58d87a1024d6c0dd9eff24f07e74ca90fb716f898441255977c3e6f",
|
|
15
|
-
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaConnection.java": "
|
|
16
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaConnection.java": "907bc9b3439078766bff03b669cd6d0338d2b309e32403371583b03f60e5eea3",
|
|
16
17
|
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaHttp.java": "43447130a1829354e50c4df4dac2a66afea3ef7d9f8fa5732d773f526fc6e72a",
|
|
17
|
-
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaJournal.java": "
|
|
18
|
-
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaNodes.java": "
|
|
19
|
-
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaRuntime.java": "
|
|
18
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaJournal.java": "718a367429c68b16f56418e36d6f73f9147508b85e424c15a8d81c061a82a892",
|
|
19
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaNodes.java": "afd426091f98ff9114ac9947c1c124a1167f566cc875e2063f171c9e42a298cf",
|
|
20
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaRuntime.java": "bfeb921d91977350e1ced62bbac850fcd12ee527e3d837904b4536c5ab9516a4",
|
|
20
21
|
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/Wire.java": "d54e79c5e064abf5c7b36d2ab43205b7d47cca001ec37bf9b82ab01e33cf816c"
|
|
21
22
|
}
|
|
22
23
|
}
|