Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set
'boot',
'install',
'install_source',
'pair-wearable',
'prepare',
'push',
'reinstall',
Expand Down
9 changes: 9 additions & 0 deletions packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: true,
recorded: false,
},
{
key: 'boot',
names: ['--boot'],
type: 'boolean',
usageLabel: '--boot',
usageDescription: 'Pair wearable: boot the selected watch or Wear emulator first',
projectConfig: false,
recorded: false,
},
{
key: 'targetApp',
names: ['--app', '--target-app'],
Expand Down
18 changes: 18 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
keyboardRuntimePlanUses,
longPressRuntimeUses,
orientationRuntimeUse,
pairWearableUse,
perfRuntimePlanUses,
pressRuntimeUses,
resolveSelectorCaptureRuntimePlan,
Expand Down Expand Up @@ -717,6 +718,23 @@ export const RAW_COMMAND_DESCRIPTORS = [
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: true,
},
{
name: 'pair-wearable',
deviceClaimPolicy: 'transient-exclusive',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}),
catalog: { group: 'public', key: 'pairWearable' },
frameworkTier: 'extended',
recordsSessionAction: false,
daemon: {
route: 'session',
refFrameEffect: 'may-invalidate',
sessionKind: 'state',
preferExplicitDeviceOverExistingSession: true,
},
platformExecution: { kind: 'device-runtime', uses: [pairWearableUse] },
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
},
{
name: 'shutdown',
deviceClaimPolicy: 'transient-exclusive',
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@
"types": "./src/platform-runtime-operations.ts",
"default": "./src/platform-runtime-operations.ts"
},
"./wearable-pairing-runtime": {
"types": "./src/wearable-pairing-runtime.ts",
"default": "./src/wearable-pairing-runtime.ts"
},
"./platform-runtime-unavailable": {
"types": "./src/platform-runtime-unavailable.ts",
"default": "./src/platform-runtime-unavailable.ts"
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export type CliFlags = CloudProviderProfileFields &
foreground?: boolean;
surface?: SessionSurface;
headless?: boolean;
boot?: boolean;
restart?: boolean;
noRecord?: boolean;
/**
Expand Down
25 changes: 24 additions & 1 deletion packages/contracts/src/client-device-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import type {
DeviceTarget,
PublicPlatform,
} from '@agent-device/kernel/device';
import type { AgentDeviceIdentifiers, DeviceCommandBaseOptions } from './client-connection.ts';
import type {
AgentDeviceIdentifiers,
AgentDeviceRequestOverrides,
DeviceCommandBaseOptions,
} from './client-connection.ts';
import type {
WearablePairingEndpoint,
WearablePairingStatus,
WearableSelector,
} from './wearable-pairing-runtime.ts';

export type AgentDeviceDevice = {
platform: PublicPlatform;
Expand Down Expand Up @@ -104,3 +113,17 @@ export type DeviceBootOptions = DeviceCommandBaseOptions & {
};

export type DeviceShutdownOptions = DeviceCommandBaseOptions;

export type PairWearableOptions = AgentDeviceRequestOverrides & {
phone: WearablePairingEndpoint;
wearable?: WearableSelector;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
boot: boolean;
};

export type PairWearableResult = {
pairId: string;
phone: AgentDeviceDevice;
wearable: AgentDeviceDevice;
status: WearablePairingStatus;
remainingHumanStep?: string;
};
2 changes: 2 additions & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export type {
AgentDeviceSessionDevice,
DeviceBootOptions,
DeviceShutdownOptions,
PairWearableOptions,
PairWearableResult,
StartupPerfSample,
} from '../client-device-view.ts';
export type {
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
} from './platform-runtime.ts';
import { runtimeUse } from './platform-runtime-use.ts';
import type { AndroidToolHost } from './platform-runtime-host.ts';
import type { WearablePairingRuntimeOperations } from './wearable-pairing-runtime.ts';

export type PlatformRuntimeOperations = AppLogRuntimeOperations &
AppInventoryRuntimeOperations &
Expand Down Expand Up @@ -89,6 +90,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
TouchRuntimeOperations &
DeviceReadinessRuntimeOperations &
DeviceShutdownRuntimeOperations &
WearablePairingRuntimeOperations &
ApplicationLifecycleRuntimeOperations;

/**
Expand All @@ -100,6 +102,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
export const defineUse = runtimeUse<PlatformRuntimeOperations>();

export const bootTargetUse = defineUse({ required: ['bootTarget'] });
export const pairWearableUse = defineUse({ required: ['pairWearable'] });
export const bootTargetHeadlessUse = defineUse({
required: ['bootTargetHeadless'],
});
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
perf?: RuntimeOperationUnavailability;
readiness?: RuntimeOperationUnavailability;
shutdown?: RuntimeOperationUnavailability;
wearablePairing?: RuntimeOperationUnavailability;
lifecycle: ApplicationLifecycleOperationFacts;
}>;

Expand Down Expand Up @@ -135,6 +136,7 @@ const UNAVAILABLE_CELLS = {
perf: true,
readiness: true,
shutdown: true,
wearablePairing: true,
} satisfies Record<UnavailableCellKey, true>;

/** Fills every cell name through `fn`, in the one place a cell record is assembled by key. */
Expand Down Expand Up @@ -272,6 +274,7 @@ export function createUnavailablePlatformRuntimeFacts(
bootTarget: frozen.readiness,
bootTargetHeadless: frozen.readiness,
shutdownTarget: frozen.shutdown,
pairWearable: frozen.wearablePairing,
...frozen.lifecycle,
},
});
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/runtime-operation-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const RUNTIME_OPERATION_NAMES = [
'materializeAppSource',
'networkDump',
'openApplication',
'pairWearable',
'perfFrames',
'perfMemorySample',
'perfMemorySnapshot',
Expand Down
31 changes: 31 additions & 0 deletions packages/contracts/src/wearable-pairing-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { DeviceInfo, PublicPlatform } from '@agent-device/kernel/device';

export type WearablePairingEndpoint = Readonly<{
platform: Extract<PublicPlatform, 'ios' | 'android'>;
deviceId: string;
}>;

export type WearableSelector = Readonly<
{ deviceId: string; name?: string } | { deviceId?: string; name: string }
>;

export type PairWearableInput = Readonly<{
wearable?: WearableSelector;
boot: boolean;
/** Daemon-derived inventory boundary; never copy this from command input. */
androidSerialAllowlist?: readonly string[];
}>;

export type WearablePairingStatus = 'connected' | 'paired' | 'human-step-required';

export type WearablePairingRuntimeResult = Readonly<{
pairId: string;
phone: DeviceInfo;
wearable: DeviceInfo;
status: WearablePairingStatus;
remainingHumanStep?: string;
}>;

export type WearablePairingRuntimeOperations = Readonly<{
pairWearable(input: PairWearableInput): Promise<WearablePairingRuntimeResult>;
}>;
16 changes: 16 additions & 0 deletions packages/kernel/src/device-selector-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ test('matching selector/platform pairs still resolve', async () => {
assert.equal((await resolveDevice([APPLE], { platform: 'ios', udid: 'SIM-001' })).id, 'SIM-001');
});

test('--platform ios excludes a booted watchOS simulator from phone selection', async () => {
const watch: DeviceInfo = {
platform: 'apple',
target: 'mobile',
appleOs: 'watchos',
id: 'WATCH-001',
name: 'Apple Watch Series 10',
kind: 'simulator',
booted: true,
};
assert.equal((await resolveDevice([APPLE, watch], { platform: 'ios' })).id, APPLE.id);
assert.equal((await resolveDevice([APPLE, watch], {})).id, APPLE.id);
assert.equal((await resolveDevice([APPLE, watch], { platform: 'apple' })).id, APPLE.id);
assert.equal((await resolveError([watch], { platform: 'ios' })).code, 'DEVICE_NOT_FOUND');
});

test('an unspecified platform keeps the existing device-not-found behavior', async () => {
const error = await resolveError([ANDROID], { udid: 'emulator-5580' });
assert.equal(error.code, 'DEVICE_NOT_FOUND');
Expand Down
17 changes: 12 additions & 5 deletions packages/kernel/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ export function matchesPlatformSelector(
if (selector === 'apple') return isApplePlatform(device.platform);
// Legacy leaf selectors resolve within the collapsed `apple` platform via `appleOs`,
// preserving the pre-collapse `--platform ios|macos` device sets exactly.
if (selector === 'ios') return isApplePlatform(device.platform) && !isMacOs(device);
if (selector === 'ios')
return isApplePlatform(device.platform) && !isMacOs(device) && device.appleOs !== 'watchos';
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (selector === 'macos') return isApplePlatform(device.platform) && isMacOs(device);
return device.platform === selector;
}
Expand Down Expand Up @@ -477,14 +478,20 @@ function selectDefaultDevice(
selector: DeviceSelector,
context: DeviceSelectionContext,
): DeviceInfo {
const onlyCandidate = candidates[0];
if (onlyCandidate !== undefined && candidates.length === 1) return onlyCandidate;
// watchOS simulators are mobile-target devices in CoreSimulator inventory, but they are not
// valid implicit phone targets. Explicit identity/name selection remains available to the
// pair-wearable command; ordinary default resolution must prefer a phone or fail closed.
const defaultCandidates = candidates.filter(
Comment thread
csark0812 marked this conversation as resolved.
(device) => !(isApplePlatform(device.platform) && device.appleOs === 'watchos'),
);
const onlyCandidate = defaultCandidates[0];
if (onlyCandidate !== undefined && defaultCandidates.length === 1) return onlyCandidate;

if (candidates.length === 0) {
if (defaultCandidates.length === 0) {
throwNoDevicesFound(selector, context);
}

const preferred = preferredDeviceCandidates(candidates);
const preferred = preferredDeviceCandidates(defaultCandidates);
if (preferred.length > 1) throwAmbiguousDeviceSelection(preferred);
const selected = preferred[0];
if (!selected) throwNoDevicesFound(selector, context);
Expand Down
14 changes: 14 additions & 0 deletions packages/platform-android/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ test.each([
expect(facts.operations.ensureReady).toEqual({ available: true });
expect(facts.operations.bootTarget).toEqual({ available: true });
expect(facts.operations.bootTargetHeadless.available).toBe(runtimeDevice.kind === 'emulator');
expect(facts.operations.pairWearable.available).toBe(
runtimeDevice.kind === 'emulator' || runtimeDevice.kind === 'device',
);
expect(facts.operations.captureSnapshot).toEqual({ available: true });
// uiautomator reads text at a point over the same adb transport the capture uses.
expect(facts.operations.readTextAtPoint).toEqual({ available: true });
Expand Down Expand Up @@ -135,6 +138,17 @@ test.each([
}
});

test('Android TV refuses wearable pairing as an unsupported platform leaf', async () => {
const facts = await createAndroidPlatformRuntime(androidRuntimeHost()).inspectFacts({
...ANDROID_EMULATOR,
target: 'tv',
});
expect(facts.operations.pairWearable).toMatchObject({
available: false,
reason: 'unsupported-platform-leaf',
});
});

test('rejects the non-discovered Android simulator cell for appstate', async () => {
const runtimeDevice = { ...ANDROID_EMULATOR, kind: 'simulator' as const };
const host = androidRuntimeHost({
Expand Down
22 changes: 22 additions & 0 deletions packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
PlatformRuntimeOperations,
PlatformRuntimeOwner,
} from '@agent-device/contracts/platform-runtime-operations';
import { pairAndroidWearable } from './wearable-pairing.ts';
import type { PairWearableInput } from '@agent-device/contracts/wearable-pairing-runtime';
import {
applicationLifecycleOperationFacts,
availableApplicationLifecycleOperations,
Expand Down Expand Up @@ -220,6 +222,16 @@ const androidTvDragUnavailable = Object.freeze({
reason: 'unsupported-platform-leaf',
hint: TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT,
} as const);
const wearablePairingKindUnavailable = Object.freeze({
available: false,
reason: 'unsupported-device-kind',
hint: 'Wearable pairing requires an Android phone device or emulator.',
} as const);
const wearablePairingTargetUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
hint: 'Wearable pairing is supported only from a mobile Android phone target.',
} as const);

/**
* A TV target has no touch input at all, which is the one Android gate the retired admission
Expand All @@ -240,6 +252,13 @@ function androidTouchFact(device: DeviceInfo) {
return device.kind === 'simulator' ? focusKindUnavailable : available;
}

function androidWearablePairingFact(device: DeviceInfo) {
if (device.target === 'tv') return wearablePairingTargetUnavailable;
return device.kind === 'emulator' || device.kind === 'device'
? available
: wearablePairingKindUnavailable;
}

const clipboardShellUnavailable = Object.freeze({
available: false,
reason: 'owner-capability-missing',
Expand Down Expand Up @@ -424,6 +443,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
ensureReady: available,
bootTarget: available,
bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable,
pairWearable: androidWearablePairingFact(device),
listApps: available,
...androidLifecycleFacts(device),
shutdownTarget: device.kind === 'emulator' ? available : shutdownKindUnavailable,
Expand Down Expand Up @@ -502,6 +522,8 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
{ ...input, headless: false },
request.scope.signal,
),
pairWearable: async (input: PairWearableInput) =>
await pairAndroidWearable(host, request.device, input, request.scope.signal),
...(facts.operations.bootTargetHeadless.available
? {
bootTargetHeadless: async (input: EnsureReadyInput) =>
Expand Down
Loading