mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
cd1551bf42
* refactor: collapse public Platform ios/macos into apple (#979) Phase 3 d.3: collapse the internal `Platform` union from `ios`/`macos` to a single `apple` platform, with `appleOs` as the sole OS discriminant. Approach (b) NON-BREAKING: the daemon still ACCEPTS the legacy `ios`/`macos` selectors on every read path and still EMITS the leaf `ios`/`macos` strings on every output, so machine consumers see no change. Kernel (src/kernel/device.ts): - PLATFORMS = ['apple','android','linux','web']; add PUBLIC_PLATFORMS (leaf) and PublicPlatform; PLATFORM_SELECTORS keeps legacy `ios`/`macos` as input aliases. - New predicates: isMacOs (appleOs- or legacy-leaf-based), isIosFamily (the post-collapse equivalent of `platform === 'ios'`), publicPlatformString (output projection), deviceFieldsFromPublicPlatform (inverse), isPublicPlatform. - isMobilePlatform and matchesPlatformSelector are now device-aware (appleOs). Discovery now stamps `platform: 'apple'` (+ appleOs); ~125 internal `device.platform === 'ios'|'macos'` branch sites migrated to the predicates, behavior-preserving. Apple plugin owns `['apple']`; platformDescriptors collapse to one `apple` row. Output projection (approach b) emits the leaf via publicPlatformString at: devices / session_list (session-inventory), boot / shutdown / appstate / prepare-ios-runner (session-state, session), the selector/backend platform (selector-runtime/screenshot-runtime/snapshot-runtime/interaction-runtime), proxy device key, request-lock backfill, runtime-set binding, click-button validation, and both `.ad` context-line writers. Contracts/client keep leaf types (PublicPlatform); read paths (parsePlatform, REPLAY_METADATA_PLATFORMS, matchesPlatformSelector) accept `apple` + legacy leaves. Adds a parity test gate (platform-collapse-parity.test.ts). Refs #979 (part of #972). * fix: project platform to the public leaf at open/perf response sites (#979) The Platform collapse left two daemon response builders emitting the raw internal `device.platform` ('apple'), which the client normalizer rejects (isPublicPlatform excludes 'apple') — dropping the resolved device from the response: - session-open-surface.ts: `open` result `platform`/device projection. - session-perf.ts: the perf/frames/memory base response builders. Both now go through `publicPlatformString(device)`, so output stays the leaf `ios`/`macos` per approach (b). (The android/non-apple perf branches were already leaf-safe.) Also update macos-desktop provider test: the lifecycle mock observes the INTERNAL DeviceInfo, which is now `platform:'apple'` (+ appleOs:'macos'), so the recorded tag is `prepare:apple:desktop`. Fixes the provider-integration assertions that blocked both the Integration Tests and Coverage CI jobs (both run the provider-integration project). Verified: provider-integration 82/82, coverage passes, tsc/oxlint/oxfmt/layering/ fallow green. * fix: project platform to the public leaf at nested output sites (#979) The Platform collapse (approach b) projects device.platform through publicPlatformString at emit sites so machine consumers keep seeing the leaf ios/macos and never the internal `apple`. Several nested output fields were missed. Project them and narrow their emitted types to PublicPlatform: - Apple perf memory snapshot support (buildAppleMemorySnapshotSupport) — response.support.platform / artifact.support.platform, plus the sibling sampleAppleFramePerf error data. - Apple xctrace perf capture/result platform surfaced in the perf cpu-profile started/stopped response data. - snapshotDiagnostics.stats.platform (recordSnapshotTiming) surfaced in snapshot/test response data and the slow-snapshot warning string. - doctor target-app evidence.platform + human summary, and doctor target-app-device evidence.booted[].platform. - provider/cloud UNSUPPORTED_OPERATION error.data.platform for cloud Apple devices (reachable via deviceFieldsFromPublicPlatform). Internal 'apple' emissions (selector-matching input, diagnostic emitDiagnostic telemetry, session appLog state, replay .ad flags) are left as-is. Adds focused tests pinning Apple perf memory support to the leaf and a guard asserting no emitted platform field equals 'apple'.
269 lines
8.9 KiB
TypeScript
269 lines
8.9 KiB
TypeScript
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
import type {
|
|
CloudArtifactProvider,
|
|
CloudArtifactsQuery,
|
|
CloudArtifactsResult,
|
|
} from './cloud-artifacts.ts';
|
|
import type { Interactor } from './core/interactor-types.ts';
|
|
import type { DeviceInventoryProvider } from './core/dispatch-resolve.ts';
|
|
import type { LeaseLifecycleContext, LeaseLifecycleProvider } from './daemon/handlers/lease.ts';
|
|
import type { DeviceLease } from './daemon/lease-registry.ts';
|
|
import { publicPlatformString, type DeviceInfo } from './kernel/device.ts';
|
|
import { AppError } from './kernel/errors.ts';
|
|
|
|
export type ProviderDeviceInstallResult = {
|
|
bundleId?: string;
|
|
packageName?: string;
|
|
appName?: string;
|
|
launchTarget?: string;
|
|
};
|
|
|
|
export type ProviderDeviceInstallOptions = {
|
|
relaunch?: boolean;
|
|
appIdentifierHint?: string;
|
|
packageNameHint?: string;
|
|
};
|
|
|
|
export type ProviderDeviceRuntime = {
|
|
provider: string;
|
|
leaseLifecycle: LeaseLifecycleProvider;
|
|
cloudArtifacts?: CloudArtifactProvider;
|
|
deviceInventoryProvider: DeviceInventoryProvider;
|
|
ownsDevice(device: DeviceInfo): boolean;
|
|
getInteractor(device: DeviceInfo): Interactor | undefined;
|
|
installApp?(
|
|
device: DeviceInfo,
|
|
app: string,
|
|
appPath: string,
|
|
options?: ProviderDeviceInstallOptions,
|
|
): Promise<ProviderDeviceInstallResult | undefined>;
|
|
installInstallablePath?(
|
|
device: DeviceInfo,
|
|
installablePath: string,
|
|
options?: ProviderDeviceInstallOptions,
|
|
): Promise<ProviderDeviceInstallResult | undefined>;
|
|
configurePortReverse?(
|
|
options: ProviderPortReverseOptions,
|
|
): Promise<Record<string, unknown> | undefined>;
|
|
removePortReverse?(
|
|
options: ProviderPortReverseOptions,
|
|
): Promise<Record<string, unknown> | undefined>;
|
|
shutdown(): Promise<void>;
|
|
};
|
|
|
|
export type ProviderPortReverseOptions = {
|
|
leaseId: string;
|
|
provider?: string;
|
|
devicePort: number;
|
|
hostPort: number;
|
|
name: string;
|
|
};
|
|
|
|
export type ProviderDeviceRuntimeRequestProviders = {
|
|
leaseLifecycleProvider?: LeaseLifecycleProvider;
|
|
cloudArtifactProvider?: CloudArtifactProvider;
|
|
deviceInventoryProvider?: DeviceInventoryProvider;
|
|
providerDeviceRuntimeScope?: <T>(task: () => Promise<T>) => Promise<T>;
|
|
};
|
|
|
|
let activeProviderDeviceRuntimes: ProviderDeviceRuntime[] = [];
|
|
const providerDeviceRuntimeScope = new AsyncLocalStorage<ProviderDeviceRuntime[]>();
|
|
|
|
export function setActiveProviderDeviceRuntimes(runtimes: ProviderDeviceRuntime[]): void {
|
|
activeProviderDeviceRuntimes = [...runtimes];
|
|
}
|
|
|
|
async function withProviderDeviceRuntimeScope<T>(
|
|
runtimes: ProviderDeviceRuntime[],
|
|
task: () => Promise<T>,
|
|
): Promise<T> {
|
|
return await providerDeviceRuntimeScope.run([...runtimes], task);
|
|
}
|
|
|
|
export function getProviderDeviceInteractor(device: DeviceInfo): Interactor | undefined {
|
|
for (const runtime of getActiveProviderDeviceRuntimes()) {
|
|
if (!runtime.ownsDevice(device)) continue;
|
|
const interactor = runtime.getInteractor(device);
|
|
if (interactor) return interactor;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function isActiveProviderDevice(device: DeviceInfo): boolean {
|
|
return getActiveProviderDeviceRuntimes().some((runtime) => runtime.ownsDevice(device));
|
|
}
|
|
|
|
export async function installProviderDeviceApp(
|
|
device: DeviceInfo,
|
|
app: string,
|
|
appPath: string,
|
|
options?: ProviderDeviceInstallOptions,
|
|
): Promise<ProviderDeviceInstallResult | undefined> {
|
|
for (const runtime of getActiveProviderDeviceRuntimes()) {
|
|
if (!runtime.ownsDevice(device)) continue;
|
|
if (!runtime.installApp) {
|
|
throw unsupportedProviderOperation(runtime, device, 'install');
|
|
}
|
|
const result = await runtime.installApp?.(device, app, appPath, options);
|
|
if (result) return result;
|
|
throw unsupportedProviderOperation(runtime, device, 'install');
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export async function installProviderDeviceInstallablePath(
|
|
device: DeviceInfo,
|
|
installablePath: string,
|
|
options?: ProviderDeviceInstallOptions,
|
|
): Promise<ProviderDeviceInstallResult | undefined> {
|
|
for (const runtime of getActiveProviderDeviceRuntimes()) {
|
|
if (!runtime.ownsDevice(device)) continue;
|
|
if (!runtime.installInstallablePath) {
|
|
throw unsupportedProviderOperation(runtime, device, 'install_from_source');
|
|
}
|
|
const result = await runtime.installInstallablePath?.(device, installablePath, options);
|
|
if (result) return result;
|
|
throw unsupportedProviderOperation(runtime, device, 'install_from_source');
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export async function configureProviderPortReverse(
|
|
options: ProviderPortReverseOptions,
|
|
): Promise<Record<string, unknown> | undefined> {
|
|
for (const runtime of getActiveProviderDeviceRuntimes()) {
|
|
if (!runtimeMatchesProvider(runtime, options.provider)) continue;
|
|
const result = await runtime.configurePortReverse?.(options);
|
|
if (result) return result;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export async function removeProviderPortReverse(
|
|
options: ProviderPortReverseOptions,
|
|
): Promise<Record<string, unknown> | undefined> {
|
|
for (const runtime of getActiveProviderDeviceRuntimes()) {
|
|
if (!runtimeMatchesProvider(runtime, options.provider)) continue;
|
|
const result = await runtime.removePortReverse?.(options);
|
|
if (result) return result;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getActiveProviderDeviceRuntimes(): ProviderDeviceRuntime[] {
|
|
return providerDeviceRuntimeScope.getStore() ?? activeProviderDeviceRuntimes;
|
|
}
|
|
|
|
export function createProviderDeviceRuntimeRequestProviders(
|
|
runtimes: ProviderDeviceRuntime[],
|
|
): ProviderDeviceRuntimeRequestProviders {
|
|
return {
|
|
leaseLifecycleProvider: composeLeaseProvider(runtimes),
|
|
cloudArtifactProvider: composeCloudArtifactProvider(runtimes),
|
|
deviceInventoryProvider: composeDeviceInventoryProvider(runtimes),
|
|
providerDeviceRuntimeScope: async (task) =>
|
|
await withProviderDeviceRuntimeScope(runtimes, task),
|
|
};
|
|
}
|
|
|
|
export function composeCloudArtifactProviders(
|
|
...providers: Array<CloudArtifactProvider | undefined>
|
|
): CloudArtifactProvider | undefined {
|
|
const activeProviders = providers.filter(
|
|
(provider): provider is CloudArtifactProvider => provider !== undefined,
|
|
);
|
|
if (activeProviders.length === 0) return undefined;
|
|
return {
|
|
listCloudArtifacts: async (query) => {
|
|
for (const provider of activeProviders) {
|
|
const result = await provider.listCloudArtifacts?.(query);
|
|
if (result) return result;
|
|
}
|
|
return undefined;
|
|
},
|
|
};
|
|
}
|
|
|
|
function composeLeaseProvider(
|
|
runtimes: ProviderDeviceRuntime[],
|
|
): LeaseLifecycleProvider | undefined {
|
|
if (runtimes.length === 0) return undefined;
|
|
return {
|
|
allocate: async (lease, context) =>
|
|
await firstProviderResult(runtimes, 'allocate', lease, context),
|
|
heartbeat: async (lease, context) =>
|
|
await firstProviderResult(runtimes, 'heartbeat', lease, context),
|
|
release: async (lease, context) =>
|
|
await firstProviderResult(runtimes, 'release', lease, context),
|
|
};
|
|
}
|
|
|
|
function composeCloudArtifactProvider(
|
|
runtimes: ProviderDeviceRuntime[],
|
|
): CloudArtifactProvider | undefined {
|
|
if (runtimes.length === 0) return undefined;
|
|
return {
|
|
listCloudArtifacts: async (query) => await firstCloudArtifactsResult(runtimes, query),
|
|
};
|
|
}
|
|
|
|
function composeDeviceInventoryProvider(
|
|
runtimes: ProviderDeviceRuntime[],
|
|
): DeviceInventoryProvider | undefined {
|
|
if (runtimes.length === 0) return undefined;
|
|
return async (request) => {
|
|
for (const runtime of runtimes) {
|
|
if (!runtimeMatchesProvider(runtime, request.leaseProvider)) continue;
|
|
const devices = await runtime.deviceInventoryProvider(request);
|
|
if (devices) return devices;
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
async function firstCloudArtifactsResult(
|
|
runtimes: ProviderDeviceRuntime[],
|
|
query: CloudArtifactsQuery,
|
|
): Promise<CloudArtifactsResult | undefined> {
|
|
for (const runtime of runtimes) {
|
|
if (!runtimeMatchesProvider(runtime, query.provider)) continue;
|
|
const result = await runtime.cloudArtifacts?.listCloudArtifacts?.(query);
|
|
if (result) return result;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function firstProviderResult(
|
|
runtimes: ProviderDeviceRuntime[],
|
|
method: keyof LeaseLifecycleProvider,
|
|
lease: DeviceLease,
|
|
context?: LeaseLifecycleContext,
|
|
): Promise<Record<string, unknown> | undefined> {
|
|
for (const runtime of runtimes) {
|
|
if (!runtimeMatchesProvider(runtime, lease.leaseProvider)) continue;
|
|
const handler = runtime.leaseLifecycle[method];
|
|
const result = handler ? await handler(lease, context) : undefined;
|
|
if (result) return result;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function runtimeMatchesProvider(
|
|
runtime: ProviderDeviceRuntime,
|
|
provider: string | undefined,
|
|
): boolean {
|
|
return runtime.provider === provider;
|
|
}
|
|
|
|
function unsupportedProviderOperation(
|
|
runtime: ProviderDeviceRuntime,
|
|
device: DeviceInfo,
|
|
operation: string,
|
|
): never {
|
|
throw new AppError(
|
|
'UNSUPPORTED_OPERATION',
|
|
`Provider device runtime ${runtime.provider} does not support ${operation} for this device.`,
|
|
{ provider: runtime.provider, deviceId: device.id, platform: publicPlatformString(device) },
|
|
);
|
|
}
|