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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ await devicectl.launchApp('com.example.app', {
env: { DEBUG: '1' },
terminateExisting: true
});
await devicectl.terminateApp('com.example.app');
await devicectl.terminateApp('com.example.app', { force: true });
```

When Node is running under `sudo`, `node-devicectl` runs `xcrun devicectl` as the original
Expand Down
1 change: 1 addition & 0 deletions lib/devicectl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class Devicectl {
sendMemoryWarning = processMixins.sendMemoryWarning;
sendSignalToProcess = processMixins.sendSignalToProcess;
launchApp = processMixins.launchApp;
terminateApp = processMixins.terminateApp;

listProcesses = infoMixins.listProcesses;
listApps = infoMixins.listApps;
Expand Down
65 changes: 64 additions & 1 deletion lib/mixins/process.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {LaunchAppOptions} from '../types';
import type {LaunchAppOptions, ProcessInfo, TerminateAppOptions} from '../types';
import type {Devicectl} from '../devicectl';

/**
Expand Down Expand Up @@ -59,3 +59,66 @@ export async function launchApp(
asJson: false,
});
}

/**
* Terminates all running processes for the app with the given bundle identifier.
*
* Resolves the app's install path via {@link Devicectl.listApps}, finds matching
* processes with `devicectl device info processes --filter`, then terminates each
* via `devicectl device process terminate`.
*
* @returns `true` if at least one process was terminated, otherwise `false`
*/
export async function terminateApp(
this: Devicectl,
bundleId: string,
opts: TerminateAppOptions = {},
): Promise<boolean> {
const apps = await this.listApps(bundleId);
if (apps.length === 0) {
return false;
}

const processes = await listProcessesForAppPath(this, appUrlToFilesystemPath(apps[0].url));
if (processes.length === 0) {
return false;
}

const {force = false} = opts;
const subcommandOptions: string[] = [];
if (force) {
subcommandOptions.push('--kill');
}

await Promise.all(
processes.map(({processIdentifier}) =>
this.execute(['device', 'process', 'terminate'], {
subcommandOptions: [...subcommandOptions, '--pid', `${processIdentifier}`],
}),
),
);

return true;
}

/** Converts a devicectl app URL to a filesystem path for process filters. */
export function appUrlToFilesystemPath(appUrl: string): string {
const path = appUrl.startsWith('file:') ? new URL(appUrl).pathname : appUrl;
return path.replace(/\/$/, '') || '/';
}

/** Escapes a value for use inside a devicectl process filter string. */
export function escapeProcessFilterValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}

async function listProcessesForAppPath(
devicectl: Devicectl,
appPath: string,
): Promise<ProcessInfo[]> {
const filter = `executable.path BEGINSWITH "${escapeProcessFilterValue(appPath)}"`;
const {stdout} = await devicectl.execute(['device', 'info', 'processes'], {
subcommandOptions: ['--filter', filter],
});
return JSON.parse(stdout).result.runningProcesses;
}
11 changes: 11 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ export interface LaunchAppOptions {
terminateExisting?: boolean;
}

/**
* Options for terminating an app
*/
export interface TerminateAppOptions {
/**
* Send SIGKILL instead of SIGTERM so the process cannot catch the signal
* @default false
*/
force?: boolean;
}

/**
* Result type for synchronous execution
*/
Expand Down
49 changes: 49 additions & 0 deletions test/unit/devicectl-specs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {expect} from 'chai';
import {Devicectl} from '../../lib/devicectl';
import {appUrlToFilesystemPath, escapeProcessFilterValue} from '../../lib/mixins/process';

describe('Devicectl', function () {
let devicectl: Devicectl;
Expand Down Expand Up @@ -82,6 +83,54 @@ describe('Devicectl', function () {
});
});

describe('terminateApp', function () {
it('should be a function', function () {
expect(devicectl.terminateApp).to.be.a('function');
});

describe('appUrlToFilesystemPath', function () {
it('should strip the file:// prefix', function () {
expect(appUrlToFilesystemPath('file:///path/to/App.app')).to.equal('/path/to/App.app');
});

it('should strip a trailing slash', function () {
expect(appUrlToFilesystemPath('/path/to/App.app/')).to.equal('/path/to/App.app');
});

it('should strip both the file:// prefix and trailing slash', function () {
expect(appUrlToFilesystemPath('file:///private/var/App.app/')).to.equal(
'/private/var/App.app',
);
});

it('should leave paths without a file:// prefix or trailing slash unchanged', function () {
expect(appUrlToFilesystemPath('/path/to/App.app')).to.equal('/path/to/App.app');
});
});

describe('escapeProcessFilterValue', function () {
it('should leave values without special characters unchanged', function () {
expect(escapeProcessFilterValue('/path/to/App.app')).to.equal('/path/to/App.app');
});

it('should escape backslashes', function () {
expect(escapeProcessFilterValue(String.raw`path\with\slashes`)).to.equal(
String.raw`path\\with\\slashes`,
);
});

it('should escape double quotes', function () {
expect(escapeProcessFilterValue('path"with"quotes')).to.equal('path\\"with\\"quotes');
});

it('should escape backslashes and double quotes together', function () {
expect(escapeProcessFilterValue(String.raw`path\"mixed`)).to.equal(
String.raw`path\\\"mixed`,
);
});
});
});

describe('listDevices', function () {
it('should be a function', function () {
expect(devicectl.listDevices).to.be.a('function');
Expand Down
Loading