Skip to content

Commit d7c8e3d

Browse files
Copilotchrisdp
andauthored
Add restart device and software update commands to Devices panel (#829)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Christopher Dwyer-Perkins <chris@inverted-solutions.com>
1 parent d06fe08 commit d7c8e3d

10 files changed

Lines changed: 667 additions & 230 deletions

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3896,6 +3896,18 @@
38963896
"category": "BrightScript",
38973897
"icon": "$(refresh)"
38983898
},
3899+
{
3900+
"command": "extension.brightscript.devicesView.restartDevice",
3901+
"title": "Restart Device",
3902+
"category": "BrightScript",
3903+
"icon": "$(debug-restart)"
3904+
},
3905+
{
3906+
"command": "extension.brightscript.devicesView.checkAndInstallUpdates",
3907+
"title": "Check for Software Updates",
3908+
"category": "BrightScript",
3909+
"icon": "$(cloud-download)"
3910+
},
38993911
{
39003912
"command": "extension.brightscript.addDeviceToUserSettings",
39013913
"title": "Add to User Settings",

src/BrightScriptCommands.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Module.prototype.require = function hijacked(file) {
1717

1818
import { BrightScriptCommands } from './BrightScriptCommands';
1919
import { util } from './util';
20+
import { rokuDeploy } from 'roku-deploy';
2021

2122
describe('BrightScriptFileUtils ', () => {
2223
let commands: BrightScriptCommands;
@@ -269,6 +270,179 @@ describe('BrightScriptFileUtils ', () => {
269270
});
270271
});
271272

273+
describe('restartDevice / checkForUpdates', () => {
274+
let sandbox: sinon.SinonSandbox;
275+
let localCommands: BrightScriptCommands;
276+
let deviceManager: any;
277+
let userInputManager: any;
278+
let rebootStub: sinon.SinonStub;
279+
let checkForUpdateStub: sinon.SinonStub;
280+
let showWarningStub: sinon.SinonStub;
281+
let showInfoStub: sinon.SinonStub;
282+
let showErrorStub: sinon.SinonStub;
283+
284+
const device = { ip: '1.2.3.4', serialNumber: 'SN123', deviceInfo: {} };
285+
286+
beforeEach(() => {
287+
sandbox = sinon.createSandbox();
288+
deviceManager = {
289+
validateAndAddDevice: sandbox.stub().resolves(device),
290+
getDevice: sandbox.stub().returns(device),
291+
getDeviceDisplayName: sandbox.stub().returns('Roku Express – 1.2.3.4')
292+
};
293+
//password resolution is delegated to UserInputManager (tested in its own spec)
294+
userInputManager = {
295+
promptForHost: sandbox.stub().resolves('1.2.3.4'),
296+
resolveDevicePassword: sandbox.stub().resolves({ status: 'ok', password: 'pw' })
297+
};
298+
//ctor: remoteControlManager, whatsNewManager, context, deviceManager, userInputManager, localPackageManager, credentialStore
299+
localCommands = new BrightScriptCommands({} as any, {} as any, vscode.context, deviceManager, userInputManager, {} as any, {} as any);
300+
301+
rebootStub = sandbox.stub(rokuDeploy, 'rebootDevice').resolves({} as any);
302+
checkForUpdateStub = sandbox.stub(rokuDeploy, 'checkForUpdate').resolves({} as any);
303+
showWarningStub = sandbox.stub(vscode.window, 'showWarningMessage') as sinon.SinonStub;
304+
showWarningStub.resolves('Restart');
305+
showInfoStub = sandbox.stub(vscode.window, 'showInformationMessage') as sinon.SinonStub;
306+
showInfoStub.resolves('Check for Updates');
307+
showErrorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves();
308+
vscode.context.workspaceState['_data'] = {};
309+
});
310+
311+
afterEach(() => {
312+
sandbox.restore();
313+
});
314+
315+
it('passes the resolved password to rokuDeploy.rebootDevice', async () => {
316+
userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' });
317+
318+
await localCommands.restartDevice('1.2.3.4');
319+
320+
assert.isTrue(rebootStub.calledOnce);
321+
assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4');
322+
assert.equal(rebootStub.firstCall.args[0].password, 'pw');
323+
assert.isFalse(showErrorStub.called);
324+
});
325+
326+
it('resolves the password against the probed device, offering remotePassword as an extra candidate', async () => {
327+
vscode.context.workspaceState['_data'].remotePassword = 'global-pw';
328+
329+
await localCommands.restartDevice('1.2.3.4');
330+
331+
assert.isTrue(deviceManager.validateAndAddDevice.calledWith('1.2.3.4'));
332+
assert.isTrue(userInputManager.resolveDevicePassword.calledOnce);
333+
const args = userInputManager.resolveDevicePassword.firstCall.args[0];
334+
assert.equal(args.host, '1.2.3.4');
335+
assert.equal(args.serialNumber, 'SN123');
336+
assert.deepEqual(args.extraCandidates, ['global-pw']);
337+
});
338+
339+
it('aborts without resolving a password or rebooting when the confirmation is dismissed', async () => {
340+
showWarningStub.resolves(undefined);
341+
342+
await localCommands.restartDevice('1.2.3.4');
343+
344+
assert.isFalse(userInputManager.resolveDevicePassword.called, 'password should not be resolved when cancelled');
345+
assert.isFalse(rebootStub.called);
346+
});
347+
348+
it('cancels when password resolution is cancelled', async () => {
349+
userInputManager.resolveDevicePassword.resolves({ status: 'cancelled' });
350+
351+
await localCommands.restartDevice('1.2.3.4');
352+
353+
assert.isFalse(rebootStub.called);
354+
});
355+
356+
it('shows an error and does not reboot when the device is unreachable', async () => {
357+
userInputManager.resolveDevicePassword.resolves({ status: 'unreachable' });
358+
359+
await localCommands.restartDevice('1.2.3.4');
360+
361+
assert.isFalse(rebootStub.called);
362+
assert.isTrue(showErrorStub.calledOnce);
363+
});
364+
365+
it('always prompts for the device with the picker when no host is provided', async () => {
366+
await localCommands.restartDevice();
367+
368+
assert.isTrue(userInputManager.promptForHost.calledOnce);
369+
assert.isTrue(rebootStub.calledOnce);
370+
assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4');
371+
});
372+
373+
it('cancels when the device picker is dismissed', async () => {
374+
userInputManager.promptForHost.rejects(new Error('No host was selected'));
375+
376+
await localCommands.restartDevice();
377+
378+
assert.isFalse(rebootStub.called);
379+
assert.isFalse(deviceManager.validateAndAddDevice.called);
380+
assert.isFalse(userInputManager.resolveDevicePassword.called);
381+
});
382+
383+
it('checkForUpdates passes the resolved password to rokuDeploy.checkForUpdate', async () => {
384+
userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' });
385+
386+
await localCommands.checkForUpdates('1.2.3.4');
387+
388+
assert.isTrue(checkForUpdateStub.calledOnce);
389+
assert.equal(checkForUpdateStub.firstCall.args[0].host, '1.2.3.4');
390+
assert.equal(checkForUpdateStub.firstCall.args[0].password, 'pw');
391+
});
392+
393+
it('checkForUpdates aborts when the confirmation is dismissed', async () => {
394+
showInfoStub.resolves(undefined);
395+
396+
await localCommands.checkForUpdates('1.2.3.4');
397+
398+
assert.isFalse(checkForUpdateStub.called);
399+
});
400+
401+
it('surfaces a rokuDeploy failure as an error message', async () => {
402+
rebootStub.rejects(new Error('boom'));
403+
404+
await localCommands.restartDevice('1.2.3.4');
405+
406+
assert.isTrue(showErrorStub.calledOnce);
407+
assert.include(showErrorStub.firstCall.args[0], 'boom');
408+
});
409+
410+
describe('command registration', () => {
411+
let capturedCommands: Record<string, (...args: any[]) => any>;
412+
let restartStub: sinon.SinonStub;
413+
let updatesStub: sinon.SinonStub;
414+
415+
beforeEach(() => {
416+
capturedCommands = {};
417+
sandbox.stub(vscode.commands as any, 'registerCommand').callsFake((name: any, cb: any) => {
418+
capturedCommands[name] = cb;
419+
});
420+
restartStub = sandbox.stub(localCommands, 'restartDevice').resolves();
421+
updatesStub = sandbox.stub(localCommands, 'checkForUpdates').resolves();
422+
localCommands.registerDevicesViewCommands({ toggleFilter: () => { }, resetFilters: () => { } } as any);
423+
});
424+
425+
it('maps the tree element key to the device ip', async () => {
426+
deviceManager.getDevice.withArgs('SN123').returns({ ip: '1.2.3.4' });
427+
428+
await capturedCommands['extension.brightscript.devicesView.restartDevice']({ key: 'SN123' });
429+
await capturedCommands['extension.brightscript.devicesView.checkAndInstallUpdates']({ key: 'SN123' });
430+
431+
assert.isTrue(restartStub.calledOnce);
432+
assert.equal(restartStub.firstCall.args[0], '1.2.3.4');
433+
assert.isTrue(updatesStub.calledOnce);
434+
assert.equal(updatesStub.firstCall.args[0], '1.2.3.4');
435+
});
436+
437+
it('passes undefined (picker fallback) when invoked with no element', async () => {
438+
await capturedCommands['extension.brightscript.devicesView.restartDevice']();
439+
440+
assert.isTrue(restartStub.calledOnce);
441+
assert.equal(restartStub.firstCall.args[0], undefined);
442+
});
443+
});
444+
});
445+
272446
describe('onToggleXml ', () => {
273447
it('does nothing when no active document', () => {
274448
vscode.window.activeTextEditor = undefined;

src/BrightScriptCommands.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { vscodeContextManager } from './managers/VscodeContextManager';
2121
import type { CredentialStore } from './managers/CredentialStore';
2222
import type { DevicesViewProvider } from './viewProviders/DevicesViewProvider';
2323
import { DEVICE_FILTER_KEYS } from './deviceFilters';
24+
import { rokuDeploy } from 'roku-deploy';
2425

2526
export class BrightScriptCommands {
2627

@@ -766,6 +767,131 @@ export class BrightScriptCommands {
766767
});
767768
}
768769

770+
/**
771+
* Restart a Roku device. When `host` is omitted (e.g. invoked from the command palette)
772+
* a device picker is shown. Resolves and validates the developer password the same way a
773+
* debug launch does before issuing the reboot.
774+
*/
775+
public async restartDevice(host?: string): Promise<void> {
776+
const target = await this.resolveDeviceHost(host);
777+
if (!target) {
778+
return;
779+
}
780+
781+
const confirm = await vscode.window.showWarningMessage(
782+
`Restart Device?`,
783+
{
784+
detail: `Any running apps or processes will be terminated.\n\n${target.label}`,
785+
modal: true
786+
},
787+
'Restart'
788+
);
789+
if (confirm !== 'Restart') {
790+
return;
791+
}
792+
793+
const password = await this.resolveValidatedPassword(target.host, target.serialNumber);
794+
if (password === undefined) {
795+
return;
796+
}
797+
798+
try {
799+
await vscode.window.withProgress({
800+
location: vscode.ProgressLocation.Notification,
801+
title: `Requesting restarting ${target.label}`
802+
}, () => rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 }));
803+
} catch (e) {
804+
void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`);
805+
}
806+
}
807+
808+
/**
809+
* Ask a Roku device to check for and install any available software updates. Host and
810+
* password are resolved the same way as `restartDevice`.
811+
*/
812+
public async checkForUpdates(host?: string): Promise<void> {
813+
const target = await this.resolveDeviceHost(host);
814+
if (!target) {
815+
return;
816+
}
817+
818+
const confirm = await vscode.window.showInformationMessage(
819+
`Check for Updates?`,
820+
{
821+
detail: `Device will check for app and Roku OS updates.\n\nAny running apps or processes will be terminated.\n\n${target.label}`,
822+
modal: true
823+
},
824+
`Check for Updates`
825+
);
826+
if (confirm !== 'Check for Updates') {
827+
return;
828+
}
829+
830+
const password = await this.resolveValidatedPassword(target.host, target.serialNumber);
831+
if (password === undefined) {
832+
return;
833+
}
834+
835+
try {
836+
await vscode.window.withProgress({
837+
location: vscode.ProgressLocation.Notification,
838+
title: `Checking for updates: ${target.label}`
839+
}, () => rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 }));
840+
} catch (e) {
841+
void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`);
842+
}
843+
}
844+
845+
/**
846+
* Resolve the device a device-targeted command should act on.
847+
*
848+
* When `host` is provided (e.g. from a Devices view tree item) it is used directly.
849+
* Otherwise the device picker is always shown; these are device-specific actions, so we
850+
* never silently fall back to the active device. The resolved host is probed so the caller
851+
* has a fresh serial number for password lookup and a friendly display label for prompts.
852+
*
853+
* Returns undefined when the user cancels device selection.
854+
*/
855+
private async resolveDeviceHost(host?: string): Promise<{ host: string; serialNumber: string | undefined; label: string } | undefined> {
856+
if (!host) {
857+
try {
858+
host = await this.userInputManager.promptForHost();
859+
} catch {
860+
// promptForHost rejects when the user dismisses the picker; treat as a cancel.
861+
return undefined;
862+
}
863+
}
864+
if (!host) {
865+
return undefined;
866+
}
867+
868+
const device = await this.deviceManager.validateAndAddDevice(host);
869+
const label = device ? this.deviceManager.getDeviceDisplayName(device, true) : host;
870+
return { host: host, serialNumber: device?.serialNumber, label: label };
871+
}
872+
873+
/**
874+
* Resolve a developer password the device accepts, delegating the candidate/validate/prompt/
875+
* persist flow to the shared resolver. The global `remotePassword` fallback is offered as an
876+
* extra candidate. Shows an error and returns undefined when the device is unreachable, and
877+
* returns undefined when the user cancels the prompt.
878+
*/
879+
private async resolveValidatedPassword(host: string, serialNumber: string | undefined): Promise<string | undefined> {
880+
const resolution = await this.userInputManager.resolveDevicePassword({
881+
host: host,
882+
serialNumber: serialNumber,
883+
extraCandidates: [await this.context.workspaceState.get('remotePassword')]
884+
});
885+
if (resolution.status === 'unreachable') {
886+
void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`);
887+
return undefined;
888+
}
889+
if (resolution.status === 'cancelled') {
890+
return undefined;
891+
}
892+
return resolution.password;
893+
}
894+
769895
public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) {
770896
for (const notifier of this.keypressNotifiers) {
771897
notifier(key, literalCharacter);
@@ -1035,6 +1161,15 @@ export class BrightScriptCommands {
10351161
this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler);
10361162
}
10371163
this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters());
1164+
// These also appear in the command palette, where there's no tree element; resolving
1165+
// a missing/unknown key to undefined lets `restartDevice`/`checkForUpdates` fall back
1166+
// to the active device or device picker.
1167+
this.registerCommand('devicesView.restartDevice', (element?: { key?: string }) => {
1168+
return this.restartDevice(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined);
1169+
});
1170+
this.registerCommand('devicesView.checkAndInstallUpdates', (element?: { key?: string }) => {
1171+
return this.checkForUpdates(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined);
1172+
});
10381173
}
10391174

10401175
private async sendAsciiToDevice(character: string) {

0 commit comments

Comments
 (0)