• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / vscode-brightscript-language / 29059443710

10 Jul 2026 12:13AM UTC coverage: 59.503% (+0.3%) from 59.231%
29059443710

Pull #854

github

web-flow
Merge 8a204c3cc into 1259f9d0e
Pull Request #854: Persist active device and re-resolve its IP by serial number

2540 of 4692 branches covered (54.13%)

Branch coverage included in aggregate %.

26 of 29 new or added lines in 2 files covered. (89.66%)

38 existing lines in 2 files now uncovered.

3972 of 6252 relevant lines covered (63.53%)

42.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.28
/src/DebugConfigurationProvider.ts
1
import { util as bslangUtil } from 'brighterscript';
1✔
2
import * as dotenv from 'dotenv';
1✔
3
import * as path from 'path';
1✔
4
import * as fsExtra from 'fs-extra';
1✔
5
import * as rta from 'roku-test-automation';
1✔
6
import type {
7
    CancellationToken,
8
    DebugConfigurationProvider,
9
    ExtensionContext,
10
    WorkspaceFolder
11
} from 'vscode';
12
import * as vscode from 'vscode';
1✔
13
import type { LaunchConfiguration } from 'roku-debug';
14
import { fileUtils } from 'roku-debug';
1✔
15
import { util } from './util';
1✔
16
import type { TelemetryManager } from './managers/TelemetryManager';
17
// eslint-disable-next-line @typescript-eslint/no-require-imports
18
import cloneDeep = require('clone-deep');
1✔
19
import { rokuDeploy } from 'roku-deploy';
1✔
20
import type { DeviceInfo } from 'roku-deploy';
21
import type { UserInputManager } from './managers/UserInputManager';
22
import type { BrightScriptCommands } from './BrightScriptCommands';
23
import type { RokuProjectManager } from './managers/RokuProject/RokuProjectManager';
24
import type { DeviceManager, RokuDevice } from './deviceDiscovery/DeviceManager';
25
import type { CredentialStore } from './managers/CredentialStore';
26

27

