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

rokucommunity / vscode-brightscript-language / 30096694707

24 Jul 2026 01:23PM UTC coverage: 59.949% (+0.04%) from 59.908%
30096694707

Pull #802

github

web-flow
Merge 333fa7808 into e465804e3
Pull Request #802: Route extension logs through the BrightScript Extension output channel

2592 of 4745 branches covered (54.63%)

Branch coverage included in aggregate %.

19 of 23 new or added lines in 3 files covered. (82.61%)

732 existing lines in 18 files now uncovered.

4036 of 6311 relevant lines covered (63.95%)

43.79 hits per line

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

86.74
/src/deviceDiscovery/DeviceManager.ts
1
import { EventEmitter } from 'eventemitter3';
1✔
2
import * as vscode from 'vscode';
1✔
3
import { firstBy } from 'thenby';
1✔
4
import type { Disposable } from 'vscode';
5
import { rokuDeploy, DeviceUnreachableError, type DeviceInfoRaw } from 'roku-deploy';
1✔
6
import { util as rokuDebugUtil } from 'roku-debug/dist/util';
1✔
7
import type { GlobalStateManager } from '../GlobalStateManager';
8
import { RokuFinder } from './RokuFinder';
1✔
9
import { NetworkChangeMonitor, getNetworkHash } from './NetworkChangeMonitor';
1✔
10
import { SystemSleepMonitor } from './SystemSleepMonitor';
1✔
11
import { util } from '../util';
1✔
12
import { vscodeContextManager } from '../managers/VscodeContextManager';
1✔
13
import { debounce } from 'lodash';
1✔
14
import { icons } from '../icons';
1✔
15

