diff --git a/LICENSE b/LICENSE index 3fdbf44..332f1f2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Shazron Abdullah +Copyright (c) 2026 Darryl Pogue Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/lib/devicectl.js b/lib/devicectl.js index eaf83fe..b7cd20a 100644 --- a/lib/devicectl.js +++ b/lib/devicectl.js @@ -24,26 +24,57 @@ THE SOFTWARE. const { spawnSync } = require('node:child_process'); +/** + * @typedef {object} devicectlResult + * @property {string} stdout - The output of the subprocess standard output + * stream. + * @property {string} stderr - The output of the subprocess standard error + * stream. + * @property {number} [status] - The exit code of the subprocess, or `null` if + * the subprocess terminated due to a signal. + * @property {string} [signal] - The signal used to kill the subprocess, or + * `null` if the subprocess did not terminate due to a signal. + * @property {Error} [error] - The error object if the child process failed or + * timed out. + */ + +/** + * @typedef {object} devicectlJSONResult + * @extends devicectlResult + * @property {Object} [json] - The structured output of the subprocess result. + */ + module.exports = { + /** + * Checks that devicectl is available and can be invoked without errors. + * + * @returns {boolean} Whether prerequisites are met. + */ check_prerequisites: function () { const result = spawnSync('xcrun', ['devicectl', 'help'], { stdio: 'ignore', encoding: 'utf8' }); - if (result.status !== 0) { - result.stdout = 'devicectl was not found.\n'; - result.stdout += 'Check that you have Xcode installed:\n'; - result.stdout += '\txcodebuild --version\n'; - result.stdout += 'Check that you have Xcode selected:\n'; - result.stdout += '\txcode-select --print-path\n'; + if (result.status === 0) { + return true; } - return result; + return false; }, + /** + * Returns the devicectl version number as an array. + * + * @returns {number[]} The major and minor components of the version number. + */ devicectl_version: function () { const res = spawnSync('xcrun', ['devicectl', '--version'], { encoding: 'utf8' }); return res.stdout.split('.').map((v) => parseInt(v, 10)); }, + /** + * Returns the Xcode version number as an array. + * + * @returns {number[]} The major and minor components of the version number. + */ xcode_version: function () { const res = spawnSync('xcodebuild', ['-version'], { encoding: 'utf8' }); const versionMatch = /Xcode (.*)/.exec(res.stdout); @@ -52,6 +83,15 @@ module.exports = { return versionString.split('.').map((v) => parseInt(v, 10)); }, + /** + * Retrieves help information about devicectl or a specific devicectl + * command. + * + * @param {string} [subcommand] - The subcommand (if any) for which to + * retrieve help information. + * @returns {devicectlResult} The devicectl result. The help output is in the + * `stdout` property as a string. + */ help: function (subcommand) { if (subcommand) { return spawnSync('xcrun', ['devicectl', 'help', subcommand], { encoding: 'utf8' }); @@ -60,8 +100,27 @@ module.exports = { } }, - list: function () { - const result = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--quiet', '--json-output', '/dev/stdout'], { encoding: 'utf8' }); + /** + * Known valid options for the {@link list} command. + * + * @readonly + * @enum {string} + */ + ListTypes: Object.freeze({ + Devices: 'devices', + PreferredDDI: 'preferredDDI' + }), + + /** + * Lists known devices or developer disk images. + * + * @param {string} type - The type of objects to list. If possible, use the + * {@link ListTypes} enum to provide a known value. + * @returns {devicectlJSONResult} The returned list in structured JSON + * format. + */ + list: function (type = module.exports.ListTypes.Devices) { + const result = spawnSync('xcrun', ['devicectl', 'list', type, '--quiet', '--json-output', '/dev/stdout'], { encoding: 'utf8' }); if (result.status === 0) { try { @@ -72,5 +131,108 @@ module.exports = { } return result; + }, + + /** + * Known valid options for the {@link info} command. + * + * @readonly + * @enum {string} + */ + InfoTypes: Object.freeze({ + AppIcon: 'appIcon', + Apps: 'apps', + AuthListing: 'authListing', + DDIServices: 'ddiServices', + Details: 'details', + Displays: 'displays', + Files: 'files', + LockState: 'lockState', + Processes: 'processes' + }), + + /** + * Retrieves information about a specific device. + * + * @param {string} infoType - The type of information to retrieve. If + * possible, use the {@link InfoTypes} enum to provide a known value. + * @param {string} deviceId - The identifier of the device from which to + * retrieve information. + * @returns {devicectlJSONResult} The returned information in structured JSON + * format. + */ + info: function (infoType, deviceId) { + const result = spawnSync('xcrun', ['devicectl', 'device', 'info', infoType, '--device', deviceId, '--quiet', '--json-output', '/dev/stdout'], { encoding: 'utf8' }); + + if (result.status === 0) { + try { + result.json = JSON.parse(result.stdout); + } catch (err) { + console.error(err.stack); + } + } + + return result; + }, + + /** + * Installs the specified app bundle on the specified device. + * + * @param {string} deviceId - The identifier of the device on which to + * install the app. + * @param {string} appPath - The path to the app bundle to be installed. + * @param {object} [options] - Additional options to devicectl. + * @param {string} [options.stdio] - Override for the stdio handling for the + * install command. Valid values are `inherit`, `ignore`, or `pipe`. + * @returns {devicectlResult} The result of the app installation command. + */ + install: function (deviceId, appPath, options = {}) { + const spawnOpts = { encoding: 'utf8' }; + + if (options.stdio) { + spawnOpts.stdio = options.stdio; + } + + return spawnSync('xcrun', ['devicectl', 'device', 'install', 'app', '--device', deviceId, appPath], spawnOpts); + }, + + /** + * Launches the app with the specified bundle ID on the specified device. + * + * @param {string} deviceId - The identifier of the device on which to + * launch the app. + * @param {string} bundleId - The bundle identifier for the application to be + * launched. + * @param {string[]} [argv] - Optional array of arguments to be passed to the + * launched application. + * @param {object} [options] - Additional options to devicectl. + * @param {string} [options.stdio] - Override for the stdio handling for the + * install command. Valid values are `inherit`, `ignore`, or `pipe`. + * @param {boolean} [options.console] - Whether to attach the console to the + * launched application. + * @param {boolean} [options.startStopped] - Whether the app should launch in + * a stopped state, allowing a debugger to attach. + * @returns {devicectlResult} The result of the app launch command. + */ + launch: function (deviceId, bundleId, argv = [], options = {}) { + const args = ['devicectl', 'device', 'process', 'launch', '--device', deviceId]; + const spawnOpts = { encoding: 'utf8' }; + + if (options.stdio) { + spawnOpts.stdio = options.stdio; + } + + if (options.waitForDebugger || options.startStopped) { + args.push('--start-stopped'); + } + + if (options.console) { + args.push('--console'); + } + + args.push(bundleId); + args.push(...argv); + + return spawnSync('xcrun', args, spawnOpts); } }; diff --git a/test/devicectl.spec.js b/test/devicectl.spec.js index aaeb368..b458fb3 100644 --- a/test/devicectl.spec.js +++ b/test/devicectl.spec.js @@ -30,6 +30,10 @@ const spawnMock = test.mock.method(childProcess, 'spawnSync'); const devicectl = require('../lib/devicectl'); +test.beforeEach(() => { + spawnMock.mock.resetCalls(); +}); + test('exports', (t) => { t.assert ||= require('node:assert'); @@ -37,7 +41,12 @@ test('exports', (t) => { t.assert.equal(typeof devicectl.devicectl_version, 'function'); t.assert.equal(typeof devicectl.xcode_version, 'function'); t.assert.equal(typeof devicectl.help, 'function'); + t.assert.equal(typeof devicectl.ListTypes, 'object'); t.assert.equal(typeof devicectl.list, 'function'); + t.assert.equal(typeof devicectl.InfoTypes, 'object'); + t.assert.equal(typeof devicectl.info, 'function'); + t.assert.equal(typeof devicectl.install, 'function'); + t.assert.equal(typeof devicectl.launch, 'function'); }); test('check_prerequisites fail', (t) => { @@ -47,9 +56,8 @@ test('check_prerequisites fail', (t) => { return { status: 1 }; }); - const retObj = devicectl.check_prerequisites(); - t.assert.ok(retObj.stdout); - t.assert.match(retObj.stdout, /devicectl was not found./); + const result = devicectl.check_prerequisites(); + t.assert.equal(result, false); }); test('check_prerequisites success', (t) => { @@ -59,8 +67,8 @@ test('check_prerequisites success', (t) => { return { status: 0 }; }); - const retObj = devicectl.check_prerequisites(); - t.assert.equal(retObj.stdout, undefined); + const result = devicectl.check_prerequisites(); + t.assert.equal(result, true); }); test('xcode version', (t) => { @@ -86,10 +94,6 @@ test('devicectl version', (t) => { }); test('devicectl help', async (ctx) => { - ctx.beforeEach((t) => { - spawnMock.mock.resetCalls(); - }); - await ctx.test('with no arguments', (t) => { t.assert ||= require('node:assert'); @@ -115,12 +119,21 @@ test('devicectl help', async (ctx) => { test('devicectl list', async (ctx) => { ctx.beforeEach((t) => { - spawnMock.mock.resetCalls(); - t.mock.method(console, 'error', () => {}); }); - await ctx.test('with a successful response', (t) => { + await ctx.test('with bad argument', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 64, stderr: "Error: Unexpected argument 'bad_command'" }; + }); + + const retObj = devicectl.list('bad_command'); + t.assert.match(retObj.stderr, /Unexpected argument/); + }); + + await ctx.test('with no arguments', (t) => { t.assert ||= require('node:assert'); spawnMock.mock.mockImplementationOnce(() => { @@ -132,6 +145,18 @@ test('devicectl list', async (ctx) => { t.assert.deepEqual(retObj.json, { result: { devices: [] } }); }); + await ctx.test('with preferredDDI argument', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '{"result":{"platforms":[]}}' }; + }); + + const retObj = devicectl.list('preferredDDI'); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'list', 'preferredDDI', '--quiet', '--json-output', '/dev/stdout']); + t.assert.deepEqual(retObj.json, { result: { platforms: [] } }); + }); + await ctx.test('with parsing error', (t) => { t.assert ||= require('node:assert'); @@ -144,3 +169,128 @@ test('devicectl list', async (ctx) => { t.assert.equal(retObj.json, undefined); }); }); + +test('devicectl info', async (ctx) => { + ctx.beforeEach((t) => { + t.mock.method(console, 'error', () => {}); + }); + + await ctx.test('with a valid subcommand', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '{"result":{}}' }; + }); + + const retObj = devicectl.info('apps', 'device_id'); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'device', 'info', 'apps', '--device', 'device_id', '--quiet', '--json-output', '/dev/stdout']); + t.assert.deepEqual(retObj.json, { result: {} }); + }); + + await ctx.test('with parsing error', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: 'This is not valid JSON' }; + }); + + const retObj = devicectl.info('details', 'device_id'); + t.assert.match(console.error.mock.calls[0].arguments[0], /SyntaxError: Unexpected token/); + t.assert.equal(retObj.json, undefined); + }); + + await ctx.test('with an invalid subcommand', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 64, stderr: "Error: Unexpected argument 'bad_command'" }; + }); + + const retObj = devicectl.info('bad_command'); + t.assert.match(retObj.stderr, /Unexpected argument/); + }); +}); + +test('devicectl install', async (ctx) => { + await ctx.test('with no options', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '' }; + }); + + devicectl.install('device_id', 'path/to/bundle.app'); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'device', 'install', 'app', '--device', 'device_id', 'path/to/bundle.app']); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[2], { encoding: 'utf8' }); + }); + + await ctx.test('with stdio option', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '' }; + }); + + devicectl.install('device_id', 'path/to/bundle.app', { stdio: 'inherit' }); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'device', 'install', 'app', '--device', 'device_id', 'path/to/bundle.app']); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[2], { encoding: 'utf8', stdio: 'inherit' }); + }); +}); + +test('devicectl launch', async (ctx) => { + await ctx.test('with no argv arguments', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '' }; + }); + + devicectl.launch('device_id', 'com.example.myapp'); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'device', 'process', 'launch', '--device', 'device_id', 'com.example.myapp']); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[2], { encoding: 'utf8' }); + }); + + await ctx.test('with argv arguments', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '' }; + }); + + devicectl.launch('device_id', 'com.example.myapp', ['https://example.com']); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'device', 'process', 'launch', '--device', 'device_id', 'com.example.myapp', 'https://example.com']); + }); + + await ctx.test('with startStopped option', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '' }; + }); + + devicectl.launch('device_id', 'com.example.myapp', [], { startStopped: true }); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'device', 'process', 'launch', '--device', 'device_id', '--start-stopped', 'com.example.myapp']); + }); + + await ctx.test('with console option', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '' }; + }); + + devicectl.launch('device_id', 'com.example.myapp', [], { console: true }); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[1], ['devicectl', 'device', 'process', 'launch', '--device', 'device_id', '--console', 'com.example.myapp']); + }); + + await ctx.test('with stdio option', (t) => { + t.assert ||= require('node:assert'); + + spawnMock.mock.mockImplementationOnce(() => { + return { status: 0, stdout: '' }; + }); + + devicectl.launch('device_id', 'com.example.myapp', [], { stdio: 'inherit' }); + t.assert.deepEqual(spawnMock.mock.calls[0].arguments[2], { encoding: 'utf8', stdio: 'inherit' }); + }); +});