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
4 changes: 4 additions & 0 deletions build/entitlements.mac.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
4 changes: 3 additions & 1 deletion docs/dev/release-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ xattr -cr /Applications/RokDock.app

A properly signed and notarized build generally launches without this.

`electron-builder.json` references `build/entitlements.mac.plist` for the `hardenedRuntime` entitlements. That file is committed to the repository (it is force-tracked past the `build/*` rule in `.gitignore` via a `!build/entitlements.mac.plist` negation), so `dist:mac` resolves it. It is a minimal plist granting `com.apple.security.cs.allow-jit` and `com.apple.security.cs.allow-unsigned-executable-memory`, which is what an Electron hardened-runtime build needs.
`electron-builder.json` references `build/entitlements.mac.plist` for the `hardenedRuntime` entitlements. That file is committed to the repository (it is force-tracked past the `build/*` rule in `.gitignore` via a `!build/entitlements.mac.plist` negation), so `dist:mac` resolves it. It grants `com.apple.security.cs.allow-jit` and `com.apple.security.cs.allow-unsigned-executable-memory` (what an Electron hardened-runtime build needs), plus `com.apple.security.device.camera` and `com.apple.security.device.audio-input` for the HDMI capture feature. The same file is used for both `entitlements` and `entitlementsInherit`, so the renderer/GPU helper processes that actually open the capture stream inherit the camera and audio-input entitlements too. Without that inheritance, capture is blocked even though the top-level app is entitled.

Under `hardenedRuntime` the entitlements alone are not enough: macOS also requires usage-description strings, or it silently denies access with no prompt. `mac.extendInfo` in `electron-builder.json` injects `NSCameraUsageDescription` and `NSMicrophoneUsageDescription` into the packaged `Info.plist`. Note that `npm run dev` does not exercise these: the dev build runs under Electron's own bundle id (`com.github.Electron`), which ships its own camera string, so a signed `dist:mac` build is the only way to validate the capture permission path. Windows and Linux need no packaging changes for capture: Windows gates desktop apps behind a global camera/microphone privacy toggle, and Linux governs device access through `/dev/video*` ownership (the `video` group) and PulseAudio/PipeWire. See the user-facing [HDMI Capture Preview](../user/capture-preview.md) doc for the per-OS troubleshooting steps.

Windows and Linux builds are unsigned.

Expand Down
23 changes: 23 additions & 0 deletions docs/user/capture-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ RokDock can display a live video feed from an HDMI capture device (a USB or HDMI
- An HDMI capture card or USB capture device connected to your computer
- The device must be recognized by your operating system before RokDock launches

## Camera and Microphone Permissions

Capture devices appear to the operating system as a camera (video) and, when they carry audio, a microphone. The first time RokDock opens a stream it may need permission to use them. If the preview stays black, the device list is empty, or audio never plays, an OS-level permission is usually the cause.

### macOS

macOS prompts for **Camera** and **Microphone** access the first time RokDock opens the stream. If you dismissed or denied the prompt, re-enable RokDock under **System Settings > Privacy & Security > Camera** (and **Microphone**), then restart the app.

If the prompt never appeared and the toggles are absent, quit RokDock and reset its permission state so the prompt can fire again:

```bash
tccutil reset Camera com.rokdock.app
tccutil reset Microphone com.rokdock.app
```

### Windows

Windows has no per-app prompt for a desktop app like RokDock. Access is controlled by a single system toggle. Open **Settings > Privacy & security > Camera**, make sure **Camera access** is on, and enable **Let desktop apps access your camera**. Repeat under **Settings > Privacy & security > Microphone**. No per-app entry for RokDock will appear in these lists; the desktop-apps toggle is what governs it.

### Linux

Linux has no permission prompt. Access is governed by device ownership: your user must be able to read the capture device (typically membership in the `video` group for `/dev/video*`), and audio flows through PulseAudio or PipeWire with no extra permission. If the device is present but RokDock cannot open it, confirm your user is in the `video` group (`groups`), then log out and back in.

## Selecting a Capture Device

Configure the capture device in **Settings > Capture**, under the **Live Capture** section. The dropdown lists all video input devices detected by your system. If no devices appear, the dropdown shows "No capture devices detected."
Expand Down
6 changes: 5 additions & 1 deletion electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@
"notarize": true,
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist"
"entitlementsInherit": "build/entitlements.mac.plist",
"extendInfo": {
"NSCameraUsageDescription": "RokDock uses the camera to capture live video from a connected Roku HDMI capture device.",
"NSMicrophoneUsageDescription": "RokDock uses the microphone input to capture audio from a connected Roku HDMI capture device."
}
},
"dmg": {
"artifactName": "RokDock-${version}-mac-${arch}.dmg",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rokdock",
"version": "1.6.0",
"version": "1.6.1",
"description": "Cross-platform desktop app for Roku development: device discovery, terminal sessions, remote control, sideloading, screenshot capture, automation scripting, and a built-in AI assistant with Gemini, Claude, Codex, and Copilot",
"main": "./out/main/main.js",
"scripts": {
Expand Down
6 changes: 3 additions & 3 deletions src/main/ipc/handlers/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export function checkForUpdates(context: IpcContext): Promise<UpdateCheckResult>
notes: releaseNotesText(info.releaseNotes),
})
const onNotAvailable = (): void => finish({ status: 'up-to-date', version: app.getVersion() })
// The dialog shows a friendly message; keep the raw HTTP-layer error in the log for support.
const onError = (err: Error): void => { logError('updates:check', err); finish({ status: 'error', error: err.message }) }
// The dialog shows a friendly message. The raw HTTP-layer error is kept in the log for support.
const onError = (err: Error): void => { logError('updates:check', err); finish({ status: 'error' }) }

