Skip to content
Open
50 changes: 49 additions & 1 deletion packages/kernel/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { DaemonError } from './errors.ts';

export type { AppErrorCode } from './errors.ts';
export { defaultHintForCode, normalizeError } from './errors.ts';
import type { PlatformSelector } from './device.ts';
import type { PlatformSelector, PublicPlatform } from './device.ts';

const SESSION_RUNTIME_PLATFORMS = ['ios', 'android', 'harmonyos'] as const;
export type SessionRuntimePlatform = (typeof SESSION_RUNTIME_PLATFORMS)[number];
Expand Down Expand Up @@ -58,6 +58,54 @@ const LEASE_BACKENDS = [
'harmonyos-instance',
] as const;
export type LeaseBackend = (typeof LEASE_BACKENDS)[number];

// Which lease backend rents a device on each platform the remote lease layer can hold. `ios-simulator`
// is a backend-specific runner guard rather than something a platform selector names, and the
// platforms with no remote lease backend (`vega`, `linux`, `web`) — and the macOS desktop host — map
// to no backend at all, so a request for one fails on the missing backend instead of renting a
// device no provider owns. Keyed on the `--platform` selector axis: callers holding a `DeviceInfo`
// project it with `publicPlatformString` first, which is the axis #2962 mixed up.
const LEASE_BACKEND_BY_PLATFORM: Partial<Record<PlatformSelector, LeaseBackend>> = {
ios: 'ios-instance',
android: 'android-instance',
harmonyos: 'harmonyos-instance',
};

/**
* Maps a platform to the lease backend that rents it. The CLI reads it for `--platform`/
* `--lease-backend` resolution and the remote connection reads it for the device it just resolved.
* Both previously keyed their own copy off a platform axis, which is where #2962 started; a further
* copy in `connect limrun` validation is tracked for follow-up.
*/
export function leaseBackendForPlatform(
platform: PlatformSelector | undefined,
): LeaseBackend | undefined {
return platform === undefined ? undefined : LEASE_BACKEND_BY_PLATFORM[platform];
}

/**
* The public leaf platform a lease backend rents devices on — the inverse of
* {@link leaseBackendForPlatform} for the backends that name a platform rather than a runner guard.
*
* A connection binds a platform at the same moment it binds a lease, and the lease is the stronger
* evidence: it names the backend that is actually holding the device. `ios-simulator` maps to no
* leaf because it is a runner/process guard below device leases, not a platform a selector names.
*
* Derived from the forward table rather than written beside it, so the two axes cannot drift, and held
* in a `Map` because a `leaseBackend` reaching here can be any string an older binary left on disk: a
* plain object would answer `constructor` and friends with an inherited function, which is a platform
* nobody rents.
*/
const PLATFORM_BY_LEASE_BACKEND = new Map<string, PublicPlatform>(
Object.entries(LEASE_BACKEND_BY_PLATFORM).flatMap(([platform, backend]) =>
backend === undefined ? [] : [[backend, platform as PublicPlatform] as const],
),
);

export function platformForLeaseBackend(backend: string): PublicPlatform | undefined {
return PLATFORM_BY_LEASE_BACKEND.get(backend);
}

const DAEMON_SERVER_MODES = ['socket', 'http', 'dual'] as const;
export type DaemonServerMode = (typeof DAEMON_SERVER_MODES)[number];
const DAEMON_TRANSPORT_PREFERENCES = ['auto', 'socket', 'http'] as const;
Expand Down
55 changes: 46 additions & 9 deletions packages/kernel/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,31 @@ export function matchesPlatformSelector(
return device.platform === selector;
}

/**
* Whether two `--platform` selections can name the SAME device.
*
* Selectors name a platform on one of two axes: the collapsed `apple` family, or an Apple leaf
* (`ios`/`macos`) — plus the non-Apple platforms, which have one axis each. Equality of the two
* strings is therefore not the question: `apple` and `ios` name overlapping devices while `ios` and
* `macos` do not. The `apple` selector is only equivalent to a leaf, never to a non-Apple platform.
*
* Comparing selectors by string instead was the shape behind #2962, where a remote connection bound
* to the public `ios` was compared with an `apple`-axis value and every iOS install was refused.
* Any caller that decides "this request targets a different platform than the one already bound"
* has to answer it on both axes, which is why this lives beside the selectors rather than in one
* caller.
*/
export function platformSelectorsConflict(
requested: PlatformSelector | undefined,
bound: PlatformSelector | undefined,
): boolean {
if (!requested || !bound) return false;
if (requested === bound) return false;
if (requested === 'apple') return !isApplePlatform(bound);
if (bound === 'apple') return !isApplePlatform(requested);
return true;
}

export function resolveApplePlatformName(
platformOrTarget: ApplePlatform | DeviceTarget | undefined,
appleOs?: AppleOS,
Expand Down Expand Up @@ -415,14 +440,27 @@ function deviceIdentityMistakenForNameHint(
if (!flag) return undefined;
return (
`${deviceName} is the id of ${JSON.stringify(identityMatch.name)}, not its name. ` +
`Did you mean ${flag} ${deviceName}?`
`Did you mean --${flag} ${deviceName}?`
);
}

/** The identity flag that can actually resolve a device on this platform, if one exists. */
function deviceIdentityFlag(platform: Platform): '--udid' | '--serial' | undefined {
if (isApplePlatform(platform)) return '--udid';
if (isSerialAddressablePlatform(platform)) return '--serial';
export type DeviceIdentityFlag = 'udid' | 'serial';

/**
* Which flag carries a device identity on a platform: `udid` addresses Apple devices, `serial`
* addresses the serial-addressable ones. Resolution rejects the wrong pairing
* (`assertSelectorFlagMatchesPlatform`), and a caller that resolved a device and has to re-issue it
* as flags — a remote lease request that must bind the device it just picked — has to name the same
* flag, or the two drift and the request binds a selector that resolves a DIFFERENT device.
*
* Two sites still spell the pairing out inline; each differs from this rule in a way that is its own
* decision, so they are tracked as follow-ups rather than folded in here.
*/
export function deviceIdentityFlag(
platform: Platform | PublicPlatform,
): DeviceIdentityFlag | undefined {
if (isApplePlatform(platform)) return 'udid';
if (isSerialAddressablePlatform(platform)) return 'serial';
return undefined;
}

Expand Down Expand Up @@ -472,10 +510,9 @@ function throwAmbiguousDeviceSelection(candidates: DeviceInfo[]): never {

function buildAmbiguousDeviceHint(candidates: DeviceInfo[]): string {
const first = candidates[0];
const identitySelector =
first && isSerialAddressablePlatform(first.platform)
? `--serial ${first.id}`
: `--udid ${first?.id ?? '<id>'}`;
const identitySelector = first
? `--${deviceIdentityFlag(first.platform) ?? 'udid'} ${first.id}`
: `--udid <id>`;
return (
`Select the intended device explicitly, for example ${identitySelector} ` +
`or --device ${JSON.stringify(first?.name ?? '<name>')}. ` +
Expand Down
92 changes: 71 additions & 21 deletions src/__tests__/remote-connection-harmonyos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,77 @@ test('HarmonyOS platform resolves to its proxy lease backend', () => {
).toBe('harmonyos-instance');
});

test.each(['apple', 'harmonyos', 'ios', 'android'] as const)(
'stored Harmony runtime compatibility respects %s selection',
test('stored Harmony runtime compatibility keeps the HarmonyOS runtime for a HarmonyOS selection', async () => {
const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-');
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
seedConnectionState({
stateDir,
state: {
session: 'runtime-compat',
remoteConfigPath,
daemon: { baseUrl: 'https://daemon.example' },
tenant: 'proxy',
runId: 'compat-run',
leaseId: 'compat-existing',
leaseBackend: 'harmonyos-instance',
runtime: { platform: 'harmonyos', launchUrl: 'demo://open' },
},
});
const materialized = await materializeRemoteConnectionForCommand({
command: 'snapshot',
client: createTestClient(),
flags: {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
session: 'runtime-compat',
platform: 'harmonyos',
},
});
expect(materialized.runtime?.platform).toBe('harmonyos');
});

/**
* A backend that rents HarmonyOS devices is the device this connection holds, even when the record
* never wrote a platform, so any other family is refused rather than sent along the existing lease
* (#2962). Before the backend was read, `apple` silently ran as a HarmonyOS snapshot and `ios` went
* out as an `ios` snapshot — both against a `harmonyos-instance` lease.
*/
async function expectRejectedSelection(
stateDir: string,
remoteConfigPath: string,
platform: 'apple' | 'ios' | 'android',
): Promise<void> {
await expect(
materializeRemoteConnectionForCommand({
command: 'snapshot',
client: createTestClient(),
flags: {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
session: 'runtime-compat',
platform,
},
}),
).rejects.toMatchObject({
code: 'INVALID_ARGS',
details: {
reason: 'CONNECTION_PLATFORM_CONFLICT',
platform: 'harmonyos',
requestedPlatform: platform,
},
});
}

test.each(['apple', 'ios', 'android'] as const)(
'a harmonyos-instance lease refuses a %s selection',
async (platform) => {
const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-');
const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-reject-');
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
seedConnectionState({
stateDir,
Expand All @@ -44,24 +111,7 @@ test.each(['apple', 'harmonyos', 'ios', 'android'] as const)(
runtime: { platform: 'harmonyos', launchUrl: 'demo://open' },
},
});
const materialized = await materializeRemoteConnectionForCommand({
command: 'snapshot',
client: createTestClient(),
flags: {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
session: 'runtime-compat',
platform,
},
});
if (platform === 'apple' || platform === 'harmonyos') {
expect(materialized.runtime?.platform).toBe('harmonyos');
} else {
expect(materialized.runtime).toBeUndefined();
}
await expectRejectedSelection(stateDir, remoteConfigPath, platform);
},
);

Expand Down
Loading
Loading