16
export class DeviceManager {
1✔
17
    // #region constructor
18
    constructor(
19
        private context: vscode.ExtensionContext,
298✔
20
        private globalStateManager: GlobalStateManager,
298✔
21
        private extensionOutputChannel?: vscode.OutputChannel
298✔
22
    ) {
23
        this.networkId = getNetworkHash();
298✔
24

25
        this.setupConfiguration();
298✔
26
        this.setupWindowFocusHandling();
298✔
27
        this.setupMonitors();
298✔
28
        this.initialize();
298✔
29
        this.context.subscriptions.push(this);
298✔
30
    }
31

32
    private setupConfiguration() {
33
        const applyConfig = (event?: vscode.ConfigurationChangeEvent) => {
213✔
34
            let config: any = util.getConfiguration('brightscript') || {};
213!
35

36
            void vscodeContextManager.set('brightscript.deviceDiscovery.enabled', config.deviceDiscovery?.enabled);
213✔
37
            void vscodeContextManager.set('brightscript.hasDefaultDevicePassword', !!this.getDefaultPassword());
213✔
38

39
            //if the `deviceDiscovery.enabled` setting was changed, start or stop monitoring
40
            if (event?.affectsConfiguration('brightscript.deviceDiscovery.enabled')) {
213!
UNCOV
41
                if (this.deviceDiscoveryEnabled) {
×
42
                    //emit that we need a scan (will trigger UI to refresh and show devices as needed when enabled)
UNCOV
43
                    this.setScanNeeded(true);
×
44
                    this.systemSleepMonitor.start();
×
UNCOV
45
                    void this.activateMonitoring();
×
46
                } else {
47
                    this.systemSleepMonitor.stop();
×
48
                    this.deactivateMonitoring();
×
49
                }
50
            }
51

52
            //if the `concealDeviceInfo` setting was changed, refresh the UI (no reload needed)
53
            if (event?.affectsConfiguration('brightscript.deviceDiscovery.concealDeviceInfo')) {
213!
UNCOV
54
                this.emitDevicesChanged();
×
55
            }
56

57
            //if the `devices` setting was changed, re-apply configured devices and health check them
58
            if (event?.affectsConfiguration('brightscript.devices')) {
213!
UNCOV
59
                this.loadConfiguredDevices().then(() => {
×
UNCOV
60
                    return this.healthCheckAllDevices(false, true);
×
61
                }).catch(() => { });
62
            }
63

64
            //if the `defaultDevicePassword` setting was changed, refresh any device views that rely on it
65
            if (event?.affectsConfiguration('brightscript.defaultDevicePassword')) {
213!
UNCOV
66
                this.emitDevicesChanged();
×
67
            }
68
        };
69
        this.context.subscriptions.push(
213✔
70
            vscode.workspace.onDidChangeConfiguration(applyConfig)
71
        );
72
        applyConfig();
213✔
73
    }
74

75
    private setupWindowFocusHandling() {
76
        this.context.subscriptions.push(
213✔
77
            vscode.window.onDidChangeWindowState((state) => {
UNCOV
78
                if (state.focused) {
×
UNCOV
79
                    this.notifyFocusGained();
×
80
                } else {
81
                    this.notifyFocusLost();
×
82
                }
83
            })
84
        );
85
    }
86

87
    private setupMonitors() {
88
        this.systemSleepMonitor = new SystemSleepMonitor(() => {
213✔
UNCOV
89
            this.setScanNeeded();
×
90
        });
91
        this.networkChangeMonitor = new NetworkChangeMonitor(() => {
213✔
92
            this.networkId = getNetworkHash();
9✔
93

94
            //reset all configured device states to unknown - need to re-verify on new network
95
            for (const entry of this.configuredDevices) {
9✔
96
                entry.lastState = entry.state;
2✔
97
                entry.state = 'unknown';
2✔
98
                entry.stateLastUpdated = Date.now();
2✔
99
            }
100

101
            //clear and reload discovered devices anytime this network changes (state goes with them)
102
            this.discoveredDevices = [];
9✔
103
            this.loadLastSeenDevices();
9✔
104

105
            //re-point the active device at its IP on this network (found by serial number)
106
            this.syncActiveDevice().catch(() => { });
9✔
107

108
            this.restartRokuFinder();
9✔
109

110
            //this is important for telling the devices view to refresh and health check its devices
111
            this.setScanNeeded();
9✔
112
        });
113
    }
114

115
    private initialize() {
116
        //clear any deviceInfo entries older than our max age
117
        this.globalStateManager.clearExpiredDevices();
213✔
118

119
        // Load configured devices and cached devices (order doesn't matter due to setDevice merge logic)
120
        this.loadConfiguredDevices().catch(() => { });
213✔
121
        this.loadLastSeenDevices();
213✔
122

123
        //restore the active device from a previous session (re-resolves its IP by serial number)
124
        this.syncActiveDevice().catch(() => { });
213✔
125

126
        // Set up event listeners for the RokuFinder
127
        this.setupFinderListeners();
213✔
128

129
        if (this.deviceDiscoveryEnabled) {
213✔
130
            // Sleep monitor runs all the time when enabled (ignores focus state)
131
            this.systemSleepMonitor.start();
12✔
132

133
            this.activateMonitoring().then(() => {
12✔
134
                const lastSeenDeviceIds = this.globalStateManager.getLastSeenDevices(this.networkId);
12✔
135
                if (lastSeenDeviceIds.length === 0) {
12!
136
                    this.refresh();
12✔
137
                } else {
UNCOV
138
                    this.setScanNeeded();
×
139
                }
140
            }).catch((e) => {
141
                console.error(e);
×
142
            });
143
        }
144
    }
145
    // #endregion
146

147
    // Core state and dependencies
148
    private configuredDevices: ConfiguredDeviceEntry[] = [];
298✔
149
    private discoveredDevices: DiscoveredDeviceEntry[] = [];
298✔
150
    private scanNeeded = false;
298✔
151
    private lastUsedDeviceIp: string | undefined = undefined;
298✔
152
    private networkId: string;
153

154
    private emitter = new EventEmitter();
298✔
155
    private systemSleepMonitor: SystemSleepMonitor;
156
    private networkChangeMonitor: NetworkChangeMonitor;
157
    private finder = new RokuFinder(this.globalStateManager, this.makeFinderLogger());
298✔
158

159
    // Health check tracking and cooldowns
160
    private resolveDeviceSequence = new Map<string, number>();
298✔
161
    private readonly DEVICE_INFO_CACHE_MS = 5 * 60 * 1_000; // 5 minutes - cache duration for fetchDeviceInfo
298✔
162
    private readonly FRESH_CACHE_THRESHOLD_MS = 5 * 60 * 1_000; // 5 minutes - cache fresher than this = online on load
298✔
163
    private readonly STALE_DEVICE_AFTER_SCAN_MS = 10_000; // 10 seconds - health check devices with cache older than this after scan
298✔
164
    private readonly OFFLINE_COOLDOWN_MS = 5_000; // 5 seconds - minimum time between resolve attempts for offline devices
298✔
165
    public static readonly HEALTH_CHECK_TIMEOUT_MS = 2_000; // 2 seconds
1✔
166

167
    // Notifications and event debouncing
168
    private readonly DEVICES_CHANGED_DEBOUNCE_MS = 50;
298✔
169
    private deviceOnlineNotifiers = new Map<string, ReturnType<typeof debounce>>();
298✔
170

171
    // Scan state management
172
    private readonly STALE_SCAN_THRESHOLD_MS = 30 * 60 * 1_000; // 30 minutes
298✔
173
    private lastScanDate: Date | null = null;
298✔
174

175
    public on(eventName: 'devices-changed', handler: () => void, disposables?: Disposable[]): () => void;
176
    public on(eventName: 'scan-started', handler: () => void, disposables?: Disposable[]): () => void;
177
    public on(eventName: 'scan-ended', handler: () => void, disposables?: Disposable[]): () => void;
178
    public on(eventName: 'scanNeeded-changed', handler: () => void, disposables?: Disposable[]): () => void;
179
    public on(eventName: string, handler: (payload: any) => void, disposables?: Disposable[]): () => void {
180
        this.emitter.on(eventName, handler);
74✔
181
        const unsubscribe = () => {
74✔
182
            if (this.emitter !== undefined) {
13!
183
                this.emitter.removeListener(eventName, handler);
13✔
184
            }
185
        };
186

187
        disposables?.push({
74✔
188
            dispose: unsubscribe
189
        });
190

191
        return unsubscribe;
74✔
192
    }
193

194
    /**
195
     * Get device by encoded key string.
196
     * Key format: "s:{serialNumber}" or "i:{ip}"
197
     *
198
     * @param key - Encoded device key
199
     * @returns Device with deviceInfo or undefined if not found
200
     */
201
    public getDevice(key: string): RokuDevice | undefined;
202
    /**
203
     * Get device by IP or serial number.
204
     * Returns device with deviceInfo hydrated from cache.
205
     *
206
     * @param lookup - Object with optional ip and/or serialNumber
207
     * @returns Device with deviceInfo or undefined if not found
208
     */
209
    public getDevice(lookup: { ip?: string; serialNumber?: string }): RokuDevice | undefined;
210
    public getDevice(keyOrLookup: string | { ip?: string; serialNumber?: string }): RokuDevice | undefined {
211
        const { configured, discovered } = this.findDeviceEntries(keyOrLookup);
44✔
212
        const device = this.buildMergedDevice(configured, discovered);
44✔
213

214
        // If lookup object with both ip and serialNumber, verify exact match
215
        if (typeof keyOrLookup !== 'string' && keyOrLookup.ip && keyOrLookup.serialNumber && device) {
44!
UNCOV
216
            if (device.ip !== keyOrLookup.ip || device.serialNumber !== keyOrLookup.serialNumber) {
×
UNCOV
217
                return undefined;
×
218
            }
219
        }
220

221
        return device;
44✔
222
    }
223

224
    /**
225
     * Probe an IP address, add it to the discovered devices list if reachable, and return the device.
226
     * Used when user manually enters an IP or before resolving a debug config.
227
     *
228
     * @param ip - The IP address to probe
229
     * @returns The device if reachable, undefined otherwise
230
     */
231
    public async validateAndAddDevice(ip: string): Promise<RokuDevice | undefined> {
232
        this.setDiscoveredDevice(ip, undefined);
10✔
233
        await this.resolveDevice({ ip: ip }, false);
10✔
234
        return this.getDevice({ ip: ip });
10✔
235
    }
236

237
    /**
238
     * Get a list of all roku devices.
239
     * Returns all devices without filtering.
240
     */
241
    public getAllDevices(): RokuDevice[] {
242
        return this.buildAllDevices();
286✔
243
    }
244

245
    /**
246
     * Generate a display name for a device.
247
     * Handles missing device info gracefully (no ugly " - - - " strings).
248
     * @param device - The device to generate a name for
249
     * @param includeIp - Whether to always append IP at the end (default: false, IP only used as fallback)
250
     */
251
    public getDeviceDisplayName(device: RokuDevice, includeIp = false): string {
15✔
252
        // Coerce to a trimmed string, or undefined when the value is missing/blank.
253
        // Whitespace-only values would otherwise pass `Boolean` and render as empty segments.
254
        const clean = (value: unknown): string | undefined => {
35✔
255
            if (value === null || value === undefined || typeof value !== 'string') {
173✔
256
                return undefined;
40✔
257
            }
258
            const str = value.trim();
133✔
259
            return str.length > 0 ? str : undefined;
133✔
260
        };
261

262
        const displayName = clean(device.configuredName) ?? clean(device.deviceInfo['user-device-name']);
35✔
263
        const modelNumber = clean(device.deviceInfo['model-number']);
35✔
264
        const softwareVersion = clean(device.deviceInfo['software-version']);
35✔
265
        const ip = clean(device.ip);
35✔
266

267
        const parts = [
35✔
268
            modelNumber,
269
            displayName,
270
            softwareVersion ? `OS ${softwareVersion}` : undefined
35✔
271
        ].filter(Boolean);
272

273
        if (includeIp && ip) {
35✔
274
            parts.push(ip);
16✔
275
        }
276

277
        return parts.join(' – ') || ip || '';
35✔
278
    }
279

280
    /**
281
     * Generate the label used when showing "host" entries in a quick picker
282
     * @param device the device containing all the info
283
     * @returns a properly formatted host string
284
     */
285
    public getIconPath(device: RokuDevice) {
286
        const hasCache = device.serialNumber && this.hasDeviceCache(device.serialNumber);
11✔
287

288
        if (device.deviceState === 'pending') {
11!
UNCOV
289
            return new vscode.ThemeIcon('circle-small', new vscode.ThemeColor('disabledForeground'));
×
290
        }
291

292
        if (device.deviceState === 'offline') {
11!
UNCOV
293
            const iconId = hasCache ? 'debug-disconnect' : 'warning';
×
UNCOV
294
            return new vscode.ThemeIcon(iconId, new vscode.ThemeColor('disabledForeground'));
×
295
        }
296

297
        if (device.deviceState === 'unknown' && !hasCache) {
11!
UNCOV
298
            return new vscode.ThemeIcon('warning', new vscode.ThemeColor('disabledForeground'));
×
299
        }
300

301
        return icons.getDeviceType(device.deviceInfo);
11✔
302
    }
303

304
    /**
305
     * Build all devices from configuredDevices and discoveredDevices arrays.
306
     * Deduplication by serial number (preferred) or IP (fallback).
307
     */
308
    private buildAllDevices(): RokuDevice[] {
309
        const mergedDevices = new Map<string, RokuDevice>();
286✔
310
        const processedDiscoveredIndices = new Set<number>();
286✔
311

312
        // Process configured devices first, finding matching discovered entries
313
        for (const configured of this.configuredDevices) {
286✔
314
            // Find matching discovered entry by serial, resolvedIp, or host
315
            let discoveredIdx = -1;
78✔
316
            let discovered: DiscoveredDeviceEntry | undefined;
317

318
            if (configured.serialNumber) {
78✔
319
                // Config has serial - ONLY match by serial (serial is primary key)
320
                discoveredIdx = this.discoveredDevices.findIndex(d => d.serialNumber === configured.serialNumber);
64✔
321
            } else {
322
                // Config has no serial - match by IP
323
                if (configured.resolvedIp) {
14!
324
                    discoveredIdx = this.discoveredDevices.findIndex(d => d.ip === configured.resolvedIp);
14✔
325
                }
326
                if (discoveredIdx < 0) {
14✔
327
                    discoveredIdx = this.discoveredDevices.findIndex(d => d.ip === configured.host);
9✔
328
                }
329
            }
330

331
            if (discoveredIdx >= 0) {
78✔
332
                discovered = this.discoveredDevices[discoveredIdx];
45✔
333
                processedDiscoveredIndices.add(discoveredIdx);
45✔
334
            }
335

336
            const device = this.buildMergedDevice(configured, discovered);
78✔
337
            if (device) {
78!
338
                mergedDevices.set(device.key, device);
78✔
339
            }
340
        }
341

342
        // Process discovered-only devices (not already merged via configured)
343
        for (let i = 0; i < this.discoveredDevices.length; i++) {
286✔
344
            if (processedDiscoveredIndices.has(i)) {
89✔
345
                continue;
42✔
346
            }
347

348
            const discovered = this.discoveredDevices[i];
47✔
349
            const device = this.buildMergedDevice(undefined, discovered);
47✔
350
            if (device) {
47!
351
                // Check for duplicate by key
352
                if (mergedDevices.has(device.key)) {
47✔
353
                    continue;
3✔
354
                }
355
                // Only skip by IP if neither device has a serial (serial is primary key)
356
                // Different serials at same IP = different devices
357
                const existingByIp = Array.from(mergedDevices.values()).find(d => d.ip === device.ip);
44✔
358
                if (existingByIp && !device.serialNumber && !existingByIp.serialNumber) {
44!
UNCOV
359
                    continue;
×
360
                }
361
                mergedDevices.set(device.key, device);
44✔
362
            }
363
        }
364

365
        // Convert to array and sort
366
        return Array.from(mergedDevices.values()).sort(
286✔
367
            // Sort by form factor
368
            firstBy<RokuDevice>((a, b) => {
369
                return this.getPriorityForDeviceFormFactor(a.deviceInfo) - this.getPriorityForDeviceFormFactor(b.deviceInfo);
18✔
370
                // Then by name
371
            }).thenBy<RokuDevice>((a, b) => {
372
                const nameA = a.deviceInfo['default-device-name'] || '';
15✔
373
                const nameB = b.deviceInfo['default-device-name'] || '';
15✔
374
                return nameA.localeCompare(nameB);
15✔
375
            }).thenBy<RokuDevice>((a, b) => {
376
                const serialA = a.serialNumber || '';
3✔
377
                const serialB = b.serialNumber || '';
3✔
378
                if (serialA < serialB) {
3!
UNCOV
379
                    return -1;
×
380
                }
381
                if (serialA > serialB) {
3✔
382
                    return 1;
2✔
383
                }
384
                // serial numbers must be equal
385
                return 0;
1✔
386
            })
387
        );
388
    }
389

390
    // #region Device State Management
391
    /**
392
     * Get device state from inline state on entries.
393
     * Priority: discovered > configured > default unknown
394
     * Searches by IP first (if provided), then by serial number
395
     * @param lookup - Device lookup by serial and/or IP
396
     * @returns The device state, defaulting to 'unknown' if not found
397
     */
398
    public getDeviceState(lookup: { serialNumber?: string; ip?: string }): DeviceStateEntry {
399
        let match = this.findStateEntry(this.discoveredDevices, lookup);
147✔
400
        if (match) {
147✔
401
            return { state: match.state, lastUpdated: match.stateLastUpdated ?? Date.now() };
98!
402
        }
403

404
        match = this.findStateEntry(this.configuredDevices, lookup);
49✔
405
        if (match) {
49✔
406
            return { state: match.state, lastUpdated: match.stateLastUpdated ?? Date.now() };
12!
407
        }
408
        return { state: 'unknown', lastUpdated: Date.now() };
37✔
409
    }
410

411
    /**
412
     * Find the highest-priority state-bearing entry across discovered then configured
413
     * sources. Within each source, try the IP first (skipping IP matches whose serial
414
     * points to a different device — otherwise changing a configured device's serial to
415
     * a new value at an IP that already hosts an online discovered device would briefly
416
     * inherit that online state), then fall back to a serial-only match. Returns the
417
     * first entry that actually has a `state` set.
418
     */
419
    private findStateEntry(entries: Array<ConfiguredDeviceEntry | DiscoveredDeviceEntry>, lookup: { serialNumber?: string; ip?: string }) {
420
        let match: ConfiguredDeviceEntry | DiscoveredDeviceEntry | undefined;
421
        if (lookup.ip) {
196✔
422
            match = entries.find(entry => {
194✔
423
                const ipMatches = (entry as DiscoveredDeviceEntry).ip === lookup.ip || (entry as ConfiguredDeviceEntry).host === lookup.ip || (entry as ConfiguredDeviceEntry).resolvedIp === lookup.ip;
168✔
424
                // when both sides carry a serial, they must agree — otherwise this IP belongs to a different device
425
                const serialMatches = !lookup.serialNumber || !entry.serialNumber || entry.serialNumber === lookup.serialNumber;
168✔
426
                return ipMatches && serialMatches;
168✔
427
            });
428
        }
429
        if (!match && lookup.serialNumber) {
196✔
430
            match = entries.find(entry => entry.serialNumber === lookup.serialNumber);
38✔
431
        }
432
        if (match?.state) {
196✔
433
            return match;
110✔
434
        }
435
        return undefined;
86✔
436
    }
437

438
    /**
439
     * Set device state directly on entries that match the IP.
440
     * Updates all configured and discovered entries at the given IP.
441
     * When called without explicit state, uses intelligent defaults:
442
     * - If already online, stays online
443
     * - Else checks cache freshness (5 min threshold) to determine online vs unknown
444
     *
445
     * @param lookup - Device lookup by IP (and optionally serial for cache lookup)
446
     * @param state - Explicit state to set, or undefined for intelligent default
447
     */
448
    public setDeviceState(lookup: { serialNumber?: string; ip?: string }, state?: DeviceState): void {
449
        const now = Date.now();
320✔
450
        let resolvedState: DeviceState;
451

452
        //if we were given a state, use it
453
        if (state !== undefined) {
320✔
454
            resolvedState = state;
241✔
455
        } else {
456
            const currentState = this.getDeviceState(lookup).state;
79✔
457
            if (currentState === 'online') {
79✔
458
                resolvedState = 'online';
12✔
459
            } else {
460
                // For non-online devices, check cache freshness
461
                const cached = lookup.serialNumber ? this.globalStateManager.getCachedDevice(lookup.serialNumber) : undefined;
67✔
462
                const isFreshCache = cached && (now - cached.createdAt < this.FRESH_CACHE_THRESHOLD_MS);
67✔
463
                resolvedState = isFreshCache ? 'online' : 'unknown';
67✔
464
            }
465
        }
466

467
        // Update configured entries at this IP that match the serial (or have no serial conflict).
468
        // stateLastUpdated bumps on every call so consumers see the latest check time, but
469
        // lastState/state only move when the state actually changes.
470
        for (const entry of this.configuredDevices) {
320✔
471
            const ipMatches = entry.host === lookup.ip || entry.resolvedIp === lookup.ip;
120✔
472
            // Only update if IP matches AND (no serial conflict OR serials match)
473
            const serialConflict = lookup.serialNumber && entry.serialNumber && entry.serialNumber !== lookup.serialNumber;
120✔
474
            if (ipMatches && !serialConflict) {
120✔
475
                if (entry.state !== resolvedState) {
85✔
476
                    entry.lastState = entry.state;
68✔
477
                    entry.state = resolvedState;
68✔
478
                }
479
                entry.stateLastUpdated = now;
85✔
480
            }
481
        }
482

483
        // Update discovered entries at this IP that match the serial (or have no serial conflict).
484
        // Same nested guard as the configured loop above.
485
        for (const entry of this.discoveredDevices) {
320✔
486
            const ipMatches = entry.ip === lookup.ip;
306✔
487
            const serialConflict = lookup.serialNumber && entry.serialNumber && entry.serialNumber !== lookup.serialNumber;
306✔
488
            if (ipMatches && !serialConflict) {
306✔
489
                if (entry.state !== resolvedState) {
255✔
490
                    entry.lastState = entry.state;
207✔
491
                    entry.state = resolvedState;
207✔
492
                }
493
                entry.stateLastUpdated = now;
255✔
494
            }
495
        }
496
    }
497
    // #endregion
498

499
    /**
500
     * Check if a device has cached info (has been successfully resolved before).
501
     * Used by view providers to determine icon: warning (no cache) vs disconnect (has cache).
502
     */
503
    public hasDeviceCache(serialNumber: string): boolean {
504
        return !!this.globalStateManager.getCachedDevice(serialNumber);
29✔
505
    }
506

507
    /**
508
     * Re-scan the network for devices and health-check existing ones
509
     */
510
    public refresh(force = false, doSyntheticDelay = true): boolean {
39✔
511
        this.healthCheckAllDevices(force, doSyntheticDelay).catch(() => { });
26✔
512
        // Block automatic scans when device discovery is disabled
513
        if (!force && !this.deviceDiscoveryEnabled) {
26✔
514
            return false;
3✔
515
        }
516
        return this.discoverAll(force);
23✔
517
    }
518

519
    /**
520
     * Trigger a network scan for devices without health checking existing devices.
521
     * Use this when you just want to discover new devices without verifying existing ones.
522
     * @param force - If true, scan even if deviceDiscovery is disabled
523
     * @returns true if a scan was started, false otherwise
524
     */
525
    public scan(force = false): boolean {
3✔
526
        if (!force && !this.deviceDiscoveryEnabled) {
7✔
527
            return false;
1✔
528
        }
529
        return this.discoverAll(force);
6✔
530
    }
531

532
    /**
533
     * Clear discovered devices from the device list, keeping configured devices.
534
     * Useful for refreshing the network scan without losing user-configured devices.
535
     */
536
    public async clearCurrentDeviceList() {
537
        // Clear discovered devices (ephemeral)
538
        this.discoveredDevices = [];
14✔
539

540
        // Only clear lastUsedDeviceIp if it belonged to a discovered-only device
541
        if (this.lastUsedDeviceIp) {
14!
UNCOV
542
            const stillExists = this.configuredDevices.some(
×
UNCOV
543
                d => d.resolvedIp === this.lastUsedDeviceIp || d.host === this.lastUsedDeviceIp
×
544
            );
545
            if (!stillExists) {
×
546
                this.lastUsedDeviceIp = undefined;
×
547
            }
548
        }
549

550
        //clear the cache for the current list of devices
551
        this.globalStateManager.setLastSeenDevices(this.networkId, []);
14✔
552

553
        await this.healthCheckAllDevices(false, false).catch(() => { });
14✔
554
        this.emitDevicesChanged();
14✔
555

556
    }
557

558
    public clearAllCache() {
559
        // Stop any in-progress scan (finder.stop() emits scan-ended if scanning)
560
        this.finder.stop();
14✔
561

562
        // Clear persisted global state
563
        this.globalStateManager.clearLastSeenDevices();
14✔
564
        this.globalStateManager.clearDeviceCache();
14✔
565
        this.globalStateManager.clearSerialNumberByIpForNetwork();
14✔
566

567
        // Clear all timestamps and per-device state
568
        this.lastScanDate = null;
14✔
569
        this.resolveDeviceSequence.clear();
14✔
570

571
        // Reset configured device states to unknown
572
        for (const entry of this.configuredDevices) {
14✔
573
            entry.lastState = entry.state;
1✔
574
            entry.state = 'unknown';
1✔
575
            entry.stateLastUpdated = Date.now();
1✔
576
        }
577

578
        // Clear discovered devices (state goes with them)
579
        this.clearCurrentDeviceList().catch(() => { });
14✔
580
    }
581

582
    public async healthCheckDevice(deviceOrLookup: RokuDevice | { ip?: string; serialNumber?: string }, force = false, doSyntheticDelay = true): Promise<boolean> {
35✔
583
        // If already a device object with deviceState, use it directly; otherwise look it up
584
        const device = 'deviceState' in deviceOrLookup
25!
585
            ? deviceOrLookup
586
            : this.getDevice(deviceOrLookup);
587

588
        if (!device) {
25!
UNCOV
589
            return false;
×
590
        }
591

592
        // Cooldown is handled by fetchDeviceInfo cache; force bypasses it
593
        const isHealthy = await this.resolveDevice(device, doSyntheticDelay, force);
25✔
594
        if (!isHealthy && device.isDiscovered) {
25✔
595
            // force a scan if passive scan is permitted
596
            this.refresh(this.deviceDiscoveryEnabled);
7✔
597
        }
598
        return isHealthy;
25✔
599
    }
600

601
    /**
602
     * Validate a developer password against the device at `host`.
603
     *
604
     * Returns:
605
     * - `'ok'` — credentials accepted
606
     * - `'bad-password'` — device reachable, credentials rejected
607
     * - `'unreachable'` — device could not be contacted (transient; don't treat as wrong password)
608
     */
609
    public async validateDevicePassword(host: string, password: string): Promise<PasswordValidationResult> {
610
        try {
5✔
611
            const accepted = await rokuDeploy.validateDeveloperPassword({ host: host, password: password });
5✔
612
            return accepted ? 'ok' : 'bad-password';
2✔
613
        } catch (e) {
614
            if (e instanceof DeviceUnreachableError) {
3✔
615
                return 'unreachable';
1✔
616
            }
617
            // Unexpected response code or any other failure — treat as unreachable so the caller retries/prompts rather than discarding credentials.
618
            return 'unreachable';
2✔
619
        }
620
    }
621

622
    /**
623
     * Set the active device. Persists the device's serial number (when known) alongside the IP in
624
     * workspace storage so the active device can be recovered in future sessions even if its IP changed.
625
     */
626
    public async setActiveDevice(ip: string): Promise<void> {
627
        const serialNumber = this.getDevice({ ip: ip })?.serialNumber;
3✔
628
        await this.context.workspaceState.update('remoteHost', ip);
3✔
629
        await this.context.workspaceState.update(DeviceManager.ACTIVE_DEVICE_STATE_KEY, { serialNumber: serialNumber, ip: ip } as ActiveDeviceEntry);
3✔
630
        await vscodeContextManager.set('activeHost', ip);
3✔
631
    }
632

633
    /**
634
     * Clear the active device (both the session context and the persisted workspace storage entry)
635
     */
636
    public async clearActiveDevice(): Promise<void> {
637
        await this.context.workspaceState.update('remoteHost', '');
1✔
638
        await this.context.workspaceState.update(DeviceManager.ACTIVE_DEVICE_STATE_KEY, undefined);
1✔
639
        await vscodeContextManager.set('activeHost', '');
1✔
640
    }
641

642
    /**
643
     * Forget the saved active device when the user has explicitly picked a different device.
644
     * Called after the device picker resolves in a flow where the active device could not be located,
645
     * so the old active device isn't automatically re-activated by `syncActiveDevice` if it comes
646
     * back online later. When the picked device IS the active device (possibly at a new IP), the
647
     * active device is kept and its pointers are re-synced instead.
648
     */
649
    public async forgetActiveDeviceIfDifferent(pickedIp: string): Promise<void> {
650
        const activeDevice = this.context.workspaceState.get<ActiveDeviceEntry>(DeviceManager.ACTIVE_DEVICE_STATE_KEY);
5✔
651
        if (!activeDevice?.ip && !activeDevice?.serialNumber) {
5!
652
            return;
2✔
653
        }
654

655
        const pickedSerialNumber = this.getDevice({ ip: pickedIp })?.serialNumber;
3✔
656
        const isSameDevice = pickedIp === activeDevice.ip ||
3✔
657
            (!!activeDevice.serialNumber && pickedSerialNumber === activeDevice.serialNumber);
658
        if (isSameDevice) {
3✔
659
            await this.syncActiveDevice();
2✔
660
            return;
2✔
661
        }
662

663
        await this.context.workspaceState.update(DeviceManager.ACTIVE_DEVICE_STATE_KEY, undefined);
1✔
664
        await vscodeContextManager.set('activeHost', '');
1✔
665
    }
666

667
    /**
668
     * Re-point the active device at its current IP, found by looking up the persisted serial number
669
     * in the device stores. Runs on activation (recovers the active device from the previous session),
670
     * after a network change, and when discovery sees the device at a new IP.
671
     */
672
    private async syncActiveDevice(): Promise<void> {
673
        const activeDevice = this.context.workspaceState.get<ActiveDeviceEntry>(DeviceManager.ACTIVE_DEVICE_STATE_KEY);
228✔
674
        if (!activeDevice?.ip && !activeDevice?.serialNumber) {
228!
675
            return;
218✔
676
        }
677

678
        //find the device's current IP by serial number (device list first, then the persisted SN↔IP store),
679
        //falling back to the last IP we saw it at
680
        let currentIp = activeDevice.ip;
10✔
681
        if (activeDevice.serialNumber) {
10!
682
            currentIp = this.getDevice({ serialNumber: activeDevice.serialNumber })?.ip ??
10✔
683
                this.globalStateManager.getIpForSerial(activeDevice.serialNumber, this.networkId) ??
684
                activeDevice.ip;
685
        }
686
        if (!currentIp) {
10!
UNCOV
687
            return;
×
688
        }
689

690
        //keep `remoteHost` following the active device, unless something else (e.g. a debug launch) has since pointed it elsewhere
691
        const remoteHost = this.context.workspaceState.get<string>('remoteHost');
10✔
692
        if (!remoteHost || remoteHost === activeDevice.ip || remoteHost === currentIp) {
10✔
693
            await this.context.workspaceState.update('remoteHost', currentIp);
8✔
694
        }
695
        if (currentIp !== activeDevice.ip) {
10✔
696
            await this.context.workspaceState.update(DeviceManager.ACTIVE_DEVICE_STATE_KEY, { serialNumber: activeDevice.serialNumber, ip: currentIp } as ActiveDeviceEntry);
6✔
697
        }
698
        await vscodeContextManager.set('activeHost', currentIp);
10✔
699
    }
700

701
    /**
702
     * workspaceState key where the active device's serial number and last-known IP are persisted
703
     */
704
    public static readonly ACTIVE_DEVICE_STATE_KEY = 'activeDevice';
1✔
705

706
    public getLastUsedDeviceIp(): string | undefined {
707
        return this.lastUsedDeviceIp;
7✔
708
    }
709

710
    public setLastUsedDeviceIp(value: string | undefined) {
711
        this.lastUsedDeviceIp = value;
4✔
712
    }
713

714
    public dispose() {
715
        this.deactivateMonitoring();
213✔
716
        this.systemSleepMonitor?.dispose?.();
213!
717
        this.networkChangeMonitor?.dispose?.();
213!
718
        this.finder?.dispose?.();
213!
719
        this.configuredDevices = [];
213✔
720
        this.discoveredDevices = [];
213✔
721
        this.emitter.removeAllListeners();
213✔
722
    }
723

724
    /**
725
     * Is device discovery enabled (i.e. passive scans are permitted)
726
     */
727
    private get deviceDiscoveryEnabled() {
728
        return util.getConfiguration('brightscript')?.deviceDiscovery?.enabled ?? true;
247!
729
    }
730

731
    /**
732
     * Should info messages be shown when new devices are discovered (e.g. "Device found: Roku TV")?
733
     */
734
    private get showInfoMessages() {
735
        return util.getConfiguration('brightscript')?.deviceDiscovery?.showInfoMessages ?? true;
12!
736
    }
737

738
    private get heartbeatLogging() {
739
        return util.getConfiguration('brightscript')?.deviceDiscovery?.heartbeatLogging ?? false;
12!
740
    }
741

742
    private makeFinderLogger(): (msg: string) => void {
743
        return (msg: string) => {
319✔
744
            if (this.heartbeatLogging) {
12!
UNCOV
745
                this.extensionOutputChannel?.appendLine(`[heartbeat] ${msg}`);
×
746
            }
747
        };
748
    }
749

750
    /**
751
     * Default password applied to any device that does not have its own configured password.
752
     * Returns undefined when the setting is empty so callers can fall through to their own logic.
753
     */
754
    public getDefaultPassword(): string | undefined {
755
        const value = util.getConfiguration('brightscript')?.defaultDevicePassword;
359!
756
        return typeof value === 'string' && value.length > 0 ? value : undefined;
359✔
757
    }
758

759
    private get timeSinceLastScan(): number {
760
        if (!this.lastScanDate) {
23✔
761
            return Infinity; // Never scanned, so always stale
19✔
762
        }
763
        return Date.now() - this.lastScanDate.getTime();
4✔
764
    }
765

766
    private getPriorityForDeviceFormFactor(deviceInfo: Record<string, any>): number {
767
        if (deviceInfo?.['is-stick'] === 'true') {
36!
768
            return 0;
2✔
769
        }
770
        if (deviceInfo?.['is-tv'] === 'true') {
34!
771
            return 2;
2✔
772
        }
773
        return 1;
32✔
774
    }
775

776
    /**
777
     * Load last seen devices from cache.
778
     * Last seen devices are used to pre-populate the IP→serial mapping.
779
     */
780
    private loadLastSeenDevices(): void {
781
        // Clear discovered devices (ephemeral - reload from network)
782
        this.discoveredDevices = [];
227✔
783

784
        // Load cached devices for current network - add to discoveredDevices (state determined by cache freshness)
785
        const lastSeenDevices = this.globalStateManager.getLastSeenDevices(this.networkId);
227✔
786
        for (const serialNumber of lastSeenDevices) {
227✔
787
            const cached = this.globalStateManager.getCachedDevice(serialNumber);
4✔
788
            if (cached && typeof cached === 'object' && !Array.isArray(cached)) {
4✔
789
                // Get IP from ip-to-serial mapping
790
                const ip = this.globalStateManager.getIpForSerial(serialNumber, this.networkId);
3✔
791
                if (!ip) {
3!
792
                    // No IP mapping found - remove stale entry
UNCOV
793
                    this.globalStateManager.removeLastSeenDevice(this.networkId, serialNumber);
×
UNCOV
794
                    continue;
×
795
                }
796
                // Add to discoveredDevices array (state determined from cache freshness)
797
                this.setDiscoveredDevice(ip, serialNumber);
3✔
798
            } else {
799
                // No cached info - remove stale entry
800
                this.globalStateManager.removeLastSeenDevice(this.networkId, serialNumber);
1✔
801
            }
802
        }
803
    }
804

805
    /**
806
     * Load configured devices from VSCode settings.
807
     * Handles removals (devices no longer in config) and adds/updates.
808
     * Safe to call at startup (removal is no-op when devices array is empty).
809
     * Resolves hostnames to IP addresses using DNS lookup.
810
     */
811
    private async loadConfiguredDevices(): Promise<void> {
812
        // Read config from all VSCode scopes
813
        const inspection = vscode.workspace.getConfiguration('brightscript').inspect<ConfiguredDevice[]>('devices');
222✔
814
        const userDevices = inspection?.globalValue ?? [];
220!
815
        const workspaceDevices = inspection?.workspaceValue ?? [];
220!
816

817
        // Build a map tracking which scopes each device is in
818
        interface ConfiguredDeviceWithScope extends ConfiguredDevice {
819
            configuredIn: ConfigurationScope[];
820
        }
821
        const deviceMap = new Map<string, ConfiguredDeviceWithScope>();
220✔
822

823
        function addDevicesFromScope(devices: ConfiguredDevice[], scope: ConfigurationScope) {
824
            for (const device of devices) {
440✔
825
                if (!device?.host) {
13!
UNCOV
826
                    continue;
×
827
                }
828
                const key = device.serialNumber || device.host;
13✔
829
                const existing = deviceMap.get(key);
13✔
830
                const scopes = existing?.configuredIn ?? [];
13!
831
                if (!scopes.includes(scope)) {
13!
832
                    scopes.push(scope);
13✔
833
                }
834
                deviceMap.set(key, {
13✔
835
                    ...existing,
836
                    ...device,
837
                    configuredIn: scopes
838
                });
839
            }
840
        }
841

842
        addDevicesFromScope(userDevices, 'user');
220✔
843
        addDevicesFromScope(workspaceDevices, 'workspace');
220✔
844

845
        // Clear and rebuild configuredDevices array
846
        this.configuredDevices = [];
220✔
847

848
        // Sort devices by deterministic key for consistent ordering
849
        const sortedDevices = Array.from(deviceMap.values()).sort((a, b) => {
220✔
850
            const keyA = a.serialNumber || a.host;
4!
851
            const keyB = b.serialNumber || b.host;
4!
852
            return keyA.localeCompare(keyB);
4✔
853
        });
854

855
        for (const configured of sortedDevices) {
220✔
856
            // Resolve hostname to IP address (handles both hostnames and IPs)
857
            let resolvedIp: string | undefined;
858
            try {
13✔
859
                resolvedIp = await rokuDebugUtil.dnsLookup(configured.host);
13✔
860
            } catch {
861
                // DNS lookup failed - resolvedIp remains undefined
862
            }
863

864
            const ip = resolvedIp ?? configured.host;
13!
865

866
            this.configuredDevices.push({
13✔
867
                ...configured,
868
                resolvedIp: resolvedIp
869
            });
870

871
            // Set device state using configured serial (not cache - cache might be stale)
872
            this.setDeviceState({ serialNumber: configured.serialNumber, ip: ip });
13✔
873
        }
874

875
        this.emitDevicesChanged();
220✔
876
    }
877

878
    private async resolveDevice(device: RokuDevice | { ip: string }, doSyntheticDelay = true, force = false): Promise<boolean> {
53✔
879
        // Extract serial from device if available (for proper state key management)
880
        const knownSerial = 'serialNumber' in device ? device.serialNumber : undefined;
62✔
881

882
        const currentStateObject = this.getDeviceState({ ip: device.ip, serialNumber: knownSerial });
62✔
883

884
        // Offline cooldown: if device is offline and we recently checked, skip unless forced
885
        // This prevents the loop: healthCheck → resolve → offline → emit → refresh → healthCheck...
886
        const isOffline = currentStateObject.state === 'offline';
62✔
887
        const recentlyCheckedOffline = isOffline && (Date.now() - currentStateObject.lastUpdated < this.OFFLINE_COOLDOWN_MS);
62!
888
        if (!force && recentlyCheckedOffline) {
62!
UNCOV
889
            return false;
×
890
        }
891

892
        // Increment and capture sequence number to handle concurrent refresh calls
893
        // Use IP for sequence tracking (primary key)
894
        const currentSeq = (this.resolveDeviceSequence.get(device.ip) ?? 0) + 1;
62✔
895
        this.resolveDeviceSequence.set(device.ip, currentSeq);
62✔
896

897
        // Get device info from cache or network
898
        let deviceInfo: DeviceInfoRaw | undefined;
899

900
        // Try to find cached data via serial number
901
        const serialForCache = knownSerial ?? this.globalStateManager.getSerialNumberForIp(device.ip, this.networkId);
62✔
902
        const cached = serialForCache ? this.globalStateManager.getCachedDevice(serialForCache) : undefined;
62✔
903
        // Check if the serial was last seen at this IP (don't trust cache if device moved)
904
        const cachedIp = serialForCache ? this.globalStateManager.getIpForSerial(serialForCache, this.networkId) : undefined;
62✔
905
        const cacheIsFresh = cached && (Date.now() - cached.createdAt < this.DEVICE_INFO_CACHE_MS) && cachedIp === device.ip;
62✔
906
        console.log('[TRACE] resolveDevice', device.ip, 'serialForCache=', serialForCache, 'cachedIp=', cachedIp, 'cacheIsFresh=', cacheIsFresh);
62✔
907

908
        // Use cache only if:
909
        // - Not forced
910
        // - Cache is fresh
911
        // - Device is not offline (offline devices should always hit network to check if back online)
912
        if (!force && cacheIsFresh && !isOffline) {
62✔
913
            // Use cached data
914
            deviceInfo = cached.deviceInfo as DeviceInfoRaw;
11✔
915
        } else {
916
            // Set to pending before making network call
917
            // This prevents unnecessary state flicker (online→pending→online) when using cache
918
            if (currentStateObject.state !== 'pending') {
51✔
919
                this.setDeviceState({ ip: device.ip, serialNumber: knownSerial }, 'pending');
45✔
920
                this.emitDevicesChanged();
45✔
921
            }
922

923
            // Fetch fresh data from network
924
            try {
51✔
925
                deviceInfo = await this.fetchDeviceInfo(device.ip, 8060);
51✔
926

927
                if (doSyntheticDelay) {
51✔
928
                    await this.randomDelay(400, 1_000);
33✔
929
                }
930
            } catch {
UNCOV
931
                deviceInfo = undefined;
×
932
            }
933
        }
934

935
        // Only apply result if this is still the latest request for this device
936
        if (this.resolveDeviceSequence.get(device.ip) !== currentSeq) {
62✔
937
            // Stale response - a newer check was started, ignore this result
938
            return !!deviceInfo;
1✔
939
        }
940

941
        if (deviceInfo) {
61✔
942
            // Extract serial from response, fall back to known serial
943
            const serial = deviceInfo['serial-number']?.toString?.() ?? knownSerial;
52✔
944

945
            if (serial) {
52!
946
                // Add to last seen devices (successfully resolved with serial)
947
                this.globalStateManager.addLastSeenDevice(this.networkId, serial);
52✔
948
            }
949

950
            // Update discoveredDevices array (handles mismatch detection internally)
951
            if ('isDiscovered' in device && device.isDiscovered) {
52✔
952
                this.setDiscoveredDevice(device.ip, serial);
34✔
953
            }
954

955
            // Mark any configured devices at this IP with different serials as offline
956
            this.markMismatchedConfiguredDevicesOffline(device.ip, serial);
52✔
957

958
            // Only emit if state actually changed
959
            this.setDeviceState({ ip: device.ip, serialNumber: serial }, 'online');
52✔
960
            this.emitDevicesChanged();
52✔
961
            return true;
52✔
962
        } else {
963
            // Remove from discoveredDevices (ephemeral - offline devices are removed)
964
            this.removeDiscoveredDevice(device.ip);
9✔
965

966
            // Set state to offline on any remaining entries at this IP (configured devices persist)
967
            this.setDeviceState({ ip: device.ip, serialNumber: knownSerial }, 'offline');
9✔
968

969
            this.emitDevicesChanged();
9✔
970
            return false;
9✔
971
        }
972
    }
973

974
    /**
975
     * Check if a newly discovered serial number at an IP represents a mismatch
976
     * with what we currently have stored. Used to trigger config reload when
977
     * a device has changed IPs or a different device is now at a known IP.
978
     *
979
     * Mismatch scenarios:
980
     * - Stored IP→serial map has SerialA for IP1, but got SerialB
981
     * - Discovered device at IP1 had SerialA, but now has SerialB
982
     *
983
     * Note: We intentionally don't check configured device serials here.
984
     * If a user misconfigured a serial, reloading won't fix it and would
985
     * cause an infinite reload loop.
986
     *
987
     * @param ip - The IP address
988
     * @param newSerial - The newly discovered serial number
989
     * @returns true if there's a mismatch that warrants reloading configurations
990
     */
991
    private checkForSerialMismatch(ip: string, newSerial: string | undefined): boolean {
992
        if (!newSerial) {
72✔
993
            // No new serial to compare
994
            return false;
12✔
995
        }
996

997
        // Check what serial we have stored for this IP in the IP→serial map
998
        const storedSerial = this.globalStateManager.getSerialNumberForIp(ip, this.networkId);
60✔
999

1000
        if (storedSerial && storedSerial !== newSerial) {
60✔
1001
            // Different device is now at this IP
1002
            return true;
3✔
1003
        }
1004

1005

1006
        // Check if any discovered device at this IP has a different serial
1007
        const discoveredDevice = this.discoveredDevices.find(d => d.ip === ip);
57✔
1008
        if (discoveredDevice?.serialNumber && discoveredDevice.serialNumber !== newSerial) {
57✔
1009
            // Discovered device has a different serial than what's actually at the IP
1010
            return true;
1✔
1011
        }
1012

1013
        return false;
56✔
1014
    }
1015

1016
    /**
1017
     * Mark configured devices as offline when a different device is found at their IP.
1018
     * Note: resolvedIp is only set during DNS resolution in loadConfiguredDevices(),
1019
     * not updated here when discovering devices.
1020
     */
1021
    private markMismatchedConfiguredDevicesOffline(ip: string, serialNumber: string | undefined): void {
1022
        for (const entry of this.configuredDevices) {
52✔
1023
            const isAtThisIp = entry.host === ip || entry.resolvedIp === ip;
14✔
1024
            const hasDifferentSerial = entry.serialNumber && serialNumber && entry.serialNumber !== serialNumber;
14✔
1025

1026
            if (isAtThisIp && hasDifferentSerial) {
14✔
1027
                // Mark the configured entry directly as offline
1028
                entry.state = 'offline';
3✔
1029
                entry.stateLastUpdated = Date.now();
3✔
1030
            }
1031
        }
1032
    }
1033

1034
    private async healthCheckAllDevices(force = false, doSyntheticDelay = true): Promise<void> {
4!
1035
        // Collect all unique IPs from both sources (same serial at different IPs = different entries to check)
1036
        const discoveredIpSet = new Set(this.discoveredDevices.map(entry => entry.ip));
40✔
1037
        const allIps = new Set([
40✔
UNCOV
1038
            ...this.configuredDevices.map(entry => entry.resolvedIp ?? entry.host),
×
1039
            ...discoveredIpSet
1040
        ]);
1041

1042
        if (allIps.size === 0) {
40✔
1043
            return;
36✔
1044
        }
1045

1046
        // Set all to pending and emit before async work
1047
        for (const ip of allIps) {
4✔
1048
            this.setDeviceState({ ip: ip }, 'pending');
6✔
1049
        }
1050
        this.emitDevicesChanged();
4✔
1051

1052
        // Health check all devices - if any discovered device is unhealthy, trigger a scan
1053
        let needsScan = false;
4✔
1054
        await Promise.all([...allIps].map(async (ip) => {
4✔
1055
            const isHealthy = await this.resolveDevice({ ip: ip }, doSyntheticDelay, force);
6✔
1056
            if (!isHealthy && discoveredIpSet.has(ip)) {
6!
UNCOV
1057
                needsScan = true;
×
1058
            }
1059
        }));
1060

1061
        if (needsScan) {
4!
UNCOV
1062
            this.discoverAll(this.deviceDiscoveryEnabled);
×
1063
        }
1064
    }
1065

1066
    /**
1067
     * Health check devices that didn't respond to a scan.
1068
     * Called after scan-ended. Checks devices whose cache is older than STALE_DEVICE_AFTER_SCAN_MS.
1069
     * Iterates over both source arrays to ensure all devices are checked even when
1070
     * the same serial exists at multiple IPs.
1071
     */
1072
    private async healthCheckStaleDevices() {
1073
        const now = Date.now();
25✔
1074

1075
        // Helper to check if a device with given serial is stale
1076
        const isStale = (serialNumber: string | undefined): boolean => {
25✔
UNCOV
1077
            if (!serialNumber) {
×
UNCOV
1078
                return true; // No serial = no cache, consider stale
×
1079
            }
1080
            const cached = this.globalStateManager.getCachedDevice(serialNumber);
×
1081
            if (!cached) {
×
UNCOV
1082
                return true;
×
1083
            }
1084
            const cacheAge = now - cached.createdAt;
×
1085
            return cacheAge > this.STALE_DEVICE_AFTER_SCAN_MS;
×
1086
        };
1087

1088
        // Collect unique stale IPs from both source arrays
1089
        const staleIps = new Set([
25✔
1090
            ...this.configuredDevices
UNCOV
1091
                .filter(entry => entry.state !== 'offline' && isStale(entry.serialNumber))
×
UNCOV
1092
                .map(entry => entry.resolvedIp ?? entry.host),
×
1093
            ...this.discoveredDevices
1094
                .filter(entry => entry.state !== 'offline' && isStale(entry.serialNumber))
×
1095
                .map(entry => entry.ip)
×
1096
        ]);
1097

1098
        if (staleIps.size === 0) {
25!
1099
            return;
25✔
1100
        }
1101

1102
        // Cooldown is handled by fetchDeviceInfo cache
UNCOV
1103
        await Promise.all([...staleIps].map(ip => this.resolveDevice({ ip: ip }, false)));
×
1104
    }
1105

1106
    /**
1107
     * Fetch device info from the network. Always makes a network request.
1108
     * Caches the result in globalStateManager for future lookups.
1109
     */
1110
    private async fetchDeviceInfo(ip: string, port: number): Promise<DeviceInfoRaw> {
1111
        try {
53✔
1112
            const info = await rokuDeploy.getDeviceInfo({
53✔
1113
                host: ip,
1114
                remotePort: port,
1115
                timeout: DeviceManager.HEALTH_CHECK_TIMEOUT_MS
1116
            });
1117
            if (info['serial-number']) {
43✔
1118
                this.globalStateManager.setCachedDevice(info['serial-number'], {
42✔
1119
                    serialNumber: info['serial-number'],
1120
                    deviceInfo: info,
1121
                    createdAt: Date.now()
1122
                });
1123
                this.globalStateManager.setSerialNumberForIp(this.networkId, ip, info['serial-number']);
42✔
1124
            }
1125

1126
            return info;
43✔
1127
        } catch (e) {
1128
            console.error(e);
10✔
1129
            return undefined;
10✔
1130
        }
1131
    }
1132

1133
    /**
1134
     * Discover all Roku devices on the network and watch for new ones that connect
1135
     */
1136
    private discoverAll(force: boolean): boolean {
1137
        if (force || this.scanNeeded || this.timeSinceLastScan > this.STALE_SCAN_THRESHOLD_MS) {
31✔
1138
            this.scanNeeded = false;
30✔
1139
            this.lastScanDate = new Date();
30✔
1140
            this.finder.scan();
30✔
1141
            return true;
30✔
1142
        }
1143
        return false;
1✔
1144
    }
1145

1146

1147
    /**
1148
     * Add or update a device in the discoveredDevices array.
1149
     * Handles deduplication by serial number (removes old IP entry if serial matches).
1150
     * Also sets device state using intelligent defaults (cache freshness check).
1151
     */
1152
    private setDiscoveredDevice(ip: string, serialNumber: string | undefined): void {
1153
        // Check for serial mismatch before updating state
1154
        const hasMismatch = this.checkForSerialMismatch(ip, serialNumber);
66✔
1155

1156
        // Serial dedupe: if same serial exists at different IP, remove old entry
1157
        if (serialNumber) {
66✔
1158
            const oldIdx = this.discoveredDevices.findIndex(d => d.ip !== ip && d.serialNumber === serialNumber);
55✔
1159
            if (oldIdx >= 0) {
55✔
1160
                const oldIp = this.discoveredDevices[oldIdx].ip;
4✔
1161
                // Transfer lastUsedDeviceIp to new IP if it was pointing to old IP
1162
                if (this.lastUsedDeviceIp === oldIp) {
4✔
1163
                    this.lastUsedDeviceIp = ip;
1✔
1164
                }
1165
                this.discoveredDevices.splice(oldIdx, 1);
4✔
1166
            }
1167
        }
1168

1169
        // IP dedupe: find existing entry at same IP
1170
        const existingIdx = this.discoveredDevices.findIndex(d => d.ip === ip);
66✔
1171
        const existing = existingIdx >= 0 ? this.discoveredDevices[existingIdx] : undefined;
66✔
1172

1173
        if (existing) {
66✔
1174
            // Update existing entry (preserve state fields so setDeviceState below sees the prior state)
1175
            this.discoveredDevices[existingIdx] = {
40✔
1176
                ...existing,
1177
                ip: ip,
1178
                serialNumber: serialNumber ?? existing.serialNumber
120✔
1179
            };
1180
        } else {
1181
            // Add new entry
1182
            this.discoveredDevices.push({
26✔
1183
                ip: ip,
1184
                serialNumber: serialNumber
1185
            });
1186
        }
1187

1188
        // Set device state using intelligent defaults (preserves existing online state or uses cache freshness)
1189
        this.setDeviceState({ serialNumber: serialNumber, ip: ip });
66✔
1190

1191
        // If this is the active device and it showed up at a new IP, re-point the active device at it
1192
        const activeDevice = this.context.workspaceState.get<ActiveDeviceEntry>(DeviceManager.ACTIVE_DEVICE_STATE_KEY);
66✔
1193
        if (serialNumber && activeDevice?.serialNumber === serialNumber && activeDevice.ip !== ip) {
66✔
1194
            this.syncActiveDevice().catch(() => { });
1✔
1195
        }
1196

1197
        // If a different device is now at this IP, reload configurations
1198
        if (hasMismatch) {
66✔
1199
            this.loadConfiguredDevices().catch(() => { });
2✔
1200
        }
1201
    }
1202

1203
    /**
1204
     * Remove a discovered device by IP. Clears from discoveredDevices array,
1205
     * clears lastUsedDeviceIp if it matches, and removes from lastSeenDevices cache.
1206
     */
1207
    private removeDiscoveredDevice(ip: string): void {
1208
        // Find the device first to get its serial number
1209
        const idx = this.discoveredDevices.findIndex(d => d.ip === ip);
14✔
1210
        if (idx < 0) {
14✔
1211
            return;
4✔
1212
        }
1213

1214
        const device = this.discoveredDevices[idx];
10✔
1215
        this.discoveredDevices.splice(idx, 1);
10✔
1216

1217
        // Clear lastUsedDeviceIp if it matches
1218
        if (this.lastUsedDeviceIp === ip) {
10✔
1219
            this.lastUsedDeviceIp = undefined;
1✔
1220
        }
1221

1222
        // Remove from lastSeenDevices if we have a serial
1223
        if (device?.serialNumber) {
10!
1224
            this.globalStateManager.removeLastSeenDevice(this.networkId, device.serialNumber);
10✔
1225
        }
1226
    }
1227

1228
    /**
1229
     * Find configured and discovered device entries by key or lookup criteria.
1230
     * Key format: "s:{serialNumber}" or "i:{ip}"
1231
     * Lookup format: { ip?: string; serialNumber?: string }
1232
     */
1233
    private findDeviceEntries(keyOrLookup: string | { ip?: string; serialNumber?: string }): {
1234
        configured: ConfiguredDeviceEntry | undefined;
1235
        discovered: DiscoveredDeviceEntry | undefined;
1236
    } {
1237
        let configured: ConfiguredDeviceEntry | undefined;
1238
        let discovered: DiscoveredDeviceEntry | undefined;
1239

1240
        if (typeof keyOrLookup === 'string') {
44✔
1241
            // Decode encoded key
1242
            const key = keyOrLookup;
10✔
1243
            if (key.startsWith('s:')) {
10✔
1244
                const serial = key.slice(2);
3✔
1245
                if (serial) {
3✔
1246
                    configured = this.configuredDevices.find(c => c.serialNumber === serial);
2✔
1247
                    discovered = this.discoveredDevices.find(d => d.serialNumber === serial);
2✔
1248
                }
1249
            } else if (key.startsWith('i:')) {
7✔
1250
                const ip = key.slice(2);
5✔
1251
                if (ip) {
5✔
1252
                    configured = this.configuredDevices.find(c => c.resolvedIp === ip || c.host === ip);
4!
1253
                    discovered = this.discoveredDevices.find(d => d.ip === ip);
4✔
1254
                }
1255
            }
1256
        } else {
1257
            // Lookup object
1258
            const lookup = keyOrLookup;
34✔
1259

1260
            if (lookup.serialNumber) {
34✔
1261
                configured = this.configuredDevices.find(c => c.serialNumber === lookup.serialNumber);
13✔
1262
                discovered = this.discoveredDevices.find(d => d.serialNumber === lookup.serialNumber);
13✔
1263
            }
1264

1265
            if (lookup.ip) {
34✔
1266
                if (!configured) {
21!
1267
                    configured = this.configuredDevices.find(c => c.resolvedIp === lookup.ip || c.host === lookup.ip);
21✔
1268
                }
1269
                if (!discovered) {
21!
1270
                    discovered = this.discoveredDevices.find(d => d.ip === lookup.ip);
21✔
1271
                }
1272
            }
1273
        }
1274

1275
        return { configured: configured, discovered: discovered };
44✔
1276
    }
1277

1278
    /**
1279
     * Build a merged RokuDevice from configured and discovered entries.
1280
     * At least one of configured or discovered must be provided.
1281
     */
1282
    private buildMergedDevice(
1283
        configuredEntry: ConfiguredDeviceEntry | undefined,
1284
        discoveredEntry: DiscoveredDeviceEntry | undefined
1285
    ): RokuDevice | undefined {
1286
        if (!configuredEntry && !discoveredEntry) {
169✔
1287
            return undefined;
18✔
1288
        }
1289

1290
        // Determine IP: discovered > resolvedIp > host
1291
        let ip: string;
1292
        if (discoveredEntry) {
151✔
1293
            ip = discoveredEntry.ip;
117✔
1294
        } else if (configuredEntry?.resolvedIp) {
34!
1295
            ip = configuredEntry.resolvedIp;
31✔
1296
        } else {
1297
            ip = configuredEntry.host;
3✔
1298
        }
1299

1300
        // Determine serial: configured > discovered > cache
1301
        // Configured is user's explicit config, discovered is fresh network data,
1302
        // cache is fallback for initial load before discovery runs
1303
        const serialNumber = configuredEntry?.serialNumber ??
151✔
1304
            discoveredEntry?.serialNumber ??
261✔
1305
            this.globalStateManager.getSerialNumberForIp(ip, this.networkId);
1306

1307
        // Determine state: discovered > configured > unknown (discovered is ground truth)
1308
        const deviceState = discoveredEntry?.state ?? configuredEntry?.state ?? 'unknown';
151✔
1309
        // Determine previous state: discovered > configured > unknown (discovered is ground truth)
1310
        const lastState = discoveredEntry?.lastState ?? configuredEntry?.lastState ?? 'unknown';
151✔
1311

1312
        // Build key
1313
        const key = serialNumber ? `s:${serialNumber}` : `i:${ip}`;
151✔
1314

1315
        // Hydrate deviceInfo from cache
1316
        const cached = serialNumber ? this.globalStateManager.getCachedDevice(serialNumber) : undefined;
151✔
1317

1318
        return {
151✔
1319
            ip: ip,
1320
            serialNumber: serialNumber,
1321
            key: key,
1322
            deviceState: deviceState,
1323
            lastDeviceState: lastState,
1324
            deviceInfo: cached?.deviceInfo ?? {},
906✔
1325
            isDiscovered: !!discoveredEntry,
1326
            isConfigured: !!configuredEntry,
1327
            configuredIn: configuredEntry?.configuredIn,
453✔
1328
            configuredName: configuredEntry?.name,
453✔
1329
            configuredPassword: configuredEntry?.password ?? this.getDefaultPassword()
906✔
1330
        };
1331
    }
1332

1333
    /**
1334
     * Handle device-online event from RokuFinder.
1335
     * Health checks the device if focused and no cache, and shows notification if enabled.
1336
     */
1337
    private handleDeviceOnline(ip: string, serialNumber?: string): void {
1338
        // Use provided serial, fall back to IP→serial mapping if not provided
1339
        const actualSerial = serialNumber ?? this.globalStateManager.getSerialNumberForIp(ip, this.networkId);
12✔
1340

1341
        // Health check if VS Code is focused and device has no cache
1342
        const hasCache = actualSerial ? this.hasDeviceCache(actualSerial) : false;
12✔
1343
        if (vscode.window.state.focused && !hasCache) {
12✔
1344
            this.resolveUncachedDiscoveredDevices().catch(() => { });
4✔
1345
        }
1346

1347
        if (!this.showInfoMessages) {
12✔
1348
            return;
8✔
1349
        }
1350

1351
        // Get cached device directly from globalStateManager
1352
        const cachedDevice = actualSerial
4!
1353
            ? this.globalStateManager.getCachedDevice(actualSerial)
1354
            : undefined;
1355

1356
        // Get display name from cache
1357
        const fallbackName = actualSerial ? `${ip} (${actualSerial})` : ip;
4!
1358
        const displayName = cachedDevice?.deviceInfo?.['default-device-name'] ?? fallbackName;
4✔
1359
        const notifierId = actualSerial ?? ip;
4!
1360

1361
        if (!this.deviceOnlineNotifiers.has(notifierId)) {
4!
1362
            this.deviceOnlineNotifiers.set(notifierId, debounce((name: string) => {
4✔
1363
                this.deviceOnlineNotifiers.delete(notifierId);
4✔
1364
                void util.showTimedNotification(`Device Online: ${name}`);
4✔
1365
            }, 500));
1366
        }
1367
        this.deviceOnlineNotifiers.get(notifierId)(displayName);
4✔
1368
    }
1369

1370
    private async activateMonitoring() {
1371
        this.networkChangeMonitor.start();
12✔
1372
        await this.startRokuFinder();
12✔
1373
    }
1374

1375
    private deactivateMonitoring() {
1376
        this.networkChangeMonitor.stop();
213✔
1377
        this.stopRokuFinder();
213✔
1378
    }
1379

1380
    /**
1381
     * Set up event listeners for the RokuFinder.
1382
     * This must be called regardless of deviceDiscoveryEnabled so that
1383
     * active scan responses are processed.
1384
     */
1385
    private setupFinderListeners() {
1386
        this.finder.removeAllListeners();
222✔
1387
        this.finder.on('found', (ip: string, options?: { serialNumber?: string }) => {
222✔
1388
            this.setDiscoveredDevice(ip, options?.serialNumber);
1!
1389
            this.emitDevicesChanged();
1✔
1390
        });
1391

1392
        this.finder.on('device-online', (ip: string, serialNumber?: string) => {
222✔
UNCOV
1393
            this.handleDeviceOnline(ip, serialNumber);
×
1394
        });
1395

1396
        this.finder.on('lost', (ip: string) => {
222✔
UNCOV
1397
            this.removeDiscoveredDevice(ip);
×
UNCOV
1398
            this.emitDevicesChanged();
×
1399
        });
1400

1401
        // Forward scan events from RokuFinder
1402
        this.finder.on('scan-started', () => {
222✔
1403
            this.emitter.emit('scan-started');
25✔
1404
        });
1405

1406
        this.finder.on('scan-ended', () => {
222✔
1407
            this.emitter.emit('scan-ended');
25✔
1408
            // Health check devices that didn't respond to the scan (stale cache)
1409
            this.healthCheckStaleDevices().catch(() => { });
25✔
1410
        });
1411
    }
1412

1413
    /**
1414
     * Restart the RokuFinder to rebind UDP sockets to new network interfaces.
1415
     * Called when network changes to ensure SSDP can communicate on the new network.
1416
     */
1417
    private restartRokuFinder() {
1418
        // Keep reference to old finder for delayed disposal
1419
        const oldFinder = this.finder;
9✔
1420

1421
        // Create new finder instance
1422
        this.finder = new RokuFinder(this.globalStateManager, this.makeFinderLogger());
9✔
1423

1424
        // Re-attach event listeners
1425
        this.setupFinderListeners();
9✔
1426

1427
        // Dispose old finder
1428
        oldFinder?.dispose();
9!
1429

1430
        // Restart if device discovery is enabled
1431
        if (this.deviceDiscoveryEnabled) {
9!
UNCOV
1432
            this.startRokuFinder().catch((e) => {
×
UNCOV
1433
                console.error('Failed to restart RokuFinder:', e);
×
1434
            });
1435
        }
1436
    }
1437

1438
    /**
1439
     * Start listening for passive SSDP announcements from Roku devices
1440
     */
1441
    private async startRokuFinder() {
1442
        await this.finder.start();
12✔
1443
        const ts = new Date().toLocaleTimeString();
12✔
1444
        this.makeFinderLogger()(`[${ts}] RokuFinder started — passive ssdp:alive monitoring active`);
12✔
1445
    }
1446

1447
    private stopRokuFinder() {
1448
        this.finder.stop();
213✔
1449
    }
1450

1451
    private notifyFocusGained() {
1452
        this.networkChangeMonitor.start();
1✔
1453
        // Resolve any discovered devices without cache that appeared while unfocused
1454
        this.resolveUncachedDiscoveredDevices().catch(() => { });
1✔
1455
    }
1456

1457
    /**
1458
     * Health check discovered devices that don't have cached info.
1459
     * Called on focus gain to resolve devices that appeared while VS Code was unfocused.
1460
     */
1461
    private async resolveUncachedDiscoveredDevices(): Promise<void> {
1462
        const uncached = this.discoveredDevices.filter(entry => {
9✔
1463
            return !entry.serialNumber || !this.hasDeviceCache(entry.serialNumber);
14✔
1464
        });
1465

1466
        if (uncached.length === 0) {
9✔
1467
            return;
3✔
1468
        }
1469

1470
        // Health check each uncached device in parallel
1471
        await Promise.all(
6✔
1472
            uncached.map(entry => this.healthCheckDevice({ ip: entry.ip, serialNumber: entry.serialNumber }, false, false).catch(() => { }))
10✔
1473
        );
1474
    }
1475

1476
    private notifyFocusLost() {
UNCOV
1477
        this.networkChangeMonitor.stop();
×
1478
    }
1479

1480
    /**
1481
     * Set the flag indicating a scan is needed. Emits 'scanNeeded-changed' event
1482
     * when the flag flips from false to true.
1483
     */
1484
    private setScanNeeded(force = false): void {
17✔
1485
        if (!this.scanNeeded || force) {
17✔
1486
            this.scanNeeded = true;
15✔
1487
            this.emitter.emit('scanNeeded-changed');
15✔
1488
        }
1489
    }
1490

1491
    private emitDevicesChanged = throttleBounce(() => {
298✔
1492
        this.emitter.emit('devices-changed');
290✔
1493
    }, this.DEVICES_CHANGED_DEBOUNCE_MS);
1494

1495
    private async randomDelay(min: number, max: number) {
1496
        const randomness = Math.random() * ((max - min) + min);
9✔
1497
        await util.sleep(randomness);
9✔
1498
    }
1499
}
1500