autoUpdater.once('update-available', onAvailable)
autoUpdater.once('update-not-available', onNotAvailable)
Expand All @@ -75,7 +75,7 @@ export function checkForUpdates(context: IpcContext): Promise<UpdateCheckResult>
// finish() is idempotent (resolve is a no-op after the first call).
autoUpdater.checkForUpdates()
.then(result => { if (!result) finish({ status: 'up-to-date', version: app.getVersion() }) })
.catch((err: unknown) => { logError('updates:check', err); finish({ status: 'error', error: err instanceof Error ? err.message : String(err) }) })
.catch((err: unknown) => { logError('updates:check', err); finish({ status: 'error' }) })
})
}

Expand Down
7 changes: 4 additions & 3 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ export default function App() {
setUpdatesOpen(true)
void window.rokdock.updates.check()
.then(setUpdateResult)
.catch((err: unknown) =>
setUpdateResult({ status: 'error', error: err instanceof Error ? err.message : String(err) })
)
.catch((err: unknown) => {
console.error('Update check failed:', err)
setUpdateResult({ status: 'error' })
})
}
// Sync CSS custom properties used by index.html global styles (non-React elements).
// These legacy aliases (no --rokdock- prefix) are kept for index.html compatibility and
Expand Down
68 changes: 68 additions & 0 deletions src/renderer/components/updatesDialog.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* Release-notes block in the Software Update dialog. The notes arrive as HTML from
the GitHub release and are rendered (sanitized) by react-markdown, so this styles
the resulting elements compactly for a small dialog. Monospace inside the notes is
sized down (0.85em) to match the app's code sizing elsewhere, since JetBrains Mono
renders larger than the proportional UI font at the same px. */

.update-notes {
margin: 0;
max-height: 200px;
overflow-y: auto;
color: var(--rokdock-text-dim);
font-size: var(--rokdock-font-sm);
line-height: 1.5;
}

.update-notes h1,
.update-notes h2,
.update-notes h3,
.update-notes h4 {
margin: 10px 0 4px;
font-size: var(--rokdock-font-base);
font-weight: 600;
color: var(--rokdock-text-primary);
}

.update-notes > :first-child {
margin-top: 0;
}

.update-notes p {
margin: 4px 0;
}

.update-notes ul,
.update-notes ol {
margin: 4px 0;
padding-left: 18px;
}

.update-notes li {
margin: 2px 0;
}

.update-notes code {
font-family: var(--rokdock-font-mono);
font-size: 0.85em;
background: var(--rokdock-bg-terminal);
padding: 1px 4px;
border-radius: 4px;
}

.update-notes pre {
margin: 6px 0;
padding: 8px;
background: var(--rokdock-bg-terminal);
border: 1px solid var(--rokdock-border);
border-radius: 6px;
overflow-x: auto;
}

.update-notes pre code {
background: transparent;
padding: 0;
}

.update-notes a {
color: var(--rokdock-brand-primary-light);
}
41 changes: 28 additions & 13 deletions src/renderer/components/updatesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import React, { useEffect, useState } from 'react'
import type { CSSProperties } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faXmark } from '@fortawesome/free-solid-svg-icons'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import DialogFrame from './common/dialogFrame'
import type { UpdateCheckResult } from '@shared/updates'
import type { IpcResult } from '@shared/types'
import './updatesDialog.css'

