Skip to content
87 changes: 87 additions & 0 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
import { platformOfAppFile, uploadLocalApp } from '../utils/app-upload.js';
import { refreshAuth, resolveAuth } from '../utils/auth.js';
import { openBrowser } from '../utils/browser.js';
import {
type DeviceArtifactFile,
downloadArtifactFiles,
fetchAllArtifacts,
planArtifactFiles,
} from '../utils/device-artifacts.js';
import { CliError, logger, validateEnum } from '../utils/cli.js';
import { resolveApiUrl } from '../utils/config-store.js';
import {
Expand Down Expand Up @@ -900,6 +906,86 @@ const openSub = defineCommand({
},
});

interface ArtifactsData {
/** Where they were saved; null with --links. */
dir: null | string;
files: DeviceArtifactFile[];
failed: { file: string; reason: string }[];
}

const artifactsSub = defineCommand({
meta: {
name: 'artifacts',
description: 'Download what a session left behind: its video, device log, and the screen after each action',
},
args: {
...commonArgs,
id: idArg,
output: {
type: 'string',
alias: ['o'],
description: 'Directory to save them in (default: ./<session>-artifacts)',
},
links: {
type: 'boolean',
default: false,
description: 'List the download links instead of downloading (they expire after a while)',
},
},
async run({ args }) {
const json = Boolean(args.json);
await runDeviceCommand<ArtifactsData>(json, async () => {
const ctx = await context(args);
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined);
const { pages, session } = await fetchAllArtifacts(ctx.apiUrl, ctx.auth, id);
const files = planArtifactFiles(pages);

if (args.links) {
return {
session,
data: { dir: null, files, failed: [] },
print() {
if (files.length === 0) {
logger.log(ui.info('This session has no artifacts yet.'));
return;
}

logger.log(ui.section(`Artifacts (${files.length})`));
logger.log(ui.branch(ui.fields(files.map((f) => [f.file, f.url ? formatUrl(f.url) : colors.dim('no link')]))));
},
};
}

const dir = resolvePath((args.output as string | undefined) || `${session.id}-artifacts`);
if (files.length > 0) ctx.progress(ui.running(`Downloading ${files.length} files to ${dir}…`));
const failed = await downloadArtifactFiles(dir, files);
const saved = files.length - failed.length;
return {
session,
data: { dir, files, failed },
...(failed.length > 0 && {
failure: {
code: null,
message: `${failed.length} of ${files.length} files could not be downloaded: ${failed.map((f) => `${f.file} (${f.reason})`).join(', ')}`,
},
}),
print() {
if (files.length === 0) {
logger.log(ui.info('This session has no artifacts yet.'));
return;
}

logger.log(ui.success(`Saved ${saved} of ${files.length} files to ${dir}`));
for (const f of failed) logger.log(ui.warn(`${f.file}: ${f.reason}`));
if (!ENDED_STATES.includes(session.state)) {
logger.log(ui.note('The session is still open: its video and log are saved when it ends.'));
}
},
};
});
},
});

