diff --git a/package.json b/package.json index 8fae4521..9022b0c4 100644 --- a/package.json +++ b/package.json @@ -3896,6 +3896,18 @@ "category": "BrightScript", "icon": "$(refresh)" }, + { + "command": "extension.brightscript.devicesView.restartDevice", + "title": "Restart Device", + "category": "BrightScript", + "icon": "$(debug-restart)" + }, + { + "command": "extension.brightscript.devicesView.checkAndInstallUpdates", + "title": "Check for Software Updates", + "category": "BrightScript", + "icon": "$(cloud-download)" + }, { "command": "extension.brightscript.addDeviceToUserSettings", "title": "Add to User Settings", diff --git a/src/BrightScriptCommands.spec.ts b/src/BrightScriptCommands.spec.ts index 586570de..763d78a7 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,179 @@ describe('BrightScriptFileUtils ', () => { }); }); + describe('restartDevice / checkForUpdates', () => { + let sandbox: sinon.SinonSandbox; + let localCommands: BrightScriptCommands; + let deviceManager: any; + let userInputManager: any; + let rebootStub: sinon.SinonStub; + let checkForUpdateStub: sinon.SinonStub; + let showWarningStub: sinon.SinonStub; + let showInfoStub: sinon.SinonStub; + let showErrorStub: sinon.SinonStub; + + const device = { ip: '1.2.3.4', serialNumber: 'SN123', deviceInfo: {} }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + deviceManager = { + validateAndAddDevice: sandbox.stub().resolves(device), + getDevice: sandbox.stub().returns(device), + getDeviceDisplayName: sandbox.stub().returns('Roku Express – 1.2.3.4') + }; + //password resolution is delegated to UserInputManager (tested in its own spec) + userInputManager = { + promptForHost: sandbox.stub().resolves('1.2.3.4'), + resolveDevicePassword: sandbox.stub().resolves({ status: 'ok', password: 'pw' }) + }; + //ctor: remoteControlManager, whatsNewManager, context, deviceManager, userInputManager, localPackageManager, credentialStore + localCommands = new BrightScriptCommands({} as any, {} as any, vscode.context, deviceManager, userInputManager, {} as any, {} as any); + + rebootStub = sandbox.stub(rokuDeploy, 'rebootDevice').resolves({} as any); + checkForUpdateStub = sandbox.stub(rokuDeploy, 'checkForUpdate').resolves({} as any); + showWarningStub = sandbox.stub(vscode.window, 'showWarningMessage') as sinon.SinonStub; + showWarningStub.resolves('Restart'); + showInfoStub = sandbox.stub(vscode.window, 'showInformationMessage') as sinon.SinonStub; + showInfoStub.resolves('Check for Updates'); + showErrorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + vscode.context.workspaceState['_data'] = {}; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('passes the resolved password to rokuDeploy.rebootDevice', async () => { + userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' }); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(rebootStub.calledOnce); + assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4'); + assert.equal(rebootStub.firstCall.args[0].password, 'pw'); + assert.isFalse(showErrorStub.called); + }); + + it('resolves the password against the probed device, offering remotePassword as an extra candidate', async () => { + vscode.context.workspaceState['_data'].remotePassword = 'global-pw'; + + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(deviceManager.validateAndAddDevice.calledWith('1.2.3.4')); + assert.isTrue(userInputManager.resolveDevicePassword.calledOnce); + const args = userInputManager.resolveDevicePassword.firstCall.args[0]; + assert.equal(args.host, '1.2.3.4'); + assert.equal(args.serialNumber, 'SN123'); + assert.deepEqual(args.extraCandidates, ['global-pw']); + }); + + it('aborts without resolving a password or rebooting when the confirmation is dismissed', async () => { + showWarningStub.resolves(undefined); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isFalse(userInputManager.resolveDevicePassword.called, 'password should not be resolved when cancelled'); + assert.isFalse(rebootStub.called); + }); + + it('cancels when password resolution is cancelled', async () => { + userInputManager.resolveDevicePassword.resolves({ status: 'cancelled' }); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isFalse(rebootStub.called); + }); + + it('shows an error and does not reboot when the device is unreachable', async () => { + userInputManager.resolveDevicePassword.resolves({ status: 'unreachable' }); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isFalse(rebootStub.called); + assert.isTrue(showErrorStub.calledOnce); + }); + + it('always prompts for the device with the picker when no host is provided', async () => { + await localCommands.restartDevice(); + + assert.isTrue(userInputManager.promptForHost.calledOnce); + assert.isTrue(rebootStub.calledOnce); + assert.equal(rebootStub.firstCall.args[0].host, '1.2.3.4'); + }); + + it('cancels when the device picker is dismissed', async () => { + userInputManager.promptForHost.rejects(new Error('No host was selected')); + + await localCommands.restartDevice(); + + assert.isFalse(rebootStub.called); + assert.isFalse(deviceManager.validateAndAddDevice.called); + assert.isFalse(userInputManager.resolveDevicePassword.called); + }); + + it('checkForUpdates passes the resolved password to rokuDeploy.checkForUpdate', async () => { + userInputManager.resolveDevicePassword.resolves({ status: 'ok', password: 'pw' }); + + await localCommands.checkForUpdates('1.2.3.4'); + + assert.isTrue(checkForUpdateStub.calledOnce); + assert.equal(checkForUpdateStub.firstCall.args[0].host, '1.2.3.4'); + assert.equal(checkForUpdateStub.firstCall.args[0].password, 'pw'); + }); + + it('checkForUpdates aborts when the confirmation is dismissed', async () => { + showInfoStub.resolves(undefined); + + await localCommands.checkForUpdates('1.2.3.4'); + + assert.isFalse(checkForUpdateStub.called); + }); + + it('surfaces a rokuDeploy failure as an error message', async () => { + rebootStub.rejects(new Error('boom')); + + await localCommands.restartDevice('1.2.3.4'); + + assert.isTrue(showErrorStub.calledOnce); + assert.include(showErrorStub.firstCall.args[0], 'boom'); + }); + + describe('command registration', () => { + let capturedCommands: Record 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 e566b1ae..91bf9cd7 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,131 @@ export class BrightScriptCommands { }); } + /** + * Restart a Roku device. When `host` is omitted (e.g. invoked from the command palette) + * a device picker is shown. Resolves and validates the developer password the same way a + * debug launch does before issuing the reboot. + */ + public async restartDevice(host?: string): Promise { + const target = await this.resolveDeviceHost(host); + if (!target) { + return; + } + + const confirm = await vscode.window.showWarningMessage( + `Restart Device?`, + { + detail: `Any running apps or processes will be terminated.\n\n${target.label}`, + modal: true + }, + 'Restart' + ); + if (confirm !== 'Restart') { + return; + } + + const password = await this.resolveValidatedPassword(target.host, target.serialNumber); + if (password === undefined) { + return; + } + + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Requesting restarting ${target.label}` + }, () => rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 })); + } catch (e) { + void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`); + } + } + + /** + * Ask a Roku device to check for and install any available software updates. Host and + * password are resolved the same way as `restartDevice`. + */ + public async checkForUpdates(host?: string): Promise { + const target = await this.resolveDeviceHost(host); + if (!target) { + return; + } + + const confirm = await vscode.window.showInformationMessage( + `Check for Updates?`, + { + detail: `Device will check for app and Roku OS updates.\n\nAny running apps or processes will be terminated.\n\n${target.label}`, + modal: true + }, + `Check for Updates` + ); + if (confirm !== 'Check for Updates') { + return; + } + + const password = await this.resolveValidatedPassword(target.host, target.serialNumber); + if (password === undefined) { + return; + } + + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Checking for updates: ${target.label}` + }, () => rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 })); + } catch (e) { + void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`); + } + } + + /** + * Resolve the device a device-targeted command should act on. + * + * When `host` is provided (e.g. from a Devices view tree item) it is used directly. + * Otherwise the device picker is always shown; these are device-specific actions, so we + * never silently fall back to the active device. The resolved host is probed so the caller + * has a fresh serial number for password lookup and a friendly display label for prompts. + * + * Returns undefined when the user cancels device selection. + */ + private async resolveDeviceHost(host?: string): Promise<{ host: string; serialNumber: string | undefined; label: string } | undefined> { + if (!host) { + try { + host = await this.userInputManager.promptForHost(); + } catch { + // promptForHost rejects when the user dismisses the picker; treat as a cancel. + return undefined; + } + } + if (!host) { + return undefined; + } + + const device = await this.deviceManager.validateAndAddDevice(host); + const label = device ? this.deviceManager.getDeviceDisplayName(device, true) : host; + return { host: host, serialNumber: device?.serialNumber, label: label }; + } + + /** + * Resolve a developer password the device accepts, delegating the candidate/validate/prompt/ + * persist flow to the shared resolver. The global `remotePassword` fallback is offered as an + * extra candidate. Shows an error and returns undefined when the device is unreachable, and + * returns undefined when the user cancels the prompt. + */ + private async resolveValidatedPassword(host: string, serialNumber: string | undefined): Promise { + const resolution = await this.userInputManager.resolveDevicePassword({ + host: host, + serialNumber: serialNumber, + extraCandidates: [await this.context.workspaceState.get('remotePassword')] + }); + if (resolution.status === 'unreachable') { + void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`); + return undefined; + } + if (resolution.status === 'cancelled') { + return undefined; + } + return resolution.password; + } + public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) { for (const notifier of this.keypressNotifiers) { notifier(key, literalCharacter); @@ -1035,6 +1161,15 @@ export class BrightScriptCommands { this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler); } this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters()); + // These also appear in the command palette, where there's no tree element; resolving + // a missing/unknown key to undefined lets `restartDevice`/`checkForUpdates` fall back + // to the active device or device picker. + this.registerCommand('devicesView.restartDevice', (element?: { key?: string }) => { + return this.restartDevice(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined); + }); + this.registerCommand('devicesView.checkAndInstallUpdates', (element?: { key?: string }) => { + return this.checkForUpdates(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined); + }); } private async sendAsciiToDevice(character: string) { diff --git a/src/DebugConfigurationProvider.spec.ts b/src/DebugConfigurationProvider.spec.ts index baf20f53..a6841873 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 86008b33..0bc676b6 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/commands/VscodeCommand.ts b/src/commands/VscodeCommand.ts index 2a2d6fe0..f3b23d2b 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', + 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/extension.ts b/src/extension.ts index f122d44d..93d0475d 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 2471561e..8cb370de 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 26a4ad9c..35c6eeac 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 */ diff --git a/src/viewProviders/DevicesViewProvider.ts b/src/viewProviders/DevicesViewProvider.ts index 2ad82285..7c7e6f69 100644 --- a/src/viewProviders/DevicesViewProvider.ts +++ b/src/viewProviders/DevicesViewProvider.ts @@ -327,6 +327,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: [{ key: element.key }] + } + }) + ); + + 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: [{ key: element.key }] + } + }) + ); + } + result.unshift( this.createDeviceInfoTreeItem({ label: '🔗 Open device web portal',