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

rokucommunity / vscode-brightscript-language / 26289399330

22 May 2026 01:03PM UTC coverage: 56.211% (+0.7%) from 55.501%
26289399330

Pull #790

github

web-flow
Merge 513edc9d2 into b9f6aae1a
Pull Request #790: Add filter dropdown to the Devices view

2267 of 4453 branches covered (50.91%)

Branch coverage included in aggregate %.

118 of 185 new or added lines in 7 files covered. (63.78%)

3 existing lines in 1 file now uncovered.

3647 of 6068 relevant lines covered (60.1%)

40.31 hits per line

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

27.57
/src/BrightScriptCommands.ts
1
import * as request from 'postman-request';
1✔
2
import * as vscode from 'vscode';
1✔
3
import BrightScriptFileUtils from './BrightScriptFileUtils';
1✔
4
import { GlobalStateManager } from './GlobalStateManager';
1✔
5
import { brighterScriptPreviewCommand } from './commands/BrighterScriptPreviewCommand';
1✔
6
import { captureScreenshotCommand } from './commands/CaptureScreenshotCommand';
1✔
7
import { rekeyAndPackageCommand } from './commands/RekeyAndPackageCommand';
1✔
8
import { languageServerInfoCommand } from './commands/LanguageServerInfoCommand';
1✔
9
import { util } from './util';
1✔
10
import { util as rokuDebugUtil } from 'roku-debug/dist/util';
1✔
11
import type { RemoteControlManager, RemoteControlModeInitiator } from './managers/RemoteControlManager';
12
import type { WhatsNewManager } from './managers/WhatsNewManager';
13
import type { ConfiguredDevice, DeviceManager, RokuDevice } from './deviceDiscovery/DeviceManager';
14
import * as xml2js from 'xml2js';
1✔
15
import { firstBy } from 'thenby';
1✔
16
import type { UserInputManager } from './managers/UserInputManager';
17
import { clearNpmPackageCacheCommand } from './commands/ClearNpmPackageCacheCommand';
1✔
18
import type { LocalPackageManager } from './managers/LocalPackageManager';
19
import { profilingCommands } from './commands/ProfilingCommands';
1✔
20
import { vscodeContextManager } from './managers/VscodeContextManager';
1✔
21
import type { CredentialStore } from './managers/CredentialStore';
22

23
export class BrightScriptCommands {
1✔
24

25
    constructor(
26
        private remoteControlManager: RemoteControlManager,
42✔
27
        private whatsNewManager: WhatsNewManager,
42✔
28
        private context: vscode.ExtensionContext,
42✔
29
        private deviceManager: DeviceManager,
42✔
30
        private userInputManager: UserInputManager,
42✔
31
        private localPackageManager: LocalPackageManager,
42✔
32
        private credentialStore: CredentialStore
42✔
33
    ) {
34
        this.fileUtils = new BrightScriptFileUtils();
42✔
35
    }
36

37
    private fileUtils: BrightScriptFileUtils;
38
    public host: string;
39
    public password: string;
40
    public workspacePath: string;
41
    private keypressNotifiers = [] as ((key: string, literalCharacter: boolean) => void)[];
42✔
42

43
    public registerCommands() {
44

45
        brighterScriptPreviewCommand.register(this.context);
16✔
46
        languageServerInfoCommand.register(this.context, this.localPackageManager);
16✔
47
        captureScreenshotCommand.register(this.context, this);
16✔
48
        rekeyAndPackageCommand.register(this.context, this, this.userInputManager);
16✔
49
        clearNpmPackageCacheCommand.register(this.context, this.localPackageManager);
16✔
50
        profilingCommands.register(this.context);
16✔
51

52
        this.registerGeneralCommands();
16✔
53

54
        this.registerCommand('sendRemoteCommand', async (key: string) => {
16✔
55
            await this.sendRemoteCommand(key);
×
56
        });
57

58
        //the "Refresh" button in the Devices list
59
        this.registerCommand('refreshDeviceList', (key: string) => {
16✔
60
            this.deviceManager.refresh(true);
×
61
        });
62

63
        this.registerCommand('rescanDevices', () => {
16✔
64
            this.deviceManager.refresh(true);
×
65
        });
66

67
        // Refresh a single device (inline button on hover in devices panel)
68
        this.registerCommand('refreshDevice', async (item: { key: string }) => {
16✔
69
            await this.deviceManager.healthCheckDevice({ serialNumber: item.key }, true);
×
70
        });
71

72
        this.registerCommand('sendRemoteText', async () => {
16✔
73
            let items: vscode.QuickPickItem[] = [];
×
74
            for (const item of new GlobalStateManager(this.context).sendRemoteTextHistory) {
×
75
                items.push({ label: item });
×
76
            }
77

78
            const stuffUserTyped = await util.showQuickPickInputBox({
×
79
                placeholder: 'Press enter to send all typed characters to the Roku',
80
                items: items
81
            });
82
            console.log('userInput', stuffUserTyped);
×
83

84
            if (stuffUserTyped) {
×
85
                new GlobalStateManager(this.context).addTextHistory(stuffUserTyped);
×
86
                let fallbackToHttp = true;
×
87
                await this.getRemoteHost();
×
88
                //TODO fix SceneGraphDebugCommandController to not timeout so quickly
89
                // try {
90
                //     let commandController = new SceneGraphDebugCommandController(this.host);
91
                //     let response = await commandController.type(stuffUserTyped);
92
                //     if (!response.error) {
93
                //         fallbackToHttp = false;
94
                //     }
95
                // } catch (error) {
96
                //     console.error(error);
97
                //     // Let this fallback to the old HTTP based logic
98
                // }
99

100
                if (fallbackToHttp) {
×
101
                    for (let character of stuffUserTyped) {
×
102
                        await this.sendAsciiToDevice(character);
×
103
                    }
104
                }
105
            }
106
            await vscode.commands.executeCommand('workbench.action.focusPanel');
×
107
        });
108

109
        this.registerCommand('toggleRemoteControlMode', (initiator: RemoteControlModeInitiator) => {
16✔
110
            return this.remoteControlManager.toggleRemoteControlMode(initiator);
×
111
        });
112

113
        this.registerCommand('enableRemoteControlMode', () => {
16✔
114
            return this.remoteControlManager.setRemoteControlMode(true, 'command');
×
115
        });
116

117
        this.registerCommand('disableRemoteControlMode', () => {
16✔
118
            return this.remoteControlManager.setRemoteControlMode(false, 'command');
×
119
        });
120

121
        this.registerCommand('pressBackButton', async () => {
16✔
122
            await this.sendRemoteCommand('Back');
×
123
        });
124

125
        this.registerCommand('pressBackspaceButton', async () => {
16✔
126
            await this.sendRemoteCommand('Backspace');
×
127
        });
128

129
        this.registerCommand('pressHomeButton', async () => {
16✔
130
            await this.sendRemoteCommand('Home');
×
131
        });
132

133
        this.registerCommand('restartDevApplication', async () => {
16✔
134
            await this.restartDevApplication();
×
135
        });
136

137
        this.registerCommand('pressUpButton', async () => {
16✔
138
            await this.sendRemoteCommand('Up');
×
139
        });
140

141
        this.registerCommand('pressDownButton', async () => {
16✔
142
            await this.sendRemoteCommand('Down');
×
143
        });
144

145
        this.registerCommand('pressRightButton', async () => {
16✔
146
            await this.sendRemoteCommand('Right');
×
147
        });
148

149
        this.registerCommand('pressLeftButton', async () => {
16✔
150
            await this.sendRemoteCommand('Left');
×
151
        });
152

153
        this.registerCommand('pressSelectButton', async () => {
16✔
154
            await this.sendRemoteCommand('Select');
×
155
        });
156

157
        this.registerCommand('pressPlayButton', async () => {
16✔
158
            await this.sendRemoteCommand('Play');
×
159
        });
160

161
        this.registerCommand('pressRevButton', async () => {
16✔
162
            await this.sendRemoteCommand('Rev');
×
163
        });
164

165
        this.registerCommand('pressFwdButton', async () => {
16✔
166
            await this.sendRemoteCommand('Fwd');
×
167
        });
168

169
        this.registerCommand('pressStarButton', async () => {
16✔
170
            await this.sendRemoteCommand('Info');
×
171
        });
172

173
        this.registerCommand('pressInstantReplayButton', async () => {
16✔
174
            await this.sendRemoteCommand('InstantReplay');
×
175
        });
176

177
        this.registerCommand('pressSearchButton', async () => {
16✔
178
            await this.sendRemoteCommand('Search');
×
179
        });
180

181
        this.registerCommand('pressEnterButton', async () => {
16✔
182
            await this.sendRemoteCommand('Enter');
×
183
        });
184

185
        this.registerCommand('pressFindRemote', async () => {
16✔
186
            await this.sendRemoteCommand('FindRemote');
×
187
        });
188

189
        this.registerCommand('pressVolumeDown', async () => {
16✔
190
            await this.sendRemoteCommand('VolumeDown');
×
191
        });
192

193
        this.registerCommand('pressVolumeMute', async () => {
16✔
194
            await this.sendRemoteCommand('VolumeMute');
×
195
        });
196

197
        this.registerCommand('pressVolumeUp', async () => {
16✔
198
            await this.sendRemoteCommand('VolumeUp');
×
199
        });
200

201
        this.registerCommand('setVolume', async () => {
16✔
202
            let result = await vscode.window.showInputBox({
×
203
                placeHolder: 'The target volume level (0-100)',
204
                value: '',
205
                validateInput: (text: string) => {
206
                    const num = Number(text);
×
207
                    if (isNaN(num)) {
×
208
                        return 'Value must be a number';
×
209
                    } else if (num < 0 || num > 100) {
×
210
                        return 'Please enter a number between 0 and 100';
×
211
                    }
212
                    return null;
×
213
                }
214
            });
215
            const targetVolume = Number(result);
×
216

217
            if (!isNaN(targetVolume)) {
×
218
                await vscode.window.withProgress({
×
219
                    location: vscode.ProgressLocation.Notification,
220
                    title: 'Setting volume'
221
                }, async (progress) => {
222
                    const totalCommands = 100 + targetVolume;
×
223
                    const incrementValue = 100 / totalCommands;
×
224
                    let executedCommands = 0;
×
225

226
                    for (let i = 0; i < 100; i++) {
×
227
                        await this.sendRemoteCommand('VolumeDown');
×
228
                        executedCommands++;
×
229
                        progress.report({ increment: incrementValue, message: `decreasing volume - ${Math.round((executedCommands / totalCommands) * 100)}%` });
×
230
                    }
231

232
                    for (let i = 0; i < targetVolume; i++) {
×
233
                        await this.sendRemoteCommand('VolumeUp');
×
234
                        executedCommands++;
×
235
                        progress.report({ increment: incrementValue, message: `increasing volume - ${Math.round((executedCommands / totalCommands) * 100)}%` });
×
236
                    }
237
                });
238
            }
239
        });
240

241
        this.registerCommand('pressPowerOff', async () => {
16✔
242
            await this.sendRemoteCommand('PowerOff');
×
243
        });
244

245
        this.registerCommand('pressPowerOn', async () => {
16✔
246
            await this.sendRemoteCommand('PowerOn');
×
247
        });
248

249
        this.registerCommand('pressChannelUp', async () => {
16✔
250
            await this.sendRemoteCommand('ChannelUp');
×
251
        });
252

253
        this.registerCommand('pressChannelDown', async () => {
16✔
254
            await this.sendRemoteCommand('ChannelDown');
×
255
        });
256

257
        this.registerCommand('pressBlue', async () => {
16✔
258
            await this.sendRemoteCommand('Blue');
×
259
        });
260

261
        this.registerCommand('pressGreen', async () => {
16✔
262
            await this.sendRemoteCommand('Green');
×
263
        });
264

265
        this.registerCommand('pressRed', async () => {
16✔
266
            await this.sendRemoteCommand('Red');
×
267
        });
268

269
        this.registerCommand('pressYellow', async () => {
16✔
270
            await this.sendRemoteCommand('Yellow');
×
271
        });
272

273
        this.registerCommand('pressExit', async () => {
16✔
274
            await this.sendRemoteCommand('Exit');
×
275
        });
276

277
        this.registerCommand('changeTvInput', async (host?: string) => {
16✔
278
            const selectedInput = await vscode.window.showQuickPick([
×
279
                'InputHDMI1',
280
                'InputHDMI2',
281
                'InputHDMI3',
282
                'InputHDMI4',
283
                'InputAV1',
284
                'InputTuner'
285
            ]);
286

287
            if (selectedInput) {
×
288
                await this.sendRemoteCommand(selectedInput, host);
×
289
            }
290
        });
291

292
        this.registerKeyboardInputs();
16✔
293
    }
294

295
    /**
296
     * Registers all the commands for a-z, A-Z, 0-9, and all the primary character such as !, @, #, ', ", etc...
297
     */
298
    private registerKeyboardInputs() {
299
        // Get all the keybindings from our package.json
300
        const extension = vscode.extensions.getExtension('RokuCommunity.brightscript');
16✔
301
        const keybindings = (extension.packageJSON.contributes.keybindings as Array<{
16✔
302
            key: string;
303
            command: string;
304
            when: string;
305
            args: any;
306
        }>);
307

308
        for (let keybinding of keybindings) {
16✔
309
            // Find every keybinding that is related to sending text characters to the device
310
            if (keybinding.command.includes('.sendAscii+')) {
2,320✔
311

312
                if (!keybinding.args) {
1,536!
313
                    throw new Error(`Can not register command: ${keybinding.command}. Missing Arguments.`);
×
314
                }
315

316
                // Dynamically register the the command defined in the keybinding
317
                this.registerCommand(keybinding.command, async (character: string) => {
1,536✔
318
                    await this.sendAsciiToDevice(character);
×
319
                });
320
            }
321
        }
322
    }
323

324
    private registerGeneralCommands() {
325
        //a command that does absolutely nothing. It's here to allow us to absorb unsupported keypresses when in **remote control mode**.
326
        this.registerCommand('doNothing', () => { });
16✔
327

328
        this.registerCommand('toggleXML', async () => {
16✔
329
            await this.onToggleXml();
×
330
        });
331

332
        this.registerCommand('goToParentComponent', async () => {
16✔
333
            await this.onGoToParentComponent();
×
334
        });
335

336
        this.registerCommand('clearGlobalState', async () => {
16✔
337
            new GlobalStateManager(this.context).clear();
×
338
            await vscode.window.showInformationMessage('BrightScript Language extension global state cleared');
×
339
        });
340

341
        this.registerCommand('clearCurrentDeviceList', async () => {
16✔
342
            const toatsPromise = util.showTimedNotification('Clearing device list');
×
343
            await this.deviceManager.clearCurrentDeviceList();
×
344
            await toatsPromise;
×
345
        });
346

347
        this.registerCommand('enableDeviceDiscovery', async () => {
16✔
348
            await util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', true);
×
349
        });
350

351
        this.registerCommand('disableDeviceDiscovery', async () => {
16✔
352
            await util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', false);
×
353
        });
354

355
        this.registerCommand('clearDeviceCache', async () => {
16✔
356
            this.deviceManager.clearAllCache();
×
357
            await util.showTimedNotification('Clearing device cache');
×
358
        });
359

360
        this.registerCommand('clearLastSeenDevices', async () => {
16✔
361
            new GlobalStateManager(this.context).clearLastSeenDevices();
×
362
            await vscode.window.showInformationMessage('Last seen devices cleared');
×
363
        });
364

365
        this.registerCommand('copyToClipboard', async (value: string) => {
16✔
366
            try {
×
367
                if (util.isNullish(value)) {
×
368
                    throw new Error('Cannot copy ${value} to clipboard');
×
369
                }
370
                await vscode.env.clipboard.writeText(value?.toString());
×
371
                await vscode.window.showInformationMessage(`Copied to clipboard: ${value}`);
×
372
            } catch (error) {
373
                await vscode.window.showErrorMessage(`Could not copy value to clipboard`);
×
374
            }
375
        });
376

377
        this.registerCommand('openUrl', async (url: string) => {
16✔
378
            try {
×
379
                await vscode.env.openExternal(vscode.Uri.parse(url));
×
380
            } catch (error) {
381
                await vscode.window.showErrorMessage(`Tried to open url but failed: ${url}`);
×
382
            }
383
        });
384

385
        this.registerCommand('openRegistryInBrowser', async (host: string) => {
16✔
386
            if (!host) {
×
387
                host = await this.userInputManager.promptForHost();
×
388
            }
389

390
            let responseText = await util.spinAsync('Fetching app list', async () => {
×
391
                return (await util.httpGet(`http://${host}:8060/query/apps`, { timeout: 4_000 })).body as string;
×
392
            });
393

394
            const parsed = await xml2js.parseStringPromise(responseText);
×
395

396
            //convert the items to QuickPick items
397
            const items: Array<vscode.QuickPickItem & { appId?: string }> = parsed.apps.app.map((appData: any) => {
×
398
                return {
×
399
                    label: appData._,
400
                    detail: `ID: ${appData.$.id}`,
401
                    description: `${appData.$.version}`,
402
                    appId: `${appData.$.id}`
403
                } as vscode.QuickPickItem;
404
                //sort the items alphabetically
405
            }).sort(firstBy('label'));
406

407
            //move the dev app to the top (and add a label/section to differentiate it)
408
            const devApp = items.find(x => x.appId === 'dev');
×
409
            if (devApp) {
×
410
                items.splice(items.indexOf(devApp), 1);
×
411
                items.unshift(
×
412
                    { kind: vscode.QuickPickItemKind.Separator, label: 'dev' },
413
                    devApp,
414
                    { kind: vscode.QuickPickItemKind.Separator, label: ' ' }
415
                );
416
            }
417

418
            const selectedApp: typeof items[0] = await vscode.window.showQuickPick(items, { placeHolder: 'Which app would you like to see the registry for?' });
×
419

420
            if (selectedApp) {
×
421
                const appId = selectedApp.appId;
×
422
                let url = `http://${host}:8060/query/registry/${appId}`;
×
423
                try {
×
424
                    await vscode.env.openExternal(vscode.Uri.parse(url));
×
425
                } catch (error) {
426
                    await vscode.window.showErrorMessage(`Tried to open url but failed: ${url}`);
×
427
                }
428
            }
429
        });
430

431
        this.registerCommand('setActiveDevice', async (deviceOrItem: string | { key: string }) => {
16✔
432
            let ip: string;
433
            if (typeof deviceOrItem === 'object' && deviceOrItem?.key) {
×
434
                ip = this.deviceManager.getDevice(deviceOrItem.key)?.ip;
×
435
            } else if (typeof deviceOrItem === 'string') {
×
436
                ip = deviceOrItem;
×
437
            }
438
            if (!ip) {
×
439
                ip = await this.userInputManager.promptForHost();
×
440
            }
441
            if (!ip) {
×
442
                throw new Error('Tried to set active device but failed.');
×
443
            } else {
444
                await this.context.workspaceState.update('remoteHost', ip);
×
445
                await vscodeContextManager.set('activeHost', ip);
×
446
                await util.showTimedNotification(`'${ip}' set as active device`);
×
447
            }
448
        });
449

450
        this.registerCommand('editDeviceInUserSettings', async (deviceOrItem: { key: string }) => {
16✔
451
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
452
            await this.openSettingsJsonAtDevice(device, 'user');
×
453
        });
454

455
        this.registerCommand('editDeviceInWorkspaceSettings', async (deviceOrItem: { key: string }) => {
16✔
456
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
457
            await this.openSettingsJsonAtDevice(device, 'workspace');
×
458
        });
459

460
        this.registerCommand('addDeviceToUserSettings', async (deviceOrItem: { key: string }) => {
16✔
461
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
462
            if (!device) {
×
463
                void vscode.window.showErrorMessage('Could not find device to add to settings.');
×
464
                return;
×
465
            }
466

467
            const config = vscode.workspace.getConfiguration('brightscript');
×
468
            const inspection = config.inspect<ConfiguredDevice[]>('devices');
×
469
            const userDevices = inspection?.globalValue || [];
×
470

471
            if (userDevices.some(d => d.host === device.ip || (device.serialNumber && d.serialNumber === device.serialNumber))) {
×
472
                void vscode.window.showInformationMessage('Device is already in your user settings.');
×
473
                return;
×
474
            }
475

476
            // Copy any cred-store-cached password into the settings entry so the device
477
            // is portable across machines via Settings Sync. The cred store keeps its own
478
            // copy — it's a running cache of validated passwords that gets refreshed on
479
            // each successful password validation.
480
            const storedPassword = device.serialNumber
×
481
                ? await this.credentialStore.getPassword(device.serialNumber)
482
                : undefined;
483

484
            const newDevice = {
×
485
                host: device.ip,
486
                ...(device.serialNumber && { serialNumber: device.serialNumber }),
×
487
                ...(storedPassword && { password: storedPassword })
×
488
            };
489
            userDevices.push(newDevice);
×
490

491
            await config.update('devices', userDevices, vscode.ConfigurationTarget.Global);
×
492
            const displayName = device.deviceInfo['user-device-name'] || device.deviceInfo['default-device-name'] || device.ip;
×
493
            void vscode.window.showInformationMessage(`Added "${displayName}" to user settings.`);
×
494
        });
495

496
        this.registerCommand('addDeviceToWorkspaceSettings', async (deviceOrItem: { key: string }) => {
16✔
497
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
498
            if (!device) {
×
499
                void vscode.window.showErrorMessage('Could not find device to add to settings.');
×
500
                return;
×
501
            }
502

503
            const config = vscode.workspace.getConfiguration('brightscript');
×
504
            const inspection = config.inspect<ConfiguredDevice[]>('devices');
×
505
            const workspaceDevices = inspection?.workspaceValue || [];
×
506

507
            if (workspaceDevices.some(d => (device.serialNumber && d.serialNumber === device.serialNumber))) {
×
508
                void vscode.window.showInformationMessage('Device is already in your workspace settings.');
×
509
                return;
×
510
            }
511

512
            const storedPassword = device.serialNumber
×
513
                ? await this.credentialStore.getPassword(device.serialNumber)
514
                : undefined;
515

516
            const newDevice = {
×
517
                host: device.ip,
518
                ...(device.serialNumber && { serialNumber: device.serialNumber }),
×
519
                ...(storedPassword && { password: storedPassword })
×
520
            };
521
            workspaceDevices.push(newDevice);
×
522

523
            await config.update('devices', workspaceDevices, vscode.ConfigurationTarget.Workspace);
×
524
            const displayName = device.deviceInfo['user-device-name'] || device.deviceInfo['default-device-name'] || device.ip;
×
525
            void vscode.window.showInformationMessage(`Added "${displayName}" to workspace settings.`);
×
526
        });
527

528
        this.registerCommand('clearDefaultDevicePassword', async () => {
16✔
529
            await vscode.workspace.getConfiguration('brightscript').update('defaultDevicePassword', undefined, vscode.ConfigurationTarget.Global);
2✔
530
            await util.showTimedNotification('Default device password cleared.');
2✔
531
        });
532

533
        this.registerCommand('setDefaultDevicePassword', async () => {
16✔
534
            const currentValue = vscode.workspace.getConfiguration('brightscript').get<string>('defaultDevicePassword') ?? '';
3!
535

536
            const password = await vscode.window.showInputBox({
3✔
537
                placeHolder: 'Enter the default developer password (applied to devices without their own password)',
538
                password: true,
539
                value: currentValue,
540
                prompt: 'Set default device password'
541
            });
542

543
            if (password === undefined) {
3✔
544
                return;
1✔
545
            }
546

547
            //this value is only supported at the global level, so just always write it there
548
            await vscode.workspace.getConfiguration('brightscript').update('defaultDevicePassword', password, vscode.ConfigurationTarget.Global);
2✔
549
        });
550

551
        this.registerCommand('setDevicePassword', async (serialNumber: string) => {
16✔
552
            if (!serialNumber) {
×
553
                throw new Error('Device serial number is required to set password.');
×
554
            }
555

556
            const device = this.deviceManager.getDevice({ serialNumber: serialNumber });
×
557
            const displayName = device?.deviceInfo?.['user-device-name'] || device?.deviceInfo?.['default-device-name'] || device?.ip || serialNumber;
×
558

559
            const password = await vscode.window.showInputBox({
×
560
                placeHolder: 'Enter the developer account password for this device',
561
                password: true,
562
                prompt: `Set password for device: ${displayName}`,
563
                // Roku's own webserver UI enforces the same 4-character minimum.
564
                validateInput: (value) => {
565
                    return value.length < 4 ? 'Password must be at least 4 characters' : undefined;
×
566
                }
567
            });
568

569
            if (password !== undefined) {
×
570
                await this.setDevicePassword(serialNumber, password);
×
571
                await vscode.window.showInformationMessage(`Password set for device: ${displayName}`);
×
572
            }
573
        });
574

575
        this.registerCommand('clearDevicePassword', async (serialNumber: string) => {
16✔
576
            if (!serialNumber) {
×
577
                throw new Error('Device serial number is required to clear password.');
×
578
            }
579

580
            const device = this.deviceManager.getDevice({ serialNumber: serialNumber });
×
581
            const displayName = device?.deviceInfo?.['user-device-name'] || device?.deviceInfo?.['default-device-name'] || device?.ip || serialNumber;
×
582

583
            await this.setDevicePassword(serialNumber, '');
×
584
            await vscode.window.showInformationMessage(`Password cleared for device: ${displayName}`);
×
585
        });
586

587
        this.registerCommand('clearActiveDevice', async () => {
16✔
588
            await this.context.workspaceState.update('remoteHost', '');
×
589
            await vscodeContextManager.set('activeHost', '');
×
590
            await util.showTimedNotification('Active device cleared');
×
591
        });
592

593
        this.registerCommand('showReleaseNotes', () => {
16✔
594
            this.whatsNewManager.showReleaseNotes();
×
595
        });
596
    }
597

598
    public async openFile(filename: string, range: vscode.Range = null, preview = false): Promise<boolean> {
×
599
        let uri = vscode.Uri.file(filename);
×
600
        try {
×
601
            let doc = await vscode.workspace.openTextDocument(uri); // calls back into the provider
×
602
            await vscode.window.showTextDocument(doc, { preview: preview });
×
603
            if (range) {
×
604
                await this.gotoRange(range);
×
605
            }
606
        } catch (e) {
607
            return false;
×
608
        }
609
        return true;
×
610
    }
611

612
    private async gotoRange(range: vscode.Range) {
613
        let editor = vscode.window.activeTextEditor;
×
614
        editor.selection = new vscode.Selection(
×
615
            range.start.line,
616
            range.start.character,
617
            range.start.line,
618
            range.start.character
619
        );
620
        await vscode.commands.executeCommand('revealLine', {
×
621
            lineNumber: range.start.line,
622
            at: 'center'
623
        });
624
    }
625

626
    public async onToggleXml() {
627
        if (vscode.window.activeTextEditor) {
3✔
628
            const currentDocument = vscode.window.activeTextEditor.document;
2✔
629
            let alternateFileName = this.fileUtils.getAlternateFileName(currentDocument.fileName);
2✔
630
            if (alternateFileName) {
2✔
631
                if (
1!
632
                    !await this.openFile(alternateFileName) &&
2✔
633
                    alternateFileName.toLowerCase().endsWith('.brs')
634
                ) {
635
                    await this.openFile(this.fileUtils.getBsFileName(alternateFileName));
×
636
                }
637
            }
638
        }
639
    }
640

641
    public async onGoToParentComponent() {
642
        const editor = vscode.window.activeTextEditor;
4✔
643
        if (!editor) {
4✔
644
            return;
1✔
645
        }
646
        const currentDocument = editor.document;
3✔
647
        const fileName = currentDocument.fileName;
3✔
648
        const lowerFileName = fileName.toLowerCase();
3✔
649
        const isXml = lowerFileName.endsWith('.xml');
3✔
650
        const isBrs = lowerFileName.endsWith('.brs') || lowerFileName.endsWith('.bs');
3✔
651

652
        if (!isXml && !isBrs) {
3✔
653
            return;
1✔
654
        }
655

656
        // Get or open the XML document
657
        let xmlDoc: vscode.TextDocument;
658
        if (isXml) {
2!
659
            xmlDoc = currentDocument;
2✔
660
        } else {
661
            const xmlFileName = this.fileUtils.getAlternateFileName(fileName);
×
662
            if (!xmlFileName) {
×
663
                return;
×
664
            }
665
            try {
×
666
                xmlDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(xmlFileName));
×
667
            } catch (e) {
668
                return;
×
669
            }
670
        }
671

672
        const xmlContent = xmlDoc.getText();
2✔
673
        const parentName = this.fileUtils.getParentComponentName(xmlContent);
2✔
674
        if (!parentName) {
2✔
675
            await vscode.window.showInformationMessage('No parent component found');
1✔
676
            return;
1✔
677
        }
678

679
        const extendsPosition = this.getExtendsValuePosition(xmlContent, xmlDoc);
1✔
680
        if (!extendsPosition) {
1!
681
            return;
×
682
        }
683

684
        // Delegate to the definition provider via the LSP
685
        const locations = await vscode.commands.executeCommand<vscode.Location[]>(
1✔
686
            'vscode.executeDefinitionProvider',
687
            xmlDoc.uri,
688
            extendsPosition
689
        );
690

691
        if (!locations || locations.length === 0) {
1!
692
            await vscode.window.showInformationMessage(`Could not find parent component: ${parentName}`);
×
693
            return;
×
694
        }
695

696
        const parentXmlPath = locations[0].uri.fsPath;
1✔
697

698
        if (isBrs) {
1!
699
            const parentBrsPath = this.fileUtils.getAlternateFileName(parentXmlPath);
×
700
            if (parentBrsPath && !await this.openFile(parentBrsPath)) {
×
701
                await this.openFile(this.fileUtils.getBsFileName(parentBrsPath));
×
702
            }
703
        } else {
704
            await this.openFile(parentXmlPath);
1✔
705
        }
706
    }
707

708
    private getExtendsValuePosition(xmlContent: string, xmlDoc: vscode.TextDocument): vscode.Position | undefined {
709
        // Match extends="VALUE" capturing the VALUE portion; [^>]+ spans across lines since [^>] matches \n
710
        const match = /<component[^>]+extends\s*=\s*["']([^"']+)/i.exec(xmlContent);
1✔
711
        if (!match) {
1!
712
            return undefined;
×
713
        }
714
        // Offset to first character of the value (after the opening quote)
715
        const valueOffset = match.index + match[0].length - match[1].length;
1✔
716
        return xmlDoc.positionAt(valueOffset);
1✔
717
    }
718

719
    public async restartDevApplication() {
720
        await this.getRemoteHost();
3✔
721
        const host = this.host;
3✔
722
        if (!host) {
3!
723
            return;
×
724
        }
725

726
        await util.spinAsync('Restarting dev app', async () => {
3✔
727
            const appsResponse = await util.httpGet(`http://${host}:8060/query/apps`, { timeout: 5_000 });
3✔
728
            const appsParsed = await xml2js.parseStringPromise(appsResponse.body as string);
3✔
729
            const appList: Array<{ $?: { id?: string } }> = appsParsed?.apps?.app ?? [];
3!
730
            const hasDev = appList.some(entry => entry.$?.id === 'dev');
3!
731
            if (!hasDev) {
3✔
732
                await vscode.window.showErrorMessage(`No dev channel sideloaded on ${host}. Sideload your project before restarting.`);
1✔
733
                return;
1✔
734
            }
735

736
            // `/true` forces a full terminate even if the channel is suspended in the background via Instant Resume.
737
            // Harmless if dev isn't running — the device just returns FAILED in the body.
738
            await this.ecpPost(host, 'exit-app/dev/true');
2✔
739

740
            const launchResponse = await this.ecpPost(host, 'launch/dev');
2✔
741
            if (launchResponse.statusCode !== 200) {
2!
742
                await vscode.window.showErrorMessage(`Failed to launch dev channel on ${host} (HTTP ${launchResponse.statusCode}).`);
×
743
                return;
×
744
            }
745

746
            // give a little bit of time to let the app boot up before checking its status
747
            await util.sleep(1000);
2✔
748
            const verifyResponse = await util.httpGet(`http://${host}:8060/query/active-app`, { timeout: 5_000 });
2✔
749
            const verifyParsed = await xml2js.parseStringPromise(verifyResponse.body as string);
2✔
750
            const verifyAppId: string | undefined = verifyParsed?.['active-app']?.app?.[0]?.$?.id;
2!
751
            if (verifyAppId === 'dev') {
2✔
752
                void util.showTimedNotification('Dev app restarted', 2000);
1✔
753
            } else {
754
                await vscode.window.showWarningMessage(`Sent the dev launch command, but the foreground app is "${verifyAppId ?? 'unknown'}". The dev app may still be loading.`);
1!
755
            }
756
        });
757
    }
758

759
    private ecpPost(host: string, path: string) {
760
        return new Promise<request.Response>((resolve, reject) => {
×
761
            request.post(`http://${host}:8060/${path}`, (err: Error | null, response: request.Response) => {
×
762
                return err ? reject(err) : resolve(response);
×
763
            });
764
        });
765
    }
766

767
    public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) {
×
768
        for (const notifier of this.keypressNotifiers) {
×
769
            notifier(key, literalCharacter);
×
770
        }
771

772
        if (literalCharacter) {
×
773
            key = 'Lit_' + encodeURIComponent(key);
×
774
        }
775

776
        // do we have a temporary override?
777
        if (!host) {
×
778
            // Get the long lived host ip
779
            await this.getRemoteHost();
×
780
            host = this.host;
×
781
        }
782

783
        if (host) {
×
784
            let clickUrl = `http://${host}:8060/keypress/${key}`;
×
785
            console.log(`send ${clickUrl}`);
×
786
            return new Promise((resolve, reject) => {
×
787
                request.post(clickUrl, (err, response) => {
×
788
                    if (err) {
×
789
                        return reject(err);
×
790
                    }
791
                    return resolve(response);
×
792
                });
793
            });
794
        }
795
    }
