From 636512d362c826ea7f7518c6c606ad140521b3fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:45:53 +0000 Subject: [PATCH 01/20] Initial plan From e0dbd94bab4de570e4a11aaa4558d2a7f93476af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:55:52 +0000 Subject: [PATCH 02/20] Add restart device and check updates commands to Device View --- src/commands/VscodeCommand.ts | 2 + src/managers/RtaManager.ts | 7 ++ src/viewProviders/BaseRdbViewProvider.ts | 26 +++++- .../RokuDeviceViewViewProvider.ts | 91 +++++++++++++++++- src/viewProviders/ViewProviderCommand.ts | 4 +- .../RokuDeviceView/RokuDeviceView.svelte | 93 +++++++++++++++++++ 6 files changed, 218 insertions(+), 5 deletions(-) diff --git a/src/commands/VscodeCommand.ts b/src/commands/VscodeCommand.ts index 2a2d6fe04..2f9932afe 100644 --- a/src/commands/VscodeCommand.ts +++ b/src/commands/VscodeCommand.ts @@ -5,6 +5,8 @@ export enum VscodeCommand { rokuDeviceViewCopyScreenshot = 'extension.brightscript.rokuDeviceView.copyScreenshot', rokuDeviceViewEnableNodeInspector = 'extension.brightscript.rokuDeviceView.enableNodeInspector', rokuDeviceViewDisableNodeInspector = 'extension.brightscript.rokuDeviceView.disableNodeInspector', + rokuDeviceViewRestartDevice = 'extension.brightscript.rokuDeviceView.restartDevice', + rokuDeviceViewCheckAndInstallUpdates = 'extension.brightscript.rokuDeviceView.checkAndInstallUpdates', rokuRegistryExportRegistry = 'extension.brightscript.rokuRegistry.exportRegistry', rokuRegistryImportRegistry = 'extension.brightscript.rokuRegistry.importRegistry', rokuRegistryClearRegistry = 'extension.brightscript.rokuRegistry.clearRegistry', diff --git a/src/managers/RtaManager.ts b/src/managers/RtaManager.ts index ec51e39e3..9feba22af 100644 --- a/src/managers/RtaManager.ts +++ b/src/managers/RtaManager.ts @@ -22,11 +22,18 @@ export class RtaManager { public onDeviceComponent?: rta.OnDeviceComponent; public device?: rta.RokuDevice; + public deviceConfig?: { host: string; password: string }; private webviewViewProviderManager?: WebviewViewProviderManager; private lastAppUIResponse: rta.AppUIResponse | undefined; public setupRtaWithConfig(config: { host: string; password: string; logLevel?: string; disableScreenSaver?: boolean; injectRdbOnDeviceComponent?: boolean }) { + // Store device config for later use + this.deviceConfig = { + host: config.host, + password: config.password + }; + const enableDebugging = ['info', 'debug', 'trace'].includes(config.logLevel); const rtaConfig: rta.ConfigOptions = { RokuDevice: { diff --git a/src/viewProviders/BaseRdbViewProvider.ts b/src/viewProviders/BaseRdbViewProvider.ts index 2bce56e82..b6d60fb5e 100644 --- a/src/viewProviders/BaseRdbViewProvider.ts +++ b/src/viewProviders/BaseRdbViewProvider.ts @@ -3,6 +3,7 @@ import type * as vscode from 'vscode'; import type { RequestType } from 'roku-test-automation'; import * as fsExtra from 'fs-extra'; import * as path from 'path'; +import { rokuDeploy } from 'roku-deploy'; import { BaseWebviewViewProvider } from './BaseWebviewViewProvider'; import { ViewProviderEvent } from './ViewProviderEvent'; @@ -21,10 +22,29 @@ export abstract class BaseRdbViewProvider extends BaseWebviewViewProvider { this.setupCommandObservers(); } - public updateDeviceAvailability() { + public async updateDeviceAvailability() { + const device = this.dependencies.rtaManager.device; + const deviceConfig = this.dependencies.rtaManager.deviceConfig; + let deviceInfo = null; + + // Try to get device info if device is available + if (device && deviceConfig) { + try { + const info = await rokuDeploy.getDeviceInfo({ + host: deviceConfig.host, + timeout: 5000 + }); + deviceInfo = info; + } catch (e) { + // Device might be temporarily unavailable, just continue without device info + console.error('Failed to get device info:', e); + } + } + const message = this.createEventMessage(ViewProviderEvent.onDeviceAvailabilityChange, { odcAvailable: !!this.dependencies.rtaManager.onDeviceComponent, - deviceAvailable: !!this.dependencies.rtaManager.device + deviceAvailable: !!device, + deviceInfo: deviceInfo }); this.postOrQueueMessage(message); @@ -73,6 +93,6 @@ export abstract class BaseRdbViewProvider extends BaseWebviewViewProvider { protected onViewReady() { // Always post back the device status so we make sure the client doesn't miss it if it got refreshed - this.updateDeviceAvailability(); + void this.updateDeviceAvailability(); } } diff --git a/src/viewProviders/RokuDeviceViewViewProvider.ts b/src/viewProviders/RokuDeviceViewViewProvider.ts index 1460fa8ef..8035546b6 100644 --- a/src/viewProviders/RokuDeviceViewViewProvider.ts +++ b/src/viewProviders/RokuDeviceViewViewProvider.ts @@ -1,5 +1,6 @@ -import type * as vscode from 'vscode'; +import * as vscode from 'vscode'; import type { ChannelPublishedEvent } from 'roku-debug'; +import { rokuDeploy } from 'roku-deploy'; import { VscodeCommand } from '../commands/VscodeCommand'; import { BaseRdbViewProvider } from './BaseRdbViewProvider'; import { ViewProviderId } from './ViewProviderId'; @@ -45,6 +46,94 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { } return true; }); + + this.addMessageCommandCallback(ViewProviderCommand.restartDevice, async (message) => { + try { + const confirm = await vscode.window.showWarningMessage( + 'Are you sure you want to restart this device? This will close all running channels.', + { modal: true }, + 'Restart' + ); + + if (confirm !== 'Restart') { + this.postOrQueueMessage(this.createResponseMessage(message, { + success: false, + cancelled: true + })); + return true; + } + + const device = this.dependencies.rtaManager.device; + const deviceConfig = this.dependencies.rtaManager.deviceConfig; + + if (!device || !deviceConfig) { + throw new Error('No device connected'); + } + + await rokuDeploy.rebootDevice({ + host: deviceConfig.host, + password: deviceConfig.password, + timeout: 10000 + }); + + this.postOrQueueMessage(this.createResponseMessage(message, { + success: true + })); + + void vscode.window.showInformationMessage('Device restart initiated successfully'); + } catch (e) { + this.postOrQueueMessage(this.createResponseMessage(message, { + success: false, + error: e.message + })); + void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); + } + return true; + }); + + this.addMessageCommandCallback(ViewProviderCommand.checkForUpdates, async (message) => { + try { + const confirm = await vscode.window.showInformationMessage( + 'Check for software updates on this device? The device will check for and install any available updates.', + { modal: true }, + 'Check for Updates' + ); + + if (confirm !== 'Check for Updates') { + this.postOrQueueMessage(this.createResponseMessage(message, { + success: false, + cancelled: true + })); + return true; + } + + const device = this.dependencies.rtaManager.device; + const deviceConfig = this.dependencies.rtaManager.deviceConfig; + + if (!device || !deviceConfig) { + throw new Error('No device connected'); + } + + await rokuDeploy.checkForUpdate({ + host: deviceConfig.host, + password: deviceConfig.password, + timeout: 10000 + }); + + this.postOrQueueMessage(this.createResponseMessage(message, { + success: true + })); + + void vscode.window.showInformationMessage('Software update check initiated successfully'); + } catch (e) { + this.postOrQueueMessage(this.createResponseMessage(message, { + success: false, + error: e.message + })); + void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); + } + return true; + }); } public onDidStartDebugSession(e: vscode.DebugSession) { diff --git a/src/viewProviders/ViewProviderCommand.ts b/src/viewProviders/ViewProviderCommand.ts index a8e9aacfa..8e8a9ee1d 100644 --- a/src/viewProviders/ViewProviderCommand.ts +++ b/src/viewProviders/ViewProviderCommand.ts @@ -19,5 +19,7 @@ export enum ViewProviderCommand { exportRokuAutomationConfigs = 'exportRokuAutomationConfigs', loadRokuAutomationConfigs = 'loadRokuAutomationConfigs', updateWorkspaceState = 'updateWorkspaceState', - viewReady = 'viewReady' + viewReady = 'viewReady', + restartDevice = 'restartDevice', + checkForUpdates = 'checkForUpdates' } diff --git a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte index 91398a6d3..f5acd3731 100644 --- a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte +++ b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte @@ -13,8 +13,77 @@ window.vscode = acquireVsCodeApi(); let deviceAvailable = false; + let deviceInfo: any = null; + + // Helper function to compare semantic versions + function semverGte(version: string, minVersion: string): boolean { + if (!version) return false; + + const parseVersion = (v: string) => { + const parts = v.split('.').map(p => parseInt(p, 10)); + return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }; + }; + + const v1 = parseVersion(version); + const v2 = parseVersion(minVersion); + + if (v1.major !== v2.major) return v1.major > v2.major; + if (v1.minor !== v2.minor) return v1.minor > v2.minor; + return v1.patch >= v2.patch; + } + + $: softwareVersion = deviceInfo?.['software-version'] || deviceInfo?.softwareVersion || ''; + $: supportsDeviceActions = semverGte(softwareVersion, '15.0.4'); + $: deviceActionTooltip = supportsDeviceActions + ? '' + : 'Requires software version 15.0.4 or later'; + + let isRestarting = false; + let isCheckingUpdates = false; + + async function onRestartDevice() { + if (!supportsDeviceActions || isRestarting) return; + + isRestarting = true; + try { + const response = await intermediary.sendCommand(ViewProviderCommand.restartDevice); + if (!response.cancelled) { + // Device will restart, so reset the flag after a delay + setTimeout(() => { + isRestarting = false; + }, 3000); + } else { + isRestarting = false; + } + } catch (e) { + console.error('Error restarting device:', e); + isRestarting = false; + } + } + + async function onCheckForUpdates() { + if (!supportsDeviceActions || isCheckingUpdates) return; + + isCheckingUpdates = true; + try { + const response = await intermediary.sendCommand(ViewProviderCommand.checkForUpdates); + if (!response.cancelled) { + // Update check initiated, reset flag after a delay + setTimeout(() => { + isCheckingUpdates = false; + }, 3000); + } else { + isCheckingUpdates = false; + } + } catch (e) { + console.error('Error checking for updates:', e); + isCheckingUpdates = false; + } + } + intermediary.observeEvent(ViewProviderEvent.onDeviceAvailabilityChange, (message) => { deviceAvailable = message.context.deviceAvailable; + deviceInfo = message.context.deviceInfo; requestScreenshot(); }); @@ -377,6 +446,30 @@
{#if deviceAvailable} +
+ + {#if isRestarting} + + Restarting... + {:else} + Restart Device + {/if} + + + {#if isCheckingUpdates} + + Checking... + {:else} + Check and Install Updates + {/if} + +
Date: Tue, 23 Jun 2026 11:57:22 +0000 Subject: [PATCH 03/20] Add tests for restart and update commands --- .../RokuDeviceViewViewProvider.spec.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/viewProviders/RokuDeviceViewViewProvider.spec.ts diff --git a/src/viewProviders/RokuDeviceViewViewProvider.spec.ts b/src/viewProviders/RokuDeviceViewViewProvider.spec.ts new file mode 100644 index 000000000..3be8814f7 --- /dev/null +++ b/src/viewProviders/RokuDeviceViewViewProvider.spec.ts @@ -0,0 +1,230 @@ +import { expect } from 'chai'; +import * as sinonImport from 'sinon'; +import { vscode } from '../mockVscode.spec'; +import { RokuDeviceViewViewProvider } from './RokuDeviceViewViewProvider'; +import { ViewProviderCommand } from './ViewProviderCommand'; +import { rokuDeploy } from 'roku-deploy'; + +let Module = require('module'); +const { require: oldRequire } = Module.prototype; +Module.prototype.require = function hijacked(file) { + if (file === 'vscode') { + return vscode; + } else { + return oldRequire.apply(this, arguments); + } +}; + +let sinon: sinonImport.SinonSandbox; +let view; +let callback; +let provider: RokuDeviceViewViewProvider; +let dependencies: any; + +beforeEach(() => { + sinon = sinonImport.createSandbox(); + view = { + webview: { + onDidReceiveMessage: (cb) => { + callback = cb; + }, + postMessage: (message) => { } + }, + show: () => { } + }; + + dependencies = { + rtaManager: { + device: { + getScreenshot: sinon.stub().resolves({ buffer: { buffer: new ArrayBuffer(0) } }) + }, + deviceConfig: { + host: '192.168.1.100', + password: 'test123' + }, + onDeviceComponent: {} + } + }; + + provider = new RokuDeviceViewViewProvider(vscode.context, dependencies) as any; +}); + +afterEach(() => { + provider.dispose(); + sinon.restore(); +}); + +describe('RokuDeviceViewViewProvider', () => { + describe('restartDevice command', () => { + it('shows confirmation dialog before restarting', async () => { + const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').resolves({} as any); + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.restartDevice, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(showWarningStub.calledOnce).to.be.true; + expect(showWarningStub.firstCall.args[0]).to.include('restart this device'); + expect(rebootStub.called).to.be.false; // Should not call reboot when cancelled + }); + + it('calls rokuDeploy.rebootDevice when confirmed', async () => { + const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves('Restart'); + const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').resolves({} as any); + const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage'); + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.restartDevice, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(rebootStub.calledOnce).to.be.true; + expect(rebootStub.firstCall.args[0]).to.deep.include({ + host: '192.168.1.100', + password: 'test123' + }); + expect(showInfoStub.calledOnce).to.be.true; + expect(showInfoStub.firstCall.args[0]).to.include('restart initiated'); + }); + + it('shows error message when restart fails', async () => { + const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves('Restart'); + const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').rejects(new Error('Connection failed')); + const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.restartDevice, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(showErrorStub.calledOnce).to.be.true; + expect(showErrorStub.firstCall.args[0]).to.include('Failed to restart device'); + expect(showErrorStub.firstCall.args[0]).to.include('Connection failed'); + }); + + it('handles missing device configuration', async () => { + const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves('Restart'); + const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + + // Remove device config + dependencies.rtaManager.deviceConfig = null; + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.restartDevice, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(showErrorStub.calledOnce).to.be.true; + expect(showErrorStub.firstCall.args[0]).to.include('No device connected'); + }); + }); + + describe('checkForUpdates command', () => { + it('shows confirmation dialog before checking updates', async () => { + const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined); + const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').resolves({} as any); + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.checkForUpdates, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(showInfoStub.calledOnce).to.be.true; // Only once for confirmation dialog + expect(showInfoStub.firstCall.args[0]).to.include('Check for software updates'); + expect(checkUpdateStub.called).to.be.false; // Should not call when cancelled + }); + + it('calls rokuDeploy.checkForUpdate when confirmed', async () => { + const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage'); + showInfoStub.onFirstCall().resolves('Check for Updates'); + showInfoStub.onSecondCall().resolves(undefined); + const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').resolves({} as any); + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.checkForUpdates, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(checkUpdateStub.calledOnce).to.be.true; + expect(checkUpdateStub.firstCall.args[0]).to.deep.include({ + host: '192.168.1.100', + password: 'test123' + }); + expect(showInfoStub.calledTwice).to.be.true; + expect(showInfoStub.secondCall.args[0]).to.include('update check initiated'); + }); + + it('shows error message when check fails', async () => { + const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage').resolves('Check for Updates'); + const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').rejects(new Error('Network error')); + const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.checkForUpdates, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(showErrorStub.calledOnce).to.be.true; + expect(showErrorStub.firstCall.args[0]).to.include('Failed to check for updates'); + expect(showErrorStub.firstCall.args[0]).to.include('Network error'); + }); + + it('handles missing device configuration', async () => { + const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage').resolves('Check for Updates'); + const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + + // Remove device + dependencies.rtaManager.device = null; + + await provider['resolveWebviewView'](view, {} as any, {} as any); + + const message = { + command: ViewProviderCommand.checkForUpdates, + context: {}, + messageId: '123' + }; + + await callback(message); + + expect(showErrorStub.calledOnce).to.be.true; + expect(showErrorStub.firstCall.args[0]).to.include('No device connected'); + }); + }); +}); From 9df19fbfb20a4ffd672333216fc0bd52e9b2ac72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:58:53 +0000 Subject: [PATCH 04/20] Fix linting errors (remove trailing spaces) --- src/viewProviders/RokuDeviceViewViewProvider.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/viewProviders/RokuDeviceViewViewProvider.ts b/src/viewProviders/RokuDeviceViewViewProvider.ts index 8035546b6..b960a192c 100644 --- a/src/viewProviders/RokuDeviceViewViewProvider.ts +++ b/src/viewProviders/RokuDeviceViewViewProvider.ts @@ -54,7 +54,7 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { { modal: true }, 'Restart' ); - + if (confirm !== 'Restart') { this.postOrQueueMessage(this.createResponseMessage(message, { success: false, @@ -65,7 +65,7 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { const device = this.dependencies.rtaManager.device; const deviceConfig = this.dependencies.rtaManager.deviceConfig; - + if (!device || !deviceConfig) { throw new Error('No device connected'); } @@ -79,7 +79,7 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { this.postOrQueueMessage(this.createResponseMessage(message, { success: true })); - + void vscode.window.showInformationMessage('Device restart initiated successfully'); } catch (e) { this.postOrQueueMessage(this.createResponseMessage(message, { @@ -98,7 +98,7 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { { modal: true }, 'Check for Updates' ); - + if (confirm !== 'Check for Updates') { this.postOrQueueMessage(this.createResponseMessage(message, { success: false, @@ -109,7 +109,7 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { const device = this.dependencies.rtaManager.device; const deviceConfig = this.dependencies.rtaManager.deviceConfig; - + if (!device || !deviceConfig) { throw new Error('No device connected'); } @@ -123,7 +123,7 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { this.postOrQueueMessage(this.createResponseMessage(message, { success: true })); - + void vscode.window.showInformationMessage('Software update check initiated successfully'); } catch (e) { this.postOrQueueMessage(this.createResponseMessage(message, { From 52d479e42143e0589809ba341444174ef1f7b9ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:03:44 +0000 Subject: [PATCH 05/20] Address code review feedback: add type safety and documentation --- src/viewProviders/BaseRdbViewProvider.ts | 6 +++ .../RokuDeviceViewViewProvider.spec.ts | 32 +++++++-------- .../RokuDeviceView/RokuDeviceView.svelte | 41 +++++++++++-------- 3 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/viewProviders/BaseRdbViewProvider.ts b/src/viewProviders/BaseRdbViewProvider.ts index b6d60fb5e..e8b06f993 100644 --- a/src/viewProviders/BaseRdbViewProvider.ts +++ b/src/viewProviders/BaseRdbViewProvider.ts @@ -22,6 +22,12 @@ export abstract class BaseRdbViewProvider extends BaseWebviewViewProvider { this.setupCommandObservers(); } + /** + * Update the device availability status and send it to the webview. + * This method is asynchronous because it fetches device info from the network, + * which may take time and could fail. Callers should use `void` or `await` + * as appropriate for their context. + */ public async updateDeviceAvailability() { const device = this.dependencies.rtaManager.device; const deviceConfig = this.dependencies.rtaManager.deviceConfig; diff --git a/src/viewProviders/RokuDeviceViewViewProvider.spec.ts b/src/viewProviders/RokuDeviceViewViewProvider.spec.ts index 3be8814f7..9c71e75bf 100644 --- a/src/viewProviders/RokuDeviceViewViewProvider.spec.ts +++ b/src/viewProviders/RokuDeviceViewViewProvider.spec.ts @@ -57,7 +57,7 @@ afterEach(() => { describe('RokuDeviceViewViewProvider', () => { describe('restartDevice command', () => { it('shows confirmation dialog before restarting', async () => { - const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const showWarningStub = (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves(undefined); const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').resolves({} as any); await provider['resolveWebviewView'](view, {} as any, {} as any); @@ -76,9 +76,9 @@ describe('RokuDeviceViewViewProvider', () => { }); it('calls rokuDeploy.rebootDevice when confirmed', async () => { - const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves('Restart'); + (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves('Restart'); const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').resolves({} as any); - const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage'); + (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves(); await provider['resolveWebviewView'](view, {} as any, {} as any); @@ -95,14 +95,12 @@ describe('RokuDeviceViewViewProvider', () => { host: '192.168.1.100', password: 'test123' }); - expect(showInfoStub.calledOnce).to.be.true; - expect(showInfoStub.firstCall.args[0]).to.include('restart initiated'); }); it('shows error message when restart fails', async () => { - const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves('Restart'); - const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').rejects(new Error('Connection failed')); - const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves('Restart'); + sinon.stub(rokuDeploy, 'rebootDevice').rejects(new Error('Connection failed')); + const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); await provider['resolveWebviewView'](view, {} as any, {} as any); @@ -120,8 +118,8 @@ describe('RokuDeviceViewViewProvider', () => { }); it('handles missing device configuration', async () => { - const showWarningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves('Restart'); - const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves('Restart'); + const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); // Remove device config dependencies.rtaManager.deviceConfig = null; @@ -143,7 +141,7 @@ describe('RokuDeviceViewViewProvider', () => { describe('checkForUpdates command', () => { it('shows confirmation dialog before checking updates', async () => { - const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined); + const showInfoStub = (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves(undefined); const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').resolves({} as any); await provider['resolveWebviewView'](view, {} as any, {} as any); @@ -162,7 +160,7 @@ describe('RokuDeviceViewViewProvider', () => { }); it('calls rokuDeploy.checkForUpdate when confirmed', async () => { - const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage'); + const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage') as any; showInfoStub.onFirstCall().resolves('Check for Updates'); showInfoStub.onSecondCall().resolves(undefined); const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').resolves({} as any); @@ -187,9 +185,9 @@ describe('RokuDeviceViewViewProvider', () => { }); it('shows error message when check fails', async () => { - const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage').resolves('Check for Updates'); - const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').rejects(new Error('Network error')); - const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves('Check for Updates'); + sinon.stub(rokuDeploy, 'checkForUpdate').rejects(new Error('Network error')); + const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); await provider['resolveWebviewView'](view, {} as any, {} as any); @@ -207,8 +205,8 @@ describe('RokuDeviceViewViewProvider', () => { }); it('handles missing device configuration', async () => { - const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage').resolves('Check for Updates'); - const showErrorStub = sinon.stub(vscode.window, 'showErrorMessage'); + (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves('Check for Updates'); + const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); // Remove device dependencies.rtaManager.device = null; diff --git a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte index f5acd3731..be20f9c5f 100644 --- a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte +++ b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte @@ -12,38 +12,47 @@ window.vscode = acquireVsCodeApi(); + // Time to wait before resetting device action states after operation completes + const DEVICE_ACTION_TIMEOUT_MS = 3000; + + interface DeviceInfo { + 'software-version'?: string; + softwareVersion?: string; + [key: string]: any; + } + let deviceAvailable = false; - let deviceInfo: any = null; - + let deviceInfo: DeviceInfo | null = null; + // Helper function to compare semantic versions function semverGte(version: string, minVersion: string): boolean { if (!version) return false; - + const parseVersion = (v: string) => { const parts = v.split('.').map(p => parseInt(p, 10)); return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }; }; - + const v1 = parseVersion(version); const v2 = parseVersion(minVersion); - + if (v1.major !== v2.major) return v1.major > v2.major; if (v1.minor !== v2.minor) return v1.minor > v2.minor; return v1.patch >= v2.patch; } - + $: softwareVersion = deviceInfo?.['software-version'] || deviceInfo?.softwareVersion || ''; $: supportsDeviceActions = semverGte(softwareVersion, '15.0.4'); - $: deviceActionTooltip = supportsDeviceActions - ? '' + $: deviceActionTooltip = supportsDeviceActions + ? '' : 'Requires software version 15.0.4 or later'; - + let isRestarting = false; let isCheckingUpdates = false; - + async function onRestartDevice() { if (!supportsDeviceActions || isRestarting) return; - + isRestarting = true; try { const response = await intermediary.sendCommand(ViewProviderCommand.restartDevice); @@ -51,7 +60,7 @@ // Device will restart, so reset the flag after a delay setTimeout(() => { isRestarting = false; - }, 3000); + }, DEVICE_ACTION_TIMEOUT_MS); } else { isRestarting = false; } @@ -60,10 +69,10 @@ isRestarting = false; } } - + async function onCheckForUpdates() { if (!supportsDeviceActions || isCheckingUpdates) return; - + isCheckingUpdates = true; try { const response = await intermediary.sendCommand(ViewProviderCommand.checkForUpdates); @@ -71,7 +80,7 @@ // Update check initiated, reset flag after a delay setTimeout(() => { isCheckingUpdates = false; - }, 3000); + }, DEVICE_ACTION_TIMEOUT_MS); } else { isCheckingUpdates = false; } @@ -80,7 +89,7 @@ isCheckingUpdates = false; } } - + intermediary.observeEvent(ViewProviderEvent.onDeviceAvailabilityChange, (message) => { deviceAvailable = message.context.deviceAvailable; deviceInfo = message.context.deviceInfo; From e99a7a08c230276f42fd516465e2a18c3589d2a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:06:07 +0000 Subject: [PATCH 06/20] Final code review fixes: improve type safety and error handling --- src/viewProviders/BaseRdbViewProvider.ts | 4 ++-- webviews/src/views/RokuDeviceView/RokuDeviceView.svelte | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/viewProviders/BaseRdbViewProvider.ts b/src/viewProviders/BaseRdbViewProvider.ts index e8b06f993..5661a05ed 100644 --- a/src/viewProviders/BaseRdbViewProvider.ts +++ b/src/viewProviders/BaseRdbViewProvider.ts @@ -42,8 +42,8 @@ export abstract class BaseRdbViewProvider extends BaseWebviewViewProvider { }); deviceInfo = info; } catch (e) { - // Device might be temporarily unavailable, just continue without device info - console.error('Failed to get device info:', e); + // Device might be temporarily unavailable or network unreachable + // This is expected behavior and we'll just continue without device info } } diff --git a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte index be20f9c5f..0472614f3 100644 --- a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte +++ b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte @@ -18,7 +18,8 @@ interface DeviceInfo { 'software-version'?: string; softwareVersion?: string; - [key: string]: any; + // Device info can contain many additional fields from the Roku device API + // Using a flexible type here to accommodate all device-specific properties } let deviceAvailable = false; From 7b2aa2b02dae620d3893485e13680f52d1776eb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:08:20 +0000 Subject: [PATCH 07/20] Refine version parsing and error handling documentation --- src/viewProviders/BaseRdbViewProvider.ts | 7 +++++-- webviews/src/views/RokuDeviceView/RokuDeviceView.svelte | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/viewProviders/BaseRdbViewProvider.ts b/src/viewProviders/BaseRdbViewProvider.ts index 5661a05ed..c9cedfcb5 100644 --- a/src/viewProviders/BaseRdbViewProvider.ts +++ b/src/viewProviders/BaseRdbViewProvider.ts @@ -42,8 +42,11 @@ export abstract class BaseRdbViewProvider extends BaseWebviewViewProvider { }); deviceInfo = info; } catch (e) { - // Device might be temporarily unavailable or network unreachable - // This is expected behavior and we'll just continue without device info + // Device might be temporarily unavailable or network unreachable. + // This is expected behavior during device startup, network transitions, or when + // the device is restarting. We silently continue without device info rather than + // logging to avoid noise in the output. The UI will gracefully handle the missing + // device info by disabling version-dependent features. } } diff --git a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte index 0472614f3..16d426545 100644 --- a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte +++ b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte @@ -30,7 +30,11 @@ if (!version) return false; const parseVersion = (v: string) => { - const parts = v.split('.').map(p => parseInt(p, 10)); + const parts = v.split('.').map(p => { + const parsed = parseInt(p, 10); + // Return 0 for invalid/NaN values to handle edge cases like beta versions + return isNaN(parsed) ? 0 : parsed; + }); return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }; }; From b3a0fb32c1a412cca09e57d206822cad720c8c6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:54:37 +0000 Subject: [PATCH 08/20] Add restart and software update commands to DevicesViewProvider --- package.json | 22 +++++++ src/BrightScriptCommands.ts | 2 + src/commands/VscodeCommand.ts | 2 + src/viewProviders/DevicesViewProvider.ts | 79 ++++++++++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/package.json b/package.json index f9af3f180..c50b846c6 100644 --- a/package.json +++ b/package.json @@ -496,6 +496,16 @@ "when": "view == devicesView && viewItem =~ /^device-/", "group": "navigation@2" }, + { + "command": "extension.brightscript.devicesView.restartDevice", + "when": "view == devicesView && viewItem =~ /^device-/", + "group": "actions@1" + }, + { + "command": "extension.brightscript.devicesView.checkAndInstallUpdates", + "when": "view == devicesView && viewItem =~ /^device-/", + "group": "actions@2" + }, { "command": "extension.brightscript.addDeviceToUserSettings", "when": "view == devicesView && (viewItem == 'device' || viewItem == 'device-workspace')", @@ -3893,6 +3903,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", diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index e566b1ae3..c7a2a3273 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -1035,6 +1035,8 @@ export class BrightScriptCommands { this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler); } this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters()); + this.registerCommand('devicesView.restartDevice', (element) => devicesViewProvider.restartDevice(element)); + this.registerCommand('devicesView.checkAndInstallUpdates', (element) => devicesViewProvider.checkForUpdates(element)); } private async sendAsciiToDevice(character: string) { diff --git a/src/commands/VscodeCommand.ts b/src/commands/VscodeCommand.ts index 2f9932afe..b22c31147 100644 --- a/src/commands/VscodeCommand.ts +++ b/src/commands/VscodeCommand.ts @@ -7,6 +7,8 @@ export enum VscodeCommand { rokuDeviceViewDisableNodeInspector = 'extension.brightscript.rokuDeviceView.disableNodeInspector', rokuDeviceViewRestartDevice = 'extension.brightscript.rokuDeviceView.restartDevice', rokuDeviceViewCheckAndInstallUpdates = 'extension.brightscript.rokuDeviceView.checkAndInstallUpdates', + devicesViewRestartDevice = 'extension.brightscript.devicesView.restartDevice', + devicesViewCheckAndInstallUpdates = 'extension.brightscript.devicesView.checkAndInstallUpdates', rokuRegistryExportRegistry = 'extension.brightscript.rokuRegistry.exportRegistry', rokuRegistryImportRegistry = 'extension.brightscript.rokuRegistry.importRegistry', rokuRegistryClearRegistry = 'extension.brightscript.rokuRegistry.clearRegistry', diff --git a/src/viewProviders/DevicesViewProvider.ts b/src/viewProviders/DevicesViewProvider.ts index 2ad822854..18a9fe294 100644 --- a/src/viewProviders/DevicesViewProvider.ts +++ b/src/viewProviders/DevicesViewProvider.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import * as semver from 'semver'; +import { rokuDeploy } from 'roku-deploy'; import type { ConfiguredDevice, DeviceManager, RokuDevice } from '../deviceDiscovery/DeviceManager'; import type { CredentialStore } from '../managers/CredentialStore'; import { util } from '../util'; @@ -526,6 +527,84 @@ export class DevicesViewProvider implements vscode.TreeDataProvider { + if (!element || element.contextValue !== 'device' && !element.contextValue?.startsWith('device-')) { + void vscode.window.showErrorMessage('Please select a device to restart'); + return; + } + + const device = this.deviceManager.getDevice(element.key); + if (!device) { + void vscode.window.showErrorMessage('Device not found'); + return; + } + + const confirm = await vscode.window.showWarningMessage( + `Are you sure you want to restart ${device.ip}? This will close all running channels.`, + { modal: true }, + 'Restart' + ); + + if (confirm !== 'Restart') { + return; + } + + try { + const password = await this.credentialStore.getPassword(device.ip); + await rokuDeploy.rebootDevice({ + host: device.ip, + password: password, + timeout: 10000 + }); + + void vscode.window.showInformationMessage(`Device ${device.ip} restart initiated successfully`); + } catch (e) { + void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); + } + } + + /** + * Check for software updates on a device + */ + public async checkForUpdates(element: DeviceTreeItem): Promise { + if (!element || element.contextValue !== 'device' && !element.contextValue?.startsWith('device-')) { + void vscode.window.showErrorMessage('Please select a device to check for updates'); + return; + } + + const device = this.deviceManager.getDevice(element.key); + if (!device) { + void vscode.window.showErrorMessage('Device not found'); + return; + } + + const confirm = await vscode.window.showInformationMessage( + `Check for software updates on ${device.ip}? The device will check for and install any available updates.`, + { modal: true }, + 'Check for Updates' + ); + + if (confirm !== 'Check for Updates') { + return; + } + + try { + const password = await this.credentialStore.getPassword(device.ip); + await rokuDeploy.checkForUpdate({ + host: device.ip, + password: password, + timeout: 10000 + }); + + void vscode.window.showInformationMessage(`Software update check initiated successfully on ${device.ip}`); + } catch (e) { + void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); + } + } } From fa3774463575ac8e9f0322164a772a205e9e086e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:56:34 +0000 Subject: [PATCH 09/20] Add tests for restartDevice and checkForUpdates in DevicesViewProvider --- src/viewProviders/DevicesViewProvider.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/viewProviders/DevicesViewProvider.spec.ts b/src/viewProviders/DevicesViewProvider.spec.ts index b5aefa79c..149dc045e 100644 --- a/src/viewProviders/DevicesViewProvider.spec.ts +++ b/src/viewProviders/DevicesViewProvider.spec.ts @@ -327,4 +327,84 @@ describe('DevicesViewProvider', () => { expect(treeChanged.called).to.be.false; }); }); + + describe('restartDevice', () => { + it('should prompt for confirmation before restarting', async () => { + const device = makeDevice({ key: 'device1' }); + const { provider } = createProvider([device]); + const element: any = { key: 'device1', contextValue: 'device' }; + + // Mock the confirmation dialog to cancel + const showWarningMessageStub = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); + + await provider.restartDevice(element); + + expect(showWarningMessageStub.calledOnce).to.be.true; + expect(showWarningMessageStub.firstCall.args[0]).to.include('Are you sure you want to restart'); + }); + + it('should show error if device not found', async () => { + const { provider } = createProvider([]); + const element: any = { key: 'nonexistent', contextValue: 'device' }; + + const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); + + await provider.restartDevice(element); + + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.equal('Device not found'); + }); + + it('should show error if element is invalid', async () => { + const device = makeDevice({ key: 'device1' }); + const { provider } = createProvider([device]); + + const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); + + await provider.restartDevice(null as any); + + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.equal('Please select a device to restart'); + }); + }); + + describe('checkForUpdates', () => { + it('should prompt for confirmation before checking for updates', async () => { + const device = makeDevice({ key: 'device1' }); + const { provider } = createProvider([device]); + const element: any = { key: 'device1', contextValue: 'device' }; + + // Mock the confirmation dialog to cancel + const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined); + + await provider.checkForUpdates(element); + + expect(showInformationMessageStub.calledOnce).to.be.true; + expect(showInformationMessageStub.firstCall.args[0]).to.include('Check for software updates'); + }); + + it('should show error if device not found', async () => { + const { provider } = createProvider([]); + const element: any = { key: 'nonexistent', contextValue: 'device' }; + + const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); + + await provider.checkForUpdates(element); + + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.equal('Device not found'); + }); + + it('should show error if element is invalid', async () => { + const device = makeDevice({ key: 'device1' }); + const { provider } = createProvider([device]); + + const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); + + await provider.checkForUpdates(null as any); + + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.equal('Please select a device to check for updates'); + }); + }); }); From 42fb61bf1bc376c25bb75af9921cede5fcad23b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:06:34 +0000 Subject: [PATCH 10/20] Revert unwanted changes to RTA/RDB views --- src/viewProviders/BaseRdbViewProvider.ts | 35 +------ .../RokuDeviceViewViewProvider.ts | 91 +------------------ 2 files changed, 4 insertions(+), 122 deletions(-) diff --git a/src/viewProviders/BaseRdbViewProvider.ts b/src/viewProviders/BaseRdbViewProvider.ts index c9cedfcb5..2bce56e82 100644 --- a/src/viewProviders/BaseRdbViewProvider.ts +++ b/src/viewProviders/BaseRdbViewProvider.ts @@ -3,7 +3,6 @@ import type * as vscode from 'vscode'; import type { RequestType } from 'roku-test-automation'; import * as fsExtra from 'fs-extra'; import * as path from 'path'; -import { rokuDeploy } from 'roku-deploy'; import { BaseWebviewViewProvider } from './BaseWebviewViewProvider'; import { ViewProviderEvent } from './ViewProviderEvent'; @@ -22,38 +21,10 @@ export abstract class BaseRdbViewProvider extends BaseWebviewViewProvider { this.setupCommandObservers(); } - /** - * Update the device availability status and send it to the webview. - * This method is asynchronous because it fetches device info from the network, - * which may take time and could fail. Callers should use `void` or `await` - * as appropriate for their context. - */ - public async updateDeviceAvailability() { - const device = this.dependencies.rtaManager.device; - const deviceConfig = this.dependencies.rtaManager.deviceConfig; - let deviceInfo = null; - - // Try to get device info if device is available - if (device && deviceConfig) { - try { - const info = await rokuDeploy.getDeviceInfo({ - host: deviceConfig.host, - timeout: 5000 - }); - deviceInfo = info; - } catch (e) { - // Device might be temporarily unavailable or network unreachable. - // This is expected behavior during device startup, network transitions, or when - // the device is restarting. We silently continue without device info rather than - // logging to avoid noise in the output. The UI will gracefully handle the missing - // device info by disabling version-dependent features. - } - } - + public updateDeviceAvailability() { const message = this.createEventMessage(ViewProviderEvent.onDeviceAvailabilityChange, { odcAvailable: !!this.dependencies.rtaManager.onDeviceComponent, - deviceAvailable: !!device, - deviceInfo: deviceInfo + deviceAvailable: !!this.dependencies.rtaManager.device }); this.postOrQueueMessage(message); @@ -102,6 +73,6 @@ export abstract class BaseRdbViewProvider extends BaseWebviewViewProvider { protected onViewReady() { // Always post back the device status so we make sure the client doesn't miss it if it got refreshed - void this.updateDeviceAvailability(); + this.updateDeviceAvailability(); } } diff --git a/src/viewProviders/RokuDeviceViewViewProvider.ts b/src/viewProviders/RokuDeviceViewViewProvider.ts index b960a192c..1460fa8ef 100644 --- a/src/viewProviders/RokuDeviceViewViewProvider.ts +++ b/src/viewProviders/RokuDeviceViewViewProvider.ts @@ -1,6 +1,5 @@ -import * as vscode from 'vscode'; +import type * as vscode from 'vscode'; import type { ChannelPublishedEvent } from 'roku-debug'; -import { rokuDeploy } from 'roku-deploy'; import { VscodeCommand } from '../commands/VscodeCommand'; import { BaseRdbViewProvider } from './BaseRdbViewProvider'; import { ViewProviderId } from './ViewProviderId'; @@ -46,94 +45,6 @@ export class RokuDeviceViewViewProvider extends BaseRdbViewProvider { } return true; }); - - this.addMessageCommandCallback(ViewProviderCommand.restartDevice, async (message) => { - try { - const confirm = await vscode.window.showWarningMessage( - 'Are you sure you want to restart this device? This will close all running channels.', - { modal: true }, - 'Restart' - ); - - if (confirm !== 'Restart') { - this.postOrQueueMessage(this.createResponseMessage(message, { - success: false, - cancelled: true - })); - return true; - } - - const device = this.dependencies.rtaManager.device; - const deviceConfig = this.dependencies.rtaManager.deviceConfig; - - if (!device || !deviceConfig) { - throw new Error('No device connected'); - } - - await rokuDeploy.rebootDevice({ - host: deviceConfig.host, - password: deviceConfig.password, - timeout: 10000 - }); - - this.postOrQueueMessage(this.createResponseMessage(message, { - success: true - })); - - void vscode.window.showInformationMessage('Device restart initiated successfully'); - } catch (e) { - this.postOrQueueMessage(this.createResponseMessage(message, { - success: false, - error: e.message - })); - void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); - } - return true; - }); - - this.addMessageCommandCallback(ViewProviderCommand.checkForUpdates, async (message) => { - try { - const confirm = await vscode.window.showInformationMessage( - 'Check for software updates on this device? The device will check for and install any available updates.', - { modal: true }, - 'Check for Updates' - ); - - if (confirm !== 'Check for Updates') { - this.postOrQueueMessage(this.createResponseMessage(message, { - success: false, - cancelled: true - })); - return true; - } - - const device = this.dependencies.rtaManager.device; - const deviceConfig = this.dependencies.rtaManager.deviceConfig; - - if (!device || !deviceConfig) { - throw new Error('No device connected'); - } - - await rokuDeploy.checkForUpdate({ - host: deviceConfig.host, - password: deviceConfig.password, - timeout: 10000 - }); - - this.postOrQueueMessage(this.createResponseMessage(message, { - success: true - })); - - void vscode.window.showInformationMessage('Software update check initiated successfully'); - } catch (e) { - this.postOrQueueMessage(this.createResponseMessage(message, { - success: false, - error: e.message - })); - void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); - } - return true; - }); } public onDidStartDebugSession(e: vscode.DebugSession) { From 350934fec8f446e270bd32e1e5499bfa344529fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:07:02 +0000 Subject: [PATCH 11/20] Remove test file for reverted RokuDeviceViewViewProvider changes --- .../RokuDeviceViewViewProvider.spec.ts | 228 ------------------ 1 file changed, 228 deletions(-) delete mode 100644 src/viewProviders/RokuDeviceViewViewProvider.spec.ts diff --git a/src/viewProviders/RokuDeviceViewViewProvider.spec.ts b/src/viewProviders/RokuDeviceViewViewProvider.spec.ts deleted file mode 100644 index 9c71e75bf..000000000 --- a/src/viewProviders/RokuDeviceViewViewProvider.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { expect } from 'chai'; -import * as sinonImport from 'sinon'; -import { vscode } from '../mockVscode.spec'; -import { RokuDeviceViewViewProvider } from './RokuDeviceViewViewProvider'; -import { ViewProviderCommand } from './ViewProviderCommand'; -import { rokuDeploy } from 'roku-deploy'; - -let Module = require('module'); -const { require: oldRequire } = Module.prototype; -Module.prototype.require = function hijacked(file) { - if (file === 'vscode') { - return vscode; - } else { - return oldRequire.apply(this, arguments); - } -}; - -let sinon: sinonImport.SinonSandbox; -let view; -let callback; -let provider: RokuDeviceViewViewProvider; -let dependencies: any; - -beforeEach(() => { - sinon = sinonImport.createSandbox(); - view = { - webview: { - onDidReceiveMessage: (cb) => { - callback = cb; - }, - postMessage: (message) => { } - }, - show: () => { } - }; - - dependencies = { - rtaManager: { - device: { - getScreenshot: sinon.stub().resolves({ buffer: { buffer: new ArrayBuffer(0) } }) - }, - deviceConfig: { - host: '192.168.1.100', - password: 'test123' - }, - onDeviceComponent: {} - } - }; - - provider = new RokuDeviceViewViewProvider(vscode.context, dependencies) as any; -}); - -afterEach(() => { - provider.dispose(); - sinon.restore(); -}); - -describe('RokuDeviceViewViewProvider', () => { - describe('restartDevice command', () => { - it('shows confirmation dialog before restarting', async () => { - const showWarningStub = (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves(undefined); - const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').resolves({} as any); - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.restartDevice, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(showWarningStub.calledOnce).to.be.true; - expect(showWarningStub.firstCall.args[0]).to.include('restart this device'); - expect(rebootStub.called).to.be.false; // Should not call reboot when cancelled - }); - - it('calls rokuDeploy.rebootDevice when confirmed', async () => { - (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves('Restart'); - const rebootStub = sinon.stub(rokuDeploy, 'rebootDevice').resolves({} as any); - (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves(); - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.restartDevice, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(rebootStub.calledOnce).to.be.true; - expect(rebootStub.firstCall.args[0]).to.deep.include({ - host: '192.168.1.100', - password: 'test123' - }); - }); - - it('shows error message when restart fails', async () => { - (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves('Restart'); - sinon.stub(rokuDeploy, 'rebootDevice').rejects(new Error('Connection failed')); - const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.restartDevice, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(showErrorStub.calledOnce).to.be.true; - expect(showErrorStub.firstCall.args[0]).to.include('Failed to restart device'); - expect(showErrorStub.firstCall.args[0]).to.include('Connection failed'); - }); - - it('handles missing device configuration', async () => { - (sinon.stub(vscode.window, 'showWarningMessage') as any).resolves('Restart'); - const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); - - // Remove device config - dependencies.rtaManager.deviceConfig = null; - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.restartDevice, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(showErrorStub.calledOnce).to.be.true; - expect(showErrorStub.firstCall.args[0]).to.include('No device connected'); - }); - }); - - describe('checkForUpdates command', () => { - it('shows confirmation dialog before checking updates', async () => { - const showInfoStub = (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves(undefined); - const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').resolves({} as any); - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.checkForUpdates, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(showInfoStub.calledOnce).to.be.true; // Only once for confirmation dialog - expect(showInfoStub.firstCall.args[0]).to.include('Check for software updates'); - expect(checkUpdateStub.called).to.be.false; // Should not call when cancelled - }); - - it('calls rokuDeploy.checkForUpdate when confirmed', async () => { - const showInfoStub = sinon.stub(vscode.window, 'showInformationMessage') as any; - showInfoStub.onFirstCall().resolves('Check for Updates'); - showInfoStub.onSecondCall().resolves(undefined); - const checkUpdateStub = sinon.stub(rokuDeploy, 'checkForUpdate').resolves({} as any); - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.checkForUpdates, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(checkUpdateStub.calledOnce).to.be.true; - expect(checkUpdateStub.firstCall.args[0]).to.deep.include({ - host: '192.168.1.100', - password: 'test123' - }); - expect(showInfoStub.calledTwice).to.be.true; - expect(showInfoStub.secondCall.args[0]).to.include('update check initiated'); - }); - - it('shows error message when check fails', async () => { - (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves('Check for Updates'); - sinon.stub(rokuDeploy, 'checkForUpdate').rejects(new Error('Network error')); - const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.checkForUpdates, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(showErrorStub.calledOnce).to.be.true; - expect(showErrorStub.firstCall.args[0]).to.include('Failed to check for updates'); - expect(showErrorStub.firstCall.args[0]).to.include('Network error'); - }); - - it('handles missing device configuration', async () => { - (sinon.stub(vscode.window, 'showInformationMessage') as any).resolves('Check for Updates'); - const showErrorStub = (sinon.stub(vscode.window, 'showErrorMessage') as any).resolves(); - - // Remove device - dependencies.rtaManager.device = null; - - await provider['resolveWebviewView'](view, {} as any, {} as any); - - const message = { - command: ViewProviderCommand.checkForUpdates, - context: {}, - messageId: '123' - }; - - await callback(message); - - expect(showErrorStub.calledOnce).to.be.true; - expect(showErrorStub.firstCall.args[0]).to.include('No device connected'); - }); - }); -}); From f886c837e50d9aa1ca2a8f3b31430c3212487734 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:07:36 +0000 Subject: [PATCH 12/20] Revert webview changes for RokuDeviceView --- .../RokuDeviceView/RokuDeviceView.svelte | 107 ------------------ 1 file changed, 107 deletions(-) diff --git a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte index 16d426545..91398a6d3 100644 --- a/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte +++ b/webviews/src/views/RokuDeviceView/RokuDeviceView.svelte @@ -12,92 +12,9 @@ window.vscode = acquireVsCodeApi(); - // Time to wait before resetting device action states after operation completes - const DEVICE_ACTION_TIMEOUT_MS = 3000; - - interface DeviceInfo { - 'software-version'?: string; - softwareVersion?: string; - // Device info can contain many additional fields from the Roku device API - // Using a flexible type here to accommodate all device-specific properties - } - let deviceAvailable = false; - let deviceInfo: DeviceInfo | null = null; - - // Helper function to compare semantic versions - function semverGte(version: string, minVersion: string): boolean { - if (!version) return false; - - const parseVersion = (v: string) => { - const parts = v.split('.').map(p => { - const parsed = parseInt(p, 10); - // Return 0 for invalid/NaN values to handle edge cases like beta versions - return isNaN(parsed) ? 0 : parsed; - }); - return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }; - }; - - const v1 = parseVersion(version); - const v2 = parseVersion(minVersion); - - if (v1.major !== v2.major) return v1.major > v2.major; - if (v1.minor !== v2.minor) return v1.minor > v2.minor; - return v1.patch >= v2.patch; - } - - $: softwareVersion = deviceInfo?.['software-version'] || deviceInfo?.softwareVersion || ''; - $: supportsDeviceActions = semverGte(softwareVersion, '15.0.4'); - $: deviceActionTooltip = supportsDeviceActions - ? '' - : 'Requires software version 15.0.4 or later'; - - let isRestarting = false; - let isCheckingUpdates = false; - - async function onRestartDevice() { - if (!supportsDeviceActions || isRestarting) return; - - isRestarting = true; - try { - const response = await intermediary.sendCommand(ViewProviderCommand.restartDevice); - if (!response.cancelled) { - // Device will restart, so reset the flag after a delay - setTimeout(() => { - isRestarting = false; - }, DEVICE_ACTION_TIMEOUT_MS); - } else { - isRestarting = false; - } - } catch (e) { - console.error('Error restarting device:', e); - isRestarting = false; - } - } - - async function onCheckForUpdates() { - if (!supportsDeviceActions || isCheckingUpdates) return; - - isCheckingUpdates = true; - try { - const response = await intermediary.sendCommand(ViewProviderCommand.checkForUpdates); - if (!response.cancelled) { - // Update check initiated, reset flag after a delay - setTimeout(() => { - isCheckingUpdates = false; - }, DEVICE_ACTION_TIMEOUT_MS); - } else { - isCheckingUpdates = false; - } - } catch (e) { - console.error('Error checking for updates:', e); - isCheckingUpdates = false; - } - } - intermediary.observeEvent(ViewProviderEvent.onDeviceAvailabilityChange, (message) => { deviceAvailable = message.context.deviceAvailable; - deviceInfo = message.context.deviceInfo; requestScreenshot(); }); @@ -460,30 +377,6 @@
{#if deviceAvailable} -
- - {#if isRestarting} - - Restarting... - {:else} - Restart Device - {/if} - - - {#if isCheckingUpdates} - - Checking... - {:else} - Check and Install Updates - {/if} - -
Date: Tue, 23 Jun 2026 13:08:15 +0000 Subject: [PATCH 13/20] Remove unused ViewProviderCommand entries --- src/viewProviders/ViewProviderCommand.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/viewProviders/ViewProviderCommand.ts b/src/viewProviders/ViewProviderCommand.ts index 8e8a9ee1d..a8e9aacfa 100644 --- a/src/viewProviders/ViewProviderCommand.ts +++ b/src/viewProviders/ViewProviderCommand.ts @@ -19,7 +19,5 @@ export enum ViewProviderCommand { exportRokuAutomationConfigs = 'exportRokuAutomationConfigs', loadRokuAutomationConfigs = 'loadRokuAutomationConfigs', updateWorkspaceState = 'updateWorkspaceState', - viewReady = 'viewReady', - restartDevice = 'restartDevice', - checkForUpdates = 'checkForUpdates' + viewReady = 'viewReady' } From d4bc83fa364dcbef0bf29f7686f589b312cece25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:18:15 +0000 Subject: [PATCH 14/20] Add restart and update as tree items under devices --- src/managers/RtaManager.ts | 7 ------ src/viewProviders/DevicesViewProvider.ts | 31 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/managers/RtaManager.ts b/src/managers/RtaManager.ts index 9feba22af..ec51e39e3 100644 --- a/src/managers/RtaManager.ts +++ b/src/managers/RtaManager.ts @@ -22,18 +22,11 @@ export class RtaManager { public onDeviceComponent?: rta.OnDeviceComponent; public device?: rta.RokuDevice; - public deviceConfig?: { host: string; password: string }; private webviewViewProviderManager?: WebviewViewProviderManager; private lastAppUIResponse: rta.AppUIResponse | undefined; public setupRtaWithConfig(config: { host: string; password: string; logLevel?: string; disableScreenSaver?: boolean; injectRdbOnDeviceComponent?: boolean }) { - // Store device config for later use - this.deviceConfig = { - host: config.host, - password: config.password - }; - const enableDebugging = ['info', 'debug', 'trace'].includes(config.logLevel); const rtaConfig: rta.ConfigOptions = { RokuDevice: { diff --git a/src/viewProviders/DevicesViewProvider.ts b/src/viewProviders/DevicesViewProvider.ts index 18a9fe294..2f3ea71a1 100644 --- a/src/viewProviders/DevicesViewProvider.ts +++ b/src/viewProviders/DevicesViewProvider.ts @@ -328,6 +328,37 @@ export class DevicesViewProvider implements vscode.TreeDataProvider=15.0.4')) { + result.unshift( + this.createDeviceInfoTreeItem({ + label: '🔄 Check for Software Updates', + parent: element, + collapsibleState: vscode.TreeItemCollapsibleState.None, + tooltip: 'Check for and install software updates', + command: { + command: 'extension.brightscript.devicesView.checkAndInstallUpdates', + title: 'Check for Updates', + arguments: [element] + } + }) + ); + + result.unshift( + this.createDeviceInfoTreeItem({ + label: '🔁 Restart Device', + parent: element, + collapsibleState: vscode.TreeItemCollapsibleState.None, + tooltip: 'Restart this device', + command: { + command: 'extension.brightscript.devicesView.restartDevice', + title: 'Restart Device', + arguments: [element] + } + }) + ); + } + result.unshift( this.createDeviceInfoTreeItem({ label: '🔗 Open device web portal', From 1ea5838ab000a3415b0be7f6628b80f29e61798b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:57:13 +0000 Subject: [PATCH 15/20] Refactor device restart and update commands per review feedback - Remove leftover rokuDeviceView command entries from VscodeCommand.ts - Move restartDevice and checkForUpdates from DevicesViewProvider to BrightScriptCommands - Commands now accept optional host parameter instead of DeviceTreeItem - Commands look up password via credentialStore using device serial number - Commands display device picker if no host provided - Update tree item arguments to pass device key for lookup - Remove corresponding tests from DevicesViewProvider.spec.ts --- src/BrightScriptCommands.ts | 91 ++++++++++++++++++- src/commands/VscodeCommand.ts | 2 - src/viewProviders/DevicesViewProvider.spec.ts | 80 ---------------- src/viewProviders/DevicesViewProvider.ts | 83 +---------------- 4 files changed, 91 insertions(+), 165 deletions(-) diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index c7a2a3273..a26a1653a 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -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 { @@ -766,6 +767,86 @@ export class BrightScriptCommands { }); } + /** + * Restart a Roku device + */ + public async restartDevice(host?: string): Promise { + if (!host) { + host = await this.userInputManager.promptForHost(); + } + if (!host) { + void vscode.window.showErrorMessage('Please select a device to restart'); + return; + } + + const confirm = await vscode.window.showWarningMessage( + `Are you sure you want to restart ${host}? This will close all running channels.`, + { modal: true }, + 'Restart' + ); + + if (confirm !== 'Restart') { + return; + } + + try { + const device = this.deviceManager.getDevice({ ip: host }); + const password = device?.serialNumber + ? await this.credentialStore.getPassword(device.serialNumber) + : undefined; + + await rokuDeploy.rebootDevice({ + host: host, + password: password, + timeout: 10000 + }); + + void vscode.window.showInformationMessage(`Device ${host} restart initiated successfully`); + } catch (e) { + void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); + } + } + + /** + * Check for software updates on a Roku device + */ + public async checkForUpdates(host?: string): Promise { + if (!host) { + host = await this.userInputManager.promptForHost(); + } + if (!host) { + void vscode.window.showErrorMessage('Please select a device to check for updates'); + return; + } + + const confirm = await vscode.window.showInformationMessage( + `Check for software updates on ${host}? The device will check for and install any available updates.`, + { modal: true }, + 'Check for Updates' + ); + + if (confirm !== 'Check for Updates') { + return; + } + + try { + const device = this.deviceManager.getDevice({ ip: host }); + const password = device?.serialNumber + ? await this.credentialStore.getPassword(device.serialNumber) + : undefined; + + await rokuDeploy.checkForUpdate({ + host: host, + password: password, + timeout: 10000 + }); + + void vscode.window.showInformationMessage(`Software update check initiated successfully on ${host}`); + } catch (e) { + void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); + } + } + public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) { for (const notifier of this.keypressNotifiers) { notifier(key, literalCharacter); @@ -1035,8 +1116,14 @@ export class BrightScriptCommands { this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler); } this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters()); - this.registerCommand('devicesView.restartDevice', (element) => devicesViewProvider.restartDevice(element)); - this.registerCommand('devicesView.checkAndInstallUpdates', (element) => devicesViewProvider.checkForUpdates(element)); + this.registerCommand('devicesView.restartDevice', (element: { key: string }) => { + const device = this.deviceManager.getDevice(element?.key); + return this.restartDevice(device?.ip); + }); + this.registerCommand('devicesView.checkAndInstallUpdates', (element: { key: string }) => { + const device = this.deviceManager.getDevice(element?.key); + return this.checkForUpdates(device?.ip); + }); } private async sendAsciiToDevice(character: string) { diff --git a/src/commands/VscodeCommand.ts b/src/commands/VscodeCommand.ts index b22c31147..f3b23d2bf 100644 --- a/src/commands/VscodeCommand.ts +++ b/src/commands/VscodeCommand.ts @@ -5,8 +5,6 @@ export enum VscodeCommand { rokuDeviceViewCopyScreenshot = 'extension.brightscript.rokuDeviceView.copyScreenshot', rokuDeviceViewEnableNodeInspector = 'extension.brightscript.rokuDeviceView.enableNodeInspector', rokuDeviceViewDisableNodeInspector = 'extension.brightscript.rokuDeviceView.disableNodeInspector', - rokuDeviceViewRestartDevice = 'extension.brightscript.rokuDeviceView.restartDevice', - rokuDeviceViewCheckAndInstallUpdates = 'extension.brightscript.rokuDeviceView.checkAndInstallUpdates', devicesViewRestartDevice = 'extension.brightscript.devicesView.restartDevice', devicesViewCheckAndInstallUpdates = 'extension.brightscript.devicesView.checkAndInstallUpdates', rokuRegistryExportRegistry = 'extension.brightscript.rokuRegistry.exportRegistry', diff --git a/src/viewProviders/DevicesViewProvider.spec.ts b/src/viewProviders/DevicesViewProvider.spec.ts index 149dc045e..b5aefa79c 100644 --- a/src/viewProviders/DevicesViewProvider.spec.ts +++ b/src/viewProviders/DevicesViewProvider.spec.ts @@ -327,84 +327,4 @@ describe('DevicesViewProvider', () => { expect(treeChanged.called).to.be.false; }); }); - - describe('restartDevice', () => { - it('should prompt for confirmation before restarting', async () => { - const device = makeDevice({ key: 'device1' }); - const { provider } = createProvider([device]); - const element: any = { key: 'device1', contextValue: 'device' }; - - // Mock the confirmation dialog to cancel - const showWarningMessageStub = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); - - await provider.restartDevice(element); - - expect(showWarningMessageStub.calledOnce).to.be.true; - expect(showWarningMessageStub.firstCall.args[0]).to.include('Are you sure you want to restart'); - }); - - it('should show error if device not found', async () => { - const { provider } = createProvider([]); - const element: any = { key: 'nonexistent', contextValue: 'device' }; - - const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); - - await provider.restartDevice(element); - - expect(showErrorMessageStub.calledOnce).to.be.true; - expect(showErrorMessageStub.firstCall.args[0]).to.equal('Device not found'); - }); - - it('should show error if element is invalid', async () => { - const device = makeDevice({ key: 'device1' }); - const { provider } = createProvider([device]); - - const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); - - await provider.restartDevice(null as any); - - expect(showErrorMessageStub.calledOnce).to.be.true; - expect(showErrorMessageStub.firstCall.args[0]).to.equal('Please select a device to restart'); - }); - }); - - describe('checkForUpdates', () => { - it('should prompt for confirmation before checking for updates', async () => { - const device = makeDevice({ key: 'device1' }); - const { provider } = createProvider([device]); - const element: any = { key: 'device1', contextValue: 'device' }; - - // Mock the confirmation dialog to cancel - const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined); - - await provider.checkForUpdates(element); - - expect(showInformationMessageStub.calledOnce).to.be.true; - expect(showInformationMessageStub.firstCall.args[0]).to.include('Check for software updates'); - }); - - it('should show error if device not found', async () => { - const { provider } = createProvider([]); - const element: any = { key: 'nonexistent', contextValue: 'device' }; - - const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); - - await provider.checkForUpdates(element); - - expect(showErrorMessageStub.calledOnce).to.be.true; - expect(showErrorMessageStub.firstCall.args[0]).to.equal('Device not found'); - }); - - it('should show error if element is invalid', async () => { - const device = makeDevice({ key: 'device1' }); - const { provider } = createProvider([device]); - - const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); - - await provider.checkForUpdates(null as any); - - expect(showErrorMessageStub.calledOnce).to.be.true; - expect(showErrorMessageStub.firstCall.args[0]).to.equal('Please select a device to check for updates'); - }); - }); }); diff --git a/src/viewProviders/DevicesViewProvider.ts b/src/viewProviders/DevicesViewProvider.ts index 2f3ea71a1..7c7e6f69c 100644 --- a/src/viewProviders/DevicesViewProvider.ts +++ b/src/viewProviders/DevicesViewProvider.ts @@ -1,6 +1,5 @@ import * as vscode from 'vscode'; import * as semver from 'semver'; -import { rokuDeploy } from 'roku-deploy'; import type { ConfiguredDevice, DeviceManager, RokuDevice } from '../deviceDiscovery/DeviceManager'; import type { CredentialStore } from '../managers/CredentialStore'; import { util } from '../util'; @@ -339,7 +338,7 @@ export class DevicesViewProvider implements vscode.TreeDataProvider { - if (!element || element.contextValue !== 'device' && !element.contextValue?.startsWith('device-')) { - void vscode.window.showErrorMessage('Please select a device to restart'); - return; - } - - const device = this.deviceManager.getDevice(element.key); - if (!device) { - void vscode.window.showErrorMessage('Device not found'); - return; - } - - const confirm = await vscode.window.showWarningMessage( - `Are you sure you want to restart ${device.ip}? This will close all running channels.`, - { modal: true }, - 'Restart' - ); - - if (confirm !== 'Restart') { - return; - } - - try { - const password = await this.credentialStore.getPassword(device.ip); - await rokuDeploy.rebootDevice({ - host: device.ip, - password: password, - timeout: 10000 - }); - - void vscode.window.showInformationMessage(`Device ${device.ip} restart initiated successfully`); - } catch (e) { - void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); - } - } - - /** - * Check for software updates on a device - */ - public async checkForUpdates(element: DeviceTreeItem): Promise { - if (!element || element.contextValue !== 'device' && !element.contextValue?.startsWith('device-')) { - void vscode.window.showErrorMessage('Please select a device to check for updates'); - return; - } - - const device = this.deviceManager.getDevice(element.key); - if (!device) { - void vscode.window.showErrorMessage('Device not found'); - return; - } - - const confirm = await vscode.window.showInformationMessage( - `Check for software updates on ${device.ip}? The device will check for and install any available updates.`, - { modal: true }, - 'Check for Updates' - ); - - if (confirm !== 'Check for Updates') { - return; - } - - try { - const password = await this.credentialStore.getPassword(device.ip); - await rokuDeploy.checkForUpdate({ - host: device.ip, - password: password, - timeout: 10000 - }); - - void vscode.window.showInformationMessage(`Software update check initiated successfully on ${device.ip}`); - } catch (e) { - void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); - } - } } From 45ac143fe96776240ffe5366349dd76ea2d11302 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:59:42 +0000 Subject: [PATCH 16/20] Add device validation in command handlers Add explicit device existence checks in command handlers before calling restartDevice and checkForUpdates to provide clear error messages when device is not found, instead of falling back to device picker. --- src/BrightScriptCommands.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index a26a1653a..81739c509 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -1118,11 +1118,19 @@ export class BrightScriptCommands { this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters()); this.registerCommand('devicesView.restartDevice', (element: { key: string }) => { const device = this.deviceManager.getDevice(element?.key); - return this.restartDevice(device?.ip); + if (!device) { + void vscode.window.showErrorMessage('Device not found'); + return; + } + return this.restartDevice(device.ip); }); this.registerCommand('devicesView.checkAndInstallUpdates', (element: { key: string }) => { const device = this.deviceManager.getDevice(element?.key); - return this.checkForUpdates(device?.ip); + if (!device) { + void vscode.window.showErrorMessage('Device not found'); + return; + } + return this.checkForUpdates(device.ip); }); } From e0ae1de1cd55f82d8913daf005f9e01c1dbd811e Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Wed, 24 Jun 2026 11:11:50 -0300 Subject: [PATCH 17/20] Resolve and validate device password for restart/update commands Restart Device and Check for Software Updates now resolve the developer password from all known sources and validate each against the device, prompting and re-prompting on rejection, the same way a debug launch does. They previously read only the credential store, leaving the password undefined and causing auth failures. - Always prompt for the target device via the picker when invoked without one - Remove the redundant Devices view context-menu entries; the commands remain as tree items and in the command palette - Add unit tests --- package.json | 10 -- src/BrightScriptCommands.spec.ts | 219 ++++++++++++++++++++++++++++ src/BrightScriptCommands.ts | 243 ++++++++++++++++++++++++------- 3 files changed, 407 insertions(+), 65 deletions(-) diff --git a/package.json b/package.json index c50b846c6..d0e522353 100644 --- a/package.json +++ b/package.json @@ -496,16 +496,6 @@ "when": "view == devicesView && viewItem =~ /^device-/", "group": "navigation@2" }, - { - "command": "extension.brightscript.devicesView.restartDevice", - "when": "view == devicesView && viewItem =~ /^device-/", - "group": "actions@1" - }, - { - "command": "extension.brightscript.devicesView.checkAndInstallUpdates", - "when": "view == devicesView && viewItem =~ /^device-/", - "group": "actions@2" - }, { "command": "extension.brightscript.addDeviceToUserSettings", "when": "view == devicesView && (viewItem == 'device' || viewItem == 'device-workspace')", diff --git a/src/BrightScriptCommands.spec.ts b/src/BrightScriptCommands.spec.ts index 586570dee..494c38b4b 100644 --- a/src/BrightScriptCommands.spec.ts +++ b/src/BrightScriptCommands.spec.ts @@ -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; @@ -269,6 +270,224 @@ describe('BrightScriptFileUtils ', () => { }); }); + describe('restartDevice / checkForUpdates', () => { + let sandbox: sinon.SinonSandbox; + let localCommands: BrightScriptCommands; + let deviceManager: any; + let credentialStore: any; + let userInputManager: any; + let rebootStub: sinon.SinonStub; + let checkForUpdateStub: sinon.SinonStub; + let showWarningStub: sinon.SinonStub; + let showInfoStub: sinon.SinonStub; + let showErrorStub: sinon.SinonStub; + let promptForPasswordStub: 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'), + validateDevicePassword: sandbox.stub().resolves('ok'), + getDefaultPassword: sandbox.stub().returns(undefined) + }; + credentialStore = { + getPassword: sandbox.stub().resolves(undefined), + setPassword: sandbox.stub().resolves() + }; + userInputManager = { + promptForHost: sandbox.stub().resolves('1.2.3.4') + }; + //ctor: remoteControlManager, whatsNewManager, context, deviceManager, userInputManager, localPackageManager, credentialStore + localCommands = new BrightScriptCommands({} as any, {} as any, vscode.context, deviceManager, userInputManager, {} as any, credentialStore); + //prompt is only reached when no stored candidate works; default to a cancel + promptForPasswordStub = sandbox.stub(localCommands as any, 'promptForDevicePassword'); + promptForPasswordStub.resolves(undefined); + + 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('resolves and passes a validated stored password to rokuDeploy.rebootDevice', async () => { + credentialStore.getPassword.resolves('storedpw'); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(deviceManager.validateDevicePassword.calledWith('1.2.3.4', 'storedpw')); + assert.isTrue(rebootStub.calledOnce); + assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4'); + assert.equal(rebootStub.firstCall.args[0].password, 'storedpw'); + assert.isFalse(showErrorStub.called); + }); + + it('falls back to the default password when nothing is stored', async () => { + credentialStore.getPassword.resolves(undefined); + deviceManager.getDefaultPassword.returns('rokudev'); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(rebootStub.calledOnce); + assert.equal(rebootStub.firstCall.args[0].password, 'rokudev'); + assert.isFalse(promptForPasswordStub.called, 'should not prompt when a candidate works'); + }); + + it('prompts and re-validates when no stored candidate is accepted', async () => { + //no stored / default candidates + promptForPasswordStub.onFirstCall().resolves('wrong'); + promptForPasswordStub.onSecondCall().resolves('right'); + deviceManager.validateDevicePassword.withArgs('1.2.3.4', 'wrong').resolves('bad-password'); + deviceManager.validateDevicePassword.withArgs('1.2.3.4', 'right').resolves('ok'); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(promptForPasswordStub.calledTwice); + assert.isTrue(rebootStub.calledOnce); + assert.equal(rebootStub.firstCall.args[0].password, 'right'); + }); + + it('aborts without prompting or rebooting when the confirmation is dismissed', async () => { + credentialStore.getPassword.resolves('storedpw'); + showWarningStub.resolves(undefined); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isFalse(deviceManager.validateDevicePassword.called, 'password should not be resolved when cancelled'); + assert.isFalse(rebootStub.called); + }); + + it('cancels when the password prompt is dismissed', async () => { + //no candidates → prompt path; promptForPasswordStub defaults to undefined (cancel) + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(promptForPasswordStub.calledOnce); + assert.isFalse(rebootStub.called); + }); + + it('shows an error and does not reboot when the device is unreachable', async () => { + credentialStore.getPassword.resolves('storedpw'); + deviceManager.validateDevicePassword.resolves('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 () => { + credentialStore.getPassword.resolves('storedpw'); + + 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); + }); + + it('persists an accepted password to the credential store only when an entry already exists', async () => { + credentialStore.getPassword.resolves('storedpw'); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(credentialStore.setPassword.calledWith('SN123', 'storedpw')); + assert.equal(vscode.context.workspaceState['_data'].remotePassword, 'storedpw'); + }); + + it('does not seed the credential store for a never-stored password', async () => { + credentialStore.getPassword.resolves(undefined); + deviceManager.getDefaultPassword.returns('rokudev'); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isFalse(credentialStore.setPassword.called); + assert.equal(vscode.context.workspaceState['_data'].remotePassword, 'rokudev'); + }); + + it('checkForUpdates resolves the password and calls rokuDeploy.checkForUpdate', async () => { + credentialStore.getPassword.resolves('storedpw'); + + 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, 'storedpw'); + }); + + 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 () => { + credentialStore.getPassword.resolves('storedpw'); + 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 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; diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index 81739c509..c9da5af32 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -768,85 +768,225 @@ export class BrightScriptCommands { } /** - * Restart a Roku device + * 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 { - if (!host) { - host = await this.userInputManager.promptForHost(); - } - if (!host) { - void vscode.window.showErrorMessage('Please select a device to restart'); + const target = await this.resolveDeviceHost(host); + if (!target) { return; } const confirm = await vscode.window.showWarningMessage( - `Are you sure you want to restart ${host}? This will close all running channels.`, + `Are you sure you want to restart ${target.label}? This will close all running channels.`, { modal: true }, 'Restart' ); - if (confirm !== 'Restart') { return; } - try { - const device = this.deviceManager.getDevice({ ip: host }); - const password = device?.serialNumber - ? await this.credentialStore.getPassword(device.serialNumber) - : undefined; - - await rokuDeploy.rebootDevice({ - host: host, - password: password, - timeout: 10000 - }); + const password = await this.resolveValidatedPassword(target.host, target.serialNumber); + if (password === undefined) { + return; + } - void vscode.window.showInformationMessage(`Device ${host} restart initiated successfully`); + try { + await rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 }); + void vscode.window.showInformationMessage(`Restart initiated on ${target.label}`); } catch (e) { void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); } } /** - * Check for software updates on a Roku device + * 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 { - if (!host) { - host = await this.userInputManager.promptForHost(); - } - if (!host) { - void vscode.window.showErrorMessage('Please select a device to check for updates'); + const target = await this.resolveDeviceHost(host); + if (!target) { return; } const confirm = await vscode.window.showInformationMessage( - `Check for software updates on ${host}? The device will check for and install any available updates.`, + `Check for software updates on ${target.label}? The device will check for and install any available updates.`, { modal: true }, 'Check for Updates' ); - if (confirm !== 'Check for Updates') { return; } - try { - const device = this.deviceManager.getDevice({ ip: host }); - const password = device?.serialNumber - ? await this.credentialStore.getPassword(device.serialNumber) - : undefined; - - await rokuDeploy.checkForUpdate({ - host: host, - password: password, - timeout: 10000 - }); + const password = await this.resolveValidatedPassword(target.host, target.serialNumber); + if (password === undefined) { + return; + } - void vscode.window.showInformationMessage(`Software update check initiated successfully on ${host}`); + try { + await rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 }); + void vscode.window.showInformationMessage(`Software update check initiated on ${target.label}`); } 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> { + 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 that the device actually accepts, mirroring the debug + * launch flow: try every known credential source in order, validate each against the + * device, and prompt (re-prompting after a rejection) when none are accepted. + * + * The accepted password is persisted following the same rules as a debug launch, so + * subsequent commands resolve without re-prompting. + * + * Returns the validated password, or undefined when the device is unreachable or the + * user cancels the prompt. + */ + private async resolveValidatedPassword(host: string, serialNumber: string | undefined): Promise { + const candidates = await this.collectPasswordCandidates(serialNumber); + for (const candidate of candidates) { + const validation = await this.deviceManager.validateDevicePassword(host, candidate); + if (validation === 'ok') { + await this.persistAcceptedPassword(serialNumber, candidate); + return candidate; + } + if (validation === 'unreachable') { + void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`); + return undefined; + } + // 'bad-password' — fall through to the next candidate + } + + // No stored / configured candidate was accepted. Prompt, re-prompting after each + // bad-password attempt until the user enters a working one or cancels (empty / Esc). + let placeholder = candidates.length > 0 + ? 'The password was rejected by the device. Try again, or press Esc to cancel.' + : 'The Roku development webserver password.'; + while (true) { + const value = await this.promptForDevicePassword(placeholder); + if (!value) { + return undefined; + } + const validation = await this.deviceManager.validateDevicePassword(host, value); + if (validation === 'ok') { + await this.persistAcceptedPassword(serialNumber, value); + return value; + } + if (validation === 'unreachable') { + void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`); + return undefined; + } + placeholder = 'The password was rejected by the device. Try again, or press Esc to cancel.'; + } + } + + /** + * Build the ordered, de-duplicated list of candidate passwords to try when resolving + * credentials for a device-targeted command. Variable placeholders and empty values are + * filtered out so the validation loop only sees real passwords. + */ + private async collectPasswordCandidates(serialNumber: string | undefined): Promise { + const candidates: string[] = []; + const addCandidate = (value: string | undefined | null) => { + const trimmed = value?.trim(); + // eslint-disable-next-line no-template-curly-in-string + if (!trimmed || trimmed === '${promptForPassword}' || trimmed === '${activeHostPassword}') { + return; + } + candidates.push(trimmed); + }; + + if (serialNumber) { + addCandidate(await this.credentialStore.getPassword(serialNumber)); + + const scanScope = (devices: ConfiguredDevice[] | undefined) => { + for (const entry of devices ?? []) { + if (entry.serialNumber === serialNumber) { + addCandidate(entry.password); + } + } + }; + const rootInspection = vscode.workspace.getConfiguration('brightscript').inspect('devices'); + scanScope(rootInspection?.globalValue); + scanScope(rootInspection?.workspaceValue); + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const folderInspection = vscode.workspace.getConfiguration('brightscript', folder.uri).inspect('devices'); + scanScope(folderInspection?.workspaceFolderValue); + } + } + + addCandidate(this.deviceManager.getDefaultPassword()); + addCandidate(await this.context.workspaceState.get('remotePassword')); + + // Dedupe while preserving insertion order so a password referenced by multiple + // sources is only validated once. + return Array.from(new Set(candidates)); + } + + /** + * Persist an accepted password the same way a debug launch does: refresh the credential + * store only when an entry already exists for this serial (persisting is an explicit + * opt-in via `setDevicePassword`/settings), and update the global `remotePassword` fallback. + */ + private async persistAcceptedPassword(serialNumber: string | undefined, password: string): Promise { + if (serialNumber && (await this.credentialStore.getPassword(serialNumber)) !== undefined) { + await this.credentialStore.setPassword(serialNumber, password); + } + await this.context.workspaceState.update('remotePassword', password); + } + + /** + * Password input dialog. Returns the typed value, or undefined on Esc / hide. + */ + private async promptForDevicePassword(placeholder: string): Promise { + const input = vscode.window.createInputBox(); + input.placeholder = placeholder; + input.password = true; + try { + return await new Promise(resolve => { + input.onDidAccept(() => { + resolve(input.value); + input.hide(); + }); + input.onDidHide(() => { + resolve(undefined); + }); + input.show(); + }); + } finally { + input.dispose(); + } + } + public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) { for (const notifier of this.keypressNotifiers) { notifier(key, literalCharacter); @@ -1116,21 +1256,14 @@ export class BrightScriptCommands { this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler); } this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters()); - this.registerCommand('devicesView.restartDevice', (element: { key: string }) => { - const device = this.deviceManager.getDevice(element?.key); - if (!device) { - void vscode.window.showErrorMessage('Device not found'); - return; - } - return this.restartDevice(device.ip); - }); - this.registerCommand('devicesView.checkAndInstallUpdates', (element: { key: string }) => { - const device = this.deviceManager.getDevice(element?.key); - if (!device) { - void vscode.window.showErrorMessage('Device not found'); - return; - } - return this.checkForUpdates(device.ip); + // 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); }); } From 2ee1c610fb386af3fbcbadda2b7d27c1ed358cf6 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Thu, 25 Jun 2026 09:41:19 -0300 Subject: [PATCH 18/20] Show progress and a timed notification for restart/update commands Wrap the reboot and update requests in an indeterminate progress notification while they're in flight, and report success with a timed notification that auto-dismisses. --- src/BrightScriptCommands.spec.ts | 5 +++++ src/BrightScriptCommands.ts | 14 ++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/BrightScriptCommands.spec.ts b/src/BrightScriptCommands.spec.ts index 494c38b4b..da3674a9e 100644 --- a/src/BrightScriptCommands.spec.ts +++ b/src/BrightScriptCommands.spec.ts @@ -281,6 +281,7 @@ describe('BrightScriptFileUtils ', () => { let showWarningStub: sinon.SinonStub; let showInfoStub: sinon.SinonStub; let showErrorStub: sinon.SinonStub; + let showTimedNotificationStub: sinon.SinonStub; let promptForPasswordStub: sinon.SinonStub; const device = { ip: '1.2.3.4', serialNumber: 'SN123', deviceInfo: {} }; @@ -314,6 +315,8 @@ describe('BrightScriptFileUtils ', () => { showInfoStub = sandbox.stub(vscode.window, 'showInformationMessage') as sinon.SinonStub; showInfoStub.resolves('Check for Updates'); showErrorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + //the real helper runs a multi-second timer loop; stub it out + showTimedNotificationStub = sandbox.stub(Object.getPrototypeOf(util), 'showTimedNotification').resolves(); vscode.context.workspaceState['_data'] = {}; }); @@ -330,6 +333,7 @@ describe('BrightScriptFileUtils ', () => { assert.isTrue(rebootStub.calledOnce); assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4'); assert.equal(rebootStub.firstCall.args[0].password, 'storedpw'); + assert.isTrue(showTimedNotificationStub.calledOnce, 'shows a timed success notification'); assert.isFalse(showErrorStub.called); }); @@ -432,6 +436,7 @@ describe('BrightScriptFileUtils ', () => { assert.isTrue(checkForUpdateStub.calledOnce); assert.equal(checkForUpdateStub.firstCall.args[0].host, '1.2.3.4'); assert.equal(checkForUpdateStub.firstCall.args[0].password, 'storedpw'); + assert.isTrue(showTimedNotificationStub.calledOnce, 'shows a timed success notification'); }); it('checkForUpdates aborts when the confirmation is dismissed', async () => { diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index c9da5af32..3039b938b 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -793,8 +793,11 @@ export class BrightScriptCommands { } try { - await rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 }); - void vscode.window.showInformationMessage(`Restart initiated on ${target.label}`); + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Restarting ${target.label}` + }, () => rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 })); + void util.showTimedNotification(`Restart initiated on ${target.label}`); } catch (e) { void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); } @@ -825,8 +828,11 @@ export class BrightScriptCommands { } try { - await rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 }); - void vscode.window.showInformationMessage(`Software update check initiated on ${target.label}`); + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Checking for software updates on ${target.label}` + }, () => rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 })); + void util.showTimedNotification(`Software update check initiated on ${target.label}`); } catch (e) { void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); } From 41999f07b44ac661c70be1768c7dba9d83d16b7a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Thu, 25 Jun 2026 11:07:58 -0300 Subject: [PATCH 19/20] Consolidate device password resolution into UserInputManager Move candidate collection, the validation loop, the prompt, and credential-store persistence into UserInputManager.resolveDevicePassword. The debug configuration provider and the restart/update commands both delegate to it, passing their own extra candidate passwords. The provider keeps its legacy-password migration and sets the remotePassword fallback itself. --- src/BrightScriptCommands.spec.ts | 97 +++++----------- src/BrightScriptCommands.ts | 135 +++------------------- src/DebugConfigurationProvider.spec.ts | 100 ++-------------- src/DebugConfigurationProvider.ts | 154 ++++--------------------- src/extension.ts | 5 +- src/managers/UserInputManager.spec.ts | 139 +++++++++++++++++++++- src/managers/UserInputManager.ts | 145 ++++++++++++++++++++++- 7 files changed, 354 insertions(+), 421 deletions(-) diff --git a/src/BrightScriptCommands.spec.ts b/src/BrightScriptCommands.spec.ts index da3674a9e..4716f6c34 100644 --- a/src/BrightScriptCommands.spec.ts +++ b/src/BrightScriptCommands.spec.ts @@ -274,7 +274,6 @@ describe('BrightScriptFileUtils ', () => { let sandbox: sinon.SinonSandbox; let localCommands: BrightScriptCommands; let deviceManager: any; - let credentialStore: any; let userInputManager: any; let rebootStub: sinon.SinonStub; let checkForUpdateStub: sinon.SinonStub; @@ -282,7 +281,6 @@ describe('BrightScriptFileUtils ', () => { let showInfoStub: sinon.SinonStub; let showErrorStub: sinon.SinonStub; let showTimedNotificationStub: sinon.SinonStub; - let promptForPasswordStub: sinon.SinonStub; const device = { ip: '1.2.3.4', serialNumber: 'SN123', deviceInfo: {} }; @@ -291,22 +289,15 @@ describe('BrightScriptFileUtils ', () => { deviceManager = { validateAndAddDevice: sandbox.stub().resolves(device), getDevice: sandbox.stub().returns(device), - getDeviceDisplayName: sandbox.stub().returns('Roku Express – 1.2.3.4'), - validateDevicePassword: sandbox.stub().resolves('ok'), - getDefaultPassword: sandbox.stub().returns(undefined) - }; - credentialStore = { - getPassword: sandbox.stub().resolves(undefined), - setPassword: sandbox.stub().resolves() + 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') + 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, credentialStore); - //prompt is only reached when no stored candidate works; default to a cancel - promptForPasswordStub = sandbox.stub(localCommands as any, 'promptForDevicePassword'); - promptForPasswordStub.resolves(undefined); + 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); @@ -324,65 +315,50 @@ describe('BrightScriptFileUtils ', () => { sandbox.restore(); }); - it('resolves and passes a validated stored password to rokuDeploy.rebootDevice', async () => { - credentialStore.getPassword.resolves('storedpw'); + it('passes the resolved password to rokuDeploy.rebootDevice and shows a timed success notification', async () => { + userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' }); await localCommands.restartDevice('1.2.3.4'); - assert.isTrue(deviceManager.validateDevicePassword.calledWith('1.2.3.4', 'storedpw')); assert.isTrue(rebootStub.calledOnce); assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4'); - assert.equal(rebootStub.firstCall.args[0].password, 'storedpw'); + assert.equal(rebootStub.firstCall.args[0].password, 'pw'); assert.isTrue(showTimedNotificationStub.calledOnce, 'shows a timed success notification'); assert.isFalse(showErrorStub.called); }); - it('falls back to the default password when nothing is stored', async () => { - credentialStore.getPassword.resolves(undefined); - deviceManager.getDefaultPassword.returns('rokudev'); - - await localCommands.restartDevice('1.2.3.4'); - - assert.isTrue(rebootStub.calledOnce); - assert.equal(rebootStub.firstCall.args[0].password, 'rokudev'); - assert.isFalse(promptForPasswordStub.called, 'should not prompt when a candidate works'); - }); - - it('prompts and re-validates when no stored candidate is accepted', async () => { - //no stored / default candidates - promptForPasswordStub.onFirstCall().resolves('wrong'); - promptForPasswordStub.onSecondCall().resolves('right'); - deviceManager.validateDevicePassword.withArgs('1.2.3.4', 'wrong').resolves('bad-password'); - deviceManager.validateDevicePassword.withArgs('1.2.3.4', 'right').resolves('ok'); + 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(promptForPasswordStub.calledTwice); - assert.isTrue(rebootStub.calledOnce); - assert.equal(rebootStub.firstCall.args[0].password, 'right'); + 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 prompting or rebooting when the confirmation is dismissed', async () => { - credentialStore.getPassword.resolves('storedpw'); + 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(deviceManager.validateDevicePassword.called, 'password should not be resolved when cancelled'); + assert.isFalse(userInputManager.resolveDevicePassword.called, 'password should not be resolved when cancelled'); assert.isFalse(rebootStub.called); }); - it('cancels when the password prompt is dismissed', async () => { - //no candidates → prompt path; promptForPasswordStub defaults to undefined (cancel) + it('cancels when password resolution is cancelled', async () => { + userInputManager.resolveDevicePassword.resolves({ status: 'cancelled' }); + await localCommands.restartDevice('1.2.3.4'); - assert.isTrue(promptForPasswordStub.calledOnce); assert.isFalse(rebootStub.called); }); it('shows an error and does not reboot when the device is unreachable', async () => { - credentialStore.getPassword.resolves('storedpw'); - deviceManager.validateDevicePassword.resolves('unreachable'); + userInputManager.resolveDevicePassword.resolves({ status: 'unreachable' }); await localCommands.restartDevice('1.2.3.4'); @@ -391,8 +367,6 @@ describe('BrightScriptFileUtils ', () => { }); it('always prompts for the device with the picker when no host is provided', async () => { - credentialStore.getPassword.resolves('storedpw'); - await localCommands.restartDevice(); assert.isTrue(userInputManager.promptForHost.calledOnce); @@ -407,35 +381,17 @@ describe('BrightScriptFileUtils ', () => { assert.isFalse(rebootStub.called); assert.isFalse(deviceManager.validateAndAddDevice.called); + assert.isFalse(userInputManager.resolveDevicePassword.called); }); - it('persists an accepted password to the credential store only when an entry already exists', async () => { - credentialStore.getPassword.resolves('storedpw'); - - await localCommands.restartDevice('1.2.3.4'); - - assert.isTrue(credentialStore.setPassword.calledWith('SN123', 'storedpw')); - assert.equal(vscode.context.workspaceState['_data'].remotePassword, 'storedpw'); - }); - - it('does not seed the credential store for a never-stored password', async () => { - credentialStore.getPassword.resolves(undefined); - deviceManager.getDefaultPassword.returns('rokudev'); - - await localCommands.restartDevice('1.2.3.4'); - - assert.isFalse(credentialStore.setPassword.called); - assert.equal(vscode.context.workspaceState['_data'].remotePassword, 'rokudev'); - }); - - it('checkForUpdates resolves the password and calls rokuDeploy.checkForUpdate', async () => { - credentialStore.getPassword.resolves('storedpw'); + 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, 'storedpw'); + assert.equal(checkForUpdateStub.firstCall.args[0].password, 'pw'); assert.isTrue(showTimedNotificationStub.calledOnce, 'shows a timed success notification'); }); @@ -448,7 +404,6 @@ describe('BrightScriptFileUtils ', () => { }); it('surfaces a rokuDeploy failure as an error message', async () => { - credentialStore.getPassword.resolves('storedpw'); rebootStub.rejects(new Error('boom')); await localCommands.restartDevice('1.2.3.4'); diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index 3039b938b..5e867807b 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -867,130 +867,25 @@ export class BrightScriptCommands { } /** - * Resolve a developer password that the device actually accepts, mirroring the debug - * launch flow: try every known credential source in order, validate each against the - * device, and prompt (re-prompting after a rejection) when none are accepted. - * - * The accepted password is persisted following the same rules as a debug launch, so - * subsequent commands resolve without re-prompting. - * - * Returns the validated password, or undefined when the device is unreachable or the - * user cancels the prompt. + * 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 { - const candidates = await this.collectPasswordCandidates(serialNumber); - for (const candidate of candidates) { - const validation = await this.deviceManager.validateDevicePassword(host, candidate); - if (validation === 'ok') { - await this.persistAcceptedPassword(serialNumber, candidate); - return candidate; - } - if (validation === 'unreachable') { - void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`); - return undefined; - } - // 'bad-password' — fall through to the next candidate - } - - // No stored / configured candidate was accepted. Prompt, re-prompting after each - // bad-password attempt until the user enters a working one or cancels (empty / Esc). - let placeholder = candidates.length > 0 - ? 'The password was rejected by the device. Try again, or press Esc to cancel.' - : 'The Roku development webserver password.'; - while (true) { - const value = await this.promptForDevicePassword(placeholder); - if (!value) { - return undefined; - } - const validation = await this.deviceManager.validateDevicePassword(host, value); - if (validation === 'ok') { - await this.persistAcceptedPassword(serialNumber, value); - return value; - } - if (validation === 'unreachable') { - void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`); - return undefined; - } - placeholder = 'The password was rejected by the device. Try again, or press Esc to cancel.'; - } - } - - /** - * Build the ordered, de-duplicated list of candidate passwords to try when resolving - * credentials for a device-targeted command. Variable placeholders and empty values are - * filtered out so the validation loop only sees real passwords. - */ - private async collectPasswordCandidates(serialNumber: string | undefined): Promise { - const candidates: string[] = []; - const addCandidate = (value: string | undefined | null) => { - const trimmed = value?.trim(); - // eslint-disable-next-line no-template-curly-in-string - if (!trimmed || trimmed === '${promptForPassword}' || trimmed === '${activeHostPassword}') { - return; - } - candidates.push(trimmed); - }; - - if (serialNumber) { - addCandidate(await this.credentialStore.getPassword(serialNumber)); - - const scanScope = (devices: ConfiguredDevice[] | undefined) => { - for (const entry of devices ?? []) { - if (entry.serialNumber === serialNumber) { - addCandidate(entry.password); - } - } - }; - const rootInspection = vscode.workspace.getConfiguration('brightscript').inspect('devices'); - scanScope(rootInspection?.globalValue); - scanScope(rootInspection?.workspaceValue); - for (const folder of vscode.workspace.workspaceFolders ?? []) { - const folderInspection = vscode.workspace.getConfiguration('brightscript', folder.uri).inspect('devices'); - scanScope(folderInspection?.workspaceFolderValue); - } - } - - addCandidate(this.deviceManager.getDefaultPassword()); - addCandidate(await this.context.workspaceState.get('remotePassword')); - - // Dedupe while preserving insertion order so a password referenced by multiple - // sources is only validated once. - return Array.from(new Set(candidates)); - } - - /** - * Persist an accepted password the same way a debug launch does: refresh the credential - * store only when an entry already exists for this serial (persisting is an explicit - * opt-in via `setDevicePassword`/settings), and update the global `remotePassword` fallback. - */ - private async persistAcceptedPassword(serialNumber: string | undefined, password: string): Promise { - if (serialNumber && (await this.credentialStore.getPassword(serialNumber)) !== undefined) { - await this.credentialStore.setPassword(serialNumber, password); + 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; } - await this.context.workspaceState.update('remotePassword', password); - } - - /** - * Password input dialog. Returns the typed value, or undefined on Esc / hide. - */ - private async promptForDevicePassword(placeholder: string): Promise { - const input = vscode.window.createInputBox(); - input.placeholder = placeholder; - input.password = true; - try { - return await new Promise(resolve => { - input.onDidAccept(() => { - resolve(input.value); - input.hide(); - }); - input.onDidHide(() => { - resolve(undefined); - }); - input.show(); - }); - } finally { - input.dispose(); + if (resolution.status === 'cancelled') { + return undefined; } + return resolution.password; } public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) { diff --git a/src/DebugConfigurationProvider.spec.ts b/src/DebugConfigurationProvider.spec.ts index baf20f53d..a6841873a 100644 --- a/src/DebugConfigurationProvider.spec.ts +++ b/src/DebugConfigurationProvider.spec.ts @@ -54,8 +54,8 @@ describe('BrightScriptConfigurationProvider', () => { sinon.stub(DeviceManager.prototype as any, 'setupMonitors').callsFake(() => { }); const globalStateManager = new GlobalStateManager(vscode.context); deviceManager = new DeviceManager(vscode.context, globalStateManager); - userInputManager = new UserInputManager(deviceManager); credentialStore = new CredentialStore(vscode.context); + userInputManager = new UserInputManager(deviceManager, credentialStore); configProvider = new BrightScriptDebugConfigurationProvider( vscode.context, @@ -419,92 +419,6 @@ describe('BrightScriptConfigurationProvider', () => { }); }); - describe('collectPasswordCandidates', () => { - const callCollect = ( - config: Partial, - result: Partial, - serialNumber: string | undefined - ): Promise => (configProvider as any).collectPasswordCandidates(config, result, serialNumber); - - it('returns an empty list when every source is empty or a variable placeholder', async () => { - const candidates = await callCollect( - { password: '${promptForPassword}' }, - { password: '${activeHostPassword}' }, - undefined - ); - expect(candidates).to.deep.equal([]); - }); - - it('filters out falsy entries', async () => { - const candidates = await callCollect( - { password: '' }, - { password: undefined as any }, - undefined - ); - expect(candidates).to.deep.equal([]); - }); - - it('includes the default, result, and config password in that order', async () => { - sinon.stub(deviceManager, 'getDefaultPassword').returns('default-pw'); - const candidates = await callCollect( - { password: 'config-pw' }, - { password: 'result-pw' }, - undefined - ); - expect(candidates).to.deep.equal(['default-pw', 'result-pw', 'config-pw']); - }); - - it('dedupes candidates that appear in multiple sources, preserving first occurrence', async () => { - sinon.stub(deviceManager, 'getDefaultPassword').returns('shared-pw'); - const candidates = await callCollect( - { password: 'shared-pw' }, - { password: 'shared-pw' }, - undefined - ); - expect(candidates).to.deep.equal(['shared-pw']); - }); - - it('trims whitespace from candidates before deduping', async () => { - sinon.stub(deviceManager, 'getDefaultPassword').returns(' padded '); - const candidates = await callCollect( - { password: 'padded' }, - { password: 'padded' }, - undefined - ); - expect(candidates).to.deep.equal(['padded']); - }); - - it('puts the cred-store password first when a serial number is known', async () => { - sinon.stub(deviceManager, 'getDefaultPassword').returns('default-pw'); - await credentialStore.setPassword('SN-001', 'cred-store-pw'); - const candidates = await callCollect( - { password: 'config-pw' }, - { password: 'result-pw' }, - 'SN-001' - ); - expect(candidates).to.deep.equal(['cred-store-pw', 'default-pw', 'result-pw', 'config-pw']); - }); - - it('skips cred-store and settings-by-SN sources when the serial number is undefined', async () => { - await credentialStore.setPassword('SN-001', 'cred-store-pw'); - const candidates = await callCollect( - { password: 'config-pw' }, - { password: 'result-pw' }, - undefined - ); - expect(candidates).to.not.include('cred-store-pw'); - }); - - it('excludes variable placeholders even when wrapped in whitespace', async () => { - const candidates = await callCollect( - { password: ' ${promptForPassword} ' }, - { password: ' ${activeHostPassword} ' }, - undefined - ); - expect(candidates).to.deep.equal([]); - }); - }); - describe('processPasswordParameter', () => { const callProcess = ( config: Partial, @@ -578,7 +492,7 @@ describe('BrightScriptConfigurationProvider', () => { const stub = sinon.stub(deviceManager, 'validateDevicePassword') as any; stub.onFirstCall().resolves('bad-password'); stub.onSecondCall().resolves('ok'); - (sinon.stub(configProvider as any, 'promptForPassword') as any).resolves('typed-pw'); + (sinon.stub(userInputManager as any, 'promptForDevicePassword') as any).resolves('typed-pw'); const result: any = { host: '1.2.3.4', password: 'rejected-pw' }; const returned = await callProcess({ password: '${promptForPassword}' }, result, { serialNumber: 'SN-001' }); @@ -588,7 +502,7 @@ describe('BrightScriptConfigurationProvider', () => { it('throws when the user cancels (empty) the password prompt', async () => { sinon.stub(deviceManager, 'validateDevicePassword').resolves('bad-password'); - (sinon.stub(configProvider as any, 'promptForPassword') as any).resolves(undefined); + (sinon.stub(userInputManager as any, 'promptForDevicePassword') as any).resolves(undefined); let threw: Error | undefined; try { @@ -603,7 +517,7 @@ describe('BrightScriptConfigurationProvider', () => { const validateStub = sinon.stub(deviceManager, 'validateDevicePassword') as any; validateStub.onCall(0).resolves('bad-password'); // first candidate validateStub.onCall(1).resolves('bad-password'); // first typed attempt - const promptStub = sinon.stub(configProvider as any, 'promptForPassword') as any; + const promptStub = sinon.stub(userInputManager as any, 'promptForDevicePassword') as any; promptStub.onCall(0).resolves('still-wrong'); promptStub.onCall(1).resolves(undefined); // user cancels the retry @@ -623,7 +537,7 @@ describe('BrightScriptConfigurationProvider', () => { validateStub.onCall(0).resolves('bad-password'); // first candidate validateStub.onCall(1).resolves('bad-password'); // first typed attempt validateStub.onCall(2).resolves('ok'); // second typed attempt - const promptStub = sinon.stub(configProvider as any, 'promptForPassword') as any; + const promptStub = sinon.stub(userInputManager as any, 'promptForDevicePassword') as any; promptStub.onCall(0).resolves('first-try'); promptStub.onCall(1).resolves('correct-pw'); @@ -759,7 +673,7 @@ describe('BrightScriptConfigurationProvider', () => { validateStub.onCall(0).resolves('bad-password'); // existing cred-store entry (candidate #1) validateStub.onCall(1).resolves('bad-password'); // result.password validateStub.onCall(2).resolves('ok'); // typed password - (sinon.stub(configProvider as any, 'promptForPassword') as any).resolves('typed-pw'); + (sinon.stub(userInputManager as any, 'promptForDevicePassword') as any).resolves('typed-pw'); const result: any = { host: '1.2.3.4', password: 'rejected-pw' }; await callProcess({ password: '${promptForPassword}' }, result, { serialNumber: 'SN-001' }); @@ -771,7 +685,7 @@ describe('BrightScriptConfigurationProvider', () => { const validateStub = sinon.stub(deviceManager, 'validateDevicePassword') as any; validateStub.onCall(0).resolves('bad-password'); validateStub.onCall(1).resolves('ok'); - (sinon.stub(configProvider as any, 'promptForPassword') as any).resolves('typed-pw'); + (sinon.stub(userInputManager as any, 'promptForDevicePassword') as any).resolves('typed-pw'); const result: any = { host: '1.2.3.4', password: 'rejected-pw' }; await callProcess({ password: '${promptForPassword}' }, result, { serialNumber: 'SN-NEW' }); diff --git a/src/DebugConfigurationProvider.ts b/src/DebugConfigurationProvider.ts index 86008b332..0bc676b6b 100644 --- a/src/DebugConfigurationProvider.ts +++ b/src/DebugConfigurationProvider.ts @@ -21,7 +21,7 @@ import type { DeviceInfo } from 'roku-deploy'; import type { UserInputManager } from './managers/UserInputManager'; import type { BrightScriptCommands } from './BrightScriptCommands'; import type { RokuProjectManager } from './managers/RokuProject/RokuProjectManager'; -import type { ConfiguredDevice, DeviceManager, RokuDevice } from './deviceDiscovery/DeviceManager'; +import type { DeviceManager, RokuDevice } from './deviceDiscovery/DeviceManager'; import type { CredentialStore } from './managers/CredentialStore'; @@ -554,12 +554,14 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio const validation = await this.deviceManager.validateDevicePassword(host, legacyPassword); if (validation === 'ok') { await this.clearLegacyIpKeyedPassword(host); - // A legacy entry is explicit historical opt-in: seed the cred store so - // acceptPassword's "refresh existing" branch persists it forward. + // A legacy entry is explicit historical opt-in: persist it to the cred store + // (unconditionally, unlike the normal "refresh existing only" gate) and the + // global fallback, then use it. if (serialNumber) { await this.credentialStore.setPassword(serialNumber, legacyPassword); } - await this.acceptPassword(result, legacyPassword, serialNumber); + result.password = legacyPassword; + await this.context.workspaceState.update('remotePassword', legacyPassword); return result; } else if (validation === 'bad-password') { // Reads don't use the legacy store anymore, so a proven-wrong entry is dead weight. @@ -569,136 +571,24 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio // fall through to the normal candidate flow, which will surface its own error. } - const candidates = await this.collectPasswordCandidates(config, result, serialNumber); - - for (const candidate of candidates) { - const validation = await this.deviceManager.validateDevicePassword(host, candidate); - if (validation === 'ok') { - await this.acceptPassword(result, candidate, serialNumber); - return result; - } - if (validation === 'unreachable') { - throw new Error(`Debug session terminated: device at ${host} is unreachable.`); - } - // 'bad-password' — fall through to the next candidate - } - - // No stored / configured candidate was accepted. Prompt the user, and keep - // re-prompting after each bad-password attempt until they either enter a - // working one or cancel (empty / Esc). Cred-store persistence is gated - // inside acceptPassword by whether an entry already exists for this SN. - let placeholder = (candidates ?? []).length > 0 ? 'The password was rejected by the device. Try again, or press Esc to cancel.' : 'The Roku development webserver password.'; - while (true) { - const value = await this.promptForPassword(placeholder); - if (!value) { - throw new Error('Debug session terminated: password is required.'); - } - const validation = await this.deviceManager.validateDevicePassword(host, value); - if (validation === 'unreachable') { - throw new Error(`Debug session terminated: device at ${host} is unreachable.`); - } - if (validation === 'ok') { - await this.acceptPassword(result, value, serialNumber); - return result; - } - // 'bad-password' — re-prompt with a hint so the user knows why their input came back. - placeholder = 'The password was rejected by the device. Try again, or press Esc to cancel.'; - } - } - - /** - * Password input dialog. Returns the typed value, or undefined on Esc / hide. - */ - private async promptForPassword(placeholder: string): Promise { - const input = vscode.window.createInputBox(); - input.placeholder = placeholder; - try { - return await new Promise(resolve => { - input.onDidAccept(() => { - resolve(input.value); - input.hide(); - }); - input.onDidHide(() => { - resolve(undefined); - }); - input.show(); - }); - } finally { - input.dispose(); + // Resolve and validate the password against the device, trying the launch config's own + // `result.password`/`config.password` after the standard credential sources. The resolver + // prompts (and persists an accepted password) the same way it does for the other commands. + const resolution = await this.userInputManager.resolveDevicePassword({ + host: host, + serialNumber: serialNumber, + extraCandidates: [result.password, config.password] + }); + if (resolution.status === 'unreachable') { + throw new Error(`Debug session terminated: device at ${host} is unreachable.`); } - } - - /** - * Build the ordered, de-duplicated list of candidate passwords to try when - * resolving credentials for a launch. Variable placeholders and empty - * values are filtered out so the validation loop only sees real passwords. - */ - private async collectPasswordCandidates( - config: BrightScriptLaunchConfiguration, - result: BrightScriptLaunchConfiguration, - serialNumber: string | undefined - ): Promise { - const candidates: string[] = []; - const addCandidate = (value: string | undefined | null) => { - if (!value) { - return; - } - const trimmed = value.trim(); - if (!trimmed) { - return; - } - if (trimmed === '${promptForPassword}' || trimmed === '${activeHostPassword}') { - return; - } - candidates.push(trimmed); - }; - - if (serialNumber) { - addCandidate(await this.credentialStore.getPassword(serialNumber)); - - const scanScope = (devices: ConfiguredDevice[] | undefined) => { - for (const entry of devices ?? []) { - if (entry.serialNumber === serialNumber) { - addCandidate(entry.password); - } - } - }; - const rootInspection = vscode.workspace.getConfiguration('brightscript').inspect('devices'); - scanScope(rootInspection?.globalValue); - scanScope(rootInspection?.workspaceValue); - for (const folder of vscode.workspace.workspaceFolders ?? []) { - const folderInspection = vscode.workspace.getConfiguration('brightscript', folder.uri).inspect('devices'); - scanScope(folderInspection?.workspaceFolderValue); - } + if (resolution.status === 'cancelled') { + throw new Error('Debug session terminated: password is required.'); } - - addCandidate(this.deviceManager.getDefaultPassword()); - addCandidate(result.password); - addCandidate(config.password); - - // Dedupe while preserving insertion order (Set iterates in insertion order), - // so a password referenced by multiple sources is still only validated once. - return Array.from(new Set(candidates)); - } - - /** - * Commit an accepted password: write it to the resolved config and update the - * global `remotePassword` fallback. When the cred store already has an entry - * for this serial, refresh it so future launches short-circuit on candidate #1. - * If no entry exists yet, the cred store is left alone, since persisting a - * password is an explicit user opt-in (via the `setDevicePassword` command, - * `brightscript.devices[].password` in settings, or legacy migration). - */ - private async acceptPassword( - result: BrightScriptLaunchConfiguration, - password: string, - serialNumber: string | undefined - ): Promise { - result.password = password; - if (serialNumber && (await this.credentialStore.getPassword(serialNumber)) !== undefined) { - await this.credentialStore.setPassword(serialNumber, password); - } - await this.context.workspaceState.update('remotePassword', password); + result.password = resolution.password; + // Keep the global password fallback in sync so later launches resolve without re-prompting. + await this.context.workspaceState.update('remotePassword', resolution.password); + return result; } private readonly legacyPasswordStoreKey = 'devicePasswords'; diff --git a/src/extension.ts b/src/extension.ts index f122d44d7..93d0475da 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -83,12 +83,13 @@ export class Extension { this.extensionOutputChannel = util.createOutputChannel('BrightScript Extension', this.writeExtensionLog.bind(this)); this.extensionOutputChannel.appendLine('Extension startup'); this.deviceManager = new DeviceManager(context, this.globalStateManager, this.extensionOutputChannel); + const credentialStore = new CredentialStore(context); let userInputManager = new UserInputManager( - this.deviceManager + this.deviceManager, + credentialStore ); this.remoteControlManager = new RemoteControlManager(this.telemetryManager); - const credentialStore = new CredentialStore(context); this.brightScriptCommands = new BrightScriptCommands( this.remoteControlManager, this.whatsNewManager, diff --git a/src/managers/UserInputManager.spec.ts b/src/managers/UserInputManager.spec.ts index 2471561e1..8cb370de1 100644 --- a/src/managers/UserInputManager.spec.ts +++ b/src/managers/UserInputManager.spec.ts @@ -9,6 +9,7 @@ import * as fsExtra from 'fs-extra'; import type { RokuDevice } from '../deviceDiscovery/DeviceManager'; import { DeviceManager } from '../deviceDiscovery/DeviceManager'; import { GlobalStateManager } from '../GlobalStateManager'; +import { CredentialStore } from './CredentialStore'; import { icons } from '../icons'; const sinon = createSandbox(); @@ -31,6 +32,7 @@ describe('UserInputManager', () => { let userInputManager: UserInputManager; let deviceManager: DeviceManager; let globalStateManager: GlobalStateManager; + let credentialStore: CredentialStore; beforeEach(() => { fsExtra.emptyDirSync(tempDir); @@ -42,7 +44,8 @@ describe('UserInputManager', () => { sinon.stub(DeviceManager.prototype as any, 'setupMonitors').callsFake(() => { }); globalStateManager = new GlobalStateManager(vscode.context); deviceManager = new DeviceManager(vscode.context, globalStateManager); - userInputManager = new UserInputManager(deviceManager); + credentialStore = new CredentialStore(vscode.context); + userInputManager = new UserInputManager(deviceManager, credentialStore); }); afterEach(() => { @@ -245,4 +248,138 @@ describe('UserInputManager', () => { await promptPromise.catch(() => { }); }); }); + + describe('collectDevicePasswordCandidates', () => { + //the new signature takes (serialNumber, extraCandidates); the launch-config equivalent is + //extraCandidates = [result.password, config.password] + const callCollect = ( + serialNumber: string | undefined, + extraCandidates: Array + ): Promise => (userInputManager as any).collectDevicePasswordCandidates(serialNumber, extraCandidates); + + beforeEach(async () => { + await credentialStore.clearAll(); + }); + + it('returns an empty list when every source is empty or a variable placeholder', async () => { + // eslint-disable-next-line no-template-curly-in-string + const candidates = await callCollect(undefined, ['${activeHostPassword}', '${promptForPassword}']); + expect(candidates).to.deep.equal([]); + }); + + it('filters out falsy entries', async () => { + const candidates = await callCollect(undefined, [undefined, '']); + expect(candidates).to.deep.equal([]); + }); + + it('includes the default password followed by the extra candidates in order', async () => { + sinon.stub(deviceManager, 'getDefaultPassword').returns('default-pw'); + const candidates = await callCollect(undefined, ['result-pw', 'config-pw']); + expect(candidates).to.deep.equal(['default-pw', 'result-pw', 'config-pw']); + }); + + it('dedupes candidates that appear in multiple sources, preserving first occurrence', async () => { + sinon.stub(deviceManager, 'getDefaultPassword').returns('shared-pw'); + const candidates = await callCollect(undefined, ['shared-pw', 'shared-pw']); + expect(candidates).to.deep.equal(['shared-pw']); + }); + + it('trims whitespace from candidates before deduping', async () => { + sinon.stub(deviceManager, 'getDefaultPassword').returns(' padded '); + const candidates = await callCollect(undefined, ['padded', 'padded']); + expect(candidates).to.deep.equal(['padded']); + }); + + it('puts the cred-store password first when a serial number is known', async () => { + sinon.stub(deviceManager, 'getDefaultPassword').returns('default-pw'); + await credentialStore.setPassword('SN-001', 'cred-store-pw'); + const candidates = await callCollect('SN-001', ['result-pw', 'config-pw']); + expect(candidates).to.deep.equal(['cred-store-pw', 'default-pw', 'result-pw', 'config-pw']); + }); + + it('skips cred-store and settings-by-SN sources when the serial number is undefined', async () => { + await credentialStore.setPassword('SN-001', 'cred-store-pw'); + const candidates = await callCollect(undefined, ['result-pw', 'config-pw']); + expect(candidates).to.not.include('cred-store-pw'); + }); + + it('excludes variable placeholders even when wrapped in whitespace', async () => { + // eslint-disable-next-line no-template-curly-in-string + const candidates = await callCollect(undefined, [' ${activeHostPassword} ', ' ${promptForPassword} ']); + expect(candidates).to.deep.equal([]); + }); + }); + + describe('resolveDevicePassword', () => { + beforeEach(async () => { + await credentialStore.clearAll(); + vscode.context.workspaceState['_data'] = {}; + sinon.stub(deviceManager, 'getDefaultPassword').returns(undefined); + }); + + it('returns the first candidate that validates ok and refreshes the existing cred-store entry', async () => { + await credentialStore.setPassword('SN-001', 'stored-pw'); + sinon.stub(deviceManager, 'validateDevicePassword').resolves('ok'); + + const resolution = await userInputManager.resolveDevicePassword({ host: '1.2.3.4', serialNumber: 'SN-001' }); + + expect(resolution).to.deep.equal({ status: 'ok', password: 'stored-pw' }); + expect(await credentialStore.getPassword('SN-001')).to.equal('stored-pw'); + }); + + it('moves past bad-password candidates and uses the first accepted one', async () => { + const stub = sinon.stub(deviceManager, 'validateDevicePassword') as any; + stub.onCall(0).resolves('bad-password'); + stub.onCall(1).resolves('ok'); + + const resolution = await userInputManager.resolveDevicePassword({ host: '1.2.3.4', serialNumber: 'SN-001', extraCandidates: ['rejected-pw', 'accepted-pw'] }); + + expect(resolution).to.deep.equal({ status: 'ok', password: 'accepted-pw' }); + expect(stub.callCount).to.equal(2); + }); + + it('reports unreachable without prompting', async () => { + await credentialStore.setPassword('SN-001', 'stored-pw'); + sinon.stub(deviceManager, 'validateDevicePassword').resolves('unreachable'); + const promptStub = sinon.stub(userInputManager as any, 'promptForDevicePassword'); + + const resolution = await userInputManager.resolveDevicePassword({ host: '1.2.3.4', serialNumber: 'SN-001' }); + + expect(resolution).to.deep.equal({ status: 'unreachable' }); + expect(promptStub.called).to.be.false; + }); + + it('prompts when every candidate is rejected, then accepts a typed password', async () => { + const stub = sinon.stub(deviceManager, 'validateDevicePassword') as any; + stub.onFirstCall().resolves('bad-password'); + stub.onSecondCall().resolves('ok'); + (sinon.stub(userInputManager as any, 'promptForDevicePassword') as any).resolves('typed-pw'); + + const resolution = await userInputManager.resolveDevicePassword({ host: '1.2.3.4', serialNumber: 'SN-001', extraCandidates: ['rejected-pw'] }); + + expect(resolution).to.deep.equal({ status: 'ok', password: 'typed-pw' }); + }); + + it('re-prompts after a rejected typed password and reports cancelled when the user cancels', async () => { + const validateStub = sinon.stub(deviceManager, 'validateDevicePassword') as any; + validateStub.resolves('bad-password'); + const promptStub = sinon.stub(userInputManager as any, 'promptForDevicePassword') as any; + promptStub.onCall(0).resolves('still-wrong'); + promptStub.onCall(1).resolves(undefined); + + const resolution = await userInputManager.resolveDevicePassword({ host: '1.2.3.4', serialNumber: undefined }); + + expect(resolution).to.deep.equal({ status: 'cancelled' }); + expect(promptStub.callCount).to.equal(2); + }); + + it('does not seed the cred store when no entry exists for the serial', async () => { + sinon.stub(deviceManager, 'validateDevicePassword').resolves('ok'); + + const resolution = await userInputManager.resolveDevicePassword({ host: '1.2.3.4', serialNumber: 'SN-NEW', extraCandidates: ['winning-pw'] }); + + expect(resolution).to.deep.equal({ status: 'ok', password: 'winning-pw' }); + expect(await credentialStore.getPassword('SN-NEW')).to.be.undefined; + }); + }); }); diff --git a/src/managers/UserInputManager.ts b/src/managers/UserInputManager.ts index 26a4ad9cd..35c6eeac5 100644 --- a/src/managers/UserInputManager.ts +++ b/src/managers/UserInputManager.ts @@ -4,7 +4,8 @@ import type { QuickPickItem } from 'vscode'; import * as vscode from 'vscode'; -import type { DeviceManager, RokuDevice } from '../deviceDiscovery/DeviceManager'; +import type { ConfiguredDevice, DeviceManager, RokuDevice } from '../deviceDiscovery/DeviceManager'; +import type { CredentialStore } from './CredentialStore'; import { icons } from '../icons'; import { vscodeContextManager } from './VscodeContextManager'; import { util } from '../util'; @@ -31,10 +32,19 @@ const manualLabel = 'Enter manually'; export const scanForDevicesItemId = `${Number.MAX_SAFE_INTEGER - 1}`; const scanForDevicesLabel = 'Scan for devices'; +/** + * The outcome of resolving a developer password for a device. + */ +export type DevicePasswordResolution = + | { status: 'ok'; password: string } + | { status: 'unreachable' } + | { status: 'cancelled' }; + export class UserInputManager { public constructor( - private deviceManager: DeviceManager + private deviceManager: DeviceManager, + private credentialStore: CredentialStore ) { } public async promptForHostManual(): Promise { @@ -59,6 +69,137 @@ export class UserInputManager { } } + /** + * Resolve a developer password that the device at `host` accepts. + * + * Every known candidate is tried in order (stored credential, configured + * `brightscript.devices[].password`, the default device password, and any caller-provided + * `extraCandidates`), each validated against the device. The first accepted candidate wins. + * If none are accepted, the user is prompted, re-prompting after each rejection until they + * enter a working password or cancel. An accepted password refreshes the credential store + * entry when one already exists; callers that keep a global password fallback persist that + * themselves. + * + * @returns `ok` with the accepted password, `unreachable` when the device can't be contacted, + * or `cancelled` when the user dismisses the prompt. + */ + public async resolveDevicePassword(options: { host: string; serialNumber: string | undefined; extraCandidates?: Array }): Promise { + const { host, serialNumber } = options; + const candidates = await this.collectDevicePasswordCandidates(serialNumber, options.extraCandidates); + + for (const candidate of candidates) { + const validation = await this.deviceManager.validateDevicePassword(host, candidate); + if (validation === 'ok') { + await this.persistDevicePassword(serialNumber, candidate); + return { status: 'ok', password: candidate }; + } + if (validation === 'unreachable') { + return { status: 'unreachable' }; + } + // 'bad-password' — fall through to the next candidate + } + + // No stored / configured candidate was accepted. Prompt, re-prompting after each + // bad-password attempt until the user enters a working one or cancels (empty / Esc). + let placeholder = candidates.length > 0 + ? 'The password was rejected by the device. Try again, or press Esc to cancel.' + : 'The Roku development webserver password.'; + while (true) { + const value = await this.promptForDevicePassword(placeholder); + if (!value) { + return { status: 'cancelled' }; + } + const validation = await this.deviceManager.validateDevicePassword(host, value); + if (validation === 'ok') { + await this.persistDevicePassword(serialNumber, value); + return { status: 'ok', password: value }; + } + if (validation === 'unreachable') { + return { status: 'unreachable' }; + } + placeholder = 'The password was rejected by the device. Try again, or press Esc to cancel.'; + } + } + + /** + * Build the ordered, de-duplicated list of candidate passwords to try when resolving + * credentials for a device. Variable placeholders and empty values are filtered out so the + * validation loop only sees real passwords. `extraCandidates` are appended after the + * standard sources (e.g. launch-config values for a debug session). + */ + private async collectDevicePasswordCandidates(serialNumber: string | undefined, extraCandidates?: Array): Promise { + const candidates: string[] = []; + const addCandidate = (value: string | undefined | null) => { + const trimmed = value?.trim(); + // eslint-disable-next-line no-template-curly-in-string + if (!trimmed || trimmed === '${promptForPassword}' || trimmed === '${activeHostPassword}') { + return; + } + candidates.push(trimmed); + }; + + if (serialNumber) { + addCandidate(await this.credentialStore.getPassword(serialNumber)); + + const scanScope = (devices: ConfiguredDevice[] | undefined) => { + for (const entry of devices ?? []) { + if (entry.serialNumber === serialNumber) { + addCandidate(entry.password); + } + } + }; + const rootInspection = vscode.workspace.getConfiguration('brightscript').inspect('devices'); + scanScope(rootInspection?.globalValue); + scanScope(rootInspection?.workspaceValue); + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const folderInspection = vscode.workspace.getConfiguration('brightscript', folder.uri).inspect('devices'); + scanScope(folderInspection?.workspaceFolderValue); + } + } + + addCandidate(this.deviceManager.getDefaultPassword()); + for (const extra of extraCandidates ?? []) { + addCandidate(extra); + } + + // Dedupe while preserving insertion order so a password referenced by multiple + // sources is only validated once. + return Array.from(new Set(candidates)); + } + + /** + * Persist an accepted password by refreshing the credential store, but only when an entry + * already exists for this serial (storing a brand-new entry is an explicit opt-in elsewhere). + */ + private async persistDevicePassword(serialNumber: string | undefined, password: string): Promise { + if (serialNumber && (await this.credentialStore.getPassword(serialNumber)) !== undefined) { + await this.credentialStore.setPassword(serialNumber, password); + } + } + + /** + * Password input dialog. Returns the typed value, or undefined on Esc / hide. + */ + private async promptForDevicePassword(placeholder: string): Promise { + const input = vscode.window.createInputBox(); + input.placeholder = placeholder; + input.password = true; + try { + return await new Promise(resolve => { + input.onDidAccept(() => { + resolve(input.value); + input.hide(); + }); + input.onDidHide(() => { + resolve(undefined); + }); + input.show(); + }); + } finally { + input.dispose(); + } + } + /** * Prompt the user to pick a host from a list of devices */ From 2433e3d7487b7249dcfa94571385293e8274f2d7 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Thu, 25 Jun 2026 12:48:05 -0300 Subject: [PATCH 20/20] Refine restart/update confirmation dialogs Use a concise title + detail modal for the restart and update confirmations, and drop the success notification now that the in-progress indicator covers that feedback. --- src/BrightScriptCommands.spec.ts | 7 +------ src/BrightScriptCommands.ts | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/BrightScriptCommands.spec.ts b/src/BrightScriptCommands.spec.ts index 4716f6c34..763d78a73 100644 --- a/src/BrightScriptCommands.spec.ts +++ b/src/BrightScriptCommands.spec.ts @@ -280,7 +280,6 @@ describe('BrightScriptFileUtils ', () => { let showWarningStub: sinon.SinonStub; let showInfoStub: sinon.SinonStub; let showErrorStub: sinon.SinonStub; - let showTimedNotificationStub: sinon.SinonStub; const device = { ip: '1.2.3.4', serialNumber: 'SN123', deviceInfo: {} }; @@ -306,8 +305,6 @@ describe('BrightScriptFileUtils ', () => { showInfoStub = sandbox.stub(vscode.window, 'showInformationMessage') as sinon.SinonStub; showInfoStub.resolves('Check for Updates'); showErrorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); - //the real helper runs a multi-second timer loop; stub it out - showTimedNotificationStub = sandbox.stub(Object.getPrototypeOf(util), 'showTimedNotification').resolves(); vscode.context.workspaceState['_data'] = {}; }); @@ -315,7 +312,7 @@ describe('BrightScriptFileUtils ', () => { sandbox.restore(); }); - it('passes the resolved password to rokuDeploy.rebootDevice and shows a timed success notification', async () => { + it('passes the resolved password to rokuDeploy.rebootDevice', async () => { userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' }); await localCommands.restartDevice('1.2.3.4'); @@ -323,7 +320,6 @@ describe('BrightScriptFileUtils ', () => { assert.isTrue(rebootStub.calledOnce); assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4'); assert.equal(rebootStub.firstCall.args[0].password, 'pw'); - assert.isTrue(showTimedNotificationStub.calledOnce, 'shows a timed success notification'); assert.isFalse(showErrorStub.called); }); @@ -392,7 +388,6 @@ describe('BrightScriptFileUtils ', () => { assert.isTrue(checkForUpdateStub.calledOnce); assert.equal(checkForUpdateStub.firstCall.args[0].host, '1.2.3.4'); assert.equal(checkForUpdateStub.firstCall.args[0].password, 'pw'); - assert.isTrue(showTimedNotificationStub.calledOnce, 'shows a timed success notification'); }); it('checkForUpdates aborts when the confirmation is dismissed', async () => { diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index 5e867807b..91bf9cd78 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -779,8 +779,11 @@ export class BrightScriptCommands { } const confirm = await vscode.window.showWarningMessage( - `Are you sure you want to restart ${target.label}? This will close all running channels.`, - { modal: true }, + `Restart Device?`, + { + detail: `Any running apps or processes will be terminated.\n\n${target.label}`, + modal: true + }, 'Restart' ); if (confirm !== 'Restart') { @@ -795,9 +798,8 @@ export class BrightScriptCommands { try { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, - title: `Restarting ${target.label}` + title: `Requesting restarting ${target.label}` }, () => rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 })); - void util.showTimedNotification(`Restart initiated on ${target.label}`); } catch (e) { void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); } @@ -814,9 +816,12 @@ export class BrightScriptCommands { } const confirm = await vscode.window.showInformationMessage( - `Check for software updates on ${target.label}? The device will check for and install any available updates.`, - { modal: true }, - 'Check for Updates' + `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; @@ -830,9 +835,8 @@ export class BrightScriptCommands { try { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, - title: `Checking for software updates on ${target.label}` + title: `Checking for updates: ${target.label}` }, () => rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 })); - void util.showTimedNotification(`Software update check initiated on ${target.label}`); } catch (e) { void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); }