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() + }) +})