fix(vision): remote camera persistence and UI bugs

- Fix camera source switching: clear remoteCameraSessionId in context when
  switching to local camera so DockedVisionFeed uses the correct source
- Fix modal drag during calibration: disable framer-motion drag when
  calibration overlay is active to allow handle dragging
- Fix initial camera source: pass initialCameraSource prop to
  AbacusVisionBridge so it shows phone camera when reconfiguring remote
- Extend session TTL from 10 to 60 minutes for remote camera sessions
- Add localStorage persistence for remote camera session IDs
- Add auto-reconnect logic for both desktop and phone hooks
- Add comprehensive tests for session-manager, useRemoteCameraDesktop,
  and useRemoteCameraPhone hooks
- Guard test setup.ts for node environment (HTMLImageElement check)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-01 17:23:27 -06:00
parent 43524d8238
commit d90d263b2a
11 changed files with 1761 additions and 158 deletions

View File

@ -42,6 +42,8 @@ export interface AbacusVisionBridgeProps {
onError?: (error: string) => void
/** Called when configuration changes (camera, calibration, or remote session) */
onConfigurationChange?: (config: VisionConfigurationChange) => void
/** Initial camera source to show (defaults to 'local', but should be 'phone' if remote session is active) */
initialCameraSource?: CameraSource
}
/**
@ -59,6 +61,7 @@ export function AbacusVisionBridge({
onClose,
onError,
onConfigurationChange,
initialCameraSource = 'local',
}: AbacusVisionBridgeProps): ReactNode {
const [videoDimensions, setVideoDimensions] = useState<{
width: number
@ -84,7 +87,7 @@ export function AbacusVisionBridge({
const [opencvReady, setOpencvReady] = useState(false)
// Camera source selection
const [cameraSource, setCameraSource] = useState<CameraSource>('local')
const [cameraSource, setCameraSource] = useState<CameraSource>(initialCameraSource)
const [remoteCameraSessionId, setRemoteCameraSessionId] = useState<string | null>(null)
// Remote camera state
@ -107,12 +110,16 @@ export function AbacusVisionBridge({
isTorchOn: remoteIsTorchOn,
isTorchAvailable: remoteIsTorchAvailable,
error: remoteError,
currentSessionId: remoteCurrentSessionId,
isReconnecting: remoteIsReconnecting,
subscribe: remoteSubscribe,
unsubscribe: remoteUnsubscribe,
setPhoneFrameMode: remoteSetPhoneFrameMode,
sendCalibration: remoteSendCalibration,
clearCalibration: remoteClearCalibration,
setRemoteTorch,
getPersistedSessionId: remoteGetPersistedSessionId,
clearSession: remoteClearSession,
} = useRemoteCameraDesktop()
// Stability tracking for remote frames
@ -130,26 +137,56 @@ export function AbacusVisionBridge({
const lastReportedCalibrationRef = useRef<CalibrationGrid | null>(null)
const lastReportedRemoteSessionRef = useRef<string | null>(null)
// Initialize remote camera session from localStorage on mount
useEffect(() => {
const persistedSessionId = remoteGetPersistedSessionId()
if (persistedSessionId) {
console.log('[AbacusVisionBridge] Found persisted remote session:', persistedSessionId)
setRemoteCameraSessionId(persistedSessionId)
// Also notify parent about the persisted session
// This ensures the parent context stays in sync with localStorage
onConfigurationChange?.({ remoteCameraSessionId: persistedSessionId })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remoteGetPersistedSessionId]) // onConfigurationChange is intentionally omitted - only run on mount
// Handle switching to phone camera
const handleCameraSourceChange = useCallback(
(source: CameraSource) => {
setCameraSource(source)
if (source === 'phone') {
// Stop local camera - session will be created by RemoteCameraQRCode
// Stop local camera
vision.disable()
// Clear local camera config in parent context
onConfigurationChange?.({ cameraDeviceId: null, calibration: null })
// Check for persisted session and reuse it
const persistedSessionId = remoteGetPersistedSessionId()
if (persistedSessionId) {
console.log('[AbacusVisionBridge] Reusing persisted remote session:', persistedSessionId)
setRemoteCameraSessionId(persistedSessionId)
// Notify parent about the reused session
onConfigurationChange?.({ remoteCameraSessionId: persistedSessionId })
}
// If no persisted session, RemoteCameraQRCode will create one
} else {
// Stop remote session and start local camera
// Switching to local camera
// Clear remote session in context so DockedVisionFeed uses local camera
// The session still persists in localStorage (via useRemoteCameraDesktop) for when we switch back
setRemoteCameraSessionId(null)
vision.enable()
// Clear remote session config in parent context
onConfigurationChange?.({ remoteCameraSessionId: null })
vision.enable()
}
},
[vision, onConfigurationChange]
[vision, onConfigurationChange, remoteGetPersistedSessionId]
)
// Handle starting a fresh session (clear persisted and create new)
const handleStartFreshSession = useCallback(() => {
console.log('[AbacusVisionBridge] Starting fresh session')
remoteClearSession()
setRemoteCameraSessionId(null)
}, [remoteClearSession])
// Handle session created by QR code component
const handleRemoteSessionCreated = useCallback((sessionId: string) => {
setRemoteCameraSessionId(sessionId)
@ -257,9 +294,14 @@ export function AbacusVisionBridge({
}, [cameraSource, vision.calibrationGrid, onConfigurationChange])
// Notify about remote camera session changes
// Reset the lastReportedRemoteSessionRef when switching away from phone camera
// so that the next time we switch to phone, we'll notify the parent again
useEffect(() => {
if (
cameraSource === 'phone' &&
if (cameraSource !== 'phone') {
// When switching away from phone camera, reset the ref
// This ensures we'll notify the parent again when switching back
lastReportedRemoteSessionRef.current = null
} else if (
remoteCameraSessionId &&
remoteCameraSessionId !== lastReportedRemoteSessionRef.current
) {
@ -469,11 +511,14 @@ export function AbacusVisionBridge({
[vision]
)
// Determine if any calibration is active (disable drag during calibration)
const isCalibrating = vision.isCalibrating || remoteIsCalibrating
return (
<motion.div
ref={containerRef}
data-component="abacus-vision-bridge"
drag
drag={!isCalibrating}
dragMomentum={false}
dragElastic={0}
className={css({
@ -485,8 +530,8 @@ export function AbacusVisionBridge({
borderRadius: 'xl',
maxWidth: '400px',
width: '100%',
cursor: 'grab',
_active: { cursor: 'grabbing' },
cursor: isCalibrating ? 'default' : 'grab',
_active: { cursor: isCalibrating ? 'default' : 'grabbing' },
})}
>
{/* Header */}
@ -588,136 +633,114 @@ export function AbacusVisionBridge({
</button>
</div>
{/* Camera controls (local camera) - only show if there's something to display */}
{cameraSource === 'local' &&
(vision.availableDevices.length > 1 || vision.isTorchAvailable) && (
<div
data-element="camera-controls"
{/* Camera controls - unified for both local and phone cameras */}
<div
data-element="camera-controls"
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: 'wrap',
})}
>
{/* Camera selector - always show for local camera */}
{cameraSource === 'local' && vision.availableDevices.length > 0 && (
<select
data-element="camera-selector"
value={vision.selectedDeviceId ?? ''}
onChange={handleCameraSelect}
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: 'wrap',
flex: 1,
p: 2,
bg: 'gray.800',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
borderRadius: 'md',
fontSize: 'sm',
minWidth: '150px',
})}
>
{/* Camera selector (if multiple cameras) */}
{vision.availableDevices.length > 1 && (
<select
data-element="camera-selector"
value={vision.selectedDeviceId ?? ''}
onChange={handleCameraSelect}
className={css({
flex: 1,
p: 2,
bg: 'gray.800',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
borderRadius: 'md',
fontSize: 'sm',
minWidth: '150px',
})}
>
{vision.availableDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Camera ${device.deviceId.slice(0, 8)}`}
</option>
))}
</select>
)}
{/* Flip camera button - only show if multiple cameras available */}
{vision.availableDevices.length > 1 && (
<button
type="button"
onClick={() => vision.flipCamera()}
data-action="flip-camera"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
bg: 'gray.700',
color: 'white',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'lg',
_hover: { bg: 'gray.600' },
})}
title={`Switch to ${vision.facingMode === 'environment' ? 'front' : 'back'} camera`}
>
🔄
</button>
)}
{/* Torch toggle button (only if available) */}
{vision.isTorchAvailable && (
<button
type="button"
onClick={() => vision.toggleTorch()}
data-action="toggle-torch"
data-status={vision.isTorchOn ? 'on' : 'off'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
bg: vision.isTorchOn ? 'yellow.600' : 'gray.700',
color: 'white',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'lg',
_hover: { bg: vision.isTorchOn ? 'yellow.500' : 'gray.600' },
})}
title={vision.isTorchOn ? 'Turn off flash' : 'Turn on flash'}
>
{vision.isTorchOn ? '🔦' : '💡'}
</button>
)}
</div>
{vision.availableDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Camera ${device.deviceId.slice(0, 8)}`}
</option>
))}
</select>
)}
{/* Camera controls (phone camera) */}
{cameraSource === 'phone' && remoteIsPhoneConnected && remoteIsTorchAvailable && (
<div
data-element="phone-camera-controls"
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
})}
>
{/* Remote torch toggle button */}
{/* Flip camera button - only show if multiple cameras available */}
{cameraSource === 'local' && vision.availableDevices.length > 1 && (
<button
type="button"
onClick={() => setRemoteTorch(!remoteIsTorchOn)}
data-action="toggle-remote-torch"
data-status={remoteIsTorchOn ? 'on' : 'off'}
onClick={() => vision.flipCamera()}
data-action="flip-camera"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
bg: remoteIsTorchOn ? 'yellow.600' : 'gray.700',
bg: 'gray.700',
color: 'white',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'lg',
_hover: { bg: remoteIsTorchOn ? 'yellow.500' : 'gray.600' },
_hover: { bg: 'gray.600' },
})}
title={remoteIsTorchOn ? 'Turn off phone flash' : 'Turn on phone flash'}
title={`Switch to ${vision.facingMode === 'environment' ? 'front' : 'back'} camera`}
>
{remoteIsTorchOn ? '🔦' : '💡'}
🔄
</button>
<span className={css({ color: 'gray.400', fontSize: 'sm' })}>Phone Flash</span>
</div>
)}
)}
{/* Torch toggle button - unified for both local and remote */}
{((cameraSource === 'local' && vision.isTorchAvailable) ||
(cameraSource === 'phone' && remoteIsPhoneConnected && remoteIsTorchAvailable)) && (
<button
type="button"
onClick={() => {
if (cameraSource === 'local') {
vision.toggleTorch()
} else {
setRemoteTorch(!remoteIsTorchOn)
}
}}
data-action="toggle-torch"
data-status={
(cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn) ? 'on' : 'off'
}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
bg: (cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn)
? 'yellow.600'
: 'gray.700',
color: 'white',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'lg',
_hover: {
bg: (cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn)
? 'yellow.500'
: 'gray.600',
},
})}
title={
(cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn)
? 'Turn off flash'
: 'Turn on flash'
}
>
{(cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn) ? '🔦' : '💡'}
</button>
)}
</div>
{/* Calibration mode toggle (both local and phone camera) */}
<div
@ -951,12 +974,60 @@ export function AbacusVisionBridge({
color: 'gray.400',
})}
>
<p className={css({ mb: 4 })}>Waiting for phone to connect...</p>
{remoteIsReconnecting ? (
<>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
mb: 2,
color: 'blue.300',
})}
>
<span
className={css({
width: 3,
height: 3,
borderRadius: 'full',
bg: 'blue.400',
animation: 'pulse 1.5s infinite',
})}
/>
Reconnecting to session...
</div>
<style>{`@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }`}</style>
</>
) : (
<p className={css({ mb: 2 })}>Waiting for phone to connect...</p>
)}
<p className={css({ fontSize: 'xs', color: 'gray.500', mb: 4 })}>
Session: {remoteCameraSessionId.slice(0, 8)}...
</p>
<RemoteCameraQRCode
onSessionCreated={handleRemoteSessionCreated}
existingSessionId={remoteCameraSessionId}
size={150}
/>
<button
type="button"
onClick={handleStartFreshSession}
className={css({
mt: 4,
px: 3,
py: 1.5,
fontSize: 'xs',
color: 'gray.400',
bg: 'transparent',
border: '1px solid',
borderColor: 'gray.600',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'gray.700', color: 'white' },
})}
>
Start Fresh Session
</button>
</div>
) : (
/* Show camera frames */

View File

@ -215,6 +215,10 @@ export function VisionSetupModal() {
setVisionRemoteSession(config.remoteCameraSessionId)
}
}}
// Start with phone camera selected if remote session is configured but no local camera
initialCameraSource={
visionConfig.remoteCameraSessionId && !visionConfig.cameraDeviceId ? 'phone' : 'local'
}
/>
</div>
)}

View File

@ -0,0 +1,501 @@
/**
* Tests for useRemoteCameraDesktop hook
*
* Tests session persistence, auto-reconnection, and Socket.IO event handling.
*/
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRemoteCameraDesktop } from '../useRemoteCameraDesktop'
// Mock socket.io-client - use vi.hoisted for variables referenced in vi.mock
const { mockSocket, mockIo } = vi.hoisted(() => {
const socket = {
id: 'test-socket-id',
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
connected: true,
}
return {
mockSocket: socket,
mockIo: vi.fn(() => socket),
}
})
vi.mock('socket.io-client', () => ({
io: mockIo,
}))
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
})
describe('useRemoteCameraDesktop', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
// Reset mock socket handlers
mockIo.mockClear()
mockSocket.on.mockClear()
mockSocket.off.mockClear()
mockSocket.emit.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initialization', () => {
it('should initialize with default state', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
expect(result.current.isPhoneConnected).toBe(false)
expect(result.current.latestFrame).toBeNull()
expect(result.current.frameRate).toBe(0)
expect(result.current.error).toBeNull()
expect(result.current.currentSessionId).toBeNull()
expect(result.current.isReconnecting).toBe(false)
})
it('should set up socket with reconnection config', () => {
renderHook(() => useRemoteCameraDesktop())
expect(mockIo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 10,
})
)
})
})
describe('localStorage persistence', () => {
it('should persist session ID when subscribing', async () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
// Simulate socket connect
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
if (connectHandler) {
act(() => {
connectHandler()
})
}
// Subscribe to a session
act(() => {
result.current.subscribe('test-session-123')
})
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'remote-camera-session-id',
'test-session-123'
)
})
it('should return persisted session ID from getPersistedSessionId', () => {
localStorageMock.getItem.mockReturnValue('persisted-session-456')
const { result } = renderHook(() => useRemoteCameraDesktop())
const persistedId = result.current.getPersistedSessionId()
expect(persistedId).toBe('persisted-session-456')
})
it('should clear persisted session ID on clearSession', async () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
act(() => {
result.current.clearSession()
})
expect(localStorageMock.removeItem).toHaveBeenCalledWith('remote-camera-session-id')
})
})
describe('auto-reconnect on socket reconnect', () => {
it('should re-subscribe to persisted session on socket connect', () => {
localStorageMock.getItem.mockReturnValue('persisted-session-789')
renderHook(() => useRemoteCameraDesktop())
// Find the connect handler
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
expect(connectHandler).toBeDefined()
// Simulate socket connect
act(() => {
connectHandler()
})
// Should emit subscribe with persisted session
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:subscribe', {
sessionId: 'persisted-session-789',
})
})
it('should not subscribe if no persisted session', () => {
localStorageMock.getItem.mockReturnValue(null)
renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
// Should not emit subscribe
expect(mockSocket.emit).not.toHaveBeenCalledWith(
'remote-camera:subscribe',
expect.anything()
)
})
})
describe('session subscription', () => {
it('should emit subscribe event with session ID', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
// Simulate connection
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('new-session-id')
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:subscribe', {
sessionId: 'new-session-id',
})
})
it('should update currentSessionId on subscribe', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
// Simulate connection
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('my-session')
})
expect(result.current.currentSessionId).toBe('my-session')
})
})
describe('event handling', () => {
it('should handle phone connected event', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
// Find the event handler setup
const setupHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:connected'
)?.[1]
if (setupHandler) {
act(() => {
setupHandler({ phoneConnected: true })
})
}
expect(result.current.isPhoneConnected).toBe(true)
})
it('should handle phone disconnected event', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
// Set connected first
const connectedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:connected'
)?.[1]
if (connectedHandler) {
act(() => {
connectedHandler({ phoneConnected: true })
})
}
// Then disconnect
const disconnectedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:disconnected'
)?.[1]
if (disconnectedHandler) {
act(() => {
disconnectedHandler({ phoneConnected: false })
})
}
expect(result.current.isPhoneConnected).toBe(false)
})
it('should handle frame events', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const frameHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:frame'
)?.[1]
const testFrame = {
imageData: 'base64-image-data',
timestamp: Date.now(),
mode: 'cropped' as const,
}
if (frameHandler) {
act(() => {
frameHandler(testFrame)
})
}
expect(result.current.latestFrame).toEqual(testFrame)
})
it('should handle error events and clear invalid sessions', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const errorHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:error'
)?.[1]
if (errorHandler) {
act(() => {
errorHandler({ error: 'Invalid session' })
})
}
expect(result.current.error).toBe('Invalid session')
expect(localStorageMock.removeItem).toHaveBeenCalledWith('remote-camera-session-id')
})
it('should handle torch state events', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const torchHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:torch-state'
)?.[1]
if (torchHandler) {
act(() => {
torchHandler({ isTorchOn: true, isTorchAvailable: true })
})
}
expect(result.current.isTorchOn).toBe(true)
expect(result.current.isTorchAvailable).toBe(true)
})
})
describe('calibration commands', () => {
it('should emit calibration to phone', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
// Simulate connection and subscription
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('calibration-session')
})
const corners = {
topLeft: { x: 0, y: 0 },
topRight: { x: 100, y: 0 },
bottomLeft: { x: 0, y: 100 },
bottomRight: { x: 100, y: 100 },
}
act(() => {
result.current.sendCalibration(corners)
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:set-calibration', {
sessionId: 'calibration-session',
corners,
})
})
it('should emit clear calibration to phone', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('clear-cal-session')
})
act(() => {
result.current.clearCalibration()
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:clear-calibration', {
sessionId: 'clear-cal-session',
})
})
})
describe('frame mode control', () => {
it('should emit frame mode change to phone', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('mode-session')
})
act(() => {
result.current.setPhoneFrameMode('raw')
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:set-mode', {
sessionId: 'mode-session',
mode: 'raw',
})
})
})
describe('torch control', () => {
it('should emit torch command to phone', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('torch-session')
})
act(() => {
result.current.setRemoteTorch(true)
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:set-torch', {
sessionId: 'torch-session',
on: true,
})
})
it('should optimistically update torch state', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('torch-session-2')
})
act(() => {
result.current.setRemoteTorch(true)
})
expect(result.current.isTorchOn).toBe(true)
})
})
describe('cleanup', () => {
it('should emit leave on unsubscribe', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('leave-session')
})
act(() => {
result.current.unsubscribe()
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:leave', {
sessionId: 'leave-session',
})
})
it('should reset state on unsubscribe', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('reset-session')
})
// Set some state
const connectedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:connected'
)?.[1]
if (connectedHandler) {
act(() => {
connectedHandler({ phoneConnected: true })
})
}
act(() => {
result.current.unsubscribe()
})
expect(result.current.isPhoneConnected).toBe(false)
expect(result.current.latestFrame).toBeNull()
expect(result.current.frameRate).toBe(0)
})
it('should clear all state on clearSession', () => {
const { result } = renderHook(() => useRemoteCameraDesktop())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.subscribe('clear-session')
})
act(() => {
result.current.clearSession()
})
expect(result.current.currentSessionId).toBeNull()
expect(result.current.isPhoneConnected).toBe(false)
expect(result.current.isReconnecting).toBe(false)
expect(localStorageMock.removeItem).toHaveBeenCalledWith('remote-camera-session-id')
})
})
})

View File

@ -0,0 +1,498 @@
/**
* Tests for useRemoteCameraPhone hook
*
* Tests socket connection, auto-reconnection, and frame sending behavior.
*/
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRemoteCameraPhone } from '../useRemoteCameraPhone'
// Mock socket.io-client - use vi.hoisted for variables referenced in vi.mock
const { mockSocket, mockIo } = vi.hoisted(() => {
const socket = {
id: 'test-phone-socket-id',
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
connected: true,
}
return {
mockSocket: socket,
mockIo: vi.fn(() => socket),
}
})
vi.mock('socket.io-client', () => ({
io: mockIo,
}))
// Mock OpenCV loading
vi.mock('@/lib/vision/perspectiveTransform', () => ({
loadOpenCV: vi.fn(() => Promise.resolve()),
isOpenCVReady: vi.fn(() => true),
rectifyQuadrilateralToBase64: vi.fn(() => 'mock-base64-image'),
}))
describe('useRemoteCameraPhone', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIo.mockClear()
mockSocket.on.mockClear()
mockSocket.off.mockClear()
mockSocket.emit.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initialization', () => {
it('should initialize with default state', async () => {
const { result } = renderHook(() => useRemoteCameraPhone())
expect(result.current.isConnected).toBe(false)
expect(result.current.isSending).toBe(false)
expect(result.current.frameMode).toBe('raw')
expect(result.current.desktopCalibration).toBeNull()
expect(result.current.error).toBeNull()
})
it('should set up socket with reconnection config', () => {
renderHook(() => useRemoteCameraPhone())
expect(mockIo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/socket',
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 10,
})
)
})
})
describe('session connection', () => {
it('should emit join event when connecting', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
// Simulate socket connect
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
if (connectHandler) {
act(() => {
connectHandler()
})
}
act(() => {
result.current.connect('phone-session-123')
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:join', {
sessionId: 'phone-session-123',
})
})
it('should update isConnected on connect', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
if (connectHandler) {
act(() => {
connectHandler()
})
}
act(() => {
result.current.connect('connect-session')
})
expect(result.current.isConnected).toBe(true)
})
it('should set error if socket not connected', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
// Don't simulate connect - socket is not connected
act(() => {
result.current.connect('fail-session')
})
expect(result.current.error).toBe('Socket not connected')
})
})
describe('auto-reconnect on socket reconnect', () => {
it('should re-join session on socket reconnect', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
// Initial connect
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
// Connect to session
act(() => {
result.current.connect('reconnect-session')
})
// Clear emit calls
mockSocket.emit.mockClear()
// Simulate socket reconnect (connect event fires again)
act(() => {
connectHandler()
})
// Should auto-rejoin
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:join', {
sessionId: 'reconnect-session',
})
})
it('should not rejoin if no session was set', () => {
renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
mockSocket.emit.mockClear()
// Simulate reconnect without ever connecting to a session
act(() => {
connectHandler()
})
expect(mockSocket.emit).not.toHaveBeenCalledWith('remote-camera:join', expect.anything())
})
})
describe('socket disconnect handling', () => {
it('should not clear session on temporary disconnect', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.connect('persist-session')
})
// Simulate temporary disconnect
const disconnectHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'disconnect'
)?.[1]
act(() => {
disconnectHandler('transport close')
})
// Session ref should still be set (will reconnect)
// isConnected might be false but session should persist internally
})
it('should clear state on server disconnect', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.connect('server-disconnect-session')
})
const disconnectHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'disconnect'
)?.[1]
act(() => {
disconnectHandler('io server disconnect')
})
expect(result.current.isConnected).toBe(false)
})
})
describe('desktop commands', () => {
it('should handle set-mode command from desktop', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const setModeHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:set-mode'
)?.[1]
if (setModeHandler) {
act(() => {
setModeHandler({ mode: 'cropped' })
})
}
expect(result.current.frameMode).toBe('cropped')
})
it('should handle set-calibration command from desktop', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const calibrationHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:set-calibration'
)?.[1]
const corners = {
topLeft: { x: 10, y: 10 },
topRight: { x: 100, y: 10 },
bottomLeft: { x: 10, y: 100 },
bottomRight: { x: 100, y: 100 },
}
if (calibrationHandler) {
act(() => {
calibrationHandler({ corners })
})
}
expect(result.current.desktopCalibration).toEqual(corners)
// Should auto-switch to cropped mode
expect(result.current.frameMode).toBe('cropped')
})
it('should handle clear-calibration command from desktop', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
// First set calibration
const calibrationHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:set-calibration'
)?.[1]
if (calibrationHandler) {
act(() => {
calibrationHandler({
corners: {
topLeft: { x: 0, y: 0 },
topRight: { x: 100, y: 0 },
bottomLeft: { x: 0, y: 100 },
bottomRight: { x: 100, y: 100 },
},
})
})
}
expect(result.current.desktopCalibration).not.toBeNull()
// Then clear it
const clearHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:clear-calibration'
)?.[1]
if (clearHandler) {
act(() => {
clearHandler()
})
}
expect(result.current.desktopCalibration).toBeNull()
})
it('should handle set-torch command from desktop', () => {
const torchCallback = vi.fn()
renderHook(() => useRemoteCameraPhone({ onTorchRequest: torchCallback }))
const torchHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:set-torch'
)?.[1]
if (torchHandler) {
act(() => {
torchHandler({ on: true })
})
}
expect(torchCallback).toHaveBeenCalledWith(true)
})
})
describe('frame mode', () => {
it('should allow setting frame mode locally', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
act(() => {
result.current.setFrameMode('cropped')
})
expect(result.current.frameMode).toBe('cropped')
})
})
describe('torch state emission', () => {
it('should emit torch state to desktop', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.connect('torch-emit-session')
})
act(() => {
result.current.emitTorchState(true, true)
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:torch-state', {
sessionId: 'torch-emit-session',
isTorchOn: true,
isTorchAvailable: true,
})
})
})
describe('disconnect', () => {
it('should emit leave event on disconnect', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.connect('disconnect-session')
})
act(() => {
result.current.disconnect()
})
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:leave', {
sessionId: 'disconnect-session',
})
})
it('should reset state on disconnect', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.connect('reset-disconnect-session')
})
expect(result.current.isConnected).toBe(true)
act(() => {
result.current.disconnect()
})
expect(result.current.isConnected).toBe(false)
})
})
describe('error handling', () => {
it('should handle error events', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const errorHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'remote-camera:error'
)?.[1]
if (errorHandler) {
act(() => {
errorHandler({ error: 'Session expired' })
})
}
expect(result.current.error).toBe('Session expired')
expect(result.current.isConnected).toBe(false)
})
})
describe('calibration update', () => {
it('should update calibration for frame processing', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const newCalibration = {
topLeft: { x: 20, y: 20 },
topRight: { x: 200, y: 20 },
bottomLeft: { x: 20, y: 200 },
bottomRight: { x: 200, y: 200 },
}
act(() => {
result.current.updateCalibration(newCalibration)
})
// The calibration is stored in a ref for frame processing
// We can verify by checking that no error is thrown
})
})
describe('sending frames', () => {
it('should set isSending when startSending is called', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.connect('sending-session')
})
// Create mock video element
const mockVideo = document.createElement('video')
act(() => {
result.current.startSending(mockVideo)
})
expect(result.current.isSending).toBe(true)
})
it('should set error if not connected when starting to send', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const mockVideo = document.createElement('video')
act(() => {
result.current.startSending(mockVideo)
})
expect(result.current.error).toBe('Not connected to session')
})
it('should reset isSending on stopSending', () => {
const { result } = renderHook(() => useRemoteCameraPhone())
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
act(() => {
connectHandler()
})
act(() => {
result.current.connect('stop-sending-session')
})
const mockVideo = document.createElement('video')
act(() => {
result.current.startSending(mockVideo)
})
act(() => {
result.current.stopSending()
})
expect(result.current.isSending).toBe(false)
})
})
})

View File

@ -162,6 +162,9 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn {
video: {
width: { ideal: 1920 },
height: { ideal: 1440 },
// Prefer widest angle lens (zoom: 1 = no zoom = widest)
// @ts-expect-error - zoom is valid but not in TS types
zoom: { ideal: 1 },
// Try to disable face-tracking auto-focus (not all cameras support this)
// @ts-expect-error - focusMode is valid but not in TS types
focusMode: 'continuous',

View File

@ -117,11 +117,14 @@ export function usePhoneCamera(options: UsePhoneCameraOptions = {}): UsePhoneCam
}
// Request camera with specified facing mode
// Prefer widest angle lens (zoom: 1 = no zoom = widest)
const constraints: MediaStreamConstraints = {
video: {
facingMode: { ideal: targetFacingMode },
width: { ideal: 1280 },
height: { ideal: 720 },
// @ts-expect-error - zoom is valid but not in TS types
zoom: { ideal: 1 },
},
audio: false,
}

View File

@ -7,6 +7,9 @@ import type { QuadCorners } from '@/types/vision'
/** Frame mode: raw sends uncropped frames, cropped applies calibration */
export type FrameMode = 'raw' | 'cropped'
/** LocalStorage key for persisting session ID */
const STORAGE_KEY = 'remote-camera-session-id'
interface RemoteCameraFrame {
imageData: string // Base64 JPEG
timestamp: number
@ -31,6 +34,10 @@ interface UseRemoteCameraDesktopReturn {
isTorchAvailable: boolean
/** Error message if connection failed */
error: string | null
/** Current session ID (null if not subscribed) */
currentSessionId: string | null
/** Whether actively trying to reconnect */
isReconnecting: boolean
/** Subscribe to receive frames for a session */
subscribe: (sessionId: string) => void
/** Unsubscribe from the session */
@ -43,6 +50,10 @@ interface UseRemoteCameraDesktopReturn {
clearCalibration: () => void
/** Set phone's torch state */
setRemoteTorch: (on: boolean) => void
/** Get the persisted session ID (if any) */
getPersistedSessionId: () => string | null
/** Clear persisted session and disconnect */
clearSession: () => void
}
/**
@ -66,22 +77,55 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
const [isTorchOn, setIsTorchOn] = useState(false)
const [isTorchAvailable, setIsTorchAvailable] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentSessionId = useRef<string | null>(null)
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [isReconnecting, setIsReconnecting] = useState(false)
// Refs for values needed in callbacks
const currentSessionIdRef = useRef<string | null>(null)
const reconnectAttemptRef = useRef(0)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Frame rate calculation
const frameTimestamps = useRef<number[]>([])
// Initialize socket connection
// Helper to persist session ID
const persistSessionId = useCallback((sessionId: string | null) => {
if (sessionId) {
localStorage.setItem(STORAGE_KEY, sessionId)
} else {
localStorage.removeItem(STORAGE_KEY)
}
}, [])
// Helper to get persisted session ID
const getPersistedSessionId = useCallback((): string | null => {
if (typeof window === 'undefined') return null
return localStorage.getItem(STORAGE_KEY)
}, [])
// Initialize socket connection with reconnection support
useEffect(() => {
console.log('[RemoteCameraDesktop] Initializing socket connection...')
const socketInstance = io({
path: '/api/socket',
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 10,
})
socketInstance.on('connect', () => {
console.log('[RemoteCameraDesktop] Socket connected! ID:', socketInstance.id)
setIsConnected(true)
// If we have a session ID (either from state or localStorage), re-subscribe
const sessionId = currentSessionIdRef.current || getPersistedSessionId()
if (sessionId) {
console.log('[RemoteCameraDesktop] Re-subscribing to session after reconnect:', sessionId)
setIsReconnecting(true)
socketInstance.emit('remote-camera:subscribe', { sessionId })
}
})
socketInstance.on('connect_error', (error) => {
@ -91,6 +135,11 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
socketInstance.on('disconnect', (reason) => {
console.log('[RemoteCameraDesktop] Socket disconnected:', reason)
setIsConnected(false)
// Don't clear phone connected state immediately - might reconnect
if (reason === 'io server disconnect') {
// Server forced disconnect - clear state
setIsPhoneConnected(false)
}
})
setSocket(socketInstance)
@ -98,7 +147,7 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
return () => {
socketInstance.disconnect()
}
}, [])
}, [getPersistedSessionId])
const calculateFrameRate = useCallback(() => {
const now = Date.now()
@ -114,19 +163,23 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
const handleConnected = ({ phoneConnected }: { phoneConnected: boolean }) => {
console.log('[RemoteCameraDesktop] Phone connected event:', phoneConnected)
setIsPhoneConnected(phoneConnected)
setIsReconnecting(false)
setError(null)
reconnectAttemptRef.current = 0
}
const handleDisconnected = ({ phoneConnected }: { phoneConnected: boolean }) => {
console.log('[RemoteCameraDesktop] Phone disconnected event:', phoneConnected)
setIsPhoneConnected(phoneConnected)
setLatestFrame(null)
setFrameRate(0)
// Don't clear frame/framerate - keep last state for visual continuity
// Phone might reconnect quickly
}
const handleStatus = ({ phoneConnected }: { phoneConnected: boolean }) => {
console.log('[RemoteCameraDesktop] Status event:', phoneConnected)
setIsPhoneConnected(phoneConnected)
setIsReconnecting(false)
reconnectAttemptRef.current = 0
}
const handleFrame = (frame: RemoteCameraFrame) => {
@ -145,7 +198,16 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
}
const handleError = ({ error: errorMsg }: { error: string }) => {
console.log('[RemoteCameraDesktop] Error event:', errorMsg)
// If session is invalid/expired, clear the persisted session
if (errorMsg.includes('Invalid') || errorMsg.includes('expired')) {
console.log('[RemoteCameraDesktop] Session invalid, clearing persisted session')
persistSessionId(null)
setCurrentSessionId(null)
currentSessionIdRef.current = null
}
setError(errorMsg)
setIsReconnecting(false)
}
const handleTorchState = ({
@ -174,7 +236,7 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
socket.off('remote-camera:error', handleError)
socket.off('remote-camera:torch-state', handleTorchState)
}
}, [socket, calculateFrameRate])
}, [socket, calculateFrameRate, persistSessionId])
// Frame rate update interval
useEffect(() => {
@ -198,19 +260,23 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
return
}
currentSessionId.current = sessionId
currentSessionIdRef.current = sessionId
setCurrentSessionId(sessionId)
persistSessionId(sessionId)
setError(null)
console.log('[RemoteCameraDesktop] Emitting remote-camera:subscribe')
socket.emit('remote-camera:subscribe', { sessionId })
},
[socket, isConnected]
[socket, isConnected, persistSessionId]
)
const unsubscribe = useCallback(() => {
if (!socket || !currentSessionId.current) return
if (!socket || !currentSessionIdRef.current) return
socket.emit('remote-camera:leave', { sessionId: currentSessionId.current })
currentSessionId.current = null
socket.emit('remote-camera:leave', { sessionId: currentSessionIdRef.current })
currentSessionIdRef.current = null
setCurrentSessionId(null)
// Don't clear persisted session - unsubscribe is for temporary disconnect
setIsPhoneConnected(false)
setLatestFrame(null)
setFrameRate(0)
@ -221,6 +287,28 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
setIsTorchAvailable(false)
}, [socket])
/**
* Clear session completely (forget persisted session)
* Use when user explicitly wants to start fresh
*/
const clearSession = useCallback(() => {
if (socket && currentSessionIdRef.current) {
socket.emit('remote-camera:leave', { sessionId: currentSessionIdRef.current })
}
currentSessionIdRef.current = null
setCurrentSessionId(null)
persistSessionId(null)
setIsPhoneConnected(false)
setLatestFrame(null)
setFrameRate(0)
setError(null)
setVideoDimensions(null)
setFrameMode('raw')
setIsTorchOn(false)
setIsTorchAvailable(false)
setIsReconnecting(false)
}, [socket, persistSessionId])
/**
* Set the phone's frame mode
* - raw: Phone sends uncropped frames (for calibration)
@ -228,10 +316,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
*/
const setPhoneFrameMode = useCallback(
(mode: FrameMode) => {
if (!socket || !currentSessionId.current) return
if (!socket || !currentSessionIdRef.current) return
socket.emit('remote-camera:set-mode', {
sessionId: currentSessionId.current,
sessionId: currentSessionIdRef.current,
mode,
})
setFrameMode(mode)
@ -245,10 +333,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
*/
const sendCalibration = useCallback(
(corners: QuadCorners) => {
if (!socket || !currentSessionId.current) return
if (!socket || !currentSessionIdRef.current) return
socket.emit('remote-camera:set-calibration', {
sessionId: currentSessionId.current,
sessionId: currentSessionIdRef.current,
corners,
})
// Phone will automatically switch to cropped mode when it receives calibration
@ -262,10 +350,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
* This tells the phone to forget the desktop calibration and go back to auto-detection
*/
const clearCalibration = useCallback(() => {
if (!socket || !currentSessionId.current) return
if (!socket || !currentSessionIdRef.current) return
socket.emit('remote-camera:clear-calibration', {
sessionId: currentSessionId.current,
sessionId: currentSessionIdRef.current,
})
}, [socket])
@ -274,10 +362,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
*/
const setRemoteTorch = useCallback(
(on: boolean) => {
if (!socket || !currentSessionId.current) return
if (!socket || !currentSessionIdRef.current) return
socket.emit('remote-camera:set-torch', {
sessionId: currentSessionId.current,
sessionId: currentSessionIdRef.current,
on,
})
// Optimistically update local state
@ -289,9 +377,9 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
// Cleanup on unmount
useEffect(() => {
return () => {
if (socket && currentSessionId.current) {
if (socket && currentSessionIdRef.current) {
socket.emit('remote-camera:leave', {
sessionId: currentSessionId.current,
sessionId: currentSessionIdRef.current,
})
}
}
@ -306,11 +394,15 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
isTorchOn,
isTorchAvailable,
error,
currentSessionId,
isReconnecting,
subscribe,
unsubscribe,
setPhoneFrameMode,
sendCalibration,
clearCalibration,
setRemoteTorch,
getPersistedSessionId,
clearSession,
}
}

View File

@ -118,17 +118,30 @@ export function useRemoteCameraPhone(
frameModeRef.current = frameMode
}, [frameMode])
// Initialize socket connection
// Initialize socket connection with reconnection support
useEffect(() => {
console.log('[RemoteCameraPhone] Initializing socket connection...')
const socketInstance = io({
path: '/api/socket',
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 10,
})
socketInstance.on('connect', () => {
console.log('[RemoteCameraPhone] Socket connected! ID:', socketInstance.id)
setIsSocketConnected(true)
// Auto-reconnect to session if we have one
const sessionId = sessionIdRef.current
if (sessionId) {
console.log('[RemoteCameraPhone] Auto-reconnecting to session after socket reconnect:', sessionId)
socketInstance.emit('remote-camera:join', { sessionId })
setIsConnected(true)
isConnectedRef.current = true
}
})
socketInstance.on('connect_error', (error) => {
@ -138,8 +151,12 @@ export function useRemoteCameraPhone(
socketInstance.on('disconnect', (reason) => {
console.log('[RemoteCameraPhone] Socket disconnected:', reason)
setIsSocketConnected(false)
setIsConnected(false)
isConnectedRef.current = false
// Don't clear isConnected or sessionIdRef - we want to auto-reconnect
// Only clear if server explicitly disconnected us
if (reason === 'io server disconnect') {
setIsConnected(false)
isConnectedRef.current = false
}
})
socketRef.current = socketInstance

View File

@ -0,0 +1,328 @@
/**
* @vitest-environment node
*
* Tests for Remote Camera Session Manager
*
* Tests session creation, TTL management, activity-based renewal,
* and calibration persistence.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createRemoteCameraSession,
deleteRemoteCameraSession,
getOrCreateSession,
getRemoteCameraSession,
getSessionCalibration,
getSessionCount,
markPhoneConnected,
markPhoneDisconnected,
renewSessionTTL,
setSessionCalibration,
} from '../session-manager'
describe('Remote Camera Session Manager', () => {
beforeEach(() => {
// Clear all sessions before each test
// Access the global sessions map directly
if (globalThis.__remoteCameraSessions) {
globalThis.__remoteCameraSessions.clear()
}
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('createRemoteCameraSession', () => {
it('should create a new session with unique ID', () => {
const session = createRemoteCameraSession()
expect(session.id).toBeDefined()
expect(session.id.length).toBeGreaterThan(0)
expect(session.phoneConnected).toBe(false)
})
it('should set correct timestamps on creation', () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
expect(session.createdAt.getTime()).toBe(now.getTime())
expect(session.lastActivityAt.getTime()).toBe(now.getTime())
// TTL should be 60 minutes
expect(session.expiresAt.getTime()).toBe(now.getTime() + 60 * 60 * 1000)
})
it('should create multiple sessions with unique IDs', () => {
const session1 = createRemoteCameraSession()
const session2 = createRemoteCameraSession()
expect(session1.id).not.toBe(session2.id)
expect(getSessionCount()).toBe(2)
})
})
describe('getRemoteCameraSession', () => {
it('should retrieve an existing session', () => {
const created = createRemoteCameraSession()
const retrieved = getRemoteCameraSession(created.id)
expect(retrieved).not.toBeNull()
expect(retrieved?.id).toBe(created.id)
})
it('should return null for non-existent session', () => {
const session = getRemoteCameraSession('non-existent-id')
expect(session).toBeNull()
})
it('should return null for expired session', () => {
const session = createRemoteCameraSession()
const sessionId = session.id
// Advance time past expiration (61 minutes)
vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000))
const retrieved = getRemoteCameraSession(sessionId)
expect(retrieved).toBeNull()
})
})
describe('getOrCreateSession', () => {
it('should create new session with provided ID if not exists', () => {
const customId = 'my-custom-session-id'
const session = getOrCreateSession(customId)
expect(session.id).toBe(customId)
expect(session.phoneConnected).toBe(false)
})
it('should return existing session if not expired', () => {
const customId = 'existing-session'
const original = getOrCreateSession(customId)
// Mark phone connected to verify we get same session
markPhoneConnected(customId)
const retrieved = getOrCreateSession(customId)
expect(retrieved.id).toBe(original.id)
expect(retrieved.phoneConnected).toBe(true)
})
it('should renew TTL when accessing existing session', () => {
const now = new Date()
vi.setSystemTime(now)
const customId = 'session-to-renew'
const original = getOrCreateSession(customId)
const originalExpiry = original.expiresAt.getTime()
// Advance time by 30 minutes
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
const retrieved = getOrCreateSession(customId)
// Expiry should be extended from current time
expect(retrieved.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
})
it('should create new session if existing one expired', () => {
const customId = 'expired-session'
const original = getOrCreateSession(customId)
markPhoneConnected(customId) // Mark to distinguish
// Advance time past expiration
vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000))
const newSession = getOrCreateSession(customId)
// Should be a fresh session (not phone connected)
expect(newSession.id).toBe(customId)
expect(newSession.phoneConnected).toBe(false)
})
})
describe('renewSessionTTL', () => {
it('should extend session expiration time', () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
const originalExpiry = session.expiresAt.getTime()
// Advance time by 30 minutes
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
const renewed = renewSessionTTL(session.id)
expect(renewed).toBe(true)
const updatedSession = getRemoteCameraSession(session.id)
expect(updatedSession?.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
})
it('should update lastActivityAt', () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
// Advance time
const later = new Date(now.getTime() + 10 * 60 * 1000)
vi.setSystemTime(later)
renewSessionTTL(session.id)
const updatedSession = getRemoteCameraSession(session.id)
expect(updatedSession?.lastActivityAt.getTime()).toBe(later.getTime())
})
it('should return false for non-existent session', () => {
const result = renewSessionTTL('non-existent')
expect(result).toBe(false)
})
})
describe('calibration persistence', () => {
const testCalibration = {
corners: {
topLeft: { x: 10, y: 10 },
topRight: { x: 100, y: 10 },
bottomLeft: { x: 10, y: 100 },
bottomRight: { x: 100, y: 100 },
},
}
it('should store calibration data', () => {
const session = createRemoteCameraSession()
const result = setSessionCalibration(session.id, testCalibration)
expect(result).toBe(true)
})
it('should retrieve calibration data', () => {
const session = createRemoteCameraSession()
setSessionCalibration(session.id, testCalibration)
const retrieved = getSessionCalibration(session.id)
expect(retrieved).toEqual(testCalibration)
})
it('should return null for session without calibration', () => {
const session = createRemoteCameraSession()
const calibration = getSessionCalibration(session.id)
expect(calibration).toBeNull()
})
it('should return null for non-existent session', () => {
const calibration = getSessionCalibration('non-existent')
expect(calibration).toBeNull()
})
it('should renew TTL when setting calibration', () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
const originalExpiry = session.expiresAt.getTime()
// Advance time
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
setSessionCalibration(session.id, testCalibration)
const updatedSession = getRemoteCameraSession(session.id)
expect(updatedSession?.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
})
it('should persist calibration across session retrievals', () => {
const customId = 'calibrated-session'
const session = getOrCreateSession(customId)
setSessionCalibration(session.id, testCalibration)
// Simulate reconnection by getting session again
const reconnected = getOrCreateSession(customId)
expect(reconnected.calibration).toEqual(testCalibration)
})
})
describe('phone connection state', () => {
it('should mark phone as connected', () => {
const session = createRemoteCameraSession()
const result = markPhoneConnected(session.id)
expect(result).toBe(true)
const updated = getRemoteCameraSession(session.id)
expect(updated?.phoneConnected).toBe(true)
})
it('should mark phone as disconnected', () => {
const session = createRemoteCameraSession()
markPhoneConnected(session.id)
const result = markPhoneDisconnected(session.id)
expect(result).toBe(true)
const updated = getRemoteCameraSession(session.id)
expect(updated?.phoneConnected).toBe(false)
})
it('should extend TTL when phone connects', () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
// Advance time
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
markPhoneConnected(session.id)
const updated = getRemoteCameraSession(session.id)
// Expiry should be 60 mins from now (not from creation)
expect(updated?.expiresAt.getTime()).toBeGreaterThan(now.getTime() + 60 * 60 * 1000)
})
it('should return false for non-existent session', () => {
expect(markPhoneConnected('non-existent')).toBe(false)
expect(markPhoneDisconnected('non-existent')).toBe(false)
})
})
describe('deleteRemoteCameraSession', () => {
it('should delete existing session', () => {
const session = createRemoteCameraSession()
const result = deleteRemoteCameraSession(session.id)
expect(result).toBe(true)
expect(getRemoteCameraSession(session.id)).toBeNull()
})
it('should return false for non-existent session', () => {
const result = deleteRemoteCameraSession('non-existent')
expect(result).toBe(false)
})
})
describe('session count', () => {
it('should track total sessions', () => {
expect(getSessionCount()).toBe(0)
createRemoteCameraSession()
expect(getSessionCount()).toBe(1)
createRemoteCameraSession()
expect(getSessionCount()).toBe(2)
})
})
})

View File

@ -2,7 +2,8 @@
* Remote Camera Session Manager
*
* Manages in-memory sessions for phone-to-desktop camera streaming.
* Sessions are short-lived (10 minute TTL) and stored in memory.
* Sessions have a 60-minute TTL but are renewed on activity.
* Sessions persist across page reloads via session ID stored client-side.
*/
import { createId } from '@paralleldrive/cuid2'
@ -11,7 +12,12 @@ export interface RemoteCameraSession {
id: string
createdAt: Date
expiresAt: Date
lastActivityAt: Date
phoneConnected: boolean
/** Calibration data sent from desktop (persists for reconnects) */
calibration?: {
corners: { topLeft: { x: number; y: number }; topRight: { x: number; y: number }; bottomLeft: { x: number; y: number }; bottomRight: { x: number; y: number } }
}
}
// In-memory session storage
@ -21,7 +27,7 @@ declare global {
var __remoteCameraSessions: Map<string, RemoteCameraSession> | undefined
}
const SESSION_TTL_MS = 10 * 60 * 1000 // 10 minutes
const SESSION_TTL_MS = 60 * 60 * 1000 // 60 minutes
const CLEANUP_INTERVAL_MS = 60 * 1000 // 1 minute
function getSessions(): Map<string, RemoteCameraSession> {
@ -44,6 +50,7 @@ export function createRemoteCameraSession(): RemoteCameraSession {
id: createId(),
createdAt: now,
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
lastActivityAt: now,
phoneConnected: false,
}
@ -51,6 +58,82 @@ export function createRemoteCameraSession(): RemoteCameraSession {
return session
}
/**
* Get or create a session by ID
* If the session exists and isn't expired, returns it (renewed)
* If the session doesn't exist, creates a new one with the given ID
*/
export function getOrCreateSession(sessionId: string): RemoteCameraSession {
const sessions = getSessions()
const existing = sessions.get(sessionId)
const now = new Date()
if (existing && now <= existing.expiresAt) {
// Renew TTL on access
existing.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
existing.lastActivityAt = now
return existing
}
// Create new session with provided ID
const session: RemoteCameraSession = {
id: sessionId,
createdAt: now,
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
lastActivityAt: now,
phoneConnected: false,
}
sessions.set(session.id, session)
return session
}
/**
* Renew session TTL (call on activity to keep session alive)
*/
export function renewSessionTTL(sessionId: string): boolean {
const sessions = getSessions()
const session = sessions.get(sessionId)
if (!session) return false
const now = new Date()
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
session.lastActivityAt = now
return true
}
/**
* Store calibration data in session (persists for reconnects)
*/
export function setSessionCalibration(
sessionId: string,
calibration: RemoteCameraSession['calibration']
): boolean {
const sessions = getSessions()
const session = sessions.get(sessionId)
if (!session) return false
session.calibration = calibration
// Also renew TTL
const now = new Date()
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
session.lastActivityAt = now
return true
}
/**
* Get calibration data from session
*/
export function getSessionCalibration(sessionId: string): RemoteCameraSession['calibration'] | null {
const sessions = getSessions()
const session = sessions.get(sessionId)
if (!session) return null
return session.calibration || null
}
/**
* Get a session by ID
*/

View File

@ -3,16 +3,19 @@ import '@testing-library/jest-dom'
// Mock canvas Image constructor to prevent jsdom errors when rendering
// images with data URIs (e.g., data:image/jpeg;base64,...)
// This works by patching HTMLImageElement.prototype before jsdom uses it
const originalSetAttribute = HTMLImageElement.prototype.setAttribute
HTMLImageElement.prototype.setAttribute = function (name: string, value: string) {
if (name === 'src' && value.startsWith('data:image/')) {
// Store the value but don't trigger jsdom's image loading
Object.defineProperty(this, 'src', {
value,
writable: true,
configurable: true,
})
return
// Guard for node environment where HTMLImageElement doesn't exist
if (typeof HTMLImageElement !== 'undefined') {
const originalSetAttribute = HTMLImageElement.prototype.setAttribute
HTMLImageElement.prototype.setAttribute = function (name: string, value: string) {
if (name === 'src' && value.startsWith('data:image/')) {
// Store the value but don't trigger jsdom's image loading
Object.defineProperty(this, 'src', {
value,
writable: true,
configurable: true,
})
return
}
return originalSetAttribute.call(this, name, value)
}
return originalSetAttribute.call(this, name, value)
}