diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts new file mode 100644 index 00000000..c285c3d5 --- /dev/null +++ b/src/RokuECP.spec.ts @@ -0,0 +1,493 @@ +import { expect } from 'chai'; +import { createSandbox } from 'sinon'; +import { describe } from 'mocha'; +import type { EcpAppStateData, EcpRegistryData } from './RokuECP'; +import { AppState, EcpStatus, rokuECP } from './RokuECP'; +import { util } from './util'; +import { expectThrowsAsync } from './testHelpers.spec'; + +const sinon = createSandbox(); + + +describe('RokuECP', () => { + + beforeEach(() => { + sinon.restore(); + }); + + describe('doRequest', () => { + it('correctly builds url without leading /', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080 + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8080/query/my-route`, undefined]); + }); + + it('correctly builds url with leading /', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080 + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8080/query/my-route`, undefined]); + }); + + it('passes request options if populated', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + requestOptions: { + timeout: 1000 + } + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8080/query/my-route`, options.requestOptions]); + }); + + it('uses default port 8060 when missing in options', async () => { + let options = { + host: '1.1.1.1' + }; + + let stub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options); + expect(stub.getCall(0).args).to.eql([`http://1.1.1.1:8060/query/my-route`, undefined]); + }); + + it('supports get and post methods', async () => { + let options = { + host: '1.1.1.1' + }; + + const getStub = sinon.stub(util as any, 'httpGet').resolves({ + body: '', + statusCode: 200 + }); + const postStub = sinon.stub(util as any, 'httpPost').resolves({ + body: '', + statusCode: 200 + }); + + await rokuECP['doRequest']('/query/my-route', options, 'get'); + expect(getStub.getCall(0).args).to.eql([`http://1.1.1.1:8060/query/my-route`, undefined]); + expect(getStub.callCount).to.eql(1); + expect(postStub.callCount).to.eql(0); + + await rokuECP['doRequest']('/query/my-route', options, 'post'); + expect(postStub.getCall(0).args).to.eql([`http://1.1.1.1:8060/query/my-route`, undefined]); + expect(getStub.callCount).to.eql(1); + expect(postStub.callCount).to.eql(1); + + }); + }); + + describe('getRegistry', () => { + it('calls doRequest with correct route and options', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + appId: 'dev' + }; + + let stub = sinon.stub(rokuECP as any, 'doRequest').resolves({ + body: '', + statusCode: 200 + }); + sinon.stub(rokuECP as any, 'processRegistry').resolves({}); + + await rokuECP.getRegistry(options); + expect(stub.getCall(0).args).to.eql(['query/registry/dev', options]); + }); + + }); + + describe('parseRegistry', () => { + describe('non-error responses', () => { + it('handles ok response with no other properties', async () => { + let response = { + body: ` + + OK + Plugin dev not found + + `, + statusCode: 200 + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + devId: undefined, + plugins: undefined, + spaceAvailable: undefined, + sections: {}, + status: EcpStatus.ok + } as EcpRegistryData); + }); + + it('handles ok response with empty sections', async () => { + let response = { + body: ` + + + + 12345 + 12,34,dev + 28075 + + + OK + + `, + statusCode: 200 + }; + + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + devId: '12345', + plugins: ['12', '34', 'dev'], + spaceAvailable: '28075', + sections: {}, + status: EcpStatus.ok + } as EcpRegistryData); + }); + + it('handles ok response with populated sections', async () => { + let response = { + body: ` + + + + 12345 + dev + 32590 + +
+ section One + + + first key in section one + value one section one + + +
+
+ section Two + + + first key in section two + value one section two + + + second key in section two + value two section two + + +
+
+
+ OK +
+ ` + }; + let result = await rokuECP['processRegistry'](response as any); + expect(result).to.eql({ + devId: '12345', + plugins: ['dev'], + sections: { + 'section One': { + 'first key in section one': 'value one section one' + }, + 'section Two': { + 'first key in section two': 'value one section two', + 'second key in section two': 'value two section two' + } + }, + spaceAvailable: '32590', + status: EcpStatus.ok + } as EcpRegistryData); + }); + }); + + describe('error responses', () => { + it('handles not in dev mode', async () => { + let response = { + body: ` + + FAILED + Plugin dev not found + + `, + statusCode: 200 + }; + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'Plugin dev not found'); + }); + + it('handles device not keyed', async () => { + let response = { + body: ` + + FAILED + Device not keyed + + `, + statusCode: 200 + }; + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'Device not keyed'); + }); + + it('handles failed status with missing error', async () => { + let response = { + body: ` + + FAILED + + `, + statusCode: 200 + }; + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'Unknown error'); + }); + + it('handles error response without xml', async () => { + let response = { + body: `ECP command not allowed in Limited mode.`, + statusCode: 403 + }; + await expectThrowsAsync(() => rokuECP['processRegistry'](response as any), 'ECP command not allowed in Limited mode.'); + }); + }); + }); + + describe('getAppState', () => { + it('calls doRequest with correct route and options', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + appId: 'dev' + }; + + let stub = sinon.stub(rokuECP as any, 'doRequest').resolves({ + body: '', + statusCode: 200 + }); + + sinon.stub(rokuECP as any, 'processAppState').resolves({}); + + await rokuECP.getAppState(options); + expect(stub.getCall(0).args).to.eql(['query/app-state/dev', options]); + }); + }); + + describe('processAppState', () => { + describe('non-error responses', () => { + it('handles ok response', async () => { + let response = { + body: ` + + + dev + my app + 10.0.0 + 12345 + active + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + appId: 'dev', + appDevId: '12345', + appTitle: 'my app', + appVersion: '10.0.0', + state: AppState.active, + status: EcpStatus.ok + } as EcpAppStateData); + }); + + it('handles ok response with unknown state', async () => { + let response = { + body: ` + + + dev + my app + 10.0.0 + 12345 + bad + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + appId: 'dev', + appDevId: '12345', + appTitle: 'my app', + appVersion: '10.0.0', + state: AppState.unknown, + status: EcpStatus.ok + } as EcpAppStateData); + }); + + it('handles ok response with uppercase state', async () => { + let response = { + body: ` + + + dev + my app + 10.0.0 + 12345 + ACTIVE + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processAppState'](response as any); + expect(result).to.eql({ + appId: 'dev', + appDevId: '12345', + appTitle: 'my app', + appVersion: '10.0.0', + state: AppState.active, + status: EcpStatus.ok + } as EcpAppStateData); + }); + }); + + describe('error responses', () => { + it('handles failed status with missing error', async () => { + let response = { + body: ` + + FAILED + + `, + statusCode: 200 + }; + await expectThrowsAsync(() => rokuECP['processAppState'](response as any), 'Unknown error'); + }); + + it('handles failed status with populated error', async () => { + let response = { + body: ` + + FAILED + App not found + + `, + statusCode: 200 + }; + await expectThrowsAsync(() => rokuECP['processAppState'](response as any), 'App not found'); + }); + + it('handles error response without xml', async () => { + let response = { + body: `ECP command not allowed in Limited mode.`, + statusCode: 403 + }; + await expectThrowsAsync(() => rokuECP['processAppState'](response as any), 'ECP command not allowed in Limited mode.'); + }); + }); + }); + + describe('exitApp', () => { + it('calls doRequest with correct route and options', async () => { + let options = { + host: '1.1.1.1', + remotePort: 8080, + appId: 'dev' + }; + + let stub = sinon.stub(rokuECP as any, 'doRequest').resolves({ + body: '', + statusCode: 200 + }); + sinon.stub(rokuECP as any, 'processExitApp').resolves({}); + + await rokuECP.exitApp(options); + expect(stub.getCall(0).args).to.eql(['exit-app/dev', options, 'post']); + }); + }); + + describe('processExitApp', () => { + describe('non-error responses', () => { + it('handles ok response', async () => { + let response = { + body: ` + + + OK + + `, + statusCode: 200 + }; + let result = await rokuECP['processExitApp'](response as any); + expect(result).to.eql({ + status: EcpStatus.ok + }); + }); + }); + + describe('error responses', () => { + it('handles failed status with missing error', async () => { + let response = { + body: ` + + FAILED + + `, + statusCode: 200 + }; + await expectThrowsAsync(() => rokuECP['processExitApp'](response as any), 'Unknown error'); + }); + + it('handles failed status with populated error', async () => { + let response = { + body: ` + + FAILED + App not found + + `, + statusCode: 200 + }; + await expectThrowsAsync(() => rokuECP['processExitApp'](response as any), 'App not found'); + }); + + it('handles error response without xml', async () => { + let response = { + body: `ECP command not allowed in Limited mode.`, + statusCode: 403 + }; + await expectThrowsAsync(() => rokuECP['processExitApp'](response as any), 'ECP command not allowed in Limited mode.'); + }); + }); + }); +}); diff --git a/src/RokuECP.ts b/src/RokuECP.ts new file mode 100644 index 00000000..a421c3da --- /dev/null +++ b/src/RokuECP.ts @@ -0,0 +1,185 @@ +import { util } from './util'; +import type * as requestType from 'request'; +import type { Response } from 'request'; + +export class RokuECP { + private async doRequest(route: string, options: BaseOptions, method: 'post' | 'get' = 'get'): Promise { + const url = `http://${options.host}:${options.remotePort ?? 8060}/${route.replace(/^\//, '')}`; + if (method === 'post') { + return util.httpPost(url, options.requestOptions); + } else { + return util.httpGet(url, options.requestOptions); + } + } + + private getEcpStatus(response: ParsedEcpRoot, rootKey: string): EcpStatus { + return EcpStatus[response?.[rootKey]?.status?.[0]?.toLowerCase()] ?? EcpStatus.failed; + } + + private async parseResponse(response: Response, rootKey: string, callback: (parsed: any, status: EcpStatus) => R): Promise { + if (typeof response.body === 'string') { + let parsed: ParsedEcpRoot; + try { + parsed = await util.parseXml(response.body); + } catch { + //if the response is not xml, just return the body as-is + throw new Error(response.body ?? 'Unknown error'); + } + + const status = this.getEcpStatus(parsed, rootKey); + if (status === EcpStatus.ok) { + return callback(parsed?.[rootKey], status); + } else { + throw new Error(parsed?.[rootKey]?.error?.[0] ?? 'Unknown error'); + } + } + } + + public async getRegistry(options: BaseOptions & { appId: string }) { + let result = await this.doRequest(`query/registry/${options.appId}`, options); + return this.processRegistry(result); + } + + private async processRegistry(response: Response) { + return this.parseResponse(response, 'plugin-registry', (parsed: RegistryAsJson, status): EcpRegistryData => { + const registry = parsed?.registry?.[0]; + let sections: EcpRegistryData['sections'] = {}; + + for (const section of registry?.sections?.[0]?.section ?? []) { + if (typeof section === 'string') { + continue; + } + let sectionName = section.name[0]; + for (const item of section.items[0].item) { + sections[sectionName] ??= {}; + sections[sectionName][item.key[0]] = item.value[0]; + } + } + + return { + devId: registry?.['dev-id']?.[0], + plugins: registry?.plugins?.[0]?.split(','), + sections: sections, + spaceAvailable: registry?.['space-available']?.[0], + status: status + }; + }); + } + + public async getAppState(options: BaseOptions & { appId: string }) { + let result = await this.doRequest(`query/app-state/${options.appId}`, options); + return this.processAppState(result); + } + + private async processAppState(response: Response) { + return this.parseResponse(response, 'app-state', (parsed: AppStateAsJson, status): EcpAppStateData => { + const state = AppState[parsed.state?.[0]?.toLowerCase()] ?? AppState.unknown; + return { + appId: parsed['app-id']?.[0], + appDevId: parsed['app-dev-id']?.[0], + appTitle: parsed['app-title']?.[0], + appVersion: parsed['app-version']?.[0], + state: state, + status: status + }; + }); + } + + public async exitApp(options: BaseOptions & { appId: string }): Promise { + let result = await this.doRequest(`exit-app/${options.appId}`, options, 'post'); + return this.processExitApp(result); + } + + private async processExitApp(response: Response): Promise { + return this.parseResponse(response, 'exit-app', (parsed: ExitAppAsJson, status): EcpExitAppData => { + return { status: status }; + }); + } +} + +export enum EcpStatus { + ok = 'ok', + failed = 'failed' +} +interface BaseOptions { + host: string; + remotePort?: number; + requestOptions?: requestType.CoreOptions; +} + +interface BaseEcpResponse { + status: EcpStatus; + errorMessage?: string; +} + +export type RokuEcpParam = Parameters[0]; + +export type ParsedEcpRoot = { + [key in T1]: T2; +}; + +interface ParsedEcpBase { + status?: [string]; + error?: [string] ; +} + +interface RegistryAsJson extends ParsedEcpBase { + registry: [{ + 'dev-id': [string]; + plugins: [string]; + sections: [{ + section: [{ + items: [{ + item: [{ + key: [string]; + value: [string]; + }]; + }]; + name: [string]; + } | string]; + }]; + 'space-available': [string]; + }]; +} + +export interface EcpRegistryData extends BaseEcpResponse { + devId?: string; + plugins?: Array; + sections?: Record>; + spaceAvailable?: string; + state?: string; +} +interface AppStateAsJson extends ParsedEcpBase { + 'app-id': [string]; + 'app-title': [string]; + 'app-version': [string]; + 'app-dev-id': [string]; + state: ['active' | 'background' | 'inactive']; +} + +export enum AppState { + active = 'active', + background = 'background', + inactive = 'inactive', + unknown = 'unknown' +} + +export interface EcpAppStateData { + appId?: string; + appTitle?: string; + appVersion?: string; + appDevId?: string; + state?: AppState; + status: EcpStatus; + errorMessage?: string; +} + +type ExitAppAsJson = ParsedEcpBase; + +export interface EcpExitAppData { + status: EcpStatus; + errorMessage?: string; +} + + +export const rokuECP = new RokuECP(); diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index c1b4c2f6..49215c16 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -58,6 +58,7 @@ import { interfaces, components, events } from 'brighterscript/dist/roku-types'; import { globalCallables } from 'brighterscript/dist/globalCallables'; import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool'; import { populateVariableFromRegistryEcp } from './ecpRegistryUtils'; +import { AppState, rokuECP } from '../RokuECP'; const diagnosticSource = 'roku-debug'; @@ -1416,10 +1417,8 @@ export class BrightScriptDebugSession extends BaseDebugSession { if (v.type === '$$Registry' && v.childVariables.length === 0) { // This is a special scope variable used to load registry data via an ECP call - const url = `http://${this.launchConfiguration.host}:${this.launchConfiguration.remotePort}/query/registry/dev`; // Send the registry ECP call for the `dev` app as side loaded apps are always `dev` - const response = await util.httpGet(url); - await populateVariableFromRegistryEcp(response, v, this.variables, this.getEvaluateRefId.bind(this)); + await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this)); } //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable @@ -2047,11 +2046,53 @@ export class BrightScriptDebugSession extends BaseDebugSession { this.rokuAdapter.removeAllListeners(); } await this.rokuAdapter.destroy(); + await this.ensureAppIsInactive(); this.rokuAdapterDeferred = defer(); } await this.launchRequest(response, args.arguments as LaunchConfiguration); } + private exitAppTimeout = 5000; + private async ensureAppIsInactive() { + const startTime = Date.now(); + + while (true) { + if (Date.now() - startTime > this.exitAppTimeout) { + return; + } + + try { + let appStateResult = await rokuECP.getAppState({ + host: this.launchConfiguration.host, + remotePort: this.launchConfiguration.remotePort, + appId: 'dev', + requestOptions: { timeout: 300 } + }); + + const state = appStateResult.state; + + if (state === AppState.active || state === AppState.background) { + // Suspends or terminates an app that is running: + // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background). + // If the app supports Instant Resume and is running in the background or the app does not support Instant Resume and is running, sending this command terminates the app. + // This means that we might need to send this command twice to terminate the app. + await rokuECP.exitApp({ + host: this.launchConfiguration.host, + remotePort: this.launchConfiguration.remotePort, + appId: 'dev', + requestOptions: { timeout: 300 } + }); + } else if (state === AppState.inactive) { + return; + } + } catch (e) { + this.logger.error('Error attempting to exit application', e); + } + + await util.sleep(200); + } + } + /** * Used to track whether the entry breakpoint has already been handled */ diff --git a/src/debugSession/ecpRegistryUtils.spec.ts b/src/debugSession/ecpRegistryUtils.spec.ts index 96bc5105..1ef68d30 100644 --- a/src/debugSession/ecpRegistryUtils.spec.ts +++ b/src/debugSession/ecpRegistryUtils.spec.ts @@ -4,6 +4,10 @@ import { VariableType } from '../debugProtocol/events/responses/VariablesRespons import type { AugmentedVariable } from './BrightScriptDebugSession'; import { BrightScriptDebugSession } from './BrightScriptDebugSession'; import { populateVariableFromRegistryEcp } from './ecpRegistryUtils'; +import { rokuECP } from '../RokuECP'; +import { createSandbox } from 'sinon'; + +const sinon = createSandbox(); describe('ecpRegistryUtils', () => { let session: BrightScriptDebugSession; @@ -12,6 +16,10 @@ describe('ecpRegistryUtils', () => { session = new BrightScriptDebugSession(); }); + afterEach(() => { + sinon.restore(); + }); + describe('populateVariableFromRegistryEcp', () => { let refFactory = (key: string, frameId: number) => session['getEvaluateRefId'](key, frameId); @@ -34,7 +42,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` OK @@ -42,7 +51,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'sections', @@ -84,7 +95,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` @@ -98,7 +110,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(4); expect(v.childVariables[0]).to.eql({ name: 'devId', @@ -173,7 +187,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` @@ -210,7 +225,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(4); expect(v.childVariables[0]).to.eql({ name: 'devId', @@ -334,7 +351,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` FAILED @@ -342,7 +360,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', @@ -361,7 +381,8 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` FAILED @@ -369,7 +390,9 @@ describe('ecpRegistryUtils', () => { `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', @@ -388,14 +411,17 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: ` FAILED `, statusCode: 200 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', @@ -414,10 +440,13 @@ describe('ecpRegistryUtils', () => { type: '$$Registry', childVariables: [] }; - await populateVariableFromRegistryEcp({ + + sinon.stub(rokuECP as any, 'doRequest').returns(Promise.resolve({ body: `ECP command not allowed in Limited mode.`, statusCode: 403 - } as Response, v, session['variables'], refFactory); + } as Response)); + + await populateVariableFromRegistryEcp({ host: '', appId: '' }, v, session['variables'], refFactory); expect(v.childVariables.length).to.eql(1); expect(v.childVariables[0]).to.eql({ name: 'error', diff --git a/src/debugSession/ecpRegistryUtils.ts b/src/debugSession/ecpRegistryUtils.ts index 576fbf91..e3f1f9a8 100644 --- a/src/debugSession/ecpRegistryUtils.ts +++ b/src/debugSession/ecpRegistryUtils.ts @@ -1,12 +1,11 @@ import { VariableType } from '../debugProtocol/events/responses/VariablesResponse'; -import { util } from '../util'; +import { EcpStatus, rokuECP } from '../RokuECP'; +import type { RokuEcpParam } from '../RokuECP'; import type { AugmentedVariable } from './BrightScriptDebugSession'; -import type { Response } from 'request'; -export async function populateVariableFromRegistryEcp(response: Response, v: AugmentedVariable, variables: Record, refIdFactory: (key: string, frameId: number) => number) { - let registryData = await convertRegistryEcpResponseToScope(response); - - if (registryData.status === 'OK') { +export async function populateVariableFromRegistryEcp(options: RokuEcpParam<'getRegistry'>, v: AugmentedVariable, variables: Record, refIdFactory: (key: string, frameId: number) => number) { + try { + let registryData = await rokuECP.getRegistry(options); // Add registry data to variable list if (registryData.devId) { v.childVariables.push({ @@ -114,91 +113,13 @@ export async function populateVariableFromRegistryEcp(response: Response, v: Aug return sectionItemVariable; })); } - } else { + } catch (e) { v.childVariables.push({ name: 'error', - value: `❌ Error: ${registryData.errorMessage ?? 'Unknown error'}`, + value: `❌ Error: ${e.message ?? 'Unknown error'}`, type: VariableType.String, variablesReference: 0, childVariables: [] }); } } - -async function convertRegistryEcpResponseToScope(response: Response): Promise { - if (typeof response.body === 'string') { - try { - let parsed = await util.parseXml(response.body) as RegistryAsJson; - - const status = parsed['plugin-registry'].status[0]; - - if (status === 'OK') { - - let registry = parsed['plugin-registry'].registry?.[0]; - - let sections: EcpRegistryData['sections'] = {}; - for (const section of registry?.sections?.[0]?.section ?? []) { - if (typeof section === 'string') { - continue; - } - let sectionName = section.name[0]; - for (const item of section.items[0].item) { - sections[sectionName] ??= {}; - sections[sectionName][item.key[0]] = item.value[0]; - } - } - - return { - devId: registry?.['dev-id']?.[0], - plugins: registry?.plugins?.[0]?.split(','), - sections: sections, - spaceAvailable: registry?.['space-available']?.[0], - status: 'OK' - }; - } else { - return { - status: 'FAILED', - errorMessage: parsed?.['plugin-registry']?.error?.[0] ?? 'Unknown error' - }; - } - } catch { - //if the response is not xml, just return the body as-is - return { - status: 'FAILED', - errorMessage: response.body - }; - } - } -} - -export interface EcpRegistryData { - devId?: string; - plugins?: Array; - sections?: Record>; - spaceAvailable?: string; - status: 'OK' | 'FAILED'; - errorMessage?: string; -} - -interface RegistryAsJson { - 'plugin-registry': { - registry: [{ - 'dev-id': [string]; - plugins: [string]; - sections: [{ - section: [{ - items: [{ - item: [{ - key: [string]; - value: [string]; - }]; - }]; - name: [string]; - } | string]; - }]; - 'space-available': [string]; - }]; - status: [string]; - error?: [string]; - }; -} diff --git a/src/util.ts b/src/util.ts index 87dded56..51e0ff6d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -425,9 +425,9 @@ class Util { /** * Do an http GET request */ - public httpGet(url: string) { + public httpGet(url: string, options?: requestType.CoreOptions) { return new Promise((resolve, reject) => { - request.get(url, (err, response) => { + request.get(url, options, (err, response) => { return err ? reject(err) : resolve(response); }); }); @@ -514,7 +514,7 @@ class Util { /** * Parse an xml file and get back a javascript object containing its results */ - public parseXml(text: string) { + public parseXml(text: string): Promise { return new Promise((resolve, reject) => { xml2js.parseString(text, (err, data) => { if (err) {