Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
- Limrun iOS snapshots no longer fail when the instance reports a numeric or boolean accessibility value (the system photo picker's tabs report `1`/`0`): the provider seam normalizes variant scalars to text, the way the Apple tree does. Limrun nodes now also publish `selected: true` for nodes carrying the `Selected` trait, so snapshot output and `selected:` selectors can tell a picked photo from an unpicked one.
- `settings` refuses a setting the target does not serve before anything changes, with `UNSUPPORTED_OPERATION`, `settings <setting> is not supported on this device`, and the target's `error.details.reason` (such as `unsupported-platform-leaf`). This replaces the macOS `INVALID_ARGS` refusal and the Apple `setting-unsupported-on-leaf` reason, and now also covers settings that used to reach the device first, such as Android `reset-keychain` or Apple `animations`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set
'push',
'reinstall',
'shutdown',
'uninstall',
],
observe: ['apps', 'appstate', 'capabilities', 'device', 'devices', 'doctor', 'takeover'],
none: [
Expand Down
6 changes: 6 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
deployAppUse,
readyMaterializeAndDeployAppUse,
readySendPushNotificationUse,
uninstallAppUse,
} from '@agent-device/contracts/app-deployment-runtime-plan';
import {
closeApplicationRuntimePlanUses,
Expand Down Expand Up @@ -952,6 +953,11 @@ export const RAW_COMMAND_DESCRIPTORS = [
name: 'reinstall',
...DEPLOY_APP_COMMAND_DESCRIPTOR,
},
{
name: 'uninstall',
...DEPLOY_APP_COMMAND_DESCRIPTOR,
platformExecution: { kind: 'device-runtime', use: uninstallAppUse },
},
{
name: 'install_source',
deviceClaimPolicy: 'transient-exclusive',
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/app-deployment-runtime-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineUse } from './platform-runtime-operations.ts';

export const deployAppUse = defineUse({ required: ['deployApp'] });

export const uninstallAppUse = defineUse({ required: ['uninstallApp'] });

/**
* Source installs retain their readiness precondition in the same request binding as
* materialization and deployment, so the handler has one admission and one bind.
Expand Down
21 changes: 19 additions & 2 deletions packages/contracts/src/app-deployment-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,21 @@ export type PushNotificationResult = Readonly<{
extrasCount?: number;
}>;

export type AppUninstallInput = Readonly<{ app: string }>;

/** `wasInstalled: false` means the target names no installed app, so nothing was removed. */
export type AppUninstallResult =
| Readonly<{ wasInstalled: false }>
| Readonly<{ wasInstalled: true; bundleId: string }>
| Readonly<{ wasInstalled: true; packageName: string }>;

/** The request-scoped deployment facet; no operation creates a durable resource. */
export type AppDeploymentRuntimeOperations = Readonly<{
deployApp(input: AppDeploymentInput): Promise<AppDeploymentResult>;
materializeAppSource(input: MaterializeAppSourceInput): Promise<MaterializedAppSource>;
deployMaterializedApp(input: DeployMaterializedAppInput): Promise<AppDeploymentResult>;
sendPushNotification(input: PushNotificationInput): Promise<PushNotificationResult>;
uninstallApp(input: AppUninstallInput): Promise<AppUninstallResult>;
}>;

/**
Expand All @@ -77,7 +86,11 @@ export type AppleAppDeploymentExecutor = Readonly<{
input: MaterializeAppSourceInput,
options: Readonly<{ appIdentifierHint?: string; signal: AbortSignal }>,
): Promise<MaterializedAppSource>;
resolveAppBundleId(device: DeviceInfo, app: string): Promise<string>;
resolveAppBundleId(
device: DeviceInfo,
app: string,
options?: Readonly<{ installedOnly?: boolean }>,
): Promise<string>;
}>;

/**
Expand All @@ -96,5 +109,9 @@ export type AndroidAppDeploymentExecutor = Readonly<{
input: MaterializeAppSourceInput,
options: Readonly<{ resolveIdentity?: boolean; signal: AbortSignal }>,
): Promise<MaterializedAppSource>;
resolveAppPackage(device: DeviceInfo, app: string): Promise<string>;
resolveAppPackage(
device: DeviceInfo,
app: string,
options?: Readonly<{ installedOnly?: boolean }>,
): Promise<string>;
}>;
4 changes: 4 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ export type AppPushOptions = DeviceCommandBaseOptions & {
payload: string | JsonObject;
};

export type AppUninstallOptions = DeviceCommandBaseOptions & {
app: string;
};

export type AppTriggerEventOptions = DeviceCommandBaseOptions & {
event: string;
payload?: JsonObject;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
AppOpenResult,
AppPushOptions,
AppTriggerEventOptions,
AppUninstallOptions,
DeviceSelectionMetadata,
DeviceSelectionReason,
DeviceSelectionSource,
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export function createUnavailablePlatformRuntimeFacts(
materializeAppSource: frozen.appDeployment,
deployMaterializedApp: frozen.appDeployment,
sendPushNotification: frozen.appDeployment,
uninstallApp: frozen.appDeployment,
appState: frozen.appState,
networkDump: frozen.network,
screenRecordingStart: frozen.screenRecording,
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 @@ -87,6 +87,7 @@ export const RUNTIME_OPERATION_NAMES = [
'triggerAppEvent',
'tvRemote',
'typeText',
'uninstallApp',
'writeClipboard',
] as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
MaterializedAppSource,
PushNotificationInput,
PushNotificationResult,
AppUninstallInput,
AppUninstallResult,
} from '@agent-device/contracts/app-deployment-runtime';
import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime';
import type { EnsureReadyInput } from '@agent-device/contracts/device-readiness-runtime';
Expand Down Expand Up @@ -66,6 +68,9 @@ export const mockDeployMaterializedAppRuntime = vi.fn(
export const mockPushNotificationRuntime = vi.fn(
async (_input: PushNotificationInput): Promise<PushNotificationResult> => ({}),
);
export const mockUninstallAppRuntime = vi.fn(
async (_input: AppUninstallInput): Promise<AppUninstallResult> => ({ wasInstalled: false }),
);
export const mockBindDeviceRuntime = vi.fn(
async (
device: DeviceInfo,
Expand Down Expand Up @@ -96,6 +101,8 @@ beforeEach(() => {
mockDeployMaterializedAppRuntime.mockResolvedValue({});
mockPushNotificationRuntime.mockReset();
mockPushNotificationRuntime.mockResolvedValue({});
mockUninstallAppRuntime.mockReset();
mockUninstallAppRuntime.mockResolvedValue({ wasInstalled: false });
mockBindDeviceRuntime.mockClear();
});

Expand Down Expand Up @@ -157,6 +164,7 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperati
materializeAppSource: operationAvailability(deployment.source),
deployMaterializedApp: operationAvailability(deployment.source),
sendPushNotification: operationAvailability(deployment.push),
uninstallApp: operationAvailability(deployment.uninstall),
appState: operationAvailability(device.platform === 'android'),
ensureReady: operationAvailability(device.appleOs !== 'watchos'),
bootTarget: operationAvailability(normalAvailable),
Expand Down Expand Up @@ -247,6 +255,11 @@ async function readinessBinding(
sendPushNotification: async (input) => await mockPushNotificationRuntime(input),
}
: {}),
...(facts.operations.uninstallApp.available
? {
uninstallApp: async (input) => await mockUninstallAppRuntime(input),
}
: {}),
...lifecycle,
},
[Symbol.asyncDispose]: async () => {},
Expand All @@ -267,6 +280,11 @@ function deploymentAvailability(device: DeviceInfo) {
source: deploy && (device.platform === 'apple' || device.platform === 'android'),
push:
device.platform === 'android' || (device.platform === 'apple' && device.kind === 'simulator'),
uninstall:
deploy &&
((device.platform === 'apple' && (device.kind === 'simulator' || device.kind === 'device')) ||
(device.platform === 'android' &&
(device.kind === 'emulator' || device.kind === 'device'))),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ test('daemon command registry preserves replay and recording traits', () => {
PUBLIC_COMMANDS.triggerAppEvent,
PUBLIC_COMMANDS.tvRemote,
PUBLIC_COMMANDS.type,
PUBLIC_COMMANDS.uninstall,
PUBLIC_COMMANDS.viewport,
PUBLIC_COMMANDS.wait,
]) {
Expand Down
1 change: 1 addition & 0 deletions packages/daemon/src/core/runtime-operation-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,6 @@ export const RUNTIME_OPERATION_EFFECTS: Readonly<
triggerAppEvent: 'mutates',
tvRemote: 'mutates',
typeText: 'mutates',
uninstallApp: 'mutates',
writeClipboard: 'mutates',
});
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ function sourceRuntimeFacts(
materializeAppSource: materializationAvailable ? { available: true } : unavailable,
deployMaterializedApp: materializationAvailable ? { available: true } : unavailable,
sendPushNotification: unavailable,
uninstallApp: unavailable,
networkDump: unavailable,
screenRecordingStart: unavailable,
screenRecordingReattach: unavailable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts';
import { expect, test } from 'vitest';
import type { SessionState } from '../../../core/session-state.ts';
import { makeSessionStore } from '../../../__tests__/support/store-factory.ts';
import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '@agent-device/test-kit';
import {
handleSessionCommands,
mockBindDeviceRuntime,
mockUninstallAppRuntime,
} from '../../../__tests__/support/handlers/session-command-harness.ts';
import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts';

async function uninstall(device: SessionState['device'], app: string) {
const sessionStore = makeSessionStore('agent-device-session-uninstall-');
sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], device });
return await handleSessionCommands({
createCommandSurface: fakeCreateCommandSurface,
platformServices: daemonPlatformServicesFixture({
appleSessionObservation: {
observeRunnerSession: async (deviceId) => ({ alive: true, sessionId: `${deviceId}:1` }),
resolveSoleForegroundApp: async () => undefined,
},
}),
req: { token: 't', session: 'default', command: 'uninstall', positionals: [app], flags: {} },
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore,
invoke: async () => {
throw new Error('uninstall must stay on the runtime route');
},
});
}

test.each([
[
'an Android removal',
ANDROID_EMULATOR,
{ wasInstalled: true, packageName: 'com.example.app' },
{
platform: 'android',
wasInstalled: true,
appId: 'com.example.app',
message: 'Uninstalled: com.example.app',
},
],
[
'an Android no-op',
ANDROID_EMULATOR,
{ wasInstalled: false },
{ platform: 'android', wasInstalled: false, message: 'Not installed: com.example.app' },
],
[
'an iOS simulator removal',
IOS_SIMULATOR,
{ wasInstalled: true, bundleId: 'com.example.app' },
{
platform: 'ios',
wasInstalled: true,
appId: 'com.example.app',
message: 'Uninstalled: com.example.app',
},
],
[
'a CoreDevice removal',
IOS_DEVICE,
{ wasInstalled: true, bundleId: 'com.example.app' },
{
platform: 'ios',
wasInstalled: true,
appId: 'com.example.app',
message: 'Uninstalled: com.example.app',
},
],
] as const)(
'uninstall reports %s through one admitted binding',
async (_name, device, removed, expected) => {
mockUninstallAppRuntime.mockResolvedValueOnce(removed);

await expect(uninstall(device, 'com.example.app')).resolves.toEqual({
ok: true,
data: { app: 'com.example.app', ...expected },
});
expect(mockUninstallAppRuntime).toHaveBeenCalledWith({ app: 'com.example.app' });
expect(mockBindDeviceRuntime.mock.calls[0]?.[1]).toMatchObject({ required: ['uninstallApp'] });
},
);

test('uninstall is refused before binding where the runtime fact closes it', async () => {
const harmony = {
platform: 'harmonyos',
id: 'hdc-1',
name: 'Harmony',
kind: 'device',
booted: true,
} as const;

await expect(uninstall(harmony, 'com.example.app')).resolves.toMatchObject({
ok: false,
error: { code: 'UNSUPPORTED_OPERATION', details: { reason: 'owner-capability-missing' } },
});
expect(mockBindDeviceRuntime).not.toHaveBeenCalled();
expect(mockUninstallAppRuntime).not.toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import type { DaemonRequest } from '../../core/daemon-request.ts';
import {
handleAppDeploymentCommand,
handlePushNotificationCommand,
handleUninstallCommand,
} from './session-app-deployment.ts';
import { handleInstallFromSourceDeploymentCommand } from './session-app-source-deployment.ts';

/** The four canonical install-family descriptors share one daemon route boundary. */
/** The canonical install-family descriptors share one daemon route boundary. */
export async function handleSessionAppDeploymentCommand(params: {
req: DaemonRequest;
sessionName: string;
Expand Down Expand Up @@ -43,6 +44,16 @@ export async function handleSessionAppDeploymentCommand(params: {
platformServices,
});
}
if (req.command === 'uninstall') {
return await handleUninstallCommand({
req,
sessionName,
sessionStore,
inspectFacts,
bindDevice,
platformServices,
});
}
if (req.command === 'push') {
return await handlePushNotificationCommand({
req,
Expand Down
41 changes: 41 additions & 0 deletions packages/daemon/src/sessions/handlers/session-app-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment
import {
deployAppUse,
readySendPushNotificationUse,
uninstallAppUse,
} from '@agent-device/contracts/app-deployment-runtime-plan';
import { isIosFamily, publicPlatformString } from '@agent-device/kernel/device';
import { readNotificationPayload } from '../dispatch-payload.ts';
Expand Down Expand Up @@ -166,6 +167,46 @@ export async function handlePushNotificationCommand(
return { ok: true, data };
}

export async function handleUninstallCommand(
params: RuntimeCommandHandlerParams,
): Promise<DaemonResponse> {
const { req, sessionName, sessionStore } = params;
const ref = sessionStore.lookup(sessionName);
const session = ref?.session;
const flags = req.flags ?? {};
const guard = requireSessionOrExplicitSelector('uninstall', session, flags);
if (guard) return guard;

const app = req.positionals?.[0]?.trim();
if (!app) return errorResponse('INVALID_ARGS', 'uninstall requires <app>');
const device = await resolveCommandDevice({
session,
flags,
platformServices: params.platformServices,
});
const facts = await requireRuntimeFacts(params.inspectFacts)(device);
const unsupported = unavailableRuntimeOperationResponse(
'uninstall',
facts.operations.uninstallApp,
);
if (unsupported) return unsupported;

const runtime = await requireRuntimeBinding(params.bindDevice)(device, uninstallAppUse);
if (ref) expireRefFrame(sessionStore.requireCurrent(ref));
const result = await runtime.operations.uninstallApp({ app });
const platform = isIosFamily(device) ? 'ios' : 'android';
const appId = !result.wasInstalled
? undefined
: 'bundleId' in result
? result.bundleId
: result.packageName;
const data = appId
? withSuccessText({ platform, app, wasInstalled: true, appId }, `Uninstalled: ${appId}`)
: withSuccessText({ platform, app, wasInstalled: false }, `Not installed: ${app}`);
recordSessionAction(sessionStore, ref, req, 'uninstall', data);
return { ok: true, data };
}

function buildDeployResult(
device: SessionState['device'],
app: string,
Expand Down
Loading
Loading