1501
export type DeviceState = 'offline' | 'unknown' | 'pending' | 'online';
1502

1503
export type PasswordValidationResult = 'ok' | 'bad-password' | 'unreachable';
1504

1505
export type ConfigurationScope = 'user' | 'workspace';
1506

1507
/**
1508
 * A resolved host paired with the raw `device-info` gathered while probing it. Returned by the
1509
 * host-resolution flows (device picker, manual entry, active-host lookup) so callers can reuse the
1510
 * device info without issuing another request to the device.
1511
 */
1512
export interface HostWithDeviceInfo {
1513
    host: string;
1514
    deviceInfo: DeviceInfoRaw;
1515
}
1516

1517
/**
1518
 * User-configured device from settings (brightscript.devices)
1519
 */
1520
export interface ConfiguredDevice {
1521
    host: string;
1522
    name?: string;
1523
    serialNumber?: string;
1524
    password?: string;
1525
}
1526

1527
/**
1528
 * Internal: configured device from settings
1529
 * Extends the raw settings shape with runtime tracking fields.
1530
 * Persists even when device goes offline.
1531
 */
1532
interface ConfiguredDeviceEntry extends ConfiguredDevice {
1533
    /**
1534
     * IP from DNS lookup (updated on resolution)
1535
     */
1536
    resolvedIp?: string;
1537
    /**
1538
     * Which settings scopes this device is configured in
1539
     */
1540
    configuredIn?: ConfigurationScope[];
1541
    /**
1542
     * Current device state (inline on entry)
1543
     */
1544
    state?: DeviceState;
1545
    /**
1546
     * Previous state, updated by setDeviceState before each transition. Undefined when no
1547
     * state has been recorded yet — readers should treat that as 'unknown'.
1548
     */
1549
    lastState?: DeviceState;
1550
    /**
1551
     * Timestamp of last state update
1552
     */
1553
    stateLastUpdated?: number;
1554
}
1555

1556
/**
1557
 * Internal: discovered device from network
1558
 * Removed when device goes offline (ephemeral)
1559
 */
1560
interface DiscoveredDeviceEntry {
1561
    /**
1562
     * Current IP from SSDP/resolution
1563
     */
1564
    ip: string;
1565
    /**
1566
     * Serial number from device-info response
1567
     */
1568
    serialNumber?: string;
1569
    /**
1570
     * Current device state (inline on entry)
1571
     */
1572
    state?: DeviceState;
1573
    /**
1574
     * Previous state, updated by setDeviceState before each transition. Undefined when no
1575
     * state has been recorded yet — readers should treat that as 'unknown'.
1576
     */
1577
    lastState?: DeviceState;
1578
    /**
1579
     * Timestamp of last state update
1580
     */
1581
    stateLastUpdated?: number;
1582
}
1583

1584
/**
1585
 * Device state with timestamp, returned by getDeviceState
1586
 */
1587
interface DeviceStateEntry {
1588
    state: DeviceState;
1589
    lastUpdated: number;
1590
}
1591

1592
/**
1593
 * Active device pointer persisted in workspaceState under `DeviceManager.ACTIVE_DEVICE_STATE_KEY`.
1594
 * The serial number is the durable identity; the ip is the last IP the device was seen at.
1595
 */
1596
export interface ActiveDeviceEntry {
1597
    serialNumber?: string;
1598
    ip?: string;
1599
}
1600

1601
/**
1602
 * Full device details returned by public API
1603
 * Built on-demand by merging configured and discovered device data
1604
 */
1605
export interface RokuDevice {
1606
    /**
1607
     * Computed IP from resolution order: discovered > resolvedIp > host
1608
     */
1609
    ip: string;
1610
    /**
1611
     * Serial number from discovered or configured
1612
     */
1613
    serialNumber?: string;
1614
    /**
1615
     * Encoded device key: "s:{serial}" or "i:{ip}"
1616
     */
1617
    key: string;
1618
    /**
1619
     * Device state: online, offline, pending (currently checking), or unknown (never checked)
1620
     */
1621
    deviceState: DeviceState;
1622
    /**
1623
     * Previous device state: online, offline, pending (currently checking), or unknown (never checked)
1624
     */
1625
    lastDeviceState: DeviceState;
1626
    /**
1627
     * Cached device info from GlobalStateManager
1628
     */
1629
    deviceInfo: Record<string, any>;
1630
    /**
1631
     * True if device exists in discoveredDevices array
1632
     */
1633
    isDiscovered: boolean;
1634
    /**
1635
     * True if device exists in configuredDevices array
1636
     */
1637
    isConfigured: boolean;
1638
    /**
1639
     * Which settings scopes this device is configured in
1640
     */
1641
    configuredIn?: ConfigurationScope[];
1642
    /**
1643
     * User-provided name from config
1644
     */
1645
    configuredName?: string;
1646
    /**
1647
     * User-provided password from config
1648
     */
1649
    configuredPassword?: string;
1650
}
1651

1652
function throttleBounce<T extends (...args: any[]) => void>(
1653
    callback: T,
1654
    threshold: number
1655
): (...args: Parameters<T>) => void {
1656
    let timer: ReturnType<typeof setTimeout> | undefined;
1657
    let pending: Parameters<T> | undefined;
1658
    function onTimer() {
1659
        if (pending) {
279✔
1660
            callback(...pending);
58✔
1661
            pending = undefined;
58✔
1662
            timer = setTimeout(onTimer, threshold);
58✔
1663
        } else {
1664
            timer = undefined;
221✔
1665
        }
1666
    }
1667

1668
    return (...args: Parameters<T>) => {
298✔
1669
        if (!timer) {
350✔
1670
            callback(...args);
232✔
1671
            timer = setTimeout(onTimer, threshold);
232✔
1672
        } else {
1673
            pending = args;
118✔
1674
        }
1675
    };
1676
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc