From 0aa523729d0cf8984106e1db30934f3b1418a80b Mon Sep 17 00:00:00 2001
From: rokdock-sync
Date: Wed, 22 Jul 2026 19:55:25 +0000
Subject: [PATCH] Fix for popout capture device switching and terminal tab
selection
---
README.md | 18 +++----
src/main/ipc/handlers/capture.ts | 7 +++
src/preload/preload.ts | 2 +-
src/renderer/capturePreview.ts | 49 ++++++++++++------
.../components/devicePanel/deviceCard.tsx | 21 +++++++-
src/renderer/store/appStore.ts | 13 +++++
tests/renderer/store/findReusableTab.test.ts | 50 +++++++++++++++++++
7 files changed, 134 insertions(+), 26 deletions(-)
create mode 100644 tests/renderer/store/findReusableTab.test.ts
diff --git a/README.md b/README.md
index d65e255..d775d6b 100644
--- a/README.md
+++ b/README.md
@@ -33,15 +33,15 @@
RokDock is a cross-platform desktop control center for Roku development. It brings
-the whole Roku workflow into one window: discover devices on your network, open
-BrightScript debug terminals, drive an on-screen remote, sideload channels, capture
-and compare device screenshots, automate device tests, fire deeplinks, build
-SceneGraph image assets, and read the official Roku developer docs in-app. An opt-in
-AI assistant is built in.
-
-It is for Roku channel developers who are tired of juggling a telnet client, the ECP
-`curl` commands, the deeplink tester, an image editor, and a browser tab of docs.
-RokDock is one tool for all of it.
+the majority of your Roku development workflow into one window: discover devices on
+your network, open BrightScript debug terminals, drive an on-screen remote, sideload
+channels, capture and compare device screenshots, automate device tests, fire
+deeplinks, build SceneGraph image assets, and read the official Roku developer docs
+in-app. An opt-in AI assistant is built in.
+
+It is for Roku channel developers who are tired of juggling their remote control, a
+telnet client, the deeplink tester, an image editor, and a dozen docs tabs. RokDock
+is one tool for all of it.
diff --git a/src/main/ipc/handlers/capture.ts b/src/main/ipc/handlers/capture.ts
index a7f173d..f592203 100644
--- a/src/main/ipc/handlers/capture.ts
+++ b/src/main/ipc/handlers/capture.ts
@@ -34,6 +34,11 @@ let capturePopoutWindow: BrowserWindow | null = null
interface PopoutConfig {
deviceId: string
+ // Stable device label, sent so the popout can re-resolve the correct capture
+ // device in its own origin. deviceId is salted per origin, so the dock's id does
+ // not resolve in the popout, but the label does. Without this the popout falls back
+ // to the default capture device, which is wrong when several cards are present.
+ deviceLabel: string | null
muted: boolean
idleTimeoutSec: number
}
@@ -73,6 +78,7 @@ export function registerCaptureHandlers(context: IpcContext): void {
// the renderer calls capture:get-popout-config during its boot sequence.
pendingPopoutConfig = {
deviceId,
+ deviceLabel: preferences.captureDeviceLabel ?? null,
muted,
idleTimeoutSec: preferences.captureIdleTimeoutSec ?? 3600
}
@@ -147,6 +153,7 @@ export function registerCaptureHandlers(context: IpcContext): void {
ipcMain.handle('capture:get-popout-config', async (): Promise => {
const config = pendingPopoutConfig ?? {
deviceId: '',
+ deviceLabel: null,
muted: true,
idleTimeoutSec: 3600
}
diff --git a/src/preload/preload.ts b/src/preload/preload.ts
index a6fab33..c2297e4 100644
--- a/src/preload/preload.ts
+++ b/src/preload/preload.ts
@@ -567,7 +567,7 @@ const api = {
ipcRenderer.invoke('capture:get-device-id'),
getMuted: (): Promise<{ ok: boolean; muted?: boolean }> =>
ipcRenderer.invoke('capture:get-muted'),
- getPopoutConfig: (): Promise<{ deviceId: string; muted: boolean; idleTimeoutSec: number }> =>
+ getPopoutConfig: (): Promise<{ deviceId: string; deviceLabel: string | null; muted: boolean; idleTimeoutSec: number }> =>
ipcRenderer.invoke('capture:get-popout-config'),
setMode: (mode: string): Promise =>
ipcRenderer.invoke('capture:set-mode', mode),
diff --git a/src/renderer/capturePreview.ts b/src/renderer/capturePreview.ts
index 85b07c4..0f047c5 100644
--- a/src/renderer/capturePreview.ts
+++ b/src/renderer/capturePreview.ts
@@ -4,7 +4,7 @@
* Streams a capture device into a frameless floating window. Handles:
* - Pull-model boot config via capture:get-popout-config IPC
* - getUserMedia stream setup with audio auto-matching
- * - The getSettings().deviceId pattern so popout-origin device IDs resolve correctly
+ * - Re-resolving the capture device by its stable label, since deviceIds are salted per origin
* - Mute / volume sync (local slider + IPC events from the main window)
* - Idle-timeout auto-pause and resume on user activity
* - Aspect-ratio reporting so the main process can lock the window shape
@@ -27,7 +27,7 @@ import {
faXmark,
} from '@fortawesome/free-solid-svg-icons'
import { faSvg } from '@shared/icons'
-import { findMatchingAudioDevice } from '@shared/captureDeviceMatch'
+import { findMatchingAudioDevice, resolveCaptureDeviceId } from '@shared/captureDeviceMatch'
import { videoFrameToPngDataUrl } from './utils/videoFrame'
// Apply theme and await fonts before the body is revealed.
@@ -83,6 +83,7 @@ closeBtn.innerHTML = svgClose
// -- State ---------------------------------------------------------------------
let requestedDeviceId = ''
+let requestedDeviceLabel: string | null = null
let muted = true
let idleTimeoutSec = 3600
let volume = 80
@@ -340,26 +341,38 @@ function tryCapture(attempts: MediaStreamConstraints[], index = 0): void {
})
}
-function startCapture(): void {
- const videoConstraints: MediaTrackConstraints = {
- deviceId: { ideal: requestedDeviceId },
+function videoConstraintsFor(deviceId: string | null): MediaTrackConstraints {
+ return {
+ // exact when we have re-resolved the device in this origin, ideal only as a
+ // last-resort hint (the dock's id does not resolve here, so ideal degrades
+ // to the default device, which is the wrong card when several are present).
+ deviceId: deviceId ? { exact: deviceId } : { ideal: requestedDeviceId },
width: { ideal: 1920 },
height: { ideal: 1080 }
}
+}
- // Acquire video-only first to resolve the actual device ID in this page's origin
- // context. Stored preference deviceIds are origin-scoped to the main renderer page.
- // enumerateDevices() in the popout returns different IDs for the same hardware.
- // MediaStreamTrack.getSettings().deviceId is always consistent with enumerateDevices()
- // in the same page, so we use it to find the matching audio endpoint.
- navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false })
+function startCapture(): void {
+ // Probe video-only first. This grants the media permission for this popout's
+ // origin, which is what makes enumerateDevices() return device labels. deviceIds
+ // are salted per origin, so the id sent from the dock does not resolve here, but
+ // the stable device label does. We re-resolve the intended card by its label in this
+ // origin's own enumeration, then acquire it with an exact constraint.
+ const probeConstraints = videoConstraintsFor(null)
+ navigator.mediaDevices.getUserMedia({ video: probeConstraints, audio: false })
.then((videoStream: MediaStream) => {
- const videoTrack = videoStream.getVideoTracks()[0]
- const actualDeviceId = videoTrack ? videoTrack.getSettings().deviceId ?? null : null
+ const probeDeviceId = videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null
+ videoStream.getTracks().forEach(track => track.stop())
return navigator.mediaDevices.enumerateDevices().then((allDevices: MediaDeviceInfo[]) => {
- const audioDeviceId = findMatchingAudioDevice(actualDeviceId ?? requestedDeviceId, allDevices)
- videoStream.getTracks().forEach(track => track.stop())
+ const videoInputs = allDevices
+ .filter(device => device.kind === 'videoinput')
+ .map(device => ({ deviceId: device.deviceId, label: device.label }))
+ // Re-resolve by the stable label. Fall back to whatever the probe
+ // opened if the label is unknown (e.g. an older popout with no label).
+ const resolvedVideoId = resolveCaptureDeviceId(videoInputs, requestedDeviceId, requestedDeviceLabel) ?? probeDeviceId
+ const videoConstraints = videoConstraintsFor(resolvedVideoId)
+ const audioDeviceId = findMatchingAudioDevice(resolvedVideoId ?? requestedDeviceId, allDevices)
const attempts: MediaStreamConstraints[] = []
if (audioDeviceId) {
@@ -373,6 +386,11 @@ function startCapture(): void {
})
}
attempts.push({ video: videoConstraints, audio: false })
+ // Last resort: the soft-hint probe constraints, so a device that could not
+ // be resolved by label still shows something rather than nothing. Skipped
+ // when resolvedVideoId is null, because videoConstraints already equals
+ // probeConstraints then and this would just duplicate the attempt above.
+ if (resolvedVideoId) attempts.push({ video: probeConstraints, audio: false })
tryCapture(attempts)
})
})
@@ -494,6 +512,7 @@ window.rokdock.capture.onVolumeChanged((newVolume: number) => {
async function init(): Promise {
const config = await window.rokdock.capture.getPopoutConfig()
requestedDeviceId = config.deviceId
+ requestedDeviceLabel = config.deviceLabel
muted = config.muted
idleTimeoutSec = config.idleTimeoutSec
diff --git a/src/renderer/components/devicePanel/deviceCard.tsx b/src/renderer/components/devicePanel/deviceCard.tsx
index e6e6d86..de78072 100644
--- a/src/renderer/components/devicePanel/deviceCard.tsx
+++ b/src/renderer/components/devicePanel/deviceCard.tsx
@@ -23,7 +23,7 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faLock, faUnlock } from '@fortawesome/free-solid-svg-icons'
-import { useAppStore, createTabInfo, type Device } from '../../store/appStore'
+import { useAppStore, createTabInfo, findReusableTab, type Device } from '../../store/appStore'
import { resolveThemeMode } from '../../styles/theme'
import ConfirmDialog from '../common/confirmDialog'
import SideloadDialog from '../sideloadDialog'
@@ -134,7 +134,12 @@ export default function DeviceCard({
const [dropError, setDropError] = useState(null)
const [droppedFile, setDroppedFile] = useState<{ filePath: string; fileName: string } | null>(null)
const dropErrorTimer = useRef(null)
+ // Ports with a createSession call in flight. The tab is added only after the async
+ // session is created, so this guards a rapid second click on the same port from
+ // opening a duplicate before the first tab exists to be found.
+ const connectingPortsRef = useRef>(new Set())
const addTab = useAppStore((state) => state.addTab)
+ const setActiveTab = useAppStore((state) => state.setActiveTab)
const remoteTargetIp = useAppStore((state) => state.remoteTargetIp)
const setRemoteTargetIp = useAppStore((state) => state.setRemoteTargetIp)
const setRightPanel = useAppStore((state) => state.setRightPanel)
@@ -227,6 +232,18 @@ export default function DeviceCard({
/** Opens a new terminal tab connected to this device on the given port. */
const handleConnect = async (port: number) => {
+ // A telnet port accepts only one connection, so a second tab for the same
+ // device and port could never connect. If a live tab already exists, focus
+ // it instead of opening a dead duplicate.
+ const existingTab = findReusableTab(useAppStore.getState().tabs, device.ip, port)
+ if (existingTab) {
+ setActiveTab(existingTab.id)
+ return
+ }
+ // Guard concurrent clicks: the tab is not added until createSession resolves,
+ // so without this a fast second click would start a second (doomed) session.
+ if (connectingPortsRef.current.has(port)) return
+ connectingPortsRef.current.add(port)
try {
const sessionId = await window.rokdock.terminal.createSession(device.ip, device.name, port)
const tab = createTabInfo(sessionId, device.ip, displayName, port, {
@@ -238,6 +255,8 @@ export default function DeviceCard({
setRemoteTargetIp(device.ip)
} catch (e) {
console.error('Failed to create session:', e)
+ } finally {
+ connectingPortsRef.current.delete(port)
}
}
diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts
index a1249b0..ece8059 100644
--- a/src/renderer/store/appStore.ts
+++ b/src/renderer/store/appStore.ts
@@ -1357,3 +1357,16 @@ export function createTabInfo(
paneId: 'a'
}
}
+
+/**
+ * Find a still-live terminal tab for a device and port. A telnet debug port accepts
+ * only one connection, so a second tab for the same target could never connect. The
+ * caller focuses the returned tab instead of opening a dead duplicate. Disconnected
+ * and errored tabs are ignored, so the user can still reconnect after a session drops.
+ */
+export function findReusableTab(tabs: TabInfo[], deviceIp: string, port: number): TabInfo | undefined {
+ return tabs.find(
+ (tab) => tab.deviceIp === deviceIp && tab.port === port &&
+ (tab.status === 'connected' || tab.status === 'connecting')
+ )
+}
diff --git a/tests/renderer/store/findReusableTab.test.ts b/tests/renderer/store/findReusableTab.test.ts
new file mode 100644
index 0000000..fa63987
--- /dev/null
+++ b/tests/renderer/store/findReusableTab.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest'
+import { findReusableTab, createTabInfo, type TabInfo } from '@renderer/store/appStore'
+
+function tab(overrides: Partial = {}): TabInfo {
+ return { ...createTabInfo('s1', '10.0.0.5', 'Living Room', 8085), ...overrides }
+}
+
+describe('findReusableTab', () => {
+ it('returns a connected tab for the same device and port', () => {
+ const tabs = [tab({ id: 's1', status: 'connected' })]
+ expect(findReusableTab(tabs, '10.0.0.5', 8085)?.id).toBe('s1')
+ })
+
+ it('returns a still-connecting tab (a second click during connect should focus it)', () => {
+ const tabs = [tab({ id: 's1', status: 'connecting' })]
+ expect(findReusableTab(tabs, '10.0.0.5', 8085)?.id).toBe('s1')
+ })
+
+ it('ignores a disconnected tab so the user can reconnect', () => {
+ const tabs = [tab({ id: 's1', status: 'disconnected' })]
+ expect(findReusableTab(tabs, '10.0.0.5', 8085)).toBeUndefined()
+ })
+
+ it('ignores an errored tab so the user can reconnect', () => {
+ const tabs = [tab({ id: 's1', status: 'error' })]
+ expect(findReusableTab(tabs, '10.0.0.5', 8085)).toBeUndefined()
+ })
+
+ it('does not match a different port on the same device', () => {
+ const tabs = [tab({ id: 's1', status: 'connected', port: 8085 })]
+ expect(findReusableTab(tabs, '10.0.0.5', 8089)).toBeUndefined()
+ })
+
+ it('does not match a different device on the same port', () => {
+ const tabs = [tab({ id: 's1', status: 'connected', deviceIp: '10.0.0.5' })]
+ expect(findReusableTab(tabs, '10.0.0.9', 8085)).toBeUndefined()
+ })
+
+ it('returns the live tab when a stale disconnected tab for the same target also exists', () => {
+ const tabs = [
+ tab({ id: 'stale', status: 'disconnected' }),
+ tab({ id: 'live', status: 'connected' })
+ ]
+ expect(findReusableTab(tabs, '10.0.0.5', 8085)?.id).toBe('live')
+ })
+
+ it('returns undefined for an empty tab list', () => {
+ expect(findReusableTab([], '10.0.0.5', 8085)).toBeUndefined()
+ })
+})