Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 52 additions & 58 deletions src/daemon/handlers/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
} from '../../core/interaction-targeting.ts';
import { isSnapshotNodeInteractionBlocked } from '../../utils/snapshot-occlusion.ts';
import { readTextForNode } from './interaction-read.ts';
import { errorResponse } from './response.ts';
import { errorResponse, noActiveSessionError } from './response.ts';
import { recordSessionAction } from './handler-utils.ts';
import { stripInternalInteractionFlags } from '../interaction-outcome-policy.ts';
import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts';
import { createSelectorCaptureRuntime } from '../selector-capture-runtime.ts';
Expand Down Expand Up @@ -88,7 +89,7 @@ export async function handleFindCommands(params: {
const session = sessionStore.get(sessionName);
const isReadOnly = isReadOnlyFindAction(action);
if (!session && !isReadOnly) {
return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
return noActiveSessionError();
}
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
if (!session) {
Expand Down Expand Up @@ -428,14 +429,14 @@ async function handleFindWait(
const match = findBestMatchesByLocator(nodes, locator, query, { requireRect: false })
.matches[0];
if (match) {
if (session) {
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: publicFlags,
result: { found: true, waitedMs: Date.now() - start },
});
}
recordSessionAction(
sessionStore,
session,
req,
command,
{ found: true, waitedMs: Date.now() - start },
{ flags: publicFlags },
);
return { ok: true, data: { found: true, waitedMs: Date.now() - start } };
}
await sleep(300);
Expand All @@ -446,14 +447,7 @@ async function handleFindWait(

async function handleFindExists(ctx: FindContext): Promise<DaemonResponse> {
const { req, sessionStore, session, command, publicFlags } = ctx;
if (session) {
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: publicFlags,
result: { found: true },
});
}
recordSessionAction(sessionStore, session, req, command, { found: true }, { flags: publicFlags });
return { ok: true, data: { found: true } };
}

Expand All @@ -469,27 +463,27 @@ async function handleFindGetText(ctx: FindContext, match: ResolvedMatch): Promis
contextFromFlags: (flags, appBundleId, traceLogPath) =>
contextFromFlags(logPath, flags, appBundleId, traceLogPath),
});
if (session) {
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: publicFlags,
result: { ref: match.ref, action: 'get text', text },
});
}
recordSessionAction(
sessionStore,
session,
req,
command,
{ ref: match.ref, action: 'get text', text },
{ flags: publicFlags },
);
return { ok: true, data: { ref: match.ref, text, node: match.node } };
}

async function handleFindGetAttrs(ctx: FindContext, match: ResolvedMatch): Promise<DaemonResponse> {
const { req, sessionStore, session, command, publicFlags } = ctx;
if (session) {
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: publicFlags,
result: { ref: match.ref, action: 'get attrs' },
});
}
recordSessionAction(
sessionStore,
session,
req,
command,
{ ref: match.ref, action: 'get attrs' },
{ flags: publicFlags },
);
return { ok: true, data: { ref: match.ref, node: match.node } };
}

Expand All @@ -514,14 +508,14 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise<
matchData.x = matchCoords.x;
matchData.y = matchCoords.y;
}
if (session) {
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: publicFlags,
result: { ref: match.ref, action: 'click', locator, query },
});
}
recordSessionAction(
sessionStore,
session,
req,
command,
{ ref: match.ref, action: 'click', locator, query },
{ flags: publicFlags },
);
return { ok: true, data: matchData };
}

Expand All @@ -542,14 +536,14 @@ async function handleFindFill(
flags: match.actionFlags,
});
if (!response.ok) return response;
if (session) {
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: publicFlags,
result: { ref: match.ref, action: 'fill' },
});
}
recordSessionAction(
sessionStore,
session,
req,
command,
{ ref: match.ref, action: 'fill' },
{ flags: publicFlags },
);
return response;
}

Expand Down Expand Up @@ -617,14 +611,14 @@ function rejectCoveredFindMatch(match: ResolvedMatch, interaction: string): Daem

function recordFindAction(ctx: FindContext, match: ResolvedMatch, action: string): void {
const { req, sessionStore, session, command, publicFlags } = ctx;
if (session) {
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: publicFlags,
result: { ref: match.ref, action },
});
}
recordSessionAction(
sessionStore,
session,
req,
command,
{ ref: match.ref, action },
{ flags: publicFlags },
);
}

// --- Helpers ---
Expand Down
8 changes: 6 additions & 2 deletions src/daemon/handlers/handler-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ import type { DaemonRequest, SessionState } from '../types.ts';

