diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 63771cc..fe6c268 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -7,5 +7,9 @@ com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.device.camera + + com.apple.security.device.audio-input + diff --git a/docs/dev/release-builds.md b/docs/dev/release-builds.md index 47af1a0..1551e01 100644 --- a/docs/dev/release-builds.md +++ b/docs/dev/release-builds.md @@ -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. diff --git a/docs/user/capture-preview.md b/docs/user/capture-preview.md index 164c9ca..778cd48 100644 --- a/docs/user/capture-preview.md +++ b/docs/user/capture-preview.md @@ -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." diff --git a/electron-builder.json b/electron-builder.json index 3521df1..2aa769f 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -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", diff --git a/package.json b/package.json index 0c94507..2277510 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/main/ipc/handlers/updates.ts b/src/main/ipc/handlers/updates.ts index a7ad6f7..7a44317 100644 --- a/src/main/ipc/handlers/updates.ts +++ b/src/main/ipc/handlers/updates.ts @@ -63,8 +63,8 @@ export function checkForUpdates(context: IpcContext): Promise 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) @@ -75,7 +75,7 @@ export function checkForUpdates(context: IpcContext): Promise // 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' }) }) }) } diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 8ee9b1d..2ee599b 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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 diff --git a/src/renderer/components/updatesDialog.css b/src/renderer/components/updatesDialog.css new file mode 100644 index 0000000..757cc2a --- /dev/null +++ b/src/renderer/components/updatesDialog.css @@ -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); +} diff --git a/src/renderer/components/updatesDialog.tsx b/src/renderer/components/updatesDialog.tsx index 3c45220..fa57dd4 100644 --- a/src/renderer/components/updatesDialog.tsx +++ b/src/renderer/components/updatesDialog.tsx @@ -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, @@ -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, @@ -95,11 +103,14 @@ export default function UpdatesDialog({ result, onClose, onRetry }: { if (downloading) { body = ( <> -

Downloading update...

+
+

Downloading update...

+ {percent}% +
-

{percent}%. RokDock will restart to install when the download completes.

+

RokDock will restart to install when the download completes.

) actions = @@ -119,7 +130,11 @@ export default function UpdatesDialog({ result, onClose, onRetry }: { <>

A new version of RokDock is available.

{result.version &&

Version {result.version}

} - {result.notes &&

{result.notes}

} + {result.notes && ( +
+ {result.notes} +
+ )} ) actions = ( diff --git a/src/shared/updates.ts b/src/shared/updates.ts index eff87c8..80801bb 100644 --- a/src/shared/updates.ts +++ b/src/shared/updates.ts @@ -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 } diff --git a/tests/renderer/updatesDialog.test.tsx b/tests/renderer/updatesDialog.test.tsx new file mode 100644 index 0000000..0e7e3dd --- /dev/null +++ b/tests/renderer/updatesDialog.test.tsx @@ -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( + Highlights
  • First thing
' }} + 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(/

Highlights<\/h2>/)).toBeNull() + }) + + it('sanitizes unsafe markup in the notes', () => { + render( + safe note

' }} + 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() + expect(screen.getByText('Could not check for updates. Please try again later.')).toBeTruthy() + }) +})