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

rokucommunity / vscode-brightscript-language / 26291440536

22 May 2026 01:44PM UTC coverage: 56.216% (+0.7%) from 55.501%
26291440536

Pull #790

github

web-flow
Merge ebe29a4a4 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.

3648 of 6069 relevant lines covered (60.11%)

40.42 hits per line

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

28.07
/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
import type { DevicesViewProvider } from './viewProviders/DevicesViewProvider';
23
import { DEVICE_FILTER_KEYS } from './deviceFilters';
1✔
24

25
export class BrightScriptCommands {
1✔
26

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

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

45
    public registerCommands() {
46

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

54
        this.registerGeneralCommands();
16✔
55

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

294
        this.registerKeyboardInputs();
16✔
295
    }
296

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

396
            const parsed = await xml2js.parseStringPromise(responseText);
×
397

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

545
            if (password === undefined) {
3✔
546
                return;
1✔
547
            }
548

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

698
        const parentXmlPath = locations[0].uri.fsPath;
1✔
699

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

908
        let found = false;
×
909

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

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

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

934
        return found;
×
935
    }
936

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

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

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

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

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

990
        const text = editor.document.getText();
×
991

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

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

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

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

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

1025
    /**
1026
     * Register the per-facet toggle commands plus the reset command backing the Devices
1027
     * view filter submenu. Each facet has two command variants (unchecked + ".active");
1028
     * the submenu picks which to render via a `when` clause on the per-facet context key.
1029
     * Both call the same toggle handler.
1030
     */
1031
    public registerDevicesViewCommands(devicesViewProvider: DevicesViewProvider) {
1032
        for (const key of DEVICE_FILTER_KEYS) {
12✔
1033
            const handler = () => devicesViewProvider.toggleFilter(key);
108✔
1034
            this.registerCommand(`devicesView.toggleFilter.${key}`, handler);
108✔
1035
            this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler);
108✔
1036
        }
1037
        this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters());
12✔
1038
    }
1039

1040
    private async sendAsciiToDevice(character: string) {
1041
        await this.sendRemoteCommand(character, undefined, true);
×
1042
    }
1043
}
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