/**
* Record a session action if a session is active. No-op when session is undefined.
*
* By default the recorded positionals/flags mirror the request; pass `overrides` to
* record a different set (e.g. resolved positionals or stripped public flags).
*/
export function recordSessionAction(
sessionStore: SessionStore,
session: SessionState | undefined,
req: DaemonRequest,
command: string,
result: Record<string, unknown> | undefined,
overrides?: { positionals?: string[]; flags?: CommandFlags },
): void {
if (!session) return;
sessionStore.recordAction(session, {
command,
positionals: req.positionals ?? [],
flags: (req.flags ?? {}) as CommandFlags,
positionals: overrides?.positionals ?? req.positionals ?? [],
flags: overrides?.flags ?? ((req.flags ?? {}) as CommandFlags),
result: result ?? {},
});
}
24 changes: 7 additions & 17 deletions src/daemon/handlers/install-source.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import { resolveTargetDevice, type CommandFlags } from '../../core/dispatch.ts';
import { ensureDeviceReady } from '../device-ready.ts';
import { getRequestSignal } from '../request-cancel.ts';
Expand All @@ -14,7 +13,8 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
import { resolveInstallFromSourceResultTarget } from '../../client-shared.ts';
import { AppError, normalizeError } from '../../utils/errors.ts';
import { withSuccessText } from '../../utils/success-text.ts';
import { errorResponse } from './response.ts';
import { requireCommandSupported } from './response.ts';
import { recordSessionAction } from './handler-utils.ts';

