Skip to content

Commit 030c34c

Browse files
committed
refactor: tighten unified gesture boundaries
1 parent 6053d87 commit 030c34c

23 files changed

Lines changed: 226 additions & 217 deletions

File tree

android-multitouch-helper/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ sh ./scripts/build-android-multitouch-helper.sh "$VERSION" .tmp/android-multitou
2121
PAYLOAD_JSON='{
2222
"protocol":"android-multitouch-helper-v1",
2323
"kind":"transform",
24-
"intent":"transform",
2524
"durationMs":32,
2625
"pointers":[
2726
{"pointerId":0,"samples":[{"offsetMs":0,"x":120,"y":180},{"offsetMs":16,"x":130,"y":175},{"offsetMs":32,"x":140,"y":170}]},

docs/adr/0013-unified-gesture-plans.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,23 @@ Platform adapters consume the canonical plan:
6161
instrumentation because Android permits only one instrumentation owner of `UiAutomation`.
6262
- iOS converts every planned point to native orientation and feeds the exact arrays to the existing
6363
private XCTest event bridge. macOS lowers a one-contact plan to its drag executor and tvOS lowers
64-
it to remote direction; multi-touch remains capability-gated to iOS simulators.
64+
it to remote direction. Core admission and the Apple adapter both consume the same shared
65+
multi-touch support policy; multi-touch remains capability-gated to iOS simulators.
6566
- WebDriver lowers a supported plan to synchronized W3C pointer action sources. Multi-touch remains
6667
capability-gated until a provider proves it.
6768

6869
The `Interactor` and backend expose one compositional `performGesture(plan)` primitive instead of a
6970
method per semantic alias. The old scalar Apple and Android multi-touch executors and the
7071
public-command alias-to-positionals-to-reparse route are deleted. `.ad` keeps its established
71-
positional syntax through one named daemon-edge compatibility adapter; CLI, Node.js, and MCP send
72+
positional syntax through one named replay compatibility codec; CLI, Node.js, and MCP send
7273
structured input. Providers should compose transport/device bindings with the shared platform
7374
adapter rather than reimplement the interaction runtime.
7475

76+
Repeated coordinate swipes are bounded at the public command contract and daemon trust boundary.
77+
Individual count and pause limits prevent pathological fields, while the combined planned gesture
78+
and pause schedule must fit within 60 seconds so valid fields cannot compose into an unbounded
79+
session lock.
80+
7581
Public two-finger pan is additive: `pointerCount?: 1 | 2` on pan and CLI
7682
`--pointer-count 2`; omission remains one contact. Responses share the canonical
7783
`kind`, `durationMs`, `pointerCount`, `from`, and `to` fields, followed by backend evidence.

src/cli/parser/cli-help.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ Navigation and gestures:
287287
For raw coordinate gestures, run snapshot -i first and choose a point near the center of the intended app-owned target. Avoid screen edges, tab bars, navigation bars, and home indicators because those areas can trigger system or app navigation instead of the gesture under test.
288288
If app-owned back is ambiguous or has just misrouted, prefer a visible nav/back button ref, tab-bar ref, or deep link over repeated back/system back.
289289
App-owned action sheets, menus, and camera/scan screens are normal UI. After opening one, run snapshot -i or wait for the option, press by label/ref, handle visible permission sheets through UI or platform-supported native alerts, then wait for a concrete result before returning to chat/form state.
290-
Keep count/pause/pattern on one swipe; flags are --count, --pause-ms, --pattern ping-pong.
290+
Keep count/pause/pattern on one swipe; flags are --count, --pause-ms, --pattern ping-pong. Count is capped at 200, pause at 10000ms, and the combined swipe/pause schedule at 60000ms.
291291
For repeated iOS gesture smoke checks, use press <x> <y> --count <n> --jitter-px <n> for tap series and swipe <x1> <y1> <x2> <y2> --count <n> for drag series.
292292
longpress accepts coordinates, @refs, or selectors. Prefer @ref/selector from snapshot -i; use coordinates only as a fallback when accessibility refs miss the exact target. Duration and gesture scale/center are positional:
293293
agent-device longpress 300 500 800

src/commands/interaction/metadata.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import { CLICK_BUTTONS } from '../../core/click-button.ts';
2424
import { SCROLL_DURATION_MAX_MS } from '../../contracts/scroll-command.ts';
2525
import {
2626
SCROLL_DIRECTIONS,
27+
SWIPE_PAUSE_MAX_MS,
2728
SWIPE_PATTERNS,
2829
SWIPE_PRESETS,
30+
SWIPE_REPETITION_MAX,
2931
} from '../../contracts/scroll-gesture.ts';
3032
import { SCROLL_INPUT_DIRECTIONS } from './runtime/gestures.ts';
3133
import { FIND_LOCATORS } from '../../selectors/find.ts';
@@ -137,9 +139,12 @@ const longPressFields = {
137139
const swipeFields = {
138140
from: requiredField(pointField('Swipe start point.')),
139141
to: requiredField(pointField('Swipe end point.')),
140-
durationMs: integerField('Deprecated: timed movement is a pan; omit for swipe.', { min: 0 }),
141-
count: integerField('Number of swipe repetitions.', { min: 1 }),
142-
pauseMs: integerField('Pause between repeated swipes.', { min: 0 }),
142+
durationMs: integerField('Deprecated: timed movement is a pan; omit for swipe.', {
143+
min: 16,
144+
max: 10_000,
145+
}),
146+
count: integerField('Number of swipe repetitions.', { min: 1, max: SWIPE_REPETITION_MAX }),
147+
pauseMs: integerField('Pause between repeated swipes.', { min: 0, max: SWIPE_PAUSE_MAX_MS }),
143148
pattern: enumField(SWIPE_PATTERNS),
144149
};
145150

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {
2+
isApplePlatform,
3+
resolveDeviceAppleOs,
4+
type AppleOS,
5+
type DeviceInfo,
6+
} from '../kernel/device.ts';
7+
import { AppError } from '../kernel/errors.ts';
8+
import type { GesturePlan } from './gesture-plan-types.ts';
9+
10+
const APPLE_OS_DISPLAY_NAMES: Record<AppleOS, string> = {
11+
ios: 'iOS',
12+
ipados: 'iPadOS',
13+
tvos: 'tvOS',
14+
watchos: 'watchOS',
15+
visionos: 'visionOS',
16+
macos: 'macOS',
17+
};
18+
19+
const APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS: Partial<Record<AppleOS, string>> = {
20+
visionos: 'visionOS uses spatial input and does not support two-finger touch synthesis.',
21+
tvos: 'tvOS has no touch input — this gesture is supported on Android and the iOS simulator only.',
22+
macos:
23+
'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.',
24+
};
25+
26+
/** One policy shared by command admission and the defensive Apple adapter check. */
27+
export function assertAppleMultiTouchSupported(
28+
device: DeviceInfo,
29+
intent: GesturePlan['intent'],
30+
): void {
31+
if (!isApplePlatform(device.platform)) {
32+
throw unsupported(intent, device.platform);
33+
}
34+
const appleOs = resolveDeviceAppleOs(device);
35+
if ((appleOs === 'ios' || appleOs === 'ipados') && device.kind === 'simulator') return;
36+
if (appleOs === 'ios' || appleOs === 'ipados') {
37+
throw new AppError(
38+
'UNSUPPORTED_OPERATION',
39+
`gesture ${intent} is not supported on physical iOS devices`,
40+
{
41+
hint: 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.',
42+
},
43+
);
44+
}
45+
throw unsupported(
46+
intent,
47+
APPLE_OS_DISPLAY_NAMES[appleOs],
48+
APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS[appleOs],
49+
);
50+
}
51+
52+
function unsupported(intent: GesturePlan['intent'], platform: string, hint?: string): AppError {
53+
return new AppError(
54+
'UNSUPPORTED_OPERATION',
55+
`gesture ${intent} is not supported on ${platform}`,
56+
hint === undefined ? undefined : { hint },
57+
);
58+
}

src/contracts/gesture-input.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import type { GesturePointerCount } from './gesture-plan-types.ts';
1111

1212
export const GESTURE_KINDS = ['pan', 'fling', 'swipe', 'pinch', 'rotate', 'transform'] as const;
13-
export type GestureKind = (typeof GESTURE_KINDS)[number];
1413

1514
export type PanGesturePayload = {
1615
kind: 'pan';

src/contracts/gesture-plan.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const GESTURE_HORIZONTAL_ANGLE_DEGREES = 0;
2121
const GESTURE_MINIMUM_RELIABLE_SPAN_PX = 48;
2222
const GESTURE_VIEWPORT_INSET_PX = 1;
2323
const DEFAULT_PAN_DURATION_MS = 500;
24-
const FLING_DURATION_MS = 100;
24+
export const GESTURE_FLING_DURATION_MS = 100;
2525
const DEFAULT_MULTI_TOUCH_DURATION_MS = 300;
2626
const MAX_ROTATION_DEGREES_PER_SAMPLE = 3;
2727
const MAX_ROTATION_DEFAULT_DURATION_MS = 2_400;
@@ -110,7 +110,7 @@ function buildFlingPlan(
110110
'fling',
111111
input.from,
112112
input.to,
113-
FLING_DURATION_MS,
113+
GESTURE_FLING_DURATION_MS,
114114
viewport,
115115
platform,
116116
);
@@ -121,7 +121,7 @@ function buildFlingPlan(
121121
'fling',
122122
start,
123123
offsetPointByDirection(start, input.direction, distance),
124-
FLING_DURATION_MS,
124+
GESTURE_FLING_DURATION_MS,
125125
viewport,
126126
platform,
127127
);

src/contracts/scroll-gesture.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export const SWIPE_PRESETS = ['left', 'right', 'left-edge', 'right-edge'] as con
88
export type SwipePreset = (typeof SWIPE_PRESETS)[number];
99
export const SWIPE_PATTERNS = ['one-way', 'ping-pong'] as const;
1010
export type SwipePattern = (typeof SWIPE_PATTERNS)[number];
11+
export const SWIPE_REPETITION_MAX = 200;
12+
export const SWIPE_PAUSE_MAX_MS = 10_000;
13+
export const SWIPE_SERIES_MAX_SCHEDULED_DURATION_MS = 60_000;
1114
const SCROLL_DIRECTION_ENUM = defineStringEnum(SCROLL_DIRECTIONS, {
1215
message: (direction) => `Unknown direction: ${direction}`,
1316
});
@@ -27,7 +30,7 @@ export type GestureReferenceFrame = {
2730
referenceHeight: number;
2831
};
2932

30-
export type GesturePoint = {
33+
type GesturePoint = {
3134
x: number;
3235
y: number;
3336
};

src/core/capabilities.ts

Lines changed: 3 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { deriveCapabilityMatrix } from './command-descriptor/derive.ts';
22
import { commandDescriptors } from './command-descriptor/registry.ts';
33
import { tryGetPlugin } from './platform-plugin/plugin.ts';
44
import { registerBuiltinPlatformPlugins } from './interactors/register-builtins.ts';
5-
import { isIosFamily, type AppleOS, type DeviceInfo } from '../kernel/device.ts';
5+
import type { DeviceInfo } from '../kernel/device.ts';
66
import { AppError } from '../kernel/errors.ts';
77
import type { NormalizedGestureInput } from '../contracts/gesture-normalization.ts';
8+
import { assertAppleMultiTouchSupported } from '../contracts/apple-multitouch-support.ts';
89

910
// Populate the PlatformPlugin registry once at module load (idempotent; registers
1011
// only lazy closures, so no leaf code is imported and CLI cold-start is unaffected
@@ -175,54 +176,7 @@ function requireMultiTouchGestureSupported(
175176
if (device.platform !== 'apple') {
176177
throw unsupportedGesture(input, gesturePlatformMessage(input, device));
177178
}
178-
const appleOs = resolveGestureAppleOs(device);
179-
if (appleOs === 'ios' || appleOs === 'ipados') {
180-
if (device.kind === 'simulator') return;
181-
if (isIosFamily(device) && device.kind === 'device') {
182-
throw unsupportedGesture(
183-
input,
184-
`gesture ${input.intent} is not supported on physical iOS devices`,
185-
'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.',
186-
);
187-
}
188-
}
189-
throw unsupportedGesture(
190-
input,
191-
`gesture ${input.intent} is not supported on ${appleOsDisplayName(appleOs)}`,
192-
multiTouchUnsupportedHint(appleOs),
193-
);
194-
}
195-
196-
function resolveGestureAppleOs(device: DeviceInfo): AppleOS {
197-
if (device.appleOs) return device.appleOs;
198-
if (device.target === 'tv') return 'tvos';
199-
if (device.target === 'desktop') return 'macos';
200-
return 'ios';
201-
}
202-
203-
function appleOsDisplayName(appleOs: AppleOS): string {
204-
const names: Record<AppleOS, string> = {
205-
ios: 'iOS',
206-
ipados: 'iPadOS',
207-
tvos: 'tvOS',
208-
watchos: 'watchOS',
209-
visionos: 'visionOS',
210-
macos: 'macOS',
211-
};
212-
return names[appleOs];
213-
}
214-
215-
function multiTouchUnsupportedHint(appleOs: AppleOS): string | undefined {
216-
if (appleOs === 'visionos') {
217-
return 'visionOS uses spatial input and does not support two-finger touch synthesis.';
218-
}
219-
if (appleOs === 'tvos') {
220-
return 'tvOS has no touch input — this gesture is supported on Android and the iOS simulator only.';
221-
}
222-
if (appleOs === 'macos') {
223-
return 'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.';
224-
}
225-
return undefined;
179+
assertAppleMultiTouchSupported(device, input.intent);
226180
}
227181

228182
function gesturePlatformMessage(input: NormalizedGestureInput, device: DeviceInfo): string {

src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isAudioProbeSupportedDevice } from '../../../kernel/audio-probe-support
44
import {
55
isIosFamily,
66
isMacOs,
7+
resolveDeviceAppleOs,
78
DEVICE_TARGETS,
89
PLATFORMS,
910
type AppleOS,
@@ -23,10 +24,7 @@ import {
2324
VISIONOS_SIMULATOR,
2425
WEB_DESKTOP_DEVICE,
2526
} from '../../../__tests__/test-utils/index.ts';
26-
import {
27-
APPLE_OS_CAPABILITIES,
28-
resolveDeviceAppleOs,
29-
} from '../../../platforms/apple/capabilities.ts';
27+
import { APPLE_OS_CAPABILITIES } from '../../../platforms/apple/capabilities.ts';
3028
import { getPlugin } from '../plugin.ts';
3129
import { registerBuiltinPlatformPlugins } from '../../interactors/register-builtins.ts';
3230

@@ -145,16 +143,13 @@ const SAMPLE_DEVICES: DeviceInfo[] = [
145143
...buildSyntheticMatrix(),
146144
];
147145

148-
test('the per-AppleOS table row keys are exhaustive and visionOS refuses touch synthesis', () => {
146+
test('the per-AppleOS capability table row keys are exhaustive', () => {
149147
const rows: AppleOS[] = ['ios', 'ipados', 'tvos', 'watchos', 'visionos', 'macos'];
150148
for (const os of rows) {
151149
assert.ok(APPLE_OS_CAPABILITIES[os], `capability row present for ${os}`);
152150
}
153-
// iOS/iPadOS share touch synthesis; visionOS keeps the lifecycle surface but uses
154-
// spatial input and therefore has its own explicit unsupported row.
151+
// iOS/iPadOS share the same platform capability profile.
155152
assert.equal(APPLE_OS_CAPABILITIES.ios, APPLE_OS_CAPABILITIES.ipados);
156-
assert.equal(APPLE_OS_CAPABILITIES.visionos.multiTouchSynthesis, false);
157-
assert.match(APPLE_OS_CAPABILITIES.visionos.multiTouchUnsupportedHint ?? '', /spatial input/i);
158153
});
159154

160155
test('resolveDeviceAppleOs prefers the stored discriminant, else infers from target', () => {

0 commit comments

Comments
 (0)