const DIALOG_CLOSE_BTN: CSSProperties = {
width: 24,
Expand All @@ -31,26 +35,30 @@ const DIALOG_CLOSE_BTN: CSSProperties = {
const MESSAGE_STYLE: CSSProperties = {
margin: 0,
color: 'var(--rokdock-text-primary)',
fontSize: 'var(--rokdock-font-xs)',
fontSize: 'var(--rokdock-font-sm)',
lineHeight: 1.45,
}

const SUBTLE_STYLE: CSSProperties = {
margin: 0,
color: 'var(--rokdock-text-dim)',
fontSize: 'var(--rokdock-font-xs)',
fontSize: 'var(--rokdock-font-sm)',
}

const NOTES_STYLE: CSSProperties = {
margin: 0,
maxHeight: 160,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
color: 'var(--rokdock-text-dim)',
fontSize: 'var(--rokdock-font-xs)',
lineHeight: 1.45,
// Header row for the downloading state: title on the left, percent right-aligned.
const DOWNLOAD_HEADER_STYLE: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 8,
}

// Rehype pipeline for the release notes: rehypeRaw reparses the raw HTML the GitHub
// release ships, then rehypeSanitize (default schema) strips anything unsafe. Order
// matters: raw MUST run before sanitize. Defined once at module scope so the array
// identity is stable across renders.
const NOTES_REHYPE_PLUGINS = [rehypeRaw, rehypeSanitize]

const PROGRESS_TRACK_STYLE: CSSProperties = {
width: '100%',
height: 6,
Expand Down Expand Up @@ -95,11 +103,14 @@ export default function UpdatesDialog({ result, onClose, onRetry }: {
if (downloading) {
body = (
<>
<p style={MESSAGE_STYLE}>Downloading update...</p>
<div style={DOWNLOAD_HEADER_STYLE}>
<p style={MESSAGE_STYLE}>Downloading update...</p>
<span style={SUBTLE_STYLE}>{percent}%</span>
</div>
<div style={PROGRESS_TRACK_STYLE}>
<div style={{ width: `${percent}%`, height: '100%', background: 'var(--rokdock-brand-primary)', transition: 'width 0.2s ease' }} />
</div>
<p style={SUBTLE_STYLE}>{percent}%. RokDock will restart to install when the download completes.</p>
<p style={SUBTLE_STYLE}>RokDock will restart to install when the download completes.</p>
</>
)
actions = <button className="rokdock-btn rokdock-btn-ghost" disabled>Downloading...</button>
Expand All @@ -119,7 +130,11 @@ export default function UpdatesDialog({ result, onClose, onRetry }: {
<>
<p style={MESSAGE_STYLE}>A new version of RokDock is available.</p>
{result.version && <p style={SUBTLE_STYLE}>Version {result.version}</p>}
{result.notes && <p style={NOTES_STYLE}>{result.notes}</p>}
{result.notes && (
<div className="update-notes">
<ReactMarkdown rehypePlugins={NOTES_REHYPE_PLUGINS}>{result.notes}</ReactMarkdown>
</div>
)}
</>
)
actions = (
Expand Down
2 changes: 0 additions & 2 deletions src/shared/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,4 @@ export interface UpdateCheckResult {
version?: string
/** Release notes text, best-effort, when available. */
notes?: string
/** Error message when status is 'error'. */
error?: string
}
47 changes: 47 additions & 0 deletions tests/renderer/updatesDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, cleanup, screen } from '@testing-library/react'
import UpdatesDialog from '@renderer/components/updatesDialog'

beforeEach(() => {
;(window as unknown as { rokdock: unknown }).rokdock = {
updates: { onDownloadProgress: vi.fn(() => () => {}) },
}
})
afterEach(() => cleanup())

const noop = () => {}

describe('UpdatesDialog', () => {
it('renders the release-notes HTML as real elements, not raw tags', () => {
render(
<UpdatesDialog
result={{ status: 'available', version: '1.6.0', notes: '<h2>Highlights</h2><ul><li>First thing</li></ul>' }}
onClose={noop}
onRetry={noop}
/>
)
expect(screen.getByRole('heading', { name: 'Highlights' })).toBeTruthy()
expect(screen.getByText('First thing').tagName).toBe('LI')
// The literal tag source must not leak through as text.
expect(screen.queryByText(/<h2>Highlights<\/h2>/)).toBeNull()
})

it('sanitizes unsafe markup in the notes', () => {
render(
<UpdatesDialog
result={{ status: 'available', notes: '<p>safe note</p><script>(window).__pwned = 1</script>' }}
onClose={noop}
onRetry={noop}
/>
)
expect(screen.getByText('safe note')).toBeTruthy()
expect(document.querySelector('script')).toBeNull()
expect((window as unknown as { __pwned?: number }).__pwned).toBeUndefined()
})

it('shows a friendly, non-technical message on a check error', () => {
render(<UpdatesDialog result={{ status: 'error' }} onClose={noop} onRetry={noop} />)
expect(screen.getByText('Could not check for updates. Please try again later.')).toBeTruthy()
})
})