796

797
    public async getRemoteHost(showPrompt = true) {
×
798
        this.host = await this.context.workspaceState.get('remoteHost');
×
799
        if (!this.host) {
×
800
            let config = util.getConfiguration('brightscript.remoteControl');
×
801
            this.host = config.get('host');
×
802
            // eslint-disable-next-line no-template-curly-in-string
803
            if ((!this.host || this.host === '${promptForHost}') && showPrompt) {
×
NEW
804
                this.host = await this.userInputManager.promptForHost();
×
805
            }
806
        }
807
        if (!this.host) {
×
808
            throw new Error('Can\'t send command: host is required.');
×
809
        } else {
810
            await this.context.workspaceState.update('remoteHost', this.host);
×
811
        }
812
        if (this.host) {
×
813
            //try resolving the hostname. (sometimes it fails for no reason, so just ignore the crash if it does)
814
            try {
×
815
                this.host = await rokuDebugUtil.dnsLookup(this.host);
×
816
            } catch (e) {
817
                console.error('Error doing dns lookup for host ', this.host, e);
×
818
            }
819
        }
820
        return this.host;
×
821
    }
822

823
    public async getRemotePassword(showPrompt = true) {
×
824
        this.password = await this.context.workspaceState.get('remotePassword');
×
825
        if (!this.password) {
×
826
            let config = util.getConfiguration('brightscript.remoteControl');
×
827
            this.password = config.get('password');
×
828
            // eslint-disable-next-line no-template-curly-in-string
829
            if ((!this.password || this.password === '${promptForPassword}') && showPrompt) {
×
830
                this.password = await vscode.window.showInputBox({
×
831
                    placeHolder: 'The developer account password for your Roku device',
832
                    value: ''
833
                });
834
            }
835
        }
836
        if (!this.password) {
×
837
            throw new Error(`Can't send command: password is required.`);
×
838
        } else {
839
            await this.context.workspaceState.update('remotePassword', this.password);
×
840
        }
841
        return this.password;
×
842
    }
843

844
    public async getWorkspacePath() {
845
        this.workspacePath = await this.context.workspaceState.get('workspacePath');
×
846
        //let folderUri: vscode.Uri;
847
        if (!this.workspacePath) {
×
848
            if (vscode.workspace.workspaceFolders?.length === 1) {
×
849
                this.workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
×
850
            } else {
851
                //there are multiple workspaces, ask the user to specify which one they want to use
852
                let workspaceFolder = await vscode.window.showWorkspaceFolderPick();
×
853
                if (workspaceFolder) {
×
854
                    this.workspacePath = workspaceFolder.uri.fsPath;
×
855
                }
856
            }
857
        }
858
        return this.workspacePath;
×
859
    }
860

861
    /**
862
     * Store a password for a specific device, keyed by serial number.
863
     * An empty password clears the stored entry.
864
     *
865
     * Writes the password to every `brightscript.devices[]` settings entry that
866
     * matches the SN (user/workspace/workspace-folder scopes) and also keeps the
867
     * cred store updated. The cred store acts as a running cache of passwords —
868
     * kept fresh alongside settings here, and refreshed on each successful
869
     * password validation elsewhere.
870
     */
871
    public async setDevicePassword(serialNumber: string, password: string) {
872
        await this.writeDevicePasswordToSettings(serialNumber, password);
×
873
        if (password) {
×
874
            await this.credentialStore.setPassword(serialNumber, password);
×
875
        } else {
876
            await this.credentialStore.clearPassword(serialNumber);
×
877
        }
878
    }
879

880
    /**
881
     * Update/clear the `password` field on any `brightscript.devices[]` entries
882
     * whose `serialNumber` matches. Writes to every writable scope that contains
883
     * a matching entry — user (Global), workspace, and each workspace folder.
884
     * The default (package.json) scope is read-only and is never written.
885
     * Empty password removes the field rather than writing an empty string.
886
     * Returns true when at least one scope contained a matching entry.
887
     */
888
    private async writeDevicePasswordToSettings(serialNumber: string, password: string): Promise<boolean> {
889
        if (!serialNumber) {
×
890
            return false;
×
891
        }
892

893
        const scopeHasMatch = (devices: ConfiguredDevice[] | undefined): boolean => !!devices?.some(entry => entry.serialNumber === serialNumber);
×
894

895
        const rewriteEntries = (devices: ConfiguredDevice[]): ConfiguredDevice[] => devices.map(entry => {
×
896
            if (entry.serialNumber !== serialNumber) {
×
897
                return entry;
×
898
            }
899
            if (password) {
×
900
                return { ...entry, password: password };
×
901
            }
902
            const { password: _existingPassword, ...entryWithoutPassword } = entry;
×
903
            return entryWithoutPassword;
×
904
        });
905

906
        let found = false;
×
907

908
        // User (Global) + workspace scopes — resource-agnostic
909
        const rootConfig = vscode.workspace.getConfiguration('brightscript');
×
910
        const rootInspection = rootConfig.inspect<ConfiguredDevice[]>('devices');
×
911

912
        if (scopeHasMatch(rootInspection?.globalValue)) {
×
913
            found = true;
×
914
            await rootConfig.update('devices', rewriteEntries(rootInspection.globalValue), vscode.ConfigurationTarget.Global);
×
915
        }
916
        if (scopeHasMatch(rootInspection?.workspaceValue)) {
×
917
            found = true;
×
918
            await rootConfig.update('devices', rewriteEntries(rootInspection.workspaceValue), vscode.ConfigurationTarget.Workspace);
×
919
        }
920

921
        // Workspace-folder scope — one setting per folder in multi-root workspaces
922
        const workspaceFolders = vscode.workspace.workspaceFolders ?? [];
×
923
        for (const folder of workspaceFolders) {
×
924
            const folderConfig = vscode.workspace.getConfiguration('brightscript', folder.uri);
×
925
            const folderInspection = folderConfig.inspect<ConfiguredDevice[]>('devices');
×
926
            if (scopeHasMatch(folderInspection?.workspaceFolderValue)) {
×
927
                found = true;
×
928
                await folderConfig.update('devices', rewriteEntries(folderInspection.workspaceFolderValue), vscode.ConfigurationTarget.WorkspaceFolder);
×
929
            }
930
        }
931

932
        return found;
×
933
    }
934

935
    /**
936
     * Get the stored password for a specific device by serial number.
937
     */
938
    public async getDevicePassword(serialNumber: string | undefined): Promise<string | undefined> {
939
        return this.credentialStore.getPassword(serialNumber);
×
940
    }
941

942
    /**
943
     * Get the password for the currently active device.
944
     * Resolves the active host's IP to a serial number via DeviceManager,
945
     * then reads from the SN-keyed credential store. Falls back to the
946
     * workspace-global password when no per-device entry exists.
947
     */
948
    public async getActiveHostPassword(): Promise<string | undefined> {
949
        const activeHost = this.context.workspaceState.get<string>('remoteHost');
×
950
        if (activeHost && typeof activeHost === 'string') {
×
951
            const serialNumber = this.deviceManager.getDevice({ ip: activeHost })?.serialNumber;
×
952
            const devicePassword = await this.credentialStore.getPassword(serialNumber);
×
953
            if (devicePassword) {
×
954
                return devicePassword;
×
955
            }
956
        }
957
        return this.getRemotePassword(false);
×
958
    }
959

960
    /**
961
     * Return the active host IP if one is set and passes a health check; otherwise undefined.
962
     */
963
    public async getHealthyActiveHost(): Promise<string | undefined> {
964
        const activeHost = vscodeContextManager.get<string>('activeHost');
×
965
        if (!activeHost) {
×
966
            return undefined;
×
967
        }
968
        const isHealthy = await this.deviceManager.healthCheckDevice({ ip: activeHost }, true, false);
×
969
        return isHealthy ? activeHost : undefined;
×
970
    }
