Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@
</p>

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.

<p align="center">
<video src="https://github.com/user-attachments/assets/032120e2-22d1-4f7c-8e8c-0277da72e489" controls muted width="860"></video>
Expand Down
7 changes: 7 additions & 0 deletions src/main/ipc/handlers/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -147,6 +153,7 @@ export function registerCaptureHandlers(context: IpcContext): void {
ipcMain.handle('capture:get-popout-config', async (): Promise<PopoutConfig> => {
const config = pendingPopoutConfig ?? {
deviceId: '',
deviceLabel: null,
muted: true,
idleTimeoutSec: 3600
}
Expand Down
2 changes: 1 addition & 1 deletion src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IpcResult> =>
ipcRenderer.invoke('capture:set-mode', mode),
Expand Down
49 changes: 34 additions & 15 deletions src/renderer/capturePreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -83,6 +83,7 @@ closeBtn.innerHTML = svgClose
// -- State ---------------------------------------------------------------------

let requestedDeviceId = ''
let requestedDeviceLabel: string | null = null
let muted = true
let idleTimeoutSec = 3600
let volume = 80
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
})
})
Expand Down Expand Up @@ -494,6 +512,7 @@ window.rokdock.capture.onVolumeChanged((newVolume: number) => {
async function init(): Promise<void> {
const config = await window.rokdock.capture.getPopoutConfig()
requestedDeviceId = config.deviceId
requestedDeviceLabel = config.deviceLabel
muted = config.muted
idleTimeoutSec = config.idleTimeoutSec

Expand Down
21 changes: 20 additions & 1 deletion src/renderer/components/devicePanel/deviceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -134,7 +134,12 @@ export default function DeviceCard({
const [dropError, setDropError] = useState<string | null>(null)
const [droppedFile, setDroppedFile] = useState<{ filePath: string; fileName: string } | null>(null)
const dropErrorTimer = useRef<number | null>(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<Set<number>>(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)
Expand Down Expand Up @@ -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, {
Expand All @@ -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)
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/renderer/store/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
}
50 changes: 50 additions & 0 deletions tests/renderer/store/findReusableTab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { findReusableTab, createTabInfo, type TabInfo } from '@renderer/store/appStore'

function tab(overrides: Partial<TabInfo> = {}): 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()
})
})