export const deviceCommand = defineCommand({
meta: {
name: 'device',
Expand All @@ -921,6 +1007,7 @@ export const deviceCommand = defineCommand({
run: runSub,
logs: logsSub,
open: openSub,
artifacts: artifactsSub,
},
});

11 changes: 11 additions & 0 deletions src/gateways/device-gateway.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AuthContext } from '../types/domain/auth.types.js';
import type {
DeviceArtifactsPage,
DeviceCatalogue,
DeviceCommandResult,
DeviceInstalledApp,
Expand Down Expand Up @@ -109,6 +110,16 @@ const sessionPath = (id: string) => `/device/sessions/${encodeURIComponent(id)}`

/** The Agent Devices session API (dcd docs/device-v1-plan.md, section 4.1). */
export const DeviceGateway = {
artifacts(
baseUrl: string,
auth: AuthContext,
id: string,
before?: string,
): Promise<DeviceArtifactsPage> {
const query = new URLSearchParams({ limit: '200', ...(before && { before }) });
return request(baseUrl, auth, 'GET', `${sessionPath(id)}/artifacts?${query}`);
},

command(
baseUrl: string,
auth: AuthContext,
Expand Down
24 changes: 24 additions & 0 deletions src/types/domain/device-session.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,27 @@ export interface DeviceLogs {
/** logcat, ios-unified-log, or none before an app is installed. */
source: string;
}

/** A short-lived download link for one stored artifact. */
export interface DeviceArtifactLink {
bytes: null | number;
content_type: string;
created_at: string;
/** Null when the file could not be signed (e.g. already cleaned up). */
url: null | string;
}

/** GET .../artifacts: one page, newest actions first. */
export interface DeviceArtifactsPage {
actions: {
command_id: null | string;
created_at: string;
hierarchy: DeviceArtifactLink | null;
screenshot: DeviceArtifactLink | null;
}[];
logs: DeviceArtifactLink | null;
/** Pass as `before` for the next (older) page; null on the last page. */
next_before: null | string;
recording: DeviceArtifactLink | null;
session: DeviceSession;
}
152 changes: 152 additions & 0 deletions src/utils/device-artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import * as path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

import { DeviceGateway } from '../gateways/device-gateway.js';
import type { AuthContext } from '../types/domain/auth.types.js';
import type {
DeviceArtifactLink,
DeviceArtifactsPage,
DeviceSession,
} from '../types/domain/device-session.types.js';

/** One file a session left behind, and where it goes on disk. */
export interface DeviceArtifactFile {
/** recording, logs, screenshot or hierarchy. */
kind: 'hierarchy' | 'logs' | 'recording' | 'screenshot';
/** Relative to the output directory. */
file: string;
url: null | string;
bytes: null | number;
/** The action a screenshot or screen dump was taken after. */
command_id?: null | string;
created_at: string;
}

const EXTENSIONS: Record<string, string> = {
'application/json': 'json',
'image/jpeg': 'jpg',
'image/png': 'png',
'text/plain': 'log',
'video/mp4': 'mp4',
};

function extensionFor(link: DeviceArtifactLink, fallback: string): string {
return EXTENSIONS[link.content_type.split(';')[0].trim()] ?? fallback;
}

/** Every page of a session's artifacts. */
export async function fetchAllArtifacts(
apiUrl: string,
auth: AuthContext,
id: string,
): Promise<{ pages: DeviceArtifactsPage[]; session: DeviceSession }> {
const pages: DeviceArtifactsPage[] = [];
let before: string | undefined;
do {
const page = await DeviceGateway.artifacts(apiUrl, auth, id, before);
pages.push(page);
before = page.next_before ?? undefined;
} while (before);
return { pages, session: pages.at(-1)!.session };
}

/**
* Lay the artifacts out as files: `recording.mp4`, `device.log`, and per
* action `actions/001-screenshot.jpg` and `actions/001-screen.json`,
* numbered oldest first so the folder reads in order.
*/
export function planArtifactFiles(pages: DeviceArtifactsPage[]): DeviceArtifactFile[] {
const files: DeviceArtifactFile[] = [];
const first = pages[0];
if (first?.recording) {
files.push({
kind: 'recording',
file: `recording.${extensionFor(first.recording, 'mp4')}`,
url: first.recording.url,
bytes: first.recording.bytes,
created_at: first.recording.created_at,
});
}

if (first?.logs) {
files.push({
kind: 'logs',
file: `device.${extensionFor(first.logs, 'log')}`,
url: first.logs.url,
bytes: first.logs.bytes,
created_at: first.logs.created_at,
});
}

const actions = pages.flatMap((p) => p.actions).reverse();
const width = Math.max(3, String(actions.length).length);
for (const [i, action] of actions.entries()) {
const n = String(i + 1).padStart(width, '0');
if (action.screenshot) {
files.push({
kind: 'screenshot',
file: path.join('actions', `${n}-screenshot.${extensionFor(action.screenshot, 'jpg')}`),
url: action.screenshot.url,
bytes: action.screenshot.bytes,
command_id: action.command_id,
created_at: action.created_at,
});
}

if (action.hierarchy) {
files.push({
kind: 'hierarchy',
file: path.join('actions', `${n}-screen.${extensionFor(action.hierarchy, 'json')}`),
url: action.hierarchy.url,
bytes: action.hierarchy.bytes,
command_id: action.command_id,
created_at: action.created_at,
});
}
}

return files;
}

const CONCURRENCY = 4;

/**
* Download every file with a link into `dir`, four at a time. Returns the
* files that could not be fetched, with why, rather than failing the rest.
*/
export async function downloadArtifactFiles(
dir: string,
files: DeviceArtifactFile[],
onFile: (file: DeviceArtifactFile) => void = () => {},
): Promise<{ file: string; reason: string }[]> {
const failed: { file: string; reason: string }[] = [];
const queue = [...files];
async function worker(): Promise<void> {
for (let next = queue.shift(); next; next = queue.shift()) {
if (!next.url) {
failed.push({ file: next.file, reason: 'no download link (it may have been cleaned up)' });
continue;
}

const target = path.join(dir, next.file);
try {
await mkdir(path.dirname(target), { recursive: true });
const res = await fetch(next.url);
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
await pipeline(
Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]),
createWriteStream(target),
);
onFile(next);
} catch (error) {
failed.push({ file: next.file, reason: error instanceof Error ? error.message : String(error) });
}
}
}

await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
return failed;
}
Loading
Loading