diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index b5840e01f..d578ad2d1 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -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'; @@ -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) { @@ -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); @@ -446,14 +447,7 @@ async function handleFindWait( async function handleFindExists(ctx: FindContext): Promise { 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 } }; } @@ -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 { 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 } }; } @@ -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 }; } @@ -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; } @@ -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 --- diff --git a/src/daemon/handlers/handler-utils.ts b/src/daemon/handlers/handler-utils.ts index 942ee7bfa..6f3c794ac 100644 --- a/src/daemon/handlers/handler-utils.ts +++ b/src/daemon/handlers/handler-utils.ts @@ -4,6 +4,9 @@ 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, @@ -11,12 +14,13 @@ export function recordSessionAction( req: DaemonRequest, command: string, result: Record | 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 ?? {}, }); } diff --git a/src/daemon/handlers/install-source.ts b/src/daemon/handlers/install-source.ts index 88da4b013..81bcf7ccc 100644 --- a/src/daemon/handlers/install-source.ts +++ b/src/daemon/handlers/install-source.ts @@ -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'; @@ -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; @@ -121,15 +121,7 @@ function recordInstallFromSourceAction(params: { data: InstallFromSourceResult & Record; }): 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: { @@ -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 ( diff --git a/src/daemon/handlers/interaction-runtime.ts b/src/daemon/handlers/interaction-runtime.ts index b6dbed77f..2c0d52173 100644 --- a/src/daemon/handlers/interaction-runtime.ts +++ b/src/daemon/handlers/interaction-runtime.ts @@ -14,6 +14,7 @@ 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 & { @@ -21,7 +22,7 @@ export function createInteractionRuntime( }, ) { 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 }), diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 0cec2d7c7..e0549e208 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -1,4 +1,3 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts'; import { buttonTag, @@ -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, @@ -79,7 +78,7 @@ async function dispatchTargetedTouchViaRuntime( ): Promise { 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'; @@ -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); @@ -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; @@ -529,7 +523,7 @@ async function dispatchRuntimeInteraction< }, ): Promise { 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 { diff --git a/src/daemon/handlers/interaction.ts b/src/daemon/handlers/interaction.ts index ff9c3d49c..636a7aaf4 100644 --- a/src/daemon/handlers/interaction.ts +++ b/src/daemon/handlers/interaction.ts @@ -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 { @@ -50,10 +49,9 @@ async function dispatchTypeViaRuntime( ): Promise { 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; diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 1a9c7251d..daa6791c3 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -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, @@ -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'; @@ -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( diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 26464ad09..1a344b09f 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -1,8 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { sleep } from '../../utils/timeouts.ts'; -import { resolveTargetDevice, type CommandFlags } from '../../core/dispatch.ts'; -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; +import { resolveTargetDevice } from '../../core/dispatch.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonArtifact, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -22,7 +21,8 @@ import { recordingQualityInputToExportQuality, } from '../../core/recording-export-quality.ts'; import { resolveRecordingProvider } from '../recording-provider.ts'; -import { errorResponse } from './response.ts'; +import { errorResponse, requireCommandSupported } from './response.ts'; +import { recordSessionAction } from './handler-utils.ts'; import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; import { resolveRecordingBackendForDevice, @@ -123,9 +123,8 @@ async function startRecording(params: { if (maxSizeFlag !== undefined && (!Number.isInteger(maxSizeFlag) || maxSizeFlag < 1)) { return errorResponse('INVALID_ARGS', 'max-size must be a positive integer'); } - if (!isCommandSupportedOnDevice('record', device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'); - } + const unsupported = requireCommandSupported('record', device); + if (unsupported) return unsupported; const outPath = backend.resolveOutputPath({ req }); const resolvedOut = SessionStore.expandHome(outPath, req.meta?.cwd); @@ -152,11 +151,9 @@ async function startRecording(params: { activeSession.recording = recording; sessionStore.set(sessionName, activeSession); const sessionStateDir = sessionStore.ensureSessionDir(sessionName); - sessionStore.recordAction(activeSession, { - command: req.command, - positionals: req.positionals ?? [], - flags: (req.flags ?? {}) as CommandFlags, - result: { action: 'start', showTouches: recording.showTouches }, + recordSessionAction(sessionStore, activeSession, req, req.command, { + action: 'start', + showTouches: recording.showTouches, }); return { @@ -336,15 +333,10 @@ export async function handleRecordCommand(params: { return response; } - sessionStore.recordAction(activeSession, { - command: req.command, - positionals: req.positionals ?? [], - flags: (req.flags ?? {}) as CommandFlags, - result: { - action: 'stop', - outPath: response.data?.outPath, - showTouches: response.data?.showTouches, - }, + recordSessionAction(sessionStore, activeSession, req, req.command, { + action: 'stop', + outPath: response.data?.outPath, + showTouches: response.data?.showTouches, }); await releaseRecordOnlySession(sessionStore, sessionName, activeSession, { writeLog: true }); return response; diff --git a/src/daemon/handlers/record-trace.ts b/src/daemon/handlers/record-trace.ts index ccff718d2..5746a268b 100644 --- a/src/daemon/handlers/record-trace.ts +++ b/src/daemon/handlers/record-trace.ts @@ -1,10 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { CommandFlags } from '../../core/dispatch.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { handleRecordCommand } from './record-trace-recording.ts'; import { errorResponse } from './response.ts'; +import { recordSessionAction } from './handler-utils.ts'; export async function handleRecordTraceCommands(params: { req: DaemonRequest; @@ -37,11 +37,9 @@ export async function handleRecordTraceCommands(params: { fs.mkdirSync(path.dirname(resolvedOut), { recursive: true }); fs.appendFileSync(resolvedOut, ''); session.trace = { outPath: resolvedOut, startedAt: Date.now() }; - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: (req.flags ?? {}) as CommandFlags, - result: { action: 'start', outPath: resolvedOut }, + recordSessionAction(sessionStore, session, req, command, { + action: 'start', + outPath: resolvedOut, }); return { ok: true, data: { trace: 'started', outPath: resolvedOut } }; } @@ -60,12 +58,7 @@ export async function handleRecordTraceCommands(params: { outPath = resolvedOut; } session.trace = undefined; - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: (req.flags ?? {}) as CommandFlags, - result: { action: 'stop', outPath }, - }); + recordSessionAction(sessionStore, session, req, command, { action: 'stop', outPath }); return { ok: true, data: { trace: 'stopped', outPath } }; } diff --git a/src/daemon/handlers/response.ts b/src/daemon/handlers/response.ts index 7ea2681b5..e9b988941 100644 --- a/src/daemon/handlers/response.ts +++ b/src/daemon/handlers/response.ts @@ -1,7 +1,11 @@ +import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../../core/capabilities.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; import type { DaemonResponse } from '../types.ts'; export type DaemonFailureResponse = Extract; +export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Run open first.'; + export function errorResponse( code: string, message: string, @@ -12,3 +16,34 @@ export function errorResponse( error: { code, message, ...(details ? { details } : {}) }, }; } + +/** + * Shared "No active session. Run open first." failure used by handlers that require + * an open session before dispatching. + */ +export function noActiveSessionError(): DaemonFailureResponse { + return errorResponse('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE); +} + +/** + * Capability guard: returns an `UNSUPPORTED_OPERATION` failure when `command` is not + * supported on `device`, otherwise `null`. Pass `message` to override the default + * " is not supported on this device" text, or `hint: true` to attach the + * device-specific unsupported hint (as generic command dispatch does). + */ +export function requireCommandSupported( + command: string, + device: DeviceInfo, + options?: { message?: string; hint?: boolean }, +): DaemonFailureResponse | null { + if (isCommandSupportedOnDevice(command, device)) return null; + const hint = options?.hint ? unsupportedHintForDevice(command, device) : undefined; + return { + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: options?.message ?? `${command} is not supported on this device`, + ...(hint ? { hint } : {}), + }, + }; +} diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index ecd307000..8e48b598d 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -19,6 +19,7 @@ import { settleIosSimulator, } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; +import { recordSessionAction } from './handler-utils.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { releaseSessionLease } from '../lease-lifecycle.ts'; import { @@ -97,11 +98,9 @@ export async function handleCloseCommand(params: { appId: session.appBundleId, }).catch(() => {}); } - sessionStore.recordAction(session, { - command: 'close', - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { session: session.name, ...successText(`Closed: ${session.name}`) }, + recordSessionAction(sessionStore, session, req, 'close', { + session: session.name, + ...successText(`Closed: ${session.name}`), }); if (req.flags?.saveScript) { session.recordSession = true; diff --git a/src/daemon/handlers/session-deploy.ts b/src/daemon/handlers/session-deploy.ts index d8974c79e..5c0707211 100644 --- a/src/daemon/handlers/session-deploy.ts +++ b/src/daemon/handlers/session-deploy.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import { cleanupUploadedArtifact, prepareUploadedArtifact } from '../artifact-tracking.ts'; -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; @@ -8,7 +7,7 @@ import { recordSessionAction } from './handler-utils.ts'; import { resolveDeployResultTarget } from '../../client-shared.ts'; import { withSuccessText } from '../../utils/success-text.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; -import { errorResponse } from './response.ts'; +import { errorResponse, requireCommandSupported } from './response.ts'; export type ReinstallOps = { ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>; @@ -118,9 +117,8 @@ export async function handleAppDeployCommand(params: { flags, ensureReady: false, }); - if (!isCommandSupportedOnDevice(command, device)) { - return errorResponse('UNSUPPORTED_OPERATION', `${command} is not supported on this device`); - } + const unsupported = requireCommandSupported(command, device); + if (unsupported) return unsupported; let result: DeployCommandResult; diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 5d9a042cf..216d593e3 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -1,4 +1,3 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { listDeviceInventory } from '../../core/dispatch-resolve.ts'; import { assertResolvedAppsFilter } from '../../contracts/app-inventory.ts'; import { asAppError } from '../../utils/errors.ts'; @@ -17,7 +16,7 @@ import { resolveSessionRunnerLogPath, SessionStore } from '../session-store.ts'; import { listAndroidApps } from '../../platforms/android/app-lifecycle.ts'; import { listIosApps } from '../../platforms/ios/apps.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; -import { errorResponse } from './response.ts'; +import { errorResponse, requireCommandSupported } from './response.ts'; import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts'; export async function handleSessionInventoryCommands(params: { @@ -112,9 +111,8 @@ export async function handleSessionInventoryCommands(params: { flags, ensureReady: true, }); - if (!isCommandSupportedOnDevice('apps', device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'apps is not supported on this device'); - } + const unsupported = requireCommandSupported('apps', device); + if (unsupported) return unsupported; const appsFilter = assertResolvedAppsFilter(req.flags?.appsFilter); if (isApplePlatform(device.platform)) { diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 36df9f286..a6885d893 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -1,4 +1,3 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { isPerfAction, isPerfArea, @@ -33,7 +32,7 @@ import { buildPerfResponseData, } from './session-perf.ts'; import { handleNativePerfCommand as handleAndroidNativePerfCommand } from './session-native-perf.ts'; -import { errorResponse, type DaemonFailureResponse } from './response.ts'; +import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; import { handleNativePerfCommand as handleAppleNativePerfCommand } from './session-perf-xctrace.ts'; import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; import type { LogBackend } from '../network-log.ts'; @@ -299,9 +298,8 @@ async function handleLogsCommand(params: ObservabilityParams): Promise, ): void { - params.sessionStore.recordAction(session, { - command: 'perf', - positionals: params.req.positionals ?? [], - flags: params.req.flags ?? {}, - result: data, - }); + recordSessionAction(params.sessionStore, session, params.req, 'perf', data); } function resolveNativePerfOutPath(params: NativePerfParams, request: NativePerfRequest): string { diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 8497fa21d..a763ab9c8 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -1,4 +1,3 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { asAppError } from '../../utils/errors.ts'; import { isApplePlatform, type DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; @@ -12,7 +11,7 @@ import { resolveCommandDevice, selectorTargetsSessionDevice, } from './session-device-utils.ts'; -import { errorResponse } from './response.ts'; +import { errorResponse, requireCommandSupported } from './response.ts'; async function ensureAndroidEmulatorBoot(params: { avdName: string; @@ -239,9 +238,8 @@ export async function handleSessionStateCommands(params: { } } - if (!isCommandSupportedOnDevice('boot', device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'boot is not supported on this device'); - } + const unsupported = requireCommandSupported('boot', device); + if (unsupported) return unsupported; return { ok: true, @@ -267,12 +265,10 @@ export async function handleSessionStateCommands(params: { flags, session: activeSession, }); - if (!isCommandSupportedOnDevice('shutdown', device)) { - return errorResponse( - 'UNSUPPORTED_OPERATION', - 'shutdown is supported only for Apple simulators and Android emulators.', - ); - } + const unsupported = requireCommandSupported('shutdown', device, { + message: 'shutdown is supported only for Apple simulators and Android emulators.', + }); + if (unsupported) return unsupported; if ( activeSession && diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 65e87c8df..26000c6a1 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,5 +1,4 @@ import { dispatchCommand } from '../../core/dispatch.ts'; -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { resolvePayloadInput } from '../../utils/payload-input.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; @@ -17,7 +16,8 @@ import { handleReleaseMaterializedPathsCommand, } from './install-source.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; -import { errorResponse } from './response.ts'; +import { errorResponse, requireCommandSupported } from './response.ts'; +import { recordSessionAction } from './handler-utils.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; import { handleOpenCommand } from './session-open.ts'; import { @@ -171,9 +171,8 @@ async function runSessionOrSelectorDispatch(params: { flags, ensureReady: true, }); - if (!isCommandSupportedOnDevice(command, device)) { - return errorResponse('UNSUPPORTED_OPERATION', `${command} is not supported on this device`); - } + const unsupported = requireCommandSupported(command, device); + if (unsupported) return unsupported; const result = await dispatchCommand(device, command, positionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), @@ -182,11 +181,8 @@ async function runSessionOrSelectorDispatch(params: { const nextSession = deriveNextSession ? await deriveNextSession(session, result, device) : session; - sessionStore.recordAction(nextSession, { - command, + recordSessionAction(sessionStore, nextSession, req, command, result ?? {}, { positionals: recordPositionals ?? positionals, - flags: req.flags ?? {}, - result: result ?? {}, }); if (nextSession !== session) { sessionStore.set(sessionName, nextSession); @@ -218,9 +214,8 @@ async function handleClipboardCommand(params: { flags, ensureReady: true, }); - if (!isCommandSupportedOnDevice(PUBLIC_COMMANDS.clipboard, device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'clipboard is not supported on this device'); - } + const unsupported = requireCommandSupported(PUBLIC_COMMANDS.clipboard, device); + if (unsupported) return unsupported; const result = await dispatchCommand( device, @@ -231,14 +226,7 @@ async function handleClipboardCommand(params: { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), }, ); - if (session) { - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: result ?? {}, - }); - } + recordSessionAction(sessionStore, session, req, req.command, result ?? {}); return { ok: true, data: { platform: device.platform, ...(result ?? {}) } }; } diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index a6cece9b8..c849f06dc 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -1,4 +1,3 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { ALERT_ACTION_RETRY_MS, ALERT_POLL_INTERVAL_MS as POLL_INTERVAL_MS, @@ -14,7 +13,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { recordIfSession } from './snapshot-session.ts'; import { parseTimeout } from '../../utils/parse-timeout.ts'; -import { errorResponse } from './response.ts'; +import { errorResponse, requireCommandSupported } from './response.ts'; type HandleAlertCommandParams = { req: DaemonRequest; @@ -45,9 +44,8 @@ export async function handleAlertCommand( surface: session.surface, }; })(); - if (!isCommandSupportedOnDevice('alert', device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'alert is not supported on this device'); - } + const unsupported = requireCommandSupported('alert', device); + if (unsupported) return unsupported; if (device.platform === 'android') { const timeoutMs = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; return recordAlertResponse( diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index d0f44e9bf..b724b0595 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -1,4 +1,3 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { getUnsupportedMacOsSettingMessage, isMacOsSettingSupported, @@ -9,7 +8,7 @@ import { contextFromFlags } from '../context.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { recordIfSession } from './snapshot-session.ts'; -import { errorResponse, type DaemonFailureResponse } from './response.ts'; +import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; type ParsedSettingsArgs = { setting: string; @@ -78,9 +77,8 @@ export async function handleSettingsCommand( latitude, longitude, } = parsed; - if (!isCommandSupportedOnDevice('settings', device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'settings is not supported on this device'); - } + const unsupported = requireCommandSupported('settings', device); + if (unsupported) return unsupported; if (device.platform === 'macos' && !isMacOsSettingSupported(setting)) { return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); } diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index ed4ea1763..0a9925701 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -1,6 +1,6 @@ import { dispatchCommand, type CommandFlags } from '../core/dispatch.ts'; import { GESTURE_SUBCOMMAND_ERROR } from '../command-catalog.ts'; -import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../core/capabilities.ts'; +import { requireCommandSupported } from './handlers/response.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; @@ -151,17 +151,8 @@ async function ensureGenericCommandReady( session: SessionState, platformCommand: string, ): Promise { - if (!isCommandSupportedOnDevice(platformCommand, session.device)) { - const hint = unsupportedHintForDevice(platformCommand, session.device); - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: `${platformCommand} is not supported on this device`, - ...(hint ? { hint } : {}), - }, - }; - } + const unsupported = requireCommandSupported(platformCommand, session.device, { hint: true }); + if (unsupported) return unsupported; if ( session.device.platform !== 'android' || !session.recording || diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 996e89a34..a7379a81e 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -6,6 +6,7 @@ import { AppError, normalizeError } from '../utils/errors.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './types.ts'; import { SessionStore } from './session-store.ts'; +import { noActiveSessionError } from './handlers/response.ts'; import { type AndroidAdbProviderResolver, type AppleRunnerProviderResolver, @@ -230,13 +231,7 @@ async function dispatchGenericForLockedScope(params: { const { lockedScope, logPath, sessionStore } = params; const session = sessionStore.get(lockedScope.sessionName); if (!session) { - return lockedScope.finalize({ - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: 'No active session. Run open first.', - }, - }); + return lockedScope.finalize(noActiveSessionError()); } const dispatchResponse = await dispatchGenericCommand({ diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index b0507c69c..6712fdb1f 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -3,11 +3,10 @@ import type { BackendSnapshotOptions, BackendSnapshotResult, } from '../backend.ts'; -import { isCommandSupportedOnDevice } from '../core/capabilities.ts'; import { resolveTargetDevice, type CommandFlags } from '../core/dispatch.ts'; import { createAgentDevice } from '../runtime.ts'; import { isApplePlatform } from '../utils/device.ts'; -import { errorResponse } from './handlers/response.ts'; +import { noActiveSessionError, requireCommandSupported } from './handlers/response.ts'; import type { SnapshotNode } from '../utils/snapshot.ts'; import { findNodeByLabel } from '../utils/snapshot-processing.ts'; import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; @@ -73,20 +72,13 @@ export async function createSelectorRuntime( if (!session && options.requireSession) { return { ok: false, - response: errorResponse('SESSION_NOT_FOUND', 'No active session. Run open first.'), + response: noActiveSessionError(), }; } const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); if (!session) await ensureDeviceReady(device); - if (!isCommandSupportedOnDevice(options.capability, device)) { - return { - ok: false, - response: errorResponse( - 'UNSUPPORTED_OPERATION', - `${options.capability} is not supported on this device`, - ), - }; - } + const unsupported = requireCommandSupported(options.capability, device); + if (unsupported) return { ok: false, response: unsupported }; return { ok: true, runtime: createSelectorRuntimeForDevice({ diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index bb65563b8..cdbd2fc0d 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -1,11 +1,10 @@ import { parseWaitPositionals } from '../core/wait-positionals.ts'; import type { WaitParsed } from '../core/wait-positionals.ts'; -import { isCommandSupportedOnDevice } from '../core/capabilities.ts'; import { AppError, asAppError, normalizeError } from '../utils/errors.ts'; import type { SnapshotNode } from '../utils/snapshot.ts'; import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; -import { errorResponse } from './handlers/response.ts'; +import { errorResponse, requireCommandSupported } from './handlers/response.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './handlers/snapshot-session.ts'; import { parseFindArgs, type FindAction } from '../utils/finders.ts'; import { splitIsSelectorArgs } from './selectors.ts'; @@ -201,8 +200,9 @@ export async function dispatchWaitViaRuntime( const parsed = parseWaitPositionals(req.positionals ?? []); if (!parsed) return errorResponse('INVALID_ARGS', 'wait requires a duration or text'); const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); - if (parsed.kind !== 'sleep' && !isCommandSupportedOnDevice('wait', device)) { - return errorResponse('UNSUPPORTED_OPERATION', 'wait is not supported on this device'); + if (parsed.kind !== 'sleep') { + const unsupported = requireCommandSupported('wait', device); + if (unsupported) return unsupported; } if (parsed.kind === 'selector') { const directResponse = await dispatchDirectIosSelectorWait({ diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index cbfa24ec1..a1e59f699 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -1,12 +1,11 @@ import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; import type { CommandSessionRecord } from '../runtime.ts'; import { createAgentDevice } from '../runtime.ts'; -import { isCommandSupportedOnDevice } from '../core/capabilities.ts'; import { AppError } from '../utils/errors.ts'; import type { SnapshotDiffSummary } from '../utils/snapshot-diff.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } from './types.ts'; import { SessionStore } from './session-store.ts'; -import { errorResponse } from './handlers/response.ts'; +import { errorResponse, requireCommandSupported } from './handlers/response.ts'; import { captureSnapshot, resolveSnapshotScope } from './handlers/snapshot-capture.ts'; import { snapshotCaptureAnnotationsFrom } from '../snapshot-capture-annotations.ts'; import { @@ -110,9 +109,10 @@ async function dispatchSnapshotRuntimeCommand( ): Promise { const { req, sessionName, logPath, sessionStore } = params; const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); - if (!isCommandSupportedOnDevice(params.command, device)) { - return errorResponse('UNSUPPORTED_OPERATION', params.unsupportedMessage); - } + const unsupported = requireCommandSupported(params.command, device, { + message: params.unsupportedMessage, + }); + if (unsupported) return unsupported; const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return resolvedScope; const iosAppSessionGuard = requireIosAppSessionForSnapshot(params.command, session, device);