SavvySolve Docs

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:

  1. PeerJS Client (lib/webrtc/screen-share.ts) - Manages WebRTC connections and media streams
  2. tRPC Signaling Router (server/routers/screen-share.ts) - Exchanges peer IDs between parties
  3. 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.

lib/webrtc/screen-share.ts
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:

StateDescription
idleInitial state, no connection attempted
initializingCreating PeerJS peer
waitingViewer ready, waiting for sharer to connect
connectingEstablishing WebRTC connection
activeScreen is being shared
endedSession completed normally
errorConnection failed

tRPC Signaling Router

WebRTC requires an out-of-band mechanism to exchange connection information between peers. Our tRPC router handles this signaling.

server/routers/screen-share.ts
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.

components/session/ScreenShareRequest.tsx
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.

components/session/ScreenViewer.tsx
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.

app/screen-share/[sessionId]/page.tsx
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.

lib/webrtc/screen-share.ts
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 ScreenViewer

Manual 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

BrowserSupportNotes
ChromeFullRecommended
FirefoxFullWorks well
EdgeFullChromium-based
SafariPartialDesktop only, some limitations
Mobile ChromeFullAndroid
Mobile SafariLimitediOS restrictions on getDisplayMedia

On this page