From 4cf368b48682d83cdc0e2e4e5bed7dc7f97355fa Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Tue, 12 May 2026 21:08:52 -0300 Subject: [PATCH 1/2] Use ECP launch to start the sideloaded channel Sets `autoLaunch: false` on the sideload so the device installs without launching, then posts `/launch/dev` via ECP to start the channel. When `deepLinkUrl` is set, its query string is appended to the launch call. --- package-lock.json | 9 +-- package.json | 2 +- src/RokuECP.spec.ts | 59 +++++++++++++++++++ src/RokuECP.ts | 27 +++++++++ .../BrightScriptDebugSession.spec.ts | 40 +++++++++++++ src/debugSession/BrightScriptDebugSession.ts | 32 +++++----- 6 files changed, 149 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 06de8125..cc398759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "postman-request": "^2.88.1-postman.40", "replace-in-file": "^6.3.2", "replace-last": "^1.2.6", - "roku-deploy": "^3.17.2", + "roku-deploy": "^3.17.3", "semver": "^7.5.4", "serialize-error": "^8.1.0", "smart-buffer": "^4.2.0", @@ -5534,9 +5534,10 @@ } }, "node_modules/roku-deploy": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.17.2.tgz", - "integrity": "sha512-+raRjlp6nkrx3+eLDotJc03tK37t9+Xv2vT+X52+c5FbMQS3zG46KchjCYRIkAtjApw5bC92nYEcU0W9xrcDKw==", + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.17.3.tgz", + "integrity": "sha512-u9xv3ug8Hfhdo0B1+YE8xzaKBuS3hahM4g4gfAief10b5WkT+4jSLGMa/84EUD/HfEDj8h3Dg7Q3gm0StB8B4Q==", + "license": "MIT", "dependencies": { "@types/request": "^2.47.0", "chalk": "^2.4.2", diff --git a/package.json b/package.json index 87017c23..793e60aa 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "postman-request": "^2.88.1-postman.40", "replace-in-file": "^6.3.2", "replace-last": "^1.2.6", - "roku-deploy": "^3.17.2", + "roku-deploy": "^3.17.3", "semver": "^7.5.4", "serialize-error": "^8.1.0", "smart-buffer": "^4.2.0", diff --git a/src/RokuECP.spec.ts b/src/RokuECP.spec.ts index 35b38a90..4e9da34d 100644 --- a/src/RokuECP.spec.ts +++ b/src/RokuECP.spec.ts @@ -437,6 +437,65 @@ describe('RokuECP', () => { }); }); + describe('launchApp', () => { + function stubDoRequest() { + return sinon.stub(rokuECP as any, 'doRequest').resolves({ body: '', statusCode: 200 }); + } + + it('posts to /launch/ with no query string when no params are supplied', async () => { + const stub = stubDoRequest(); + await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev' }); + expect(stub.getCall(0).args[0]).to.equal('launch/dev'); + expect(stub.getCall(0).args[2]).to.equal('post'); + }); + + it('honors a non-dev channelId', async () => { + const stub = stubDoRequest(); + await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: '12345' }); + expect(stub.getCall(0).args[0]).to.equal('launch/12345'); + }); + + it('treats a raw key=value string as the query string', async () => { + const stub = stubDoRequest(); + await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev', params: 'contentId=abc&mediaType=movie' }); + expect(stub.getCall(0).args[0]).to.equal('launch/dev?contentId=abc&mediaType=movie'); + }); + + it('passes a leading-? query string through unchanged', async () => { + const stub = stubDoRequest(); + await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev', params: '?contentId=abc' }); + expect(stub.getCall(0).args[0]).to.equal('launch/dev?contentId=abc'); + }); + + it('extracts the query string from a full URL', async () => { + const stub = stubDoRequest(); + await rokuECP.launchApp({ + host: '1.1.1.1', + remotePort: 8080, + channelId: 'dev', + params: 'http://9.9.9.9:8060/launch/dev?contentId=abc&mediaType=movie' + }); + expect(stub.getCall(0).args[0]).to.equal('launch/dev?contentId=abc&mediaType=movie'); + }); + + it('returns no query string when a full URL has no query', async () => { + const stub = stubDoRequest(); + await rokuECP.launchApp({ + host: '1.1.1.1', + remotePort: 8080, + channelId: 'dev', + params: 'http://9.9.9.9:8060/launch/dev' + }); + expect(stub.getCall(0).args[0]).to.equal('launch/dev'); + }); + + it('treats empty params the same as omitted', async () => { + const stub = stubDoRequest(); + await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev', params: '' }); + expect(stub.getCall(0).args[0]).to.equal('launch/dev'); + }); + }); + describe('captureHeapSnapshot', () => { it('calls doRequest with correct route and options', async () => { let options = { diff --git a/src/RokuECP.ts b/src/RokuECP.ts index c0a843b2..ec308c19 100644 --- a/src/RokuECP.ts +++ b/src/RokuECP.ts @@ -124,6 +124,33 @@ export class RokuECP { return this.processExitApp(result); } + /** + * Launches a channel via ECP. Pass `dev` to launch the currently sideloaded developer channel. + * Any `params` are appended as a query string, which Roku will surface to the channel as launch arguments (deep link). + * Accepts a full URL, a leading-`?` query string, or a raw `key=value&...` query string. + */ + public async launchApp(options: BaseOptions & { channelId: string; params?: string }): Promise { + const suffix = this.buildLaunchQueryString(options.params); + return this.doRequest(`launch/${options.channelId}${suffix}`, options, 'post'); + } + + private buildLaunchQueryString(params: string | undefined): string { + if (!params) { + return ''; + } + if (params.startsWith('?')) { + return params; + } + if (/^https?:\/\//i.test(params)) { + try { + return new URL(params).search; + } catch { + //fall through to raw handling + } + } + return `?${params}`; + } + private async processExitApp(response: Response): Promise { return this.parseResponse(response, 'exit-app', (parsed: ExitAppAsJson, status): EcpExitAppData => { return { status: status }; diff --git a/src/debugSession/BrightScriptDebugSession.spec.ts b/src/debugSession/BrightScriptDebugSession.spec.ts index bf3d3362..bd2c26e3 100644 --- a/src/debugSession/BrightScriptDebugSession.spec.ts +++ b/src/debugSession/BrightScriptDebugSession.spec.ts @@ -24,6 +24,7 @@ import { EventEmitter } from 'eventemitter3'; import type { EvaluateContainer } from '../adapters/DebugProtocolAdapter'; import { VariableType } from '../debugProtocol/events/responses/VariablesResponse'; import { PerfettoManager } from '../PerfettoManager'; +import { rokuECP } from '../RokuECP'; //DebugSession.shutdown() calls process.exit() after a sleep, so we need to prevent that during tests. This should not be a mock, it needs to be permanent for this flow DebugSession.prototype.shutdown = () => { }; @@ -2627,6 +2628,7 @@ describe('BrightScriptDebugSession', () => { const shutdownStub = sinon.stub(session, 'shutdown').resolves() as unknown as SinonStub; rokuAdapter.connected = false; sinon.stub(session.rokuDeploy, 'publish').resolves(); + sinon.stub(rokuECP, 'launchApp').resolves({} as any); const publishPromise = (session as any).publish(); @@ -2639,6 +2641,44 @@ describe('BrightScriptDebugSession', () => { expect(shutdownStub.calledOnceWithExactly('Debug session cancelled: failed to connect to debug protocol control port.')).to.be.true; clock.restore(); }); + + it('sideloads with autoLaunch disabled and starts the channel via ECP launch after publish', async () => { + rokuAdapter.connected = true; + const publishStub = sinon.stub(session.rokuDeploy, 'publish').resolves(); + const launchAppStub = sinon.stub(rokuECP, 'launchApp').resolves({} as any); + sinon.stub(rokuAdapter as any, 'once').resolves(); + + await (session as any).publish(); + + expect(publishStub.calledOnce).to.be.true; + expect(publishStub.firstCall.args[0]).to.include({ autoLaunch: false }); + expect(launchAppStub.calledOnce).to.be.true; + expect(launchAppStub.firstCall.args[0]).to.include({ channelId: 'dev' }); + }); + + it('forwards deepLinkUrl as the params for the ECP launch', async () => { + rokuAdapter.connected = true; + launchConfiguration.deepLinkUrl = 'http://1.2.3.4:8060/launch/dev?contentId=movie-42&mediaType=movie'; + sinon.stub(session.rokuDeploy, 'publish').resolves(); + const launchAppStub = sinon.stub(rokuECP, 'launchApp').resolves({} as any); + sinon.stub(rokuAdapter as any, 'once').resolves(); + + await (session as any).publish(); + + expect(launchAppStub.calledOnce).to.be.true; + expect(launchAppStub.firstCall.args[0].params).to.equal(launchConfiguration.deepLinkUrl); + }); + + it('skips the ECP launch when publish soft-fails', async () => { + rokuAdapter.connected = true; + sinon.stub(session.rokuDeploy, 'publish').rejects(new Error('non-fatal publish error')); + const launchAppStub = sinon.stub(rokuECP, 'launchApp').resolves({} as any); + sinon.stub(rokuAdapter as any, 'once').resolves(); + + await (session as any).publish(); + + expect(launchAppStub.called).to.be.false; + }); }); describe('threadsRequest', () => { diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index 54f4e510..4a32a203 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -773,20 +773,6 @@ export class BrightScriptDebugSession extends LoggingDebugSession { throw error; } - //at this point, the project has been deployed. If we need to use a deep link, launch it now. - if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) { - //wait until the first entry breakpoint has been hit - await this.firstRunDeferred.promise; - //if we are at a breakpoint, continue - await this.rokuAdapter.continue(); - //kill the app on the roku - // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort); - //convert a hostname to an ip address - const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl); - //send the deep link http request - await util.httpPost(deepLinkUrl); - } - } catch (e) { //if the message is anything other than compile errors, we want to display the error if (!(e instanceof CompileError)) { @@ -990,6 +976,8 @@ export class BrightScriptDebugSession extends LoggingDebugSession { remoteDebugConnectEarly: false, //we don't want to fail if there were compile errors...we'll let our compile error processor handle that failOnCompileError: true, + //prevent the device from auto-launching the channel — we'll start it ourselves via ECP below + autoLaunch: false, //pass any upload form overrides the client may have configured packageUploadOverrides: this.launchConfiguration.packageUploadOverrides }; @@ -1016,6 +1004,20 @@ export class BrightScriptDebugSession extends LoggingDebugSession { uploadingEnd(); + //the channel was sideloaded with autoLaunch disabled — start it now via ECP, folding in deep link params when provided + if (packageIsPublished) { + try { + await rokuECP.launchApp({ + host: this.launchConfiguration.host, + remotePort: this.launchConfiguration.remotePort, + channelId: 'dev', + params: this.launchConfiguration.deepLinkUrl + }); + } catch (e) { + this.logger.error('Failed to launch sideloaded channel via ECP', e); + } + } + //the channel has been deployed. Wait for the adapter to finish connecting. //if it hasn't connected after 60 seconds, abort the launch. let didTimeOut = false; @@ -2905,7 +2907,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession { public async handleEntryBreakpoint() { if (!this.enableDebugProtocol) { this.entryBreakpointWasHandled = true; - if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) { + if (this.launchConfiguration.stopOnEntry) { await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir); } } From cea940287ca4d0e049b3a7b7b5ac9f4d507dd6ee Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Wed, 13 May 2026 12:30:17 -0300 Subject: [PATCH 2/2] Wait for installed-but-not-running state before ECP launch After publish with `autoLaunch: false`, poll `query/app-state` until the device reports the dev channel as `inactive` or `background` before firing the ECP launch. Bounded at 10s. Avoids racing the device's install-settle step. --- src/debugSession/BrightScriptDebugSession.ts | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index 4a32a203..ff3e2664 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -1006,6 +1006,10 @@ export class BrightScriptDebugSession extends LoggingDebugSession { //the channel was sideloaded with autoLaunch disabled — start it now via ECP, folding in deep link params when provided if (packageIsPublished) { + //wait until the device reports the installed-but-not-running state before firing the launch. + //firing immediately after publish can race the device's install-settle step, which can drop + //the remotedebug=1 flag on the floor and break the debug protocol attach. + await this.waitForDevAppInstalled(); try { await rokuECP.launchApp({ host: this.launchConfiguration.host, @@ -1037,6 +1041,33 @@ export class BrightScriptDebugSession extends LoggingDebugSession { } } + /** + * After a `dev_autolaunch=0` sideload, poll `query/app-state` until the device reports the + * channel as `inactive` or `background` (installed but not running). This makes sure the device + * has finished settling the install before we fire the ECP launch. + */ + private async waitForDevAppInstalled(timeoutMs = 10_000, pollIntervalMs = 200) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const result = await rokuECP.getAppState({ + host: this.launchConfiguration.host, + remotePort: this.launchConfiguration.remotePort, + appId: 'dev', + requestOptions: { timeout: 500 } + }); + if (result.state === AppState.inactive || result.state === AppState.background) { + return; + } + } catch (e) { + //the device may briefly refuse the query mid-install — keep polling + this.logger.warn('app-state poll failed; retrying', e); + } + await util.sleep(pollIntervalMs); + } + this.logger.warn(`waitForDevAppInstalled: timed out after ${timeoutMs}ms; firing launch anyway`); + } + private pendingSendLogPromise = Promise.resolve(); /**