type PreparedInstallArtifact = {
archivePath?: string;
Expand Down Expand Up @@ -121,15 +121,7 @@ function recordInstallFromSourceAction(params: {
data: InstallFromSourceResult & Record<string, unknown>;
}): void {
const { session, sessionStore, req, data } = params;
if (!session) {
return;
}
sessionStore.recordAction(session, {
command: 'install_source',
positionals: [],
flags: req.flags ?? {},
result: data,
});
recordSessionAction(sessionStore, session, req, 'install_source', data, { positionals: [] });
}

export async function handleInstallFromSourceCommand(params: {
Expand All @@ -146,12 +138,10 @@ export async function handleInstallFromSourceCommand(params: {
session,
flags: req.flags,
});
if (!isCommandSupportedOnDevice('install', device)) {
return errorResponse(
'UNSUPPORTED_OPERATION',
'install_from_source is not supported on this device',
);
}
const unsupported = requireCommandSupported('install', device, {
message: 'install_from_source is not supported on this device',
});
if (unsupported) return unsupported;

const requestSignal = getRequestSignal(req.meta?.requestId);
const completeInstall = async (
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/handlers/interaction-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import { createDaemonRuntimePolicy } from '../runtime-policy.ts';
import { createDaemonRuntimeSessionStore } from '../runtime-session.ts';
import { resolveWebProvider, type WebProvider } from '../../platforms/web/provider.ts';
import { stripAtPrefix } from './interaction-touch-targets.ts';
import { NO_ACTIVE_SESSION_MESSAGE } from './response.ts';

export function createInteractionRuntime(
params: InteractionHandlerParams & {
captureSnapshotForSession: CaptureSnapshotForSession;
},
) {
const session = params.sessionStore.get(params.sessionName);
if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!session) throw new AppError('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE);
return createAgentDevice({
backend: createInteractionBackend({ ...params, session }),
...createDaemonRuntimePolicy('interaction commands', { plural: true }),
Expand Down
22 changes: 8 additions & 14 deletions src/daemon/handlers/interaction-touch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts';
import {
buttonTag,
Expand All @@ -25,7 +24,7 @@ import {
resolveDirectTouchReferenceFrameSafely,
} from './interaction-touch-reference-frame.ts';
import { unsupportedMacOsDesktopSurfaceInteraction } from './interaction-touch-policy.ts';
import { errorResponse } from './response.ts';
import { errorResponse, noActiveSessionError, requireCommandSupported } from './response.ts';
import {
assertAndroidPressStayedInApp,
isAndroidEscapeError,
Expand Down Expand Up @@ -79,7 +78,7 @@ async function dispatchTargetedTouchViaRuntime(
): Promise<DaemonResponse> {
const { req, sessionName, sessionStore } = params;
const session = sessionStore.get(sessionName);
if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!session) return noActiveSessionError();

const commandLabel = command === 'click' ? 'click' : command;
const capabilityCommand = command === 'longpress' ? 'longpress' : 'press';
Expand All @@ -88,12 +87,8 @@ async function dispatchTargetedTouchViaRuntime(
commandLabel,
);
if (unsupportedSurfaceResponse) return unsupportedSurfaceResponse;
if (!isCommandSupportedOnDevice(capabilityCommand, session.device)) {
return errorResponse(
'UNSUPPORTED_OPERATION',
`${capabilityCommand} is not supported on this device`,
);
}
const unsupported = requireCommandSupported(capabilityCommand, session.device);
if (unsupported) return unsupported;

const clickButton = resolveClickButton(req.flags);
const resultButtonTag = buttonTag(clickButton);
Expand Down Expand Up @@ -410,11 +405,10 @@ async function dispatchFillViaRuntime(
if (session) {
const unsupportedSurfaceResponse = unsupportedMacOsDesktopSurfaceInteraction(session, 'fill');
if (unsupportedSurfaceResponse) return unsupportedSurfaceResponse;
const unsupported = requireCommandSupported('fill', session.device);
if (unsupported) return unsupported;
}
if (session && !isCommandSupportedOnDevice('fill', session.device)) {
return errorResponse('UNSUPPORTED_OPERATION', 'fill is not supported on this device');
}
if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!session) return noActiveSessionError();

const parsedTarget = parseFillTarget(req.positionals ?? []);
if (!parsedTarget.ok) return parsedTarget.response;
Expand Down Expand Up @@ -529,7 +523,7 @@ async function dispatchRuntimeInteraction<
},
): Promise<DaemonResponse> {
const session = params.sessionStore.get(params.sessionName);
if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!session) return noActiveSessionError();
const runtime = createInteractionRuntime(params);
const actionStartedAt = Date.now();
try {
Expand Down
10 changes: 4 additions & 6 deletions src/daemon/handlers/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import { refSnapshotFlagGuardResponse } from './interaction-flags.ts';
import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../selector-runtime.ts';
import { createInteractionRuntime } from './interaction-runtime.ts';
import { finalizeTouchInteraction } from './interaction-common.ts';
import { errorResponse } from './response.ts';
import { errorResponse, noActiveSessionError, requireCommandSupported } from './response.ts';
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import { normalizeError } from '../../utils/errors.ts';
import { successText } from '../../utils/success-text.ts';
import {
Expand Down Expand Up @@ -50,10 +49,9 @@ async function dispatchTypeViaRuntime(
): Promise<DaemonResponse> {
const { sessionName, sessionStore } = params;
const session = sessionStore.get(sessionName);
if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!isCommandSupportedOnDevice(PUBLIC_COMMANDS.type, session.device)) {
return errorResponse('UNSUPPORTED_OPERATION', 'type is not supported on this device');
}
if (!session) return noActiveSessionError();
const unsupported = requireCommandSupported(PUBLIC_COMMANDS.type, session.device);
if (unsupported) return unsupported;
const recordingRecoveryResponse = await recoverAndroidRecordingDialogForType(session);
if (recordingRecoveryResponse) return recordingRecoveryResponse;

Expand Down
15 changes: 6 additions & 9 deletions src/daemon/handlers/react-native.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { dispatchCommand } from '../../core/dispatch.ts';
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import {
analyzeReactNativeOverlay,
Expand All @@ -14,7 +13,7 @@ import {
type SnapshotQualityVerdict,
} from '../../utils/snapshot-quality.ts';
import type { DaemonResponse, SessionState } from '../types.ts';
import { errorResponse } from './response.ts';
import { errorResponse, noActiveSessionError, requireCommandSupported } from './response.ts';
import { captureSnapshotForSession } from './interaction-snapshot.ts';
import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts';
import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-frame.ts';
Expand All @@ -28,13 +27,11 @@ export async function handleReactNativeCommands(
if (!parsed.ok) return parsed.response;

const session = sessionStore.get(sessionName);
if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!isCommandSupportedOnDevice(PUBLIC_COMMANDS.reactNative, session.device)) {
return errorResponse(
'UNSUPPORTED_OPERATION',
'react-native dismiss-overlay is not supported on this device',
);
}
if (!session) return noActiveSessionError();
const unsupported = requireCommandSupported(PUBLIC_COMMANDS.reactNative, session.device, {
message: 'react-native dismiss-overlay is not supported on this device',
});
if (unsupported) return unsupported;

try {
const snapshot = await captureSnapshotForSession(
Expand Down
Loading
Loading