971

972
    /**
973
     * Open the settings JSON file and position cursor at the specified device entry
974
     */
975
    private async openSettingsJsonAtDevice(device: RokuDevice | undefined, scope: 'user' | 'workspace'): Promise<void> {
976
        // Open the appropriate settings JSON file
977
        const command = scope === 'user'
×
978
            ? 'workbench.action.openSettingsJson'
979
            : 'workbench.action.openWorkspaceSettingsFile';
980
        await vscode.commands.executeCommand(command);
×
981

982
        // Get the active editor (should be the settings file we just opened)
983
        const editor = vscode.window.activeTextEditor;
×
984
        if (!editor || !device) {
×
985
            return;
×
986
        }
987

988
        const text = editor.document.getText();
×
989

990
        // Search for the device by IP or serial number
991
        const searchTerms = [device.ip];
×
992
        if (device.serialNumber) {
×
993
            searchTerms.push(device.serialNumber);
×
994
        }
995

996
        let matchIndex = -1;
×
997
        for (const term of searchTerms) {
×
998
            const index = text.indexOf(`"${term}"`);
×
999
            if (index !== -1) {
×
1000
                matchIndex = index;
×
1001
                break;
×
1002
            }
1003
        }
1004

1005
        if (matchIndex !== -1) {
×
1006
            const position = editor.document.positionAt(matchIndex + 1); // +1 to skip opening quote
×
1007
            const selection = new vscode.Selection(position, position);
×
1008
            editor.selection = selection;
×
1009
            editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.InCenter);
×
1010
        }
1011
    }
1012

1013
    public registerKeypressNotifier(notifier: (key: string, literalCharacter: boolean) => void) {
1014
        this.keypressNotifiers.push(notifier);
14✔
1015
    }
1016

1017
    private registerCommand(name: string, callback: (...args: any[]) => any, thisArg?: any) {
1018
        const prefix = 'extension.brightscript.';
2,528✔
1019
        const commandName = name.startsWith(prefix) ? name : prefix + name;
2,528✔
1020
        this.context.subscriptions.push(vscode.commands.registerCommand(commandName, callback, thisArg));
2,528✔
1021
    }
1022

1023
    private async sendAsciiToDevice(character: string) {
1024
        await this.sendRemoteCommand(character, undefined, true);
×
1025
    }
1026
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc