diff --git a/src/commands/device.ts b/src/commands/device.ts index a989870..9dd70a0 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; import { apiFlags } from '../config/flags/api.flags.js'; import { jsonFlag } from '../config/flags/output.flags.js'; @@ -9,12 +10,15 @@ import { type DeviceCatalogue, type DeviceCommandResult, type DeviceInstalledApp, + type DeviceRunResponse, + type DeviceScreenElement, type DeviceSession, ENDED_STATES, type StartDeviceSessionRequest, } from '../types/domain/device-session.types.js'; import { platformOfAppFile, uploadLocalApp } from '../utils/app-upload.js'; -import { resolveAuth } from '../utils/auth.js'; +import { refreshAuth, resolveAuth } from '../utils/auth.js'; +import { openBrowser } from '../utils/browser.js'; import { CliError, logger, validateEnum } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { @@ -24,7 +28,7 @@ import { runDeviceCommand, } from '../utils/device-output.js'; import { isUrl } from '../utils/expo.js'; -import { colors } from '../utils/styling.js'; +import { colors, formatUrl } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; const PLATFORMS = ['android', 'ios'] as const; @@ -67,6 +71,15 @@ function interactive(json: boolean): boolean { return !json && process.stdout.isTTY === true && process.stdin.isTTY === true; } +/** + * Polling loops can outlast a `dcd login` access token (about an hour), so + * they renew it before each call. An API key is returned unchanged. + */ +async function freshAuth(ctx: Ctx): Promise { + ctx.auth = await refreshAuth(ctx.auth); + return ctx.auth; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -79,7 +92,7 @@ async function waitUntilUsable(ctx: Ctx, id: string): Promise { const deadline = Date.now() + WAIT_TIMEOUT_MS; let last = ''; for (;;) { - const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id); + const { session } = await DeviceGateway.status(ctx.apiUrl, await freshAuth(ctx), id); if (USABLE_STATES.has(session.state)) return session; if (ENDED_STATES.includes(session.state)) { throw new DeviceApiError( @@ -553,6 +566,340 @@ const modelsSub = defineCommand({ }, }); +const inspectSub = defineCommand({ + meta: { name: 'inspect', description: 'The screen as compact JSON: each element with text, an id or a label' }, + args: { ...commonArgs, id: idArg }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + const { screen, session } = await DeviceGateway.inspect(ctx.apiUrl, ctx.auth, id); + return { + session, + data: { screen }, + print() { + logger.log( + ui.section( + `Screen ${screen.width}x${screen.height}, ${screen.elements.length} elements` + + (screen.fresh ? '' : colors.dim(' (no new frame; this is the latest)')), + ), + ); + logger.log(ui.branch(screen.elements.map((el) => formatElement(el)))); + }, + }; + }); + }, +}); + +/** One element on one line: what to target it by, then its bounds. */ +export function formatElement(el: DeviceScreenElement): string { + const parts: string[] = []; + if (el.txt) parts.push(`"${el.txt}"`); + if (el.a11y && el.a11y !== el.txt) parts.push(`a11y:"${el.a11y}"`); + if (el.rid) parts.push(`id:${el.rid}`); + if (el.hint) parts.push(`hint:"${el.hint}"`); + return `${parts.join(' ')} ${colors.dim(el.b)}`; +} + +/** `data:image/jpeg;base64,...` to bytes and a file extension. */ +export function decodeDataUrl(dataUrl: string): { bytes: Buffer; ext: string } { + const match = /^data:image\/([a-z]+);base64,(.*)$/s.exec(dataUrl); + if (!match) throw new Error('The API returned a screenshot in an unexpected format.'); + return { bytes: Buffer.from(match[2], 'base64'), ext: match[1] === 'jpeg' ? 'jpg' : match[1] }; +} + +const screenshotSub = defineCommand({ + meta: { name: 'screenshot', description: 'Save a screenshot of the device' }, + args: { + ...commonArgs, + id: idArg, + output: { + type: 'string', + alias: ['o'], + description: 'Where to save it (default: ./-