Skip to content

Commit c127ba2

Browse files
committed
refactor: tighten unified gesture seams
1 parent 769b69a commit c127ba2

7 files changed

Lines changed: 126 additions & 149 deletions

File tree

src/contracts/gesture-normalization.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Point } from '../kernel/snapshot.ts';
22
import { AppError } from '../kernel/errors.ts';
33
import type { SwipePreset } from './scroll-gesture.ts';
44
import { readGesturePayload, type GesturePayload } from './gesture-input.ts';
5+
import { GESTURE_FLING_DEFAULT_DISTANCE } from './gesture-plan.ts';
56
import type { GestureSemanticInput } from './gesture-plan-types.ts';
67

78
export type GestureCompatibilityRule = 'swipe-duration' | 'fling-duration' | 'rotate-velocity';
@@ -101,12 +102,16 @@ export function gesturePayloadToPositionals(input: GesturePayload): string[] {
101102
input.durationMs,
102103
]);
103104
case 'fling':
105+
// `.ad` positionals cannot encode an empty distance slot. Preserve the
106+
// planner default when a deprecated duration must occupy the next slot.
104107
return compact([
105108
input.kind,
106109
input.direction,
107110
input.origin.x,
108111
input.origin.y,
109-
input.durationMs === undefined ? input.distance : (input.distance ?? 180),
112+
input.durationMs === undefined
113+
? input.distance
114+
: (input.distance ?? GESTURE_FLING_DEFAULT_DISTANCE),
110115
input.durationMs,
111116
]);
112117
case 'swipe':
@@ -166,7 +171,10 @@ export function normalizePublicGesture(input: GesturePayload): NormalizedPublicG
166171
gesture: {
167172
intent: 'pan',
168173
origin: input.origin,
169-
delta: directionDelta(input.direction, input.distance ?? 180),
174+
delta: directionDelta(
175+
input.direction,
176+
input.distance ?? GESTURE_FLING_DEFAULT_DISTANCE,
177+
),
170178
durationMs: input.durationMs,
171179
},
172180
deprecations: [

src/contracts/gesture-plan.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './gesture-plan-types.ts';
1515

1616
export const GESTURE_SAMPLE_INTERVAL_MS = 16;
1717
export const GESTURE_INITIAL_ANGLE_DEGREES = -90;
18+
export const GESTURE_FLING_DEFAULT_DISTANCE = 180;
1819

1920
const GESTURE_INITIAL_SPAN_RATIO = 0.25;
2021
const GESTURE_PINCH_INITIAL_SPAN_RATIO = 0.4;
@@ -117,7 +118,10 @@ function buildFlingPlan(
117118
);
118119
}
119120
const start = finitePoint(input.origin, 'gesture fling origin');
120-
const distance = positiveNumber(input.distance ?? 180, 'gesture fling distance');
121+
const distance = positiveNumber(
122+
input.distance ?? GESTURE_FLING_DEFAULT_DISTANCE,
123+
'gesture fling distance',
124+
);
121125
return buildSinglePointerPlan(
122126
'fling',
123127
start,

src/core/dispatch.ts

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,7 @@ export async function dispatchCommand(
4444
outPath?: string,
4545
context?: DispatchContext,
4646
): Promise<Record<string, unknown> | void> {
47-
const runnerCtx: RunnerContext = {
48-
requestId: context?.requestId,
49-
appBundleId: context?.appBundleId,
50-
verbose: context?.verbose,
51-
logPath: context?.logPath,
52-
traceLogPath: context?.traceLogPath,
53-
iosXctestrunFile: context?.iosXctestrunFile,
54-
iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath,
55-
iosXctestEnvDir: context?.iosXctestEnvDir,
56-
runnerLeaseContext: context?.runnerLeaseContext,
57-
};
47+
const runnerCtx = runnerContextFromDispatchContext(context);
5848
const interactor = await getInteractor(device, runnerCtx);
5949
emitDiagnostic({
6050
level: 'debug',
@@ -90,17 +80,7 @@ export async function dispatchGesturePlan(
9080
plan: GesturePlan,
9181
context?: DispatchContext,
9282
): Promise<Record<string, unknown> | void> {
93-
const interactor = await getInteractor(device, {
94-
requestId: context?.requestId,
95-
appBundleId: context?.appBundleId,
96-
verbose: context?.verbose,
97-
logPath: context?.logPath,
98-
traceLogPath: context?.traceLogPath,
99-
iosXctestrunFile: context?.iosXctestrunFile,
100-
iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath,
101-
iosXctestEnvDir: context?.iosXctestEnvDir,
102-
runnerLeaseContext: context?.runnerLeaseContext,
103-
});
83+
const interactor = await getInteractor(device, runnerContextFromDispatchContext(context));
10484
if (!interactor.performGesture) {
10585
throw new AppError('UNSUPPORTED_OPERATION', 'Gesture execution is unavailable');
10686
}
@@ -111,7 +91,12 @@ export async function dispatchGestureViewport(
11191
device: DeviceInfo,
11292
context?: DispatchContext,
11393
): Promise<Rect | undefined> {
114-
const interactor = await getInteractor(device, {
94+
const interactor = await getInteractor(device, runnerContextFromDispatchContext(context));
95+
return await interactor.gestureViewport?.();
96+
}
97+
98+
function runnerContextFromDispatchContext(context?: DispatchContext): RunnerContext {
99+
return {
115100
requestId: context?.requestId,
116101
appBundleId: context?.appBundleId,
117102
verbose: context?.verbose,
@@ -121,8 +106,7 @@ export async function dispatchGestureViewport(
121106
iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath,
122107
iosXctestEnvDir: context?.iosXctestEnvDir,
123108
runnerLeaseContext: context?.runnerLeaseContext,
124-
});
125-
return await interactor.gestureViewport?.();
109+
};
126110
}
127111

128112
type DispatchCommand = DescriptorDispatchCommandName;

src/daemon/handlers/interaction-gesture.ts

Lines changed: 81 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
gesturePayloadToPositionals,
44
normalizePublicGesture,
55
normalizePublicSwipeMotion,
6+
type GestureDeprecation,
67
type SwipePayload,
78
} from '../../contracts/gesture-normalization.ts';
89
import { requireGestureSupported } from '../../core/capabilities.ts';
@@ -17,139 +18,138 @@ import { readOptionalInteger } from '../../kernel/input-validation.ts';
1718
import type { Point } from '../../kernel/snapshot.ts';
1819
import { isActiveProviderDevice } from '../../provider-device-runtime.ts';
1920
import { sleep } from '../../utils/timeouts.ts';
20-
import type { DaemonResponse } from '../types.ts';
21+
import type { DaemonResponse, SessionState } from '../types.ts';
2122
import { ensureAndroidBlockingSystemDialogReady } from '../android-system-dialog.ts';
2223
import type { InteractionHandlerParams } from './interaction-common.ts';
2324
import { finalizeTouchInteraction } from './interaction-common.ts';
2425
import { createInteractionRuntime } from './interaction-runtime.ts';
2526
import type { CaptureSnapshotForSession } from './interaction-snapshot.ts';
2627
import { noActiveSessionError } from './response.ts';
2728

29+
type GestureHandlerParams = InteractionHandlerParams & {
30+
captureSnapshotForSession: CaptureSnapshotForSession;
31+
};
32+
33+
type GestureRuntime = ReturnType<typeof createInteractionRuntime>;
34+
type GestureRuntimeResult = Awaited<ReturnType<GestureRuntime['interactions']['gesture']>>;
35+
36+
type GestureInteractionOutcome = {
37+
positionals: string[];
38+
flags: InteractionHandlerParams['req']['flags'];
39+
responseData: Record<string, unknown>;
40+
recordingResultExtra?: Record<string, unknown>;
41+
};
42+
2843
export async function dispatchGestureViaRuntime(
29-
params: InteractionHandlerParams & {
30-
captureSnapshotForSession: CaptureSnapshotForSession;
31-
},
44+
params: GestureHandlerParams,
3245
): Promise<DaemonResponse> {
33-
const session = params.sessionStore.get(params.sessionName);
34-
if (!session) return noActiveSessionError();
35-
const startedAt = Date.now();
36-
try {
46+
return await dispatchGestureInteraction(params, 'gesture', async (session) => {
3747
const input = readGesturePayload(params.req.input);
3848
const normalized = normalizePublicGesture(input);
3949
requireGestureSupported(normalized.gesture, session.device);
40-
const providerDevice = isActiveProviderDevice(session.device);
41-
const readiness = providerDevice
42-
? ({ status: 'clear' } as const)
43-
: await ensureAndroidBlockingSystemDialogReady({
44-
session,
45-
command: 'gesture',
46-
phase: 'before-command',
47-
});
4850
const result = await createInteractionRuntime(params).interactions.gesture({
4951
session: params.sessionName,
5052
requestId: params.req.meta?.requestId,
5153
gesture: normalized.gesture,
5254
});
53-
if (!providerDevice) {
54-
await ensureAndroidBlockingSystemDialogReady({
55-
session,
56-
command: 'gesture',
57-
phase: 'after-command',
58-
});
59-
}
60-
const responseData: Record<string, unknown> = {
61-
kind: result.kind,
62-
durationMs: result.durationMs,
63-
pointerCount: result.pointerCount,
64-
from: result.from,
65-
to: result.to,
66-
...(result.backendResult ?? {}),
67-
...(normalized.deprecations.length > 0 ? { deprecations: normalized.deprecations } : {}),
68-
message: result.message,
69-
};
70-
if (readiness.status === 'recovered') responseData.warning = readiness.warning;
71-
return finalizeTouchInteraction({
72-
session,
73-
sessionStore: params.sessionStore,
74-
command: 'gesture',
75-
actionCommand: 'gesture',
55+
return {
7656
positionals: gesturePayloadToPositionals(input),
7757
flags: gestureReplayFlags(input, params.req.flags),
78-
result: {
79-
...responseData,
80-
...(input.kind === 'pinch' ? { scale: input.scale } : {}),
81-
},
82-
responseData,
83-
actionStartedAt: startedAt,
84-
actionFinishedAt: Date.now(),
85-
});
86-
} catch (error) {
87-
return { ok: false, error: normalizeError(error) };
88-
}
58+
responseData: gestureResponseData(result, normalized.deprecations),
59+
...(input.kind === 'pinch' ? { recordingResultExtra: { scale: input.scale } } : {}),
60+
};
61+
});
8962
}
9063

9164
export async function dispatchSwipeViaRuntime(
92-
params: InteractionHandlerParams & {
93-
captureSnapshotForSession: CaptureSnapshotForSession;
94-
},
65+
params: GestureHandlerParams,
9566
): Promise<DaemonResponse> {
96-
const session = params.sessionStore.get(params.sessionName);
97-
if (!session) return noActiveSessionError();
98-
const startedAt = Date.now();
99-
try {
67+
return await dispatchGestureInteraction(params, 'swipe', async (session) => {
10068
const input = readSwipeInput(params.req.input);
10169
requireGestureSupported(normalizePublicSwipeMotion(input).gesture, session.device);
10270
const count = input.count ?? 1;
10371
const pauseMs = input.pauseMs ?? 0;
10472
const pattern = input.pattern ?? 'one-way';
73+
const runtime = createInteractionRuntime(params);
74+
const result = await runSwipeRepetitions(runtime, params, input, count, pauseMs, pattern);
75+
return {
76+
positionals: swipeReplayPositionals(input),
77+
flags: params.req.flags,
78+
responseData: gestureResponseData(result, result.deprecations ?? [], {
79+
count,
80+
pauseMs,
81+
pattern,
82+
}),
83+
};
84+
});
85+
}
86+
87+
async function dispatchGestureInteraction(
88+
params: GestureHandlerParams,
89+
command: 'gesture' | 'swipe',
90+
run: (session: SessionState) => Promise<GestureInteractionOutcome>,
91+
): Promise<DaemonResponse> {
92+
const session = params.sessionStore.get(params.sessionName);
93+
if (!session) return noActiveSessionError();
94+
const actionStartedAt = Date.now();
95+
try {
10596
const providerDevice = isActiveProviderDevice(session.device);
10697
const readiness = providerDevice
10798
? ({ status: 'clear' } as const)
10899
: await ensureAndroidBlockingSystemDialogReady({
109100
session,
110-
command: 'swipe',
101+
command,
111102
phase: 'before-command',
112103
});
113-
const runtime = createInteractionRuntime(params);
114-
const result = await runSwipeRepetitions(runtime, params, input, count, pauseMs, pattern);
104+
const outcome = await run(session);
115105
if (!providerDevice) {
116106
await ensureAndroidBlockingSystemDialogReady({
117107
session,
118-
command: 'swipe',
108+
command,
119109
phase: 'after-command',
120110
});
121111
}
122-
const responseData: Record<string, unknown> = {
123-
kind: result.kind,
124-
durationMs: result.durationMs,
125-
pointerCount: result.pointerCount,
126-
from: result.from,
127-
to: result.to,
128-
count,
129-
pauseMs,
130-
pattern,
131-
...(result.backendResult ?? {}),
132-
...(result.deprecations ? { deprecations: result.deprecations } : {}),
133-
message: result.message,
134-
};
135-
if (readiness.status === 'recovered') responseData.warning = readiness.warning;
112+
const responseData = { ...outcome.responseData };
113+
if (readiness.status === 'recovered') {
114+
const existingWarning =
115+
typeof responseData.warning === 'string' ? `${responseData.warning} ` : '';
116+
responseData.warning = `${existingWarning}${readiness.warning}`;
117+
}
136118
return finalizeTouchInteraction({
137119
session,
138120
sessionStore: params.sessionStore,
139-
command: 'swipe',
140-
actionCommand: 'swipe',
141-
positionals: swipeReplayPositionals(input),
142-
flags: params.req.flags,
143-
result: responseData,
121+
command,
122+
actionCommand: command,
123+
positionals: outcome.positionals,
124+
flags: outcome.flags,
125+
result: { ...responseData, ...(outcome.recordingResultExtra ?? {}) },
144126
responseData,
145-
actionStartedAt: startedAt,
127+
actionStartedAt,
146128
actionFinishedAt: Date.now(),
147129
});
148130
} catch (error) {
149131
return { ok: false, error: normalizeError(error) };
150132
}
151133
}
152134

135+
function gestureResponseData(
136+
result: GestureRuntimeResult,
137+
deprecations: readonly GestureDeprecation[],
138+
extra: Record<string, unknown> = {},
139+
): Record<string, unknown> {
140+
return {
141+
kind: result.kind,
142+
durationMs: result.durationMs,
143+
pointerCount: result.pointerCount,
144+
from: result.from,
145+
to: result.to,
146+
...extra,
147+
...(result.backendResult ?? {}),
148+
...(deprecations.length > 0 ? { deprecations } : {}),
149+
message: result.message,
150+
};
151+
}
152+
153153
function readSwipeInput(input: unknown): SwipePayload {
154154
if (!input || typeof input !== 'object' || Array.isArray(input)) {
155155
throw new AppError('INVALID_ARGS', 'swipe requires structured object input');

0 commit comments

Comments
 (0)