Skip to content
Open
50 changes: 50 additions & 0 deletions oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,56 @@ export default defineConfig({
],
},
},
{
files: ['src/commands/**/*.ts', 'src/cli/commands/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'node:child_process',
message:
'Use process helpers from @agent-device/host-kit/command instead of importing node:child_process directly.',
},
],
patterns: [
{
group: ['@agent-device/provider-*'],
message:
'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.',
},
],
},
],
'no-restricted-properties': [
'error',
{
property: 'leaseProvider',
message:
'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.',
},
],
},
},
{
files: ['src/cli/commands/**/*.ts'],
rules: {
'no-restricted-properties': [
'error',
{
property: 'leaseProvider',
message:
'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.',
},
{
property: 'provider',
message:
'Connection commands must ask src/cli/connection/provider-policy.ts for provider capabilities.',
},
],
},
},
{
files: [
'packages/host-kit/src/internal/exec.ts',
Expand Down
33 changes: 32 additions & 1 deletion packages/contracts/src/application-lifecycle-interaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { Interactor } from './interactor-types.ts';
import type { OpenApplicationInput } from './application-lifecycle-runtime.ts';
Expand Down Expand Up @@ -91,6 +91,37 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up',
expect(calls[1]?.options).toHaveProperty('launchArgs', undefined);
});

test('direct lifecycle owners resolve provider app references before target dispatch', async () => {
const open = vi.fn(async () => undefined);
const binding = bindLocalApplicationLifecycleInteractor({
device: WEB_DEVICE,
signal: new AbortController().signal,
resolveInteractor: async () => interactorWithOpen(open),
});
const lifecycle = bindDirectApplicationLifecycle({
binding,
owner: 'Provider',
openTargetIdentity: 'bundle-id',
resolveAppReference: (app) => (app === 'Example.app.zip' ? 'com.example.app' : app),
});

await expect(
lifecycle.resolveOpenTarget({ target: 'Example.app.zip', surface: 'app' }),
).resolves.toEqual({ appBundleId: 'com.example.app', appName: 'com.example.app' });
await lifecycle.openApplication(
openInput({
target: 'Example.app.zip',
positionals: ['Example.app.zip'],
appBundleId: 'Example.app.zip',
}),
);

expect(open).toHaveBeenCalledWith(
'com.example.app',
expect.objectContaining({ appBundleId: 'com.example.app' }),
);
});

test.each([
{
name: 'more than two positionals',
Expand Down
50 changes: 39 additions & 11 deletions packages/contracts/src/application-lifecycle-interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export type DirectApplicationLifecycleParams = Readonly<{
openTargetIdentity: DirectOpenTargetIdentity;
/** Owners whose native open does not replace a running application close it first. */
closeBeforeRelaunch?: boolean;
resolveAppReference?(app: string): string;
/** Port reverse is the one non-direct operation a provider owner may still implement. */
configureProviderPortReverse?: ApplicationLifecycleRuntimeOperations['configureProviderPortReverse'];
}>;
Expand All @@ -282,7 +283,8 @@ export function bindDirectApplicationLifecycle(
);
};
return Object.freeze({
resolveOpenTarget: async (input) => resolveDirectOpenTarget(params.openTargetIdentity, input),
resolveOpenTarget: async (input) =>
resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetReference(params, input)),
prepareApplicationOpen: async () => undefined,
openApplication: async (input) => await openDirectApplication(params, input),
applyRuntimeHints: unavailable,
Expand All @@ -304,38 +306,64 @@ async function openDirectApplication(
params: DirectApplicationLifecycleParams,
input: OpenApplicationInput,
): Promise<OpenApplicationOutcome> {
const resolvedInput = resolveOpenApplicationReferences(params, input);
const { binding } = params;
const interactor = await binding.resolveInteractor(input.execution, input.appBundleId);
if (params.closeBeforeRelaunch && input.relaunch && input.target !== undefined) {
const interactor = await binding.resolveInteractor(
resolvedInput.execution,
resolvedInput.appBundleId,
);
if (params.closeBeforeRelaunch && resolvedInput.relaunch && resolvedInput.target !== undefined) {
await invokeApplicationClose({
device: binding.device,
interactor,
positionals: [input.appBundleId ?? input.target],
positionals: [resolvedInput.appBundleId ?? resolvedInput.target],
});
}
await invokeApplicationOpen({
device: binding.device,
interactor,
positionals: input.positionals,
appBundleId: input.appBundleId,
execution: input.execution,
positionals: resolvedInput.positionals,
appBundleId: resolvedInput.appBundleId,
execution: resolvedInput.execution,
});
const followUpUrl = followUpRuntimeLaunchUrl(input);
const followUpUrl = followUpRuntimeLaunchUrl(resolvedInput);
if (followUpUrl) {
await invokeApplicationOpen({
device: binding.device,
interactor,
positionals: [followUpUrl],
appBundleId: input.appBundleId,
appBundleId: resolvedInput.appBundleId,
execution: {
...input.execution,
...resolvedInput.execution,
clearAppState: undefined,
launchConsole: undefined,
launchArgs: undefined,
},
});
}
return { appBundleId: input.appBundleId, timing: {} };
return { appBundleId: resolvedInput.appBundleId, timing: {} };
}

function resolveOpenTargetReference(
params: DirectApplicationLifecycleParams,
input: OpenTargetResolutionInput,
): OpenTargetResolutionInput {
if (!input.target || !params.resolveAppReference) return input;
return { ...input, target: params.resolveAppReference(input.target) };
}

function resolveOpenApplicationReferences(
params: DirectApplicationLifecycleParams,
input: OpenApplicationInput,
): OpenApplicationInput {
const resolve = params.resolveAppReference;
if (!resolve) return input;
return {
...input,
target: input.target ? resolve(input.target) : undefined,
positionals: input.positionals.map((value, index) => (index === 0 ? resolve(value) : value)),
appBundleId: input.appBundleId ? resolve(input.appBundleId) : undefined,
};
}

export function followUpRuntimeLaunchUrl(input: OpenApplicationInput): string | undefined {
Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/device-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,18 @@ export type ProviderDeviceInventorySource = Readonly<{
signal: AbortSignal,
): Promise<ProviderDeviceInventoryOutcome>;
}>;

export type ProviderAppCatalogQuery = Readonly<{
provider: string;
platform: 'android' | 'ios';
}>;

export type ProviderAppCatalogHandler = (
query: ProviderAppCatalogQuery,
signal?: AbortSignal,
) => Promise<readonly string[]>;

export type ProviderAppCatalog = Readonly<{
supports(provider: string): boolean;
list: ProviderAppCatalogHandler;
}>;
3 changes: 3 additions & 0 deletions packages/contracts/src/facades/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export type {
DeviceLease,
LeaseLifecycleContext,
LeaseLifecycleProvider,
ProviderAppCatalog,
ProviderAppCatalogHandler,
ProviderAppCatalogQuery,
ProviderDeviceInventoryOutcome,
ProviderDeviceInventorySource,
} from '../device-provider.ts';
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/provider-device-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
DeviceInventoryProvider,
DeviceLease,
LeaseLifecycleProvider,
ProviderAppCatalogHandler,
} from './device-provider.ts';
import type { Interactor, RunnerContext } from './interactor-types.ts';

Expand Down Expand Up @@ -36,6 +37,7 @@ export type ProviderDeviceRuntime = {
leaseLifecycle: LeaseLifecycleProvider;
recoverExpiredLease?: ProviderExpiredLeaseRecovery;
cloudArtifacts?: CloudArtifactProvider;
appCatalog?: ProviderAppCatalogHandler;
deviceInventoryProvider: DeviceInventoryProvider;
ownsDevice(device: DeviceInfo): boolean;
getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor | undefined;
Expand Down
68 changes: 68 additions & 0 deletions packages/provider-limrun/src/app-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, test, vi } from 'vitest';
import {
listLimrunAppAssets,
resolveInstalledAppIdForAsset,
resolveLimrunAppAsset,
} from './app-catalog.ts';

describe('Limrun uploaded app catalog', () => {
test('lists only uploaded assets compatible with the requested platform', async () => {
const list = vi.fn(async () => [
{ id: 'android-explicit', name: 'build.bin', os: 'android', md5: 'a' },
{ id: 'android-apk', name: 'com.example.app.apk', md5: 'b' },
{ id: 'ios-zip', name: 'Example.app.zip', md5: 'c' },
{ id: 'pending', name: 'pending.apk' },
{ id: 'unknown', name: 'notes.txt', md5: 'd' },
]);
const limrun = { assets: { list } } as never;

await expect(listLimrunAppAssets(limrun, 'android')).resolves.toEqual([
{ id: 'android-explicit', name: 'build.bin' },
{ id: 'android-apk', name: 'com.example.app.apk' },
]);
await expect(listLimrunAppAssets(limrun, 'ios')).resolves.toEqual([
{ id: 'ios-zip', name: 'Example.app.zip' },
]);
});

test('resolves an exact uploaded asset name and rejects platform mismatches', async () => {
const list = vi
.fn()
.mockResolvedValueOnce([
{ id: 'similar', name: 'Example.app.zip.backup.zip', md5: 'z' },
{ id: 'ios-app', name: 'Example.app.zip', md5: 'a' },
])
.mockResolvedValueOnce([{ id: 'android-app', name: 'Example.apk', md5: 'b' }]);
const limrun = { assets: { list } } as never;

await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.app.zip')).resolves.toEqual({
id: 'ios-app',
name: 'Example.app.zip',
});
await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.apk')).resolves.toBeUndefined();
});

test('matches an uploaded iOS asset when the instance also contains Expo Go', () => {
expect(
resolveInstalledAppIdForAsset('easagentdevice.app.zip', [
{ id: 'dev.expo.easagentdevice', name: 'Agent Device' },
{ id: 'host.exp.Exponent', name: 'Expo Go' },
]),
).toBe('dev.expo.easagentdevice');
expect(
resolveInstalledAppIdForAsset('unrelated-build.zip', [
{ id: 'com.example.first' },
{ id: 'com.example.second' },
]),
).toBeUndefined();
});

test('rejects colliding exact installed identities', () => {
expect(
resolveInstalledAppIdForAsset('example.app.zip', [
{ id: 'com.first', name: 'Example' },
{ id: 'com.second.example', name: 'Second' },
]),
).toBeUndefined();
});
});
Loading
Loading