Screen Sharing
WebRTC-based screen sharing between customers and solvers using PeerJS
Screen Sharing
Screen sharing enables solvers to see exactly what a customer is looking at on their device. This is essential for tech support scenarios where describing a problem over chat or phone isn't enough. Rather than asking "what do you see on your screen?", solvers can view the customer's screen directly and guide them through solutions.
The implementation uses WebRTC for peer-to-peer video streaming with PeerJS handling the signaling complexity. This means streams flow directly between the customer's browser and the solver's browser without passing through our servers, ensuring low latency and privacy.
Architecture Overview
Screen sharing involves three main components working together:
- PeerJS Client (
lib/webrtc/screen-share.ts) - Manages WebRTC connections and media streams - tRPC Signaling Router (
server/routers/screen-share.ts) - Exchanges peer IDs between parties - UI Components - Solver-side request/viewer and customer-side sharing page
The flow works like this: when a solver requests screen sharing, we generate a unique peer ID and store it in the database. The customer opens a link, retrieves the solver's peer ID, and establishes a direct WebRTC connection. Once connected, the customer's screen streams directly to the solver.
PeerJS Client
The ScreenShareClient class wraps PeerJS to provide a clean interface for both roles in the screen sharing session.
export class ScreenShareClient {
private peer: Peer | null = null;
private mediaConnection: MediaConnection | null = null;
private state: ScreenShareState = "idle";
private reconnectAttempts: number = 0;
constructor(options: ScreenShareClientOptions) {
this.sessionId = options.sessionId;
this.onEvent = options.onEvent;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 3;
}
// Solver initializes as viewer, waiting for incoming stream
async initializeAsViewer(): Promise<string> {
this.setState("initializing");
const peerId = generatePeerId(this.sessionId);
this.peer = new Peer(peerId);
this.peer.on("call", (call) => {
call.answer(); // Accept incoming stream
call.on("stream", (remoteStream) => {
this.onEvent({ type: "stream_received", stream: remoteStream });
});
});
return peerId;
}
// Customer initializes as sharer, sending their screen
async initializeAsSharer(viewerPeerId: string): Promise<void> {
this.localStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
this.peer = new Peer(generatePeerId(this.sessionId));
this.mediaConnection = this.peer.call(viewerPeerId, this.localStream);
}
}The client tracks connection state through a finite state machine with these states:
| State | Description |
|---|---|
idle | Initial state, no connection attempted |
initializing | Creating PeerJS peer |
waiting | Viewer ready, waiting for sharer to connect |
connecting | Establishing WebRTC connection |
active | Screen is being shared |
ended | Session completed normally |
error | Connection failed |
tRPC Signaling Router
WebRTC requires an out-of-band mechanism to exchange connection information between peers. Our tRPC router handles this signaling.
export const screenShareRouter = router({
// Solver requests screen sharing - stores their peer ID
requestScreenShare: protectedProcedure
.input(z.object({
sessionId: z.string().uuid(),
solverPeerId: z.string().min(1).max(255),
}))
.mutation(async ({ input, ctx }) => {
const screenShareId = crypto.randomUUID();
screenShareSessions.set(input.sessionId, {
id: screenShareId,
peerId: input.solverPeerId,
status: "waiting",
createdAt: new Date(),
});
return { screenShareId, shareLink: `/screen-share/${input.sessionId}` };
}),
// Customer retrieves solver's peer ID to connect
getScreenShare: publicProcedure
.input(z.object({ sessionId: z.string().uuid() }))
.query(async ({ input }) => {
return screenShareSessions.get(input.sessionId) ?? null;
}),
// Update status as connection progresses
updateStatus: publicProcedure
.input(z.object({
screenShareId: z.string().uuid(),
status: z.enum(["connecting", "active", "ended", "failed"]),
}))
.mutation(async ({ input }) => {
// Update session status and timestamps
}),
});The signaling data is stored in memory for the MVP. In production, this would use Redis or the database with TTL-based cleanup for abandoned sessions.
Solver Components
ScreenShareRequest
The solver uses this component to initiate screen sharing. It creates the peer connection, registers with the backend, and provides a shareable link.
export function ScreenShareRequest({
sessionId,
customerName,
onStreamReceived,
}: ScreenShareRequestProps) {
const [state, setState] = useState<ScreenShareState>("idle");
const [shareLink, setShareLink] = useState<string | null>(null);
const clientRef = useRef<ScreenShareClient | null>(null);
const handleRequestShare = async () => {
const client = new ScreenShareClient({
sessionId,
onEvent: handleEvent,
});
const viewerPeerId = await client.initializeAsViewer();
await requestScreenShare.mutateAsync({ sessionId, solverPeerId: viewerPeerId });
setShareLink(`${window.location.origin}/screen-share/${sessionId}`);
};
return (
<div>
<StatusBadge state={state} />
{canRequest && (
<Button onClick={handleRequestShare}>
Request Screen Share
</Button>
)}
{shareLink && (
<div>
<span>{shareLink}</span>
<Button onClick={handleCopyLink}>Copy</Button>
</div>
)}
</div>
);
}ScreenViewer
Once connected, this component displays the incoming video stream with controls for fullscreen and ending the session.
export function ScreenViewer({ stream, onEnd }: ScreenViewerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = stream;
}
}, [stream]);
return (
<div>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full rounded-lg"
/>
<div className="flex gap-2">
<Button onClick={toggleFullscreen}>
{isFullscreen ? <Minimize2 /> : <Maximize2 />}
</Button>
<Button onClick={onEnd} variant="destructive">
End Share
</Button>
</div>
</div>
);
}Customer Screen Share Page
Customers access screen sharing via /screen-share/[sessionId]. The page is designed with seniors in mind: large touch targets, clear instructions, and reassuring feedback about privacy.
export default function CustomerScreenSharePage() {
const { sessionId } = useParams();
const [state, setState] = useState<ScreenShareState>("idle");
// Fetch solver's peer ID
const { data: screenShare } = trpc.screenShare.getScreenShare.useQuery(
{ sessionId },
{ refetchInterval: state === "idle" ? 5000 : false }
);
const startSharing = async () => {
const client = new ScreenShareClient({ sessionId, onEvent: handleEvent });
await client.initializeAsSharer(screenShare.peerId);
};
return (
<div className="flex flex-col items-center p-6">
<Monitor className="h-24 w-24" />
<h1>Share Your Screen</h1>
<div className="text-muted-foreground">
<ShieldCheck /> Your solver can only see what you choose to share.
</div>
<Button onClick={startSharing} className="py-6 text-lg">
Start Sharing My Screen
</Button>
</div>
);
}The page polls for the solver's peer ID until it's available, then shows a prominent "Start Sharing" button. When clicked, the browser's native screen picker appears, letting the customer choose what to share.
Error Handling
Network issues and permission denials are common with WebRTC. The client includes robust error handling with user-friendly messages.
const ERROR_MESSAGES: Record<string, string> = {
NotAllowedError: "Screen share permission was denied. Please allow access in your browser settings.",
NotFoundError: "No screen or window is available to share. Please try again.",
NotSupportedError: "Your browser doesn't support screen sharing. Please use Chrome, Firefox, or Edge.",
"peer-unavailable": "The solver's connection is unavailable. Please wait and try again.",
network: "Network connection issue. Please check your internet connection and try again.",
};
// Determine if error can be recovered via reconnection
isRecoverableError(error: Error): boolean {
const RECOVERABLE = ["network", "disconnected", "socket-closed"];
return RECOVERABLE.some(e => error.message.includes(e));
}
// Automatic reconnection with exponential backoff
attemptReconnect(): void {
this.reconnectAttempts++;
if (this.reconnectAttempts > this.maxReconnectAttempts) {
this.onEvent({ type: "reconnect_failed" });
return;
}
this.onEvent({
type: "reconnecting",
attempt: this.reconnectAttempts,
maxAttempts: this.maxReconnectAttempts
});
this.peer?.reconnect();
}Safari has limited WebRTC support. The checkScreenShareSupport() function detects browser compatibility and shows appropriate messaging when screen sharing isn't available.
Testing Strategy
The screen sharing feature is tested at multiple levels:
Unit tests (lib/webrtc/screen-share.test.ts) cover:
- Peer ID generation
- State machine transitions
- Error message mapping
- Reconnection logic
Router tests (server/routers/screen-share.test.ts) cover:
- Creating screen share sessions
- Retrieving peer IDs
- Status updates
Component tests cover:
- ScreenShareRequest rendering and interactions
- ScreenViewer video element binding
# Run all screen share tests
bun run test screen-share ScreenShareRequest ScreenViewerManual testing is essential for WebRTC features. Test across:
- Chrome, Firefox, Edge (full support)
- Safari (limited support)
- Mobile browsers
- Different network conditions
Integration Points
Screen sharing integrates with other SavvySolve features:
- Sessions - Screen share links are scoped to session IDs
- Chat - System messages announce when sharing starts/ends
- Real-time - Status updates could flow through Ably for instant UI updates
Browser Compatibility
| Browser | Support | Notes |
|---|---|---|
| Chrome | Full | Recommended |
| Firefox | Full | Works well |
| Edge | Full | Chromium-based |
| Safari | Partial | Desktop only, some limitations |
| Mobile Chrome | Full | Android |
| Mobile Safari | Limited | iOS restrictions on getDisplayMedia |
Related Documentation
- Session Interface - Where screen sharing is initiated
- Ably Integration - Real-time status updates
- Customer Session - Customer-side experience