From 9fa3e4634260a94f28c47770d3b953ba758dae33 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 16 May 2026 17:44:27 +0200 Subject: [PATCH] feat: Add API for app termination --- README.md | 2 ++ lib/devicectl.ts | 1 + lib/mixins/process.ts | 65 +++++++++++++++++++++++++++++++++++- lib/types.ts | 11 ++++++ test/unit/devicectl-specs.ts | 49 +++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 82d6d75..0f71009 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/devicectl.ts b/lib/devicectl.ts index 7617f68..e82b502 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -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; diff --git a/lib/mixins/process.ts b/lib/mixins/process.ts index 46a5e4d..8d3b48d 100644 --- a/lib/mixins/process.ts +++ b/lib/mixins/process.ts @@ -1,4 +1,4 @@ -import type {LaunchAppOptions} from '../types'; +import type {LaunchAppOptions, ProcessInfo, TerminateAppOptions} from '../types'; import type {Devicectl} from '../devicectl'; /** @@ -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 { + 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 { + const filter = `executable.path BEGINSWITH "${escapeProcessFilterValue(appPath)}"`; + const {stdout} = await devicectl.execute(['device', 'info', 'processes'], { + subcommandOptions: ['--filter', filter], + }); + return JSON.parse(stdout).result.runningProcesses; +} diff --git a/lib/types.ts b/lib/types.ts index b5c52ba..1a8a74a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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 */ diff --git a/test/unit/devicectl-specs.ts b/test/unit/devicectl-specs.ts index 70f1410..b506c10 100644 --- a/test/unit/devicectl-specs.ts +++ b/test/unit/devicectl-specs.ts @@ -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; @@ -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');