Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
636512d
Initial plan
Copilot Jun 23, 2026
e0dbd94
Add restart device and check updates commands to Device View
Copilot Jun 23, 2026
18bac68
Add tests for restart and update commands
Copilot Jun 23, 2026
9df19fb
Fix linting errors (remove trailing spaces)
Copilot Jun 23, 2026
52d479e
Address code review feedback: add type safety and documentation
Copilot Jun 23, 2026
e99a7a0
Final code review fixes: improve type safety and error handling
Copilot Jun 23, 2026
7b2aa2b
Refine version parsing and error handling documentation
Copilot Jun 23, 2026
b3a0fb3
Add restart and software update commands to DevicesViewProvider
Copilot Jun 23, 2026
fa37744
Add tests for restartDevice and checkForUpdates in DevicesViewProvider
Copilot Jun 23, 2026
42fb61b
Revert unwanted changes to RTA/RDB views
Copilot Jun 23, 2026
350934f
Remove test file for reverted RokuDeviceViewViewProvider changes
Copilot Jun 23, 2026
f886c83
Revert webview changes for RokuDeviceView
Copilot Jun 23, 2026
26ed257
Remove unused ViewProviderCommand entries
Copilot Jun 23, 2026
d4bc83f
Add restart and update as tree items under devices
Copilot Jun 23, 2026
1ea5838
Refactor device restart and update commands per review feedback
Copilot Jun 23, 2026
45ac143
Add device validation in command handlers
Copilot Jun 23, 2026
e0ae1de
Resolve and validate device password for restart/update commands
chrisdp Jun 24, 2026
5b60889
Merge branch 'master' into copilot/add-restart-device-software-update…
chrisdp Jun 24, 2026
2ee1c61
Show progress and a timed notification for restart/update commands
chrisdp Jun 25, 2026
41999f0
Consolidate device password resolution into UserInputManager
chrisdp Jun 25, 2026
2433e3d
Refine restart/update confirmation dialogs
chrisdp Jun 25, 2026
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
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3896,6 +3896,18 @@
"category": "BrightScript",
"icon": "$(refresh)"
},
{
"command": "extension.brightscript.devicesView.restartDevice",
"title": "Restart Device",
"category": "BrightScript",
"icon": "$(debug-restart)"
},
{
"command": "extension.brightscript.devicesView.checkAndInstallUpdates",
"title": "Check for Software Updates",
"category": "BrightScript",
"icon": "$(cloud-download)"
},
{
"command": "extension.brightscript.addDeviceToUserSettings",
"title": "Add to User Settings",
Expand Down
174 changes: 174 additions & 0 deletions src/BrightScriptCommands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Module.prototype.require = function hijacked(file) {

import { BrightScriptCommands } from './BrightScriptCommands';
import { util } from './util';
import { rokuDeploy } from 'roku-deploy';

describe('BrightScriptFileUtils ', () => {
let commands: BrightScriptCommands;
Expand Down Expand Up @@ -269,6 +270,179 @@ describe('BrightScriptFileUtils ', () => {
});
});

describe('restartDevice / checkForUpdates', () => {
let sandbox: sinon.SinonSandbox;
let localCommands: BrightScriptCommands;
let deviceManager: any;
let userInputManager: any;
let rebootStub: sinon.SinonStub;
let checkForUpdateStub: sinon.SinonStub;
let showWarningStub: sinon.SinonStub;
let showInfoStub: sinon.SinonStub;
let showErrorStub: sinon.SinonStub;

const device = { ip: '1.2.3.4', serialNumber: 'SN123', deviceInfo: {} };

beforeEach(() => {
sandbox = sinon.createSandbox();
deviceManager = {
validateAndAddDevice: sandbox.stub().resolves(device),
getDevice: sandbox.stub().returns(device),
getDeviceDisplayName: sandbox.stub().returns('Roku Express – 1.2.3.4')
};
//password resolution is delegated to UserInputManager (tested in its own spec)
userInputManager = {
promptForHost: sandbox.stub().resolves('1.2.3.4'),
resolveDevicePassword: sandbox.stub().resolves({ status: 'ok', password: 'pw' })
};
//ctor: remoteControlManager, whatsNewManager, context, deviceManager, userInputManager, localPackageManager, credentialStore
localCommands = new BrightScriptCommands({} as any, {} as any, vscode.context, deviceManager, userInputManager, {} as any, {} as any);

rebootStub = sandbox.stub(rokuDeploy, 'rebootDevice').resolves({} as any);
checkForUpdateStub = sandbox.stub(rokuDeploy, 'checkForUpdate').resolves({} as any);
showWarningStub = sandbox.stub(vscode.window, 'showWarningMessage') as sinon.SinonStub;
showWarningStub.resolves('Restart');
showInfoStub = sandbox.stub(vscode.window, 'showInformationMessage') as sinon.SinonStub;
showInfoStub.resolves('Check for Updates');
showErrorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves();
vscode.context.workspaceState['_data'] = {};
});

afterEach(() => {
sandbox.restore();
});

it('passes the resolved password to rokuDeploy.rebootDevice', async () => {
userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' });

await localCommands.restartDevice('1.2.3.4');

assert.isTrue(rebootStub.calledOnce);
assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4');
assert.equal(rebootStub.firstCall.args[0].password, 'pw');
assert.isFalse(showErrorStub.called);
});

it('resolves the password against the probed device, offering remotePassword as an extra candidate', async () => {
vscode.context.workspaceState['_data'].remotePassword = 'global-pw';

await localCommands.restartDevice('1.2.3.4');

assert.isTrue(deviceManager.validateAndAddDevice.calledWith('1.2.3.4'));
assert.isTrue(userInputManager.resolveDevicePassword.calledOnce);
const args = userInputManager.resolveDevicePassword.firstCall.args[0];
assert.equal(args.host, '1.2.3.4');
assert.equal(args.serialNumber, 'SN123');
assert.deepEqual(args.extraCandidates, ['global-pw']);
});

it('aborts without resolving a password or rebooting when the confirmation is dismissed', async () => {
showWarningStub.resolves(undefined);

await localCommands.restartDevice('1.2.3.4');

assert.isFalse(userInputManager.resolveDevicePassword.called, 'password should not be resolved when cancelled');
assert.isFalse(rebootStub.called);
});

it('cancels when password resolution is cancelled', async () => {
userInputManager.resolveDevicePassword.resolves({ status: 'cancelled' });

await localCommands.restartDevice('1.2.3.4');

assert.isFalse(rebootStub.called);
});

it('shows an error and does not reboot when the device is unreachable', async () => {
userInputManager.resolveDevicePassword.resolves({ status: 'unreachable' });

await localCommands.restartDevice('1.2.3.4');

assert.isFalse(rebootStub.called);
assert.isTrue(showErrorStub.calledOnce);
});

it('always prompts for the device with the picker when no host is provided', async () => {
await localCommands.restartDevice();

assert.isTrue(userInputManager.promptForHost.calledOnce);
assert.isTrue(rebootStub.calledOnce);
assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4');
});

it('cancels when the device picker is dismissed', async () => {
userInputManager.promptForHost.rejects(new Error('No host was selected'));

await localCommands.restartDevice();

assert.isFalse(rebootStub.called);
assert.isFalse(deviceManager.validateAndAddDevice.called);
assert.isFalse(userInputManager.resolveDevicePassword.called);
});

it('checkForUpdates passes the resolved password to rokuDeploy.checkForUpdate', async () => {
userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' });

await localCommands.checkForUpdates('1.2.3.4');

assert.isTrue(checkForUpdateStub.calledOnce);
assert.equal(checkForUpdateStub.firstCall.args[0].host, '1.2.3.4');
assert.equal(checkForUpdateStub.firstCall.args[0].password, 'pw');
});

it('checkForUpdates aborts when the confirmation is dismissed', async () => {
showInfoStub.resolves(undefined);

await localCommands.checkForUpdates('1.2.3.4');

assert.isFalse(checkForUpdateStub.called);
});

it('surfaces a rokuDeploy failure as an error message', async () => {
rebootStub.rejects(new Error('boom'));

await localCommands.restartDevice('1.2.3.4');

assert.isTrue(showErrorStub.calledOnce);
assert.include(showErrorStub.firstCall.args[0], 'boom');
});

describe('command registration', () => {
let capturedCommands: Record<string, (...args: any[]) => any>;
let restartStub: sinon.SinonStub;
let updatesStub: sinon.SinonStub;

beforeEach(() => {
capturedCommands = {};
sandbox.stub(vscode.commands as any, 'registerCommand').callsFake((name: any, cb: any) => {
capturedCommands[name] = cb;
});
restartStub = sandbox.stub(localCommands, 'restartDevice').resolves();
updatesStub = sandbox.stub(localCommands, 'checkForUpdates').resolves();
localCommands.registerDevicesViewCommands({ toggleFilter: () => { }, resetFilters: () => { } } as any);
});

it('maps the tree element key to the device ip', async () => {
deviceManager.getDevice.withArgs('SN123').returns({ ip: '1.2.3.4' });

await capturedCommands['extension.brightscript.devicesView.restartDevice']({ key: 'SN123' });
await capturedCommands['extension.brightscript.devicesView.checkAndInstallUpdates']({ key: 'SN123' });

assert.isTrue(restartStub.calledOnce);
assert.equal(restartStub.firstCall.args[0], '1.2.3.4');
assert.isTrue(updatesStub.calledOnce);
assert.equal(updatesStub.firstCall.args[0], '1.2.3.4');
});

it('passes undefined (picker fallback) when invoked with no element', async () => {
await capturedCommands['extension.brightscript.devicesView.restartDevice']();

assert.isTrue(restartStub.calledOnce);
assert.equal(restartStub.firstCall.args[0], undefined);
});
});
});

describe('onToggleXml ', () => {
it('does nothing when no active document', () => {
vscode.window.activeTextEditor = undefined;
Expand Down
135 changes: 135 additions & 0 deletions src/BrightScriptCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { vscodeContextManager } from './managers/VscodeContextManager';
import type { CredentialStore } from './managers/CredentialStore';
import type { DevicesViewProvider } from './viewProviders/DevicesViewProvider';
import { DEVICE_FILTER_KEYS } from './deviceFilters';
import { rokuDeploy } from 'roku-deploy';

export class BrightScriptCommands {

Expand Down Expand Up @@ -766,6 +767,131 @@ export class BrightScriptCommands {
});
}

/**
* Restart a Roku device. When `host` is omitted (e.g. invoked from the command palette)
* a device picker is shown. Resolves and validates the developer password the same way a
* debug launch does before issuing the reboot.
*/
public async restartDevice(host?: string): Promise<void> {
const target = await this.resolveDeviceHost(host);
if (!target) {
return;
}

const confirm = await vscode.window.showWarningMessage(
`Restart Device?`,
{
detail: `Any running apps or processes will be terminated.\n\n${target.label}`,
modal: true
},
'Restart'
);
if (confirm !== 'Restart') {
return;
}

const password = await this.resolveValidatedPassword(target.host, target.serialNumber);
if (password === undefined) {
return;
}

try {
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: `Requesting restarting ${target.label}`
}, () => rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 }));
} catch (e) {
void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`);
}
}

/**
* Ask a Roku device to check for and install any available software updates. Host and
* password are resolved the same way as `restartDevice`.
*/
public async checkForUpdates(host?: string): Promise<void> {
const target = await this.resolveDeviceHost(host);
if (!target) {
return;
}

const confirm = await vscode.window.showInformationMessage(
`Check for Updates?`,
{
detail: `Device will check for app and Roku OS updates.\n\nAny running apps or processes will be terminated.\n\n${target.label}`,
modal: true
},
`Check for Updates`
);
if (confirm !== 'Check for Updates') {
return;
}

const password = await this.resolveValidatedPassword(target.host, target.serialNumber);
if (password === undefined) {
return;
}

try {
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: `Checking for updates: ${target.label}`
}, () => rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 }));
} catch (e) {
void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`);
}
}

/**
* Resolve the device a device-targeted command should act on.
*
* When `host` is provided (e.g. from a Devices view tree item) it is used directly.
* Otherwise the device picker is always shown; these are device-specific actions, so we
* never silently fall back to the active device. The resolved host is probed so the caller
* has a fresh serial number for password lookup and a friendly display label for prompts.
*
* Returns undefined when the user cancels device selection.
*/
private async resolveDeviceHost(host?: string): Promise<{ host: string; serialNumber: string | undefined; label: string } | undefined> {
Comment thread
chrisdp marked this conversation as resolved.
if (!host) {
try {
host = await this.userInputManager.promptForHost();
} catch {
// promptForHost rejects when the user dismisses the picker; treat as a cancel.
return undefined;
}
}
if (!host) {
return undefined;
}

const device = await this.deviceManager.validateAndAddDevice(host);
const label = device ? this.deviceManager.getDeviceDisplayName(device, true) : host;
return { host: host, serialNumber: device?.serialNumber, label: label };
}

/**
* Resolve a developer password the device accepts, delegating the candidate/validate/prompt/
* persist flow to the shared resolver. The global `remotePassword` fallback is offered as an
* extra candidate. Shows an error and returns undefined when the device is unreachable, and
* returns undefined when the user cancels the prompt.
*/
private async resolveValidatedPassword(host: string, serialNumber: string | undefined): Promise<string | undefined> {
const resolution = await this.userInputManager.resolveDevicePassword({
host: host,
serialNumber: serialNumber,
extraCandidates: [await this.context.workspaceState.get('remotePassword')]
});
if (resolution.status === 'unreachable') {
void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`);
return undefined;
}
if (resolution.status === 'cancelled') {
return undefined;
}
return resolution.password;
}

public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) {
for (const notifier of this.keypressNotifiers) {
notifier(key, literalCharacter);
Expand Down Expand Up @@ -1035,6 +1161,15 @@ export class BrightScriptCommands {
this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler);
}
this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters());
// These also appear in the command palette, where there's no tree element; resolving
// a missing/unknown key to undefined lets `restartDevice`/`checkForUpdates` fall back
// to the active device or device picker.
this.registerCommand('devicesView.restartDevice', (element?: { key?: string }) => {
return this.restartDevice(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined);
});
this.registerCommand('devicesView.checkAndInstallUpdates', (element?: { key?: string }) => {
return this.checkForUpdates(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined);
});
}

private async sendAsciiToDevice(character: string) {
Expand Down
Loading
Loading