28
export class BrightScriptDebugConfigurationProvider implements DebugConfigurationProvider {
1✔
29

30
    /**
31
     * Canonical default `files` glob list for a Roku channel launch configuration.
32
     * Other providers (e.g. ManifestProjectProvider) reference this so the project's
33
     * notion of "what to deploy" stays in one place.
34
     *
35
     * Backed by Roku's documented channel package layout:
36
     *   - manifest, source/, components/, images/ — listed as standard folders in
37
     *     https://developer.roku.com/dev/docs/hello-world
38
     *   - locale/ — auto-loaded for translations and localized images per
39
     *     https://developer.roku.com/dev/docs/localization
40
     *   - fonts/ — TrueType/OpenType files loaded via roFontRegistry
41
     *   - componentLibraries/ — output dir for component-library builds bundled with the channel
42
     */
43
    public static readonly defaultFiles: ReadonlyArray<string> = [
1✔
44
        'source/**/*.*',
45
        'componentLibraries/**/*.*',
46
        'components/**/*.*',
47
        'images/**/*.*',
48
        'locale/**/*',
49
        'fonts/**/*',
50
        'manifest'
51
    ];
52

53
    public constructor(
54
        private context: ExtensionContext,
69✔
55
        private telemetryManager: TelemetryManager,
69✔
56
        private extensionOutputChannel: vscode.OutputChannel,
69✔
57
        private userInputManager: UserInputManager,
69✔
58
        private brightScriptCommands: BrightScriptCommands,
69✔
59
        private deviceManager: DeviceManager,
69✔
60
        private credentialStore: CredentialStore,
69✔
61
        private rokuProjectDiscovery?: RokuProjectManager
69✔
62
    ) {
63
        this.context = context;
69✔
64
    }
65

66
    //make unit testing easier by adding these imports properties
67
    public fsExtra = fsExtra;
69✔
68
    public util = util;
69✔
69

70
    private configDefaults: Partial<BrightScriptLaunchConfiguration> = {
69✔
71
        type: 'brightscript',
72
        name: 'BrightScript Debug: Launch',
73
        host: '${promptForHost}',
74
        password: '${promptForPassword}',
75
        consoleOutput: 'normal',
76
        request: 'launch',
77
        stopOnEntry: false,
78
        outDir: '${workspaceFolder}/out/',
79
        retainDeploymentArchive: true,
80
        injectRaleTrackerTask: false,
81
        injectRdbOnDeviceComponent: false,
82
        disableScreenSaver: true,
83
        retainStagingFolder: false,
84
        enableVariablesPanel: true,
85
        deferScopeLoading: false,
86
        autoResolveVirtualVariables: false,
87
        enhanceREPLCompletions: true,
88
        showHiddenVariables: false,
89
        enableDebuggerAutoRecovery: false,
90
        stopDebuggerOnAppExit: false,
91
        autoRunSgDebugCommands: [],
92
        files: [...BrightScriptDebugConfigurationProvider.defaultFiles],
93
        enableSourceMaps: true,
94
        rewriteDevicePathsInLogs: true,
95
        packagePort: 80,
96
        enableDebugProtocol: true,
97
        remotePort: 8060,
98
        rendezvousTracking: true,
99
        deleteDevChannelBeforeInstall: false,
100
        sceneGraphDebugCommandsPort: 8080,
101
        componentLibrariesPort: 8080,
102
        remoteControlMode: {
103
            activateOnSessionStart: false,
104
            deactivateOnSessionEnd: false
105
        }
106
    };
107

108
    public async provideDebugConfigurations(folder?: WorkspaceFolder, _token?: CancellationToken): Promise<vscode.DebugConfiguration[]> {
109
        return (await this.rokuProjectDiscovery?.provideDebugConfigurations(folder)) ?? [];
×
110
    }
111

112
    /**
113
     * Massage a debug configuration just before a debug session is being launched,
114
     * e.g. add all missing attributes to the debug configuration.
115
     */
116
    public async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration, token?: CancellationToken): Promise<BrightScriptLaunchConfiguration> {
117
        // F5 with no launch.json — ask RokuProjectDiscovery to find a config from the active file.
118
        if (!config.type && !config.request) {
15✔
119
            const discovered = await this.rokuProjectDiscovery?.resolveDebugConfigFromActiveFile();
3✔
120
            if (!discovered) {
3✔
121
                return undefined;
2✔
122
            }
123
            config = discovered as BrightScriptLaunchConfiguration;
1✔
124
        }
125

126
        let deviceInfo: DeviceInfo;
127
        let result: BrightScriptLaunchConfiguration;
128
        try {
13✔
129
            // merge user and workspace settings into the config
130
            result = this.processUserWorkspaceSettings(config);
13✔
131

132
            //force a specific stagingDir because sometimes this conflicts with bsconfig.json
133
            result.stagingDir ??= path.join('${outDir}/.roku-deploy-staging');
13!
134
            result.stagingFolderPath = result.stagingDir;
13✔
135

136
            result = await this.sanitizeConfiguration(result, folder);
13✔
137
            result = await this.processEnvVariables(folder, result);
13✔
138
            result = await this.processHostParameter(result);
13✔
139
            result = await this.processPasswordParameter(config, result);
12✔
140
            result = await this.processDeepLinkUrlParameter(result);
12✔
141
            result = await this.processLogfilePath(folder, result);
12✔
142
            result = this.processDapLogFilePath(folder, result);
12✔
143

144
            // `processHostParameter` attached the raw device-info it gathered while probing the host.
145
            // Enhance a local copy here for the developer-mode check + telemetry (no request to the device).
146
            if (result.deviceInfo) {
12!
147
                deviceInfo = rokuDeploy.enhanceDeviceInfo(result.deviceInfo);
12✔
148
            }
149

150
            if (deviceInfo && !deviceInfo.developerEnabled) {
12✔
151
                throw new Error(`Cannot deploy: developer mode is disabled on '${result.host}'`);
1✔
152
            }
153
            await this.context.workspaceState.update('enableDebuggerAutoRecovery', result.enableDebuggerAutoRecovery);
11✔
154

155
            //advertise the optional features this client supports to the debug adapter. This is populated by the
156
            //extension itself (not a user-facing setting) and gates optional roku-debug behavior like the
157
            //`processStagingDir` reverse request.
158
            result.clientCapabilities = {
11✔
159
                supportsProcessStagingDir: true
160
            };
161

162
            return result;
11✔
163
        } catch (e) {
164
            //log any exceptions to the extension panel
165
            this.extensionOutputChannel.append((e as Error).stack);
2✔
166
            throw e;
2✔
167
        } finally {
168
            //send telemetry about this debug session (don't worry, it gets sanitized...we're just checking if certain features are being used)
169
            this.telemetryManager?.sendStartDebugSessionEvent(
13!
170
                this.processUserWorkspaceSettings(config) as any,
171
                result,
172
                deviceInfo
173
            );
174
        }
175
    }
176

177
    /**
178
     * There are several debug-level config values that can be stored in user settings, so get those
179
     */
180
    private processUserWorkspaceSettings(config: BrightScriptLaunchConfiguration): BrightScriptLaunchConfiguration {
181
        const workspaceConfig = util.getConfiguration('brightscript.debug');
13✔
182

183
        let userWorkspaceSettings = {} as BrightScriptLaunchConfiguration;
13✔
184

185
        //only keep the config values that were explicitly defined in a config file (i.e. exclude default values)
186
        for (const key of Object.keys(workspaceConfig)) {
13✔
187
            const inspection = workspaceConfig.inspect(key);
39✔
188
            //if the value was explicitly defined by the user in one of the various locations, then keep this value
189
            if (
39!
190
                inspection.globalValue !== undefined ||
273✔
191
                inspection.workspaceValue !== undefined ||
192
                inspection.globalLanguageValue !== undefined ||
193
                inspection.defaultLanguageValue !== undefined ||
194
                inspection.workspaceFolderValue !== undefined ||
195
                inspection.workspaceLanguageValue !== undefined ||
196
                inspection.workspaceFolderLanguageValue !== undefined
197
            ) {
198
                userWorkspaceSettings[key] = workspaceConfig[key];
×
199
            }
200
        }
201

202
        //merge the user/workspace settings in with the config (the config wins on conflict)
203
        const result = {
13✔
204
            ...userWorkspaceSettings ?? {},
39!
205
            ...cloneDeep(config ?? {})
39!
206
        };
207
        return result as BrightScriptLaunchConfiguration;
13✔
208
    }
209

210
    /**
211
     * Takes the launch.json config and applies any defaults to missing values and sanitizes some of the more complex options
212
     * @param config current config object
213
     */
214
    private async sanitizeConfiguration(config: BrightScriptLaunchConfiguration, folder: WorkspaceFolder): Promise<BrightScriptLaunchConfiguration> {
215
        let userWorkspaceSettings: any = util.getConfiguration('brightscript') || {};
13!
216

217
        //make sure we have an object
218
        config = {
13✔
219

220
            //the workspace settings are the baseline
221
            ...userWorkspaceSettings,
222
            //override with any debug-specific settings
223
            ...config
224
        };
225

226
        let folderUri: vscode.Uri;
227
        //use the workspace folder provided
228
        if (folder) {
13!
229
            folderUri = folder.uri;
13✔
230

231
            //if there's only one workspace, use that workspace's folder path
232
        } else if (vscode.workspace.workspaceFolders?.length === 1) {
×
233
            folderUri = vscode.workspace.workspaceFolders[0].uri;
×
234
        } else {
235
            //there are multiple workspaces, ask the user to specify which one they want to use
236
            let workspaceFolder = await vscode.window.showWorkspaceFolderPick();
×
237
            if (workspaceFolder) {
×
238
                folderUri = workspaceFolder.uri;
×
239
            }
240
        }
241

242
        if (!folderUri) {
13!
243
            //cancel this whole thing because we can't continue without the user specifying a workspace folder
244
            throw new Error('Cannot determine which workspace to use for brightscript debugging');
×
245
        }
246

247
        //load the bsconfig settings (if available)
248
        let bsconfig = this.getBsConfig(folderUri);
13✔
249
        if (bsconfig) {
13✔
250
            config = { ...bsconfig, ...config };
7✔
251
        }
252

253
        config.cwd = folderUri.fsPath;
13✔
254

255
        config.rootDir = this.util.ensureTrailingSlash(config.rootDir ? config.rootDir : '${workspaceFolder}');
13✔
256

257
        //Check for depreciated Items
258
        if (config.debugRootDir) {
13!
259
            if (config.sourceDirs) {
×
260
                throw new Error('Cannot set both debugRootDir AND sourceDirs');
×
261
            } else {
262
                config.sourceDirs = [this.util.ensureTrailingSlash(config.debugRootDir)];
×
263
            }
264
        } else if (config.sourceDirs) {
13!
265
            let dirs: string[] = [];
×
266

267
            for (let dir of config.sourceDirs) {
×
268
                dirs.push(this.util.ensureTrailingSlash(dir));
×
269
            }
270
            config.sourceDirs = dirs;
×
271
        } else if (!config.sourceDirs) {
13!
272
            config.sourceDirs = [];
13✔
273
        }
274

275
        if (config.componentLibraries) {
13!
276
            config.componentLibrariesOutDir = this.util.ensureTrailingSlash(config.componentLibrariesOutDir ? config.componentLibrariesOutDir : '${workspaceFolder}/libs');
×
277

278
            for (let library of config.componentLibraries as any) {
×
279
                library.rootDir = this.util.ensureTrailingSlash(library.rootDir);
×
280
                library.files = library.files ? library.files : [...BrightScriptDebugConfigurationProvider.defaultFiles];
×
281
            }
282
        } else {
283
            //create an empty array so it's easier to reason with downstream
284
            config.componentLibraries = [];
13✔
285
        }
286

287
        // Apply any defaults to missing values
288
        for (const key in this.configDefaults) {
13✔
289
            config[key] ??= this.configDefaults[key];
416✔
290
        }
291

292
        // Run any required post processing after applying defaults
293
        config.outDir = this.util.ensureTrailingSlash(config.outDir);
13✔
294

295
        // Pass along files needed by RDB to roku-debug
296
        config.rdbFilesBasePath = rta.utils.getDeviceFilesPath();
13✔
297

298
        //if packageTask is defined, make sure there's actually a task with that name defined
299
        if (config.packageTask) {
13!
300
            const targetTask = (await vscode.tasks.fetchTasks()).find(x => x.name === config.packageTask);
×
301
            if (!targetTask) {
×
302
                throw new Error(`Cannot find task '${config.packageTask}' for launch option 'packageTask'`);
×
303
            }
304
        }
305

306
        if (typeof config.remoteControlMode === 'boolean') {
13✔
307
            config.remoteControlMode = {
2✔
308
                activateOnSessionStart: config.remoteControlMode,
309
                deactivateOnSessionEnd: config.remoteControlMode
310
            };
311
        } else {
312
            config.remoteControlMode = {
11✔
313
                activateOnSessionStart: config.remoteControlMode?.activateOnSessionStart ?? this.configDefaults.remoteControlMode.activateOnSessionStart,
66!
314
                deactivateOnSessionEnd: config.remoteControlMode?.deactivateOnSessionEnd ?? this.configDefaults.remoteControlMode.deactivateOnSessionEnd
66!
315
            };
316
        }
317

318
        if (config.request !== 'launch') {
13!
319
            await vscode.window.showErrorMessage(`roku-debug only supports the 'launch' request type`);
×
320
        }
321

322
        if (config.raleTrackerTaskFileLocation?.includes('${workspaceFolder}')) {
13!
323
            config.raleTrackerTaskFileLocation = path.normalize(config.raleTrackerTaskFileLocation.replace('${workspaceFolder}', folderUri.fsPath));
×
324
        }
325

326
        // Check for the existence of the tracker task file in auto injection is enabled
327
        if (config.injectRaleTrackerTask) {
13!
328
            if (!config.raleTrackerTaskFileLocation) {
×
329
                await vscode.window.showErrorMessage(`"raleTrackerTaskFileLocation" must be defined when "injectRaleTrackerTask" is enabled`);
×
330
            } else if (await this.util.fileExists(config.raleTrackerTaskFileLocation) === false) {
×
331
                await vscode.window.showErrorMessage(`injectRaleTrackerTask was set to true but could not find TrackerTask.xml at:\n${config.raleTrackerTaskFileLocation}`);
×
332
            }
333
        }
334

335
        //for rootDir, replace workspaceFolder now to avoid issues in vscode itself
336
        if (config.rootDir.includes('${workspaceFolder}')) {
13✔
337
            config.rootDir = path.normalize(config.rootDir.replace('${workspaceFolder}', folderUri.fsPath));
12✔
338
        }
339

340
        //for outDir, replace workspaceFolder now
341
        if (config.outDir.includes('${workspaceFolder}')) {
13!
342
            config.outDir = path.normalize(config.outDir.replace('${workspaceFolder}', folderUri.fsPath));
13✔
343
        }
344

345
        if (config.stagingFolderPath.includes('${outDir}')) {
13!
346
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${outDir}', config.outDir));
13✔
347
        }
348
        if (config.stagingFolderPath.includes('${workspaceFolder}')) {
13!
349
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${workspaceFolder}', folderUri.fsPath));
×
350
        }
351

352
        if (config.stagingDir.includes('${outDir}')) {
13!
353
            config.stagingDir = path.normalize(config.stagingDir.replace('${outDir}', config.outDir));
13✔
354
        }
355
        if (config.stagingDir.includes('${workspaceFolder}')) {
13!
356
            config.stagingDir = path.normalize(config.stagingDir.replace('${workspaceFolder}', folderUri.fsPath));
×
357
        }
358

359

360
        // Make sure that directory paths end in a trailing slash
361
        if (config.debugRootDir) {
13!
362
            config.debugRootDir = this.util.ensureTrailingSlash(config.debugRootDir);
×
363
        }
364

365
        if (config.packagePath?.includes('${workspaceFolder}')) {
13!
366
            config.packagePath = path.normalize(config.packagePath.replace('${workspaceFolder}', folderUri.fsPath));
×
367
        }
368
        if (config.packagePath?.includes('${outDir}')) {
13!
369
            config.packagePath = path.normalize(config.packagePath.replace('${outDir}', config.outDir));
×
370
        }
371

372
        if (!config.rootDir) {
13!
373
            console.log('No rootDir specified: defaulting to ${workspaceFolder}');
×
374
            //use the current workspace folder
375
            config.rootDir = folderUri.fsPath;
×
376
        }
377

378
        return config;
13✔
379
    }
380

381
    public async processLogfilePath(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration) {
382
        if (config?.logfilePath?.trim()) {
22✔
383
            config.logfilePath = config.logfilePath.trim();
5✔
384
            if (config.logfilePath.includes('${workspaceFolder}')) {
5✔
385
                config.logfilePath = config.logfilePath.replace('${workspaceFolder}', folder.uri.fsPath);
1✔
386
            }
387

388
            try {
5✔
389
                config.logfilePath = fileUtils.standardizePath(config.logfilePath);
5✔
390
                //create the logfile folder structure if not exist
391
                fsExtra.ensureDirSync(path.dirname(config.logfilePath));
5✔
392

393
                //create the log file if it doesn't exist
394
                if (!fsExtra.pathExistsSync(config.logfilePath)) {
5✔
395
                    fsExtra.createFileSync(config.logfilePath);
4✔
396
                }
397
                await this.context.workspaceState.update('logfilePath', config.logfilePath);
5✔
398
            } catch (e) {
399
                throw new Error(`Could not create logfile at "${config.logfilePath}"`);
×
400
            }
401
        }
402
        return config;
22✔
403
    }
404

405
    public processDapLogFilePath(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration) {
406
        if (!config.debugAdapterProtocolLogging) {
18✔
407
            return config;
14✔
408
        }
409
        const folderPath = folder?.uri.fsPath ?? process.cwd();
4!
410
        const dir = path.resolve(folderPath, './logs');
4✔
411
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
4✔
412
        fsExtra.ensureDirSync(dir);
4✔
413
        config.debugAdapterProtocolLogFilePath = path.join(dir, `${timestamp}-debugAdapterProtocol.log`);
4✔
414
        return config;
4✔
415
    }
416

417
    /**
418
     * Resolves any `${env:*}` placeholders in the config using the process environment
419
     * and (if present) the optional `.env` file, without mutating `process.env`.
420
     * @param folder current workspace folder
421
     * @param config current config object
422
     */
423
    private async processEnvVariables(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration): Promise<BrightScriptLaunchConfiguration> {
424
        //start with a copy of the current process environment so we never mutate process.env
425
        let environmentValues = { ...process.env };
24✔
426
        let loadedEnvFile = false;
24✔
427

428
        //layer any values from the .env file on top of the process environment (the .env file is optional)
429
        if (config.envFile) {
24✔
430
            let envFilePath = config.envFile;
13✔
431
            //resolve ${workspaceFolder} so we can actually load the .env file now
432
            if (config.envFile.includes('${workspaceFolder}')) {
13✔
433
                envFilePath = config.envFile.replace('${workspaceFolder}', folder.uri.fsPath);
11✔
434
            }
435
            if (await this.util.fileExists(envFilePath) === false) {
13✔
436
                //the .env file is optional, so just warn instead of failing the debug session
437
                console.warn(`Cannot find .env file at "${envFilePath}". Falling back to the process environment for '\${env:*}' values.`);
4✔
438
            } else {
439
                //parse the .env file, letting its values override the process environment
440
                environmentValues = {
9✔
441
                    ...environmentValues,
442
                    ...dotenv.parse(await this.fsExtra.readFile(envFilePath))
443
                };
444
                loadedEnvFile = true;
9✔
445
            }
446
        }
447

448
        // temporarily convert entire config to string for any environment replacements.
449
        let configString = JSON.stringify(config);
24✔
450
        let match: RegExpMatchArray;
451
        let regexp = /\$\{env:([\w\d_]*)\}/g;
24✔
452
        let updatedConfigString = configString;
24✔
453

454
        // apply any defined values to env placeholders
455
        while ((match = regexp.exec(configString))) {
24✔
456
            let environmentVariableName = match[1];
14✔
457
            let environmentVariableValue = environmentValues[environmentVariableName];
14✔
458

459
            if (environmentVariableValue) {
14✔
460
                updatedConfigString = updatedConfigString.replace(match[0], environmentVariableValue);
11✔
461
            }
462
        }
463

464
        config = JSON.parse(updatedConfigString);
24✔
465

466
        let configDefaults = {
24✔
467
            rootDir: config.rootDir,
468
            ...this.configDefaults
469
        };
470

471
        // apply any default values to env placeholders
472
        for (let key in config) {
24✔
473
            let configValue = config[key];
530✔
474
            let match: RegExpMatchArray;
475
            //replace all environment variable placeholders with their values
476
            while ((match = regexp.exec(configValue))) {
530✔
477
                let environmentVariableName = match[1];
3✔
478
                configValue = configDefaults[key];
3✔
479
                console.log(`The configuration value for ${key} was not found in the environment variables${loadedEnvFile ? ' or env file' : ''} under the name ${environmentVariableName}. Defaulting the value to: ${configValue}`);
3✔
480
            }
481
            config[key] = configValue;
530✔
482
        }
483
        return config;
24✔
484
    }
485

486
    /**
487
     * Validates the host parameter in the config and opens an input ui if set to ${promptForHost}.
488
     * ${activeHost} is a deprecated alias for ${promptForHost}.
489
     * Both use the active device when it's set and passes a health check, otherwise fall back to the device picker.
490
     *
491
     * Assigns the raw `device-info` gathered while probing the resolved host onto `config.deviceInfo`,
492
     * so downstream password resolution and the debug session can reuse it without re-fetching.
493
     * Throws if the device couldn't be reached (no device-info came back).
494
     * @param config  current config object
495
     */
496
    private async processHostParameter(config: BrightScriptLaunchConfiguration): Promise<BrightScriptLaunchConfiguration> {
497
        const trimmedHost = config.host.trim();
17✔
498
        const needsHostPrompt =
499
            trimmedHost === '' ||
17✔
500
            trimmedHost === '${promptForHost}' ||
501
            trimmedHost === '${activeHost}' ||
502
            config?.deepLinkUrl?.includes('${promptForHost}');
90!
503

504
        let device: RokuDevice | undefined;
505

506
        if (needsHostPrompt) {
17✔
507
            // both the active-host lookup and the picker probe + register the device in the device
508
            // manager, so reuse it below instead of probing again
509
            const resolved = await this.brightScriptCommands.getHealthyActiveHost() ??
2✔
510
                await this.userInputManager.promptForHost();
511
            config.host = resolved?.host;
2!
512
            if (resolved?.host) {
2!
513
                device = this.deviceManager.getDevice({ ip: resolved.host });
2✔
514
            }
515
        }
516

517
        //check the host and throw error if not provided or update the workspace to set last host
518
        if (!config.host) {
17!
UNCOV
519
            throw new Error('Debug session terminated: host is required.');
×
520
        } else {
521
            await this.context.workspaceState.update('remoteHost', config.host);
17✔
522
        }
523

524
        // If the host didn't come from the picker, probe it so we have fresh SN/deviceInfo.
525
        device ??= await this.deviceManager.validateAndAddDevice(config.host);
17✔
526

527
        // A reachable developer device always returns device-info; its absence means we couldn't reach it.
528
        if (!device?.deviceInfo || Object.keys(device.deviceInfo).length === 0) {
17!
529
            throw new Error(`Debug session terminated: unable to reach device at '${config.host}'.`);
2✔
530
        }
531

532
        // Attach the raw device-info so downstream password resolution and the debug session can reuse it
533
        // without another request to the device.
534
        config.deviceInfo = device.deviceInfo;
15✔
535

536
        return config;
15✔
537
    }
538

539
    /**
540
     * Resolve the device password for the launch configuration.
541
     *
542
     * Collects candidate passwords from every known source (cred store, matching
543
     * `brightscript.devices[]` entry across scopes, `brightscript.defaultDevicePassword`,
544
     * the merged `result.password`, and the raw `config.password`), dedupes them,
545
     * validates each against the device in order, and uses the first that is
546
     * accepted. The winning password is cached in the cred store so later launches
547
     * resolve without re-validating every candidate. If no candidate is accepted,
548
     * the user is prompted.
549
     *
550
     * @param config  the raw launch configuration as received from VS Code
551
     * @param result  the merged/resolved config being built up. Its `deviceInfo` (set by
552
     *                `processHostParameter`) supplies the serial number used to look up credentials.
553
     */
554
    private async processPasswordParameter(
555
        config: BrightScriptLaunchConfiguration,
556
        result: BrightScriptLaunchConfiguration
557
    ): Promise<BrightScriptLaunchConfiguration> {
558
        const host = result.host;
30✔
559
        const serialNumber = result.deviceInfo?.['serial-number'];
30✔
560

561
        // Opportunistically drain any legacy IP-keyed password that still lives in
562
        // workspaceState from pre-refactor extension installs. Reads never consult
563
        // this store anymore; it's peeked here and disposed of once we know whether
564
        // it works. This step is best-effort: an unreachable device just means we
565
        // try again next launch; the authoritative error surfaces from the main flow.
566
        const legacyPassword = this.getLegacyIpKeyedPassword(host);
30✔
567
        if (legacyPassword !== undefined) {
30✔
568
            const validation = await this.deviceManager.validateDevicePassword(host, legacyPassword);
4✔
569
            if (validation === 'ok') {
4✔
570
                await this.clearLegacyIpKeyedPassword(host);
2✔
571
                // A legacy entry is explicit historical opt-in: persist it to the cred store
572
                // (unconditionally, unlike the normal "refresh existing only" gate) and the
573
                // global fallback, then use it.
574
                if (serialNumber) {
2!
575
                    await this.credentialStore.setPassword(serialNumber, legacyPassword);
2✔
576
                }
577
                result.password = legacyPassword;
2✔
578
                await this.context.workspaceState.update('remotePassword', legacyPassword);
2✔
579
                return result;
2✔
580
            } else if (validation === 'bad-password') {
2✔
581
                // Reads don't use the legacy store anymore, so a proven-wrong entry is dead weight.
582
                await this.clearLegacyIpKeyedPassword(host);
1✔
583
            }
584
            // 'unreachable' — leave the legacy entry alone (it may still be correct) and
585
            // fall through to the normal candidate flow, which will surface its own error.
586
        }
587

588
        // Resolve and validate the password against the device, trying the launch config's own
589
        // `result.password`/`config.password` after the standard credential sources. The resolver
590
        // prompts (and persists an accepted password) the same way it does for the other commands.
591
        const resolution = await this.userInputManager.resolveDevicePassword({
28✔
592
            host: host,
593
            serialNumber: serialNumber,
594
            extraCandidates: [result.password, config.password]
595
        });
596
        if (resolution.status === 'unreachable') {
28✔
597
            throw new Error(`Debug session terminated: device at ${host} is unreachable.`);
1✔
598
        }
599
        if (resolution.status === 'cancelled') {
27✔
600
            throw new Error('Debug session terminated: password is required.');
2✔
601
        }
602
        result.password = resolution.password;
25✔
603
        // Keep the global password fallback in sync so later launches resolve without re-prompting.
604
        await this.context.workspaceState.update('remotePassword', resolution.password);
25✔
605
        return result;
25✔
606
    }
607

608
    private readonly legacyPasswordStoreKey = 'devicePasswords';
69✔
609

610
    /**
611
     * Peek the legacy IP-keyed password store (`workspaceState.devicePasswords`).
612
     * Reads never use this store anymore; it's consulted only for one-shot
613
     * migration inside `processPasswordParameter` and drained on the first
614
     * successful device contact.
615
     */
616
    private getLegacyIpKeyedPassword(ip: string): string | undefined {
617
        if (!ip) {
30!
UNCOV
618
            return undefined;
×
619
        }
620
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
30✔
621
        return map[ip];
30✔
622
    }
623

624
    /**
625
     * Remove an entry from the legacy IP-keyed password store.
626
     */
627
    private async clearLegacyIpKeyedPassword(ip: string): Promise<void> {
628
        if (!ip) {
3!
UNCOV
629
            return;
×
630
        }
631
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
3!
632
        if (!(ip in map)) {
3!
UNCOV
633
            return;
×
634
        }
635
        delete map[ip];
3✔
636
        await this.context.workspaceState.update(this.legacyPasswordStoreKey, map);
3✔
637
    }
638

639
    /**
640
     * Validates the deepLinkUrl parameter in the config and opens an input ui if set to ${promptForDeepLinkUrl} or if the url contains ${promptForQueryParams
641
     * @param config  current config object
642
     */
643
    private async processDeepLinkUrlParameter(config: BrightScriptLaunchConfiguration) {
644
        if (config.deepLinkUrl) {
12!
UNCOV
645
            config.deepLinkUrl = config.deepLinkUrl.replace('${host}', config.host);
×
UNCOV
646
            config.deepLinkUrl = config.deepLinkUrl.replace('${promptForHost}', config.host);
×
UNCOV
647
            if (config.deepLinkUrl.includes('${promptForQueryParams}')) {
×
UNCOV
648
                let queryParams = await this.openInputBox('Querystring params for deep link');
×
UNCOV
649
                config.deepLinkUrl = config.deepLinkUrl.replace('${promptForQueryParams}', queryParams);
×
650
            }
UNCOV
651
            if (config.deepLinkUrl === '${promptForDeepLinkUrl}') {
×
652
                config.deepLinkUrl = await this.openInputBox('Full deep link url');
×
653
            }
654
        }
655
        return config;
12✔
656
    }
657

658
    /**
659
     * Helper to open a vscode input box ui
660
     * @param placeHolder placeHolder text
661
     * @param value default value
662
     */
663
    private async openInputBox(placeHolder: string, value = '') {
×
UNCOV
664
        return vscode.window.showInputBox({
×
665
            placeHolder: placeHolder,
666
            value: value
667
        });
668
    }
669

670
    /**
671
     * Get the bsconfig file, if available
672
     */
673
    public getBsConfig(workspaceFolder: vscode.Uri) {
674
        //try to load bsconfig settings
675
        let settings = util.getConfiguration('brightscript', workspaceFolder);
6✔
676
        let configFilePath = settings.get<string>('configFile');
6✔
677
        let isDefaultPath = false;
6✔
678
        if (!configFilePath) {
6!
679
            isDefaultPath = true;
6✔
680
            configFilePath = 'bsconfig.json';
6✔
681
        }
682

683
        //if the path is relative, resolve it relative to the workspace folder. If it's absolute, use as is (path.resolve handles this logic for us)
684
        let workspaceFolderPath = bslangUtil.uriToPath(workspaceFolder.toString());
6✔
685
        configFilePath = path.resolve(workspaceFolderPath, configFilePath);
6✔
686
        try {
6✔
687
            let bsconfig = bslangUtil.loadConfigFile(configFilePath);
6✔
UNCOV
688
            return bsconfig;
×
689
        } catch (e) {
690
            //only log the error if the user explicitly defined a config path
691
            if (!isDefaultPath) {
6!
UNCOV
692
                console.error(`Could not load bsconfig file at "${configFilePath}`);
×
693
            }
694
            return undefined;
6✔
695
        }
696
    }
697
}
698

699
export interface BrightScriptLaunchConfiguration extends LaunchConfiguration {
700
    /**
701
     * The name of this launch configuration
702
     */
703
    name: string;
704
    /**
705
     * The type of this debug configuration
706
     */
707
    type: string;
708
    /**
709
     * Should the debugger launch or attach. roku-debug only supports launching
710
     */
711
    request: 'launch' | 'attach';
712

713
    /**
714
     * A path to a file where all brightscript console output will be written. If falsey, file logging will be disabled.
715
     */
716
    logfilePath?: string;
717

718
    /**
719
     * Enable DAP protocol logging. Can be set via the `brightscript.debug.debugAdapterProtocolLogging` workspace setting or in launch.json.
720
     * The resolved absolute log file path is written to `debugAdapterProtocolLogFilePath` by `processDapLogFilePath`
721
     * and passed to the debug adapter process as the `ROKU_DAP_LOG_FILE` env var by the descriptor factory.
722
     */
723
    debugAdapterProtocolLogging?: boolean;
724

725
    /**
726
     * Resolved absolute path for the DAP protocol log file, populated by `processDapLogFilePath`.
727
     * Consumed by the DebugAdapterDescriptorFactory to inject `ROKU_DAP_LOG_FILE` into the adapter process.
728
     */
729
    debugAdapterProtocolLogFilePath?: string;
730
    /**
731
     *  If true, then the zip archive is NOT deleted after a debug session has been closed.
732
     * @default true
733
     */
734
    retainDeploymentArchive?: boolean;
735

736
    /**
737
     * A path to an environment variables file which will be used to augment the launch config
738
     */
739
    envFile?: string;
740

741
    /**
742
     * If injectRdbOnDeviceComponent is true and this is true the screen saver will be be disabled while the deployed application is running.
743
     */
744
    disableScreenSaver?: boolean;
745

746
    /**
747
     * If set, the remote control will be enabled/disabled at the start/end of the debug session, respectively.
748
     * @default { activateOnSessionStart: false, deactivateOnSessionEnd: false }
749
     */
750
    remoteControlMode?: { activateOnSessionStart?: boolean; deactivateOnSessionEnd?: boolean };
751
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc