Skip to content
This repository was archived by the owner on Oct 2, 2026. It is now read-only.
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
15 changes: 15 additions & 0 deletions lib/devicectl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class Devicectl {

private readonly preferNonRootWhenSudo: boolean;
private readonly sudoUser: SudoUser | null;
private jsonVersion: number | undefined;

/**
* Creates a new Devicectl instance
Expand Down Expand Up @@ -106,6 +107,20 @@ export class Devicectl {
}
}

/**
* Retrieves the version of the JSON output (`info.jsonVersion`) from devicectl
*
* @returns Promise that resolves to the JSON output format version
*/
async getJsonVersion(): Promise<number> {
if (this.jsonVersion === undefined) {
const {stdout} = await this.execute(['list', 'devices'], {noDevice: true});
const jsonVersion: number = JSON.parse(stdout).info.jsonVersion;
this.jsonVersion = jsonVersion;
}
return this.jsonVersion;
}

private resolveSudoUser(): SudoUser | null {
if (!process.geteuid || process.geteuid() !== 0) {
return null;
Expand Down
11 changes: 10 additions & 1 deletion lib/mixins/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,17 @@ export function escapeProcessFilterValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}

/**
* Picks the process filter field for the executable path.
* Xcode 27 renamed it.
*/
export function executablePathField(jsonVersion: number): string {
return jsonVersion >= 5 ? 'ExecutablePath' : 'executable.path';
}

async function listProcessesForAppPath(devicectl: Devicectl, appPath: string): Promise<ProcessInfo[]> {
const filter = `executable.path BEGINSWITH "${escapeProcessFilterValue(appPath)}"`;
const field = executablePathField(await devicectl.getJsonVersion());
const filter = `${field} BEGINSWITH "${escapeProcessFilterValue(appPath)}"`;
const {stdout} = await devicectl.execute(['device', 'info', 'processes'], {
subcommandOptions: ['--filter', filter],
});
Expand Down
102 changes: 100 additions & 2 deletions test/unit/devicectl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict';
import {describe, it, beforeEach} from 'node:test';
import {afterEach, beforeEach, describe, it, mock} from 'node:test';

import {Devicectl} from '../../lib/devicectl.js';
import {appUrlToFilesystemPath, escapeProcessFilterValue} from '../../lib/mixins/process.js';
import {appUrlToFilesystemPath, escapeProcessFilterValue, executablePathField} from '../../lib/mixins/process.js';

describe('Devicectl', function () {
let devicectl: Devicectl;
Expand Down Expand Up @@ -88,6 +88,104 @@ describe('Devicectl', function () {
assert.strictEqual(typeof devicectl.terminateApp, 'function');
});

describe('finding the app process', function () {
const APP_URL = 'file:///private/var/containers/Bundle/Application/ABC/App.app/';
const APP_PATH = '/private/var/containers/Bundle/Application/ABC/App.app';
const PROCESSES = JSON.stringify({
result: {runningProcesses: [{processIdentifier: 42, executable: `${APP_URL}App`}]},
});

function fakeDevicectl(jsonVersion: number, failures: {processes?: Error; jsonVersion?: Error} = {}) {
const filters: string[] = [];
const terminated: string[] = [];
const calls = {filters, terminated, jsonVersionLookups: 0};
mock.method(devicectl, 'execute', async (subcommand: string[], opts: {subcommandOptions: string[]}) => {
switch (subcommand.join(' ')) {
case 'list devices':
calls.jsonVersionLookups++;
// only the first lookup fails
if (failures.jsonVersion && calls.jsonVersionLookups === 1) {
throw failures.jsonVersion;
}
return {stdout: JSON.stringify({info: {jsonVersion}})};
case 'device process terminate':
calls.terminated.push(opts.subcommandOptions[1]);
return {stdout: '{}'};
default:
calls.filters.push(opts.subcommandOptions[1]);
if (failures.processes) {
throw failures.processes;
}
return {stdout: PROCESSES};
}
});
return calls;
}

beforeEach(function () {
mock.method(devicectl, 'listApps', async () => [{url: APP_URL}]);
});

afterEach(function () {
mock.restoreAll();
});

it('should filter on ExecutablePath, which Xcode 27 accepts', async function () {
const {filters, terminated} = fakeDevicectl(5);

assert.strictEqual(await devicectl.terminateApp('com.example.app'), true);
assert.deepStrictEqual(filters, [`ExecutablePath BEGINSWITH "${APP_PATH}"`]);
assert.deepStrictEqual(terminated, ['42']);
});

it('should filter on executable.path before JSON version 5', async function () {
const {filters, terminated} = fakeDevicectl(4);

assert.strictEqual(await devicectl.terminateApp('com.example.app'), true);
assert.deepStrictEqual(filters, [`executable.path BEGINSWITH "${APP_PATH}"`]);
assert.deepStrictEqual(terminated, ['42']);
});

it('should not retry when devicectl fails', async function () {
const {filters} = fakeDevicectl(5, {
processes: new Error(
"'xcrun devicectl device info processes' failed. Original error: ERROR: The device was not found.",
),
});

await assert.rejects(devicectl.terminateApp('com.example.app'), /The device was not found/);
assert.strictEqual(filters.length, 1);
});

it('should look up the JSON version only once', async function () {
const calls = fakeDevicectl(5);

await devicectl.terminateApp('com.example.app');
await devicectl.terminateApp('com.example.app');
assert.strictEqual(calls.jsonVersionLookups, 1);
assert.strictEqual(calls.filters.length, 2);
});

it('should look up the JSON version again after a failed lookup', async function () {
const calls = fakeDevicectl(5, {jsonVersion: new Error("'xcrun devicectl list devices' failed.")});

await assert.rejects(devicectl.terminateApp('com.example.app'), /list devices' failed/);
assert.strictEqual(await devicectl.terminateApp('com.example.app'), true);
assert.strictEqual(calls.jsonVersionLookups, 2);
});
});

describe('executablePathField', function () {
it('should be ExecutablePath as of JSON version 5', function () {
assert.strictEqual(executablePathField(5), 'ExecutablePath');
assert.strictEqual(executablePathField(6), 'ExecutablePath');
});

it('should be executable.path before JSON version 5', function () {
assert.strictEqual(executablePathField(4), 'executable.path');
});
});

describe('appUrlToFilesystemPath', function () {
it('should strip the file:// prefix', function () {
assert.strictEqual(appUrlToFilesystemPath('file:///path/to/App.app'), '/path/to/App.app');
Expand Down
Loading