From b4e09a73a98a17357fab3d061ad2a64b3f08158f Mon Sep 17 00:00:00 2001 From: Darryl Pogue Date: Thu, 26 Feb 2026 15:01:47 -0800 Subject: [PATCH] feat(devicectl): Add install and launch commands --- LICENSE | 3 +- lib/devicectl.js | 55 ++++++++++++++++++++++++++- test/devicectl.spec.js | 84 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 131 insertions(+), 11 deletions(-) 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..ac7ae73 100644 --- a/lib/devicectl.js +++ b/lib/devicectl.js @@ -60,8 +60,8 @@ module.exports = { } }, - list: function () { - const result = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--quiet', '--json-output', '/dev/stdout'], { encoding: 'utf8' }); + list: function (type = 'devices') { + const result = spawnSync('xcrun', ['devicectl', 'list', type, '--quiet', '--json-output', '/dev/stdout'], { encoding: 'utf8' }); if (result.status === 0) { try { @@ -72,5 +72,56 @@ module.exports = { } return result; + }, + + InfoTypes: Object.freeze({ + AppIcon: 'appIcon', + Apps: 'apps', + AuthListing: 'authListing', + DDIServices: 'ddiServices', + Details: 'details', + Displays: 'displays', + Files: 'files', + LockState: 'lockState', + Processes: 'processes' + }), + + info: function (infoType, deviceId) { + if (!Object.values(module.exports.InfoTypes).contains(infoType)) { + throw new TypeError(`Unexpected device info command '${infoType}'`); + } + + 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; + }, + + install: function (deviceId, appPath) { + return spawnSync('xcrun', ['devicectl', 'device', 'install', 'app', '--device', deviceId, appPath], { encoding: 'utf8' }); + }, + + launch: function (deviceId, bundleId, argv = [], options = {}) { + const args = ['devicectl', 'device', 'process', 'launch', '--device', deviceId]; + + 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, { encoding: 'utf8' }); } }; diff --git a/test/devicectl.spec.js b/test/devicectl.spec.js index aaeb368..384a27b 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'); @@ -38,6 +42,9 @@ test('exports', (t) => { t.assert.equal(typeof devicectl.xcode_version, 'function'); t.assert.equal(typeof devicectl.help, 'function'); t.assert.equal(typeof devicectl.list, 'function'); + 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) => { @@ -86,10 +93,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 +118,10 @@ 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 no arguments', (t) => { t.assert ||= require('node:assert'); spawnMock.mock.mockImplementationOnce(() => { @@ -132,6 +133,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 +157,60 @@ test('devicectl list', async (ctx) => { t.assert.equal(retObj.json, undefined); }); }); + +test('devicectl install', (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']); +}); + +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']); + }); + + 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']); + }); +});