This commit is contained in:
2026-02-03 11:14:25 +08:00
commit 8d6a720e8d
26 changed files with 35602 additions and 0 deletions

182
examples/README.md Normal file
View File

@@ -0,0 +1,182 @@
# wl-webrtc Examples
This directory contains example client applications for connecting to wl-webrtc servers.
## client.html
A simple web-based client that demonstrates how to connect to a wl-webrtc server using WebRTC.
### Usage
1. **Start the wl-webrtc server:**
```bash
./target/release/wl-webrtc start
```
2. **Open the client in a web browser:**
- Directly open `examples/client.html` in a browser
- Or serve it with a local web server:
```bash
# Python 3
python -m http.server 8000
# Then navigate to http://localhost:8000/examples/client.html
```
3. **Connect to the server:**
- Enter the server URL (default: `ws://localhost:8443`)
- Click "Connect"
- Grant screen capture permissions when prompted by PipeWire
- The video stream will appear in the browser
### Features
- **WebRTC Connection**: Real-time video streaming via WebRTC
- **Status Monitoring**: Display connection status, bitrate, resolution, FPS, and latency
- **Fullscreen Mode**: Press `Alt+F` or click "Fullscreen" button
- **Error Handling**: Displays connection errors with helpful messages
- **Responsive Design**: Works on desktop and mobile browsers
### Configuration
The client connects to a wl-webrtc server via WebSocket. By default, it connects to:
- **Server URL**: `ws://localhost:8443`
To connect to a remote server:
- Change the server URL in the input field
- Ensure the server is accessible (firewall, NAT, etc.)
- Configure STUN/TURN servers in the wl-webrtc config if needed
### Limitations
This is a minimal example client. Production clients should include:
- Authentication and authorization
- Secure HTTPS/WSS connections
- Input event forwarding (mouse, keyboard)
- Clipboard sharing
- Audio support
- Multiple screen selection
- Connection quality indicators
- Automatic reconnection
### Browser Compatibility
Tested on modern browsers with WebRTC support:
- Chrome 88+
- Firefox 85+
- Safari 15+
- Edge 88+
### Troubleshooting
**Connection fails:**
- Verify wl-webrtc server is running
- Check server URL is correct
- Check firewall settings
- Review browser console for errors
**No video appears:**
- Grant screen capture permissions
- Check if PipeWire is running
- Verify encoder is configured correctly
- Check server logs for errors
**Poor quality:**
- Increase bitrate in server config
- Use hardware encoder (h264_vaapi or h264_nvenc)
- Check network bandwidth
- Reduce resolution or frame rate
**High latency:**
- Check network ping to server
- Use wired connection instead of WiFi
- Reduce frame rate in server config
- Use faster preset (ultrafast)
- Ensure hardware encoder is being used
## Advanced Usage
### Custom Client Implementation
To build your own client:
1. **Connect via WebSocket** to the signaling server
2. **Create WebRTC PeerConnection** with ICE servers
3. **Create Offer** and send to server via WebSocket
4. **Receive Answer** and set as remote description
5. **Exchange ICE candidates** with server
6. **Receive video track** and display in HTML video element
Example signaling flow:
```
Client Server
| |
|-------- WebSocket ----->|
| |
|------- Offer --------->|
|<------ Answer ---------|
| |
|--- ICE Candidate ----->|
|<--- ICE Candidate -----|
| |
|<---- Video Stream -----|
| |
```
### Data Channel Usage
The server supports WebRTC data channels for bi-directional messaging:
```javascript
// Client side
const dataChannel = peerConnection.createDataChannel('control');
dataChannel.onopen = () => {
console.log('Data channel opened');
};
dataChannel.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
// Send control messages
dataChannel.send(JSON.stringify({
type: 'mouse_move',
x: 100,
y: 200
}));
```
### Server-Side Events
The server sends events over the data channel:
- `connection_established`: Connection is ready
- `connection_failed`: Connection failed
- `stats_update`: Performance statistics
- `error`: Error occurred
## Security Considerations
For production use:
1. **Use HTTPS/WSS** instead of HTTP/WS
2. **Implement authentication** (tokens, certificates)
3. **Validate all input** from clients
4. **Rate limit** connections
5. **Monitor for abuse**
6. **Keep dependencies updated**
7. **Use firewall rules** to restrict access
8. **Enable TLS** for encryption
## Additional Resources
- [wl-webrtc README](../README.md) - Main project documentation
- [DESIGN_CN.md](../DESIGN_CN.md) - Technical design and architecture
- [config.toml.template](../config.toml.template) - Server configuration reference
- [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API) - Browser WebRTC documentation
- [webrtc-rs](https://webrtc.rs/) - WebRTC implementation in Rust
## Contributing
Contributions and improvements to the examples are welcome! Please follow the main project's [CONTRIBUTING.md](../CONTRIBUTING.md) guidelines.

600
examples/client.html Normal file
View File

@@ -0,0 +1,600 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>wl-webrtc - Remote Desktop Client</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #1a1a1a;
color: #fff;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #2d2d2d;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #404040;
}
.header h1 {
font-size: 1.5rem;
color: #fff;
}
.status {
display: flex;
gap: 1rem;
align-items: center;
}
.status-badge {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.disconnected {
background: #dc2626;
color: #fff;
}
.status-badge.connecting {
background: #ea580c;
color: #fff;
}
.status-badge.connected {
background: #16a34a;
color: #fff;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.video-container {
background: #0a0a0a;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
max-width: 100%;
}
#remoteVideo {
display: block;
max-width: 100%;
height: auto;
background: #000;
}
.controls {
margin-top: 2rem;
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-primary {
background: #3b82f6;
color: #fff;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-danger {
background: #dc2626;
color: #fff;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-secondary {
background: #6b7280;
color: #fff;
}
.btn-secondary:hover {
background: #4b5563;
}
.connection-info {
margin-top: 1rem;
display: flex;
gap: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.info-item {
text-align: center;
}
.info-item .label {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-item .value {
font-size: 1.125rem;
font-weight: 600;
color: #fff;
margin-top: 0.25rem;
}
.config-form {
background: #2d2d2d;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
width: 100%;
max-width: 600px;
}
.config-form h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #9ca3af;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid #404040;
border-radius: 4px;
background: #1a1a1a;
color: #fff;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
}
.hidden {
display: none !important;
}
.error-message {
background: #dc2626;
color: #fff;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.error-message.visible {
display: block;
}
</style>
</head>
<body>
<div class="header">
<h1>wl-webrtc</h1>
<div class="status">
<span id="connectionStatus" class="status-badge disconnected">Disconnected</span>
</div>
</div>
<div class="main">
<div id="errorMessage" class="error-message"></div>
<div id="configForm" class="config-form">
<h2>Connect to Server</h2>
<div class="form-group">
<label for="serverUrl">Server URL</label>
<input type="text" id="serverUrl" value="ws://localhost:8443" placeholder="ws://localhost:8443">
</div>
<div class="controls">
<button id="connectBtn" class="btn-primary">Connect</button>
</div>
</div>
<div id="videoContainer" class="video-container hidden">
<video id="remoteVideo" autoplay playsinline></video>
</div>
<div id="connectionInfo" class="connection-info hidden">
<div class="info-item">
<div class="label">Bitrate</div>
<div id="bitrate" class="value">0 Kbps</div>
</div>
<div class="info-item">
<div class="label">Resolution</div>
<div id="resolution" class="value">0x0</div>
</div>
<div class="info-item">
<div class="label">FPS</div>
<div id="fps" class="value">0</div>
</div>
<div class="info-item">
<div class="label">Latency</div>
<div id="latency" class="value">0 ms</div>
</div>
</div>
<div id="controls" class="controls hidden">
<button id="disconnectBtn" class="btn-danger">Disconnect</button>
<button id="fullscreenBtn" class="btn-secondary">Fullscreen</button>
</div>
</div>
<script>
// State
let peerConnection = null;
let dataChannel = null;
let ws = null;
let statsInterval = null;
let frameCount = 0;
let lastFrameTime = Date.now();
let rttInterval = null;
let rttSamples = [];
// DOM Elements
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const fullscreenBtn = document.getElementById('fullscreenBtn');
const serverUrlInput = document.getElementById('serverUrl');
const connectionStatus = document.getElementById('connectionStatus');
const remoteVideo = document.getElementById('remoteVideo');
const configForm = document.getElementById('configForm');
const videoContainer = document.getElementById('videoContainer');
const connectionInfo = document.getElementById('connectionInfo');
const controls = document.getElementById('controls');
const errorMessage = document.getElementById('errorMessage');
// Stats Elements
const bitrateEl = document.getElementById('bitrate');
const resolutionEl = document.getElementById('resolution');
const fpsEl = document.getElementById('fps');
const latencyEl = document.getElementById('latency');
// Configuration
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
// Update connection status
function updateStatus(status) {
connectionStatus.className = `status-badge ${status}`;
connectionStatus.textContent = status.charAt(0).toUpperCase() + status.slice(1);
}
// Show error message
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.add('visible');
setTimeout(() => {
errorMessage.classList.remove('visible');
}, 5000);
}
// Create WebRTC peer connection
function createPeerConnection() {
peerConnection = new RTCPeerConnection(config);
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendJson({
type: 'ice-candidate',
candidate: event.candidate
});
}
};
// Handle connection state changes
peerConnection.onconnectionstatechange = () => {
console.log('Connection state:', peerConnection.connectionState);
switch (peerConnection.connectionState) {
case 'connected':
updateStatus('connected');
startStatsMonitoring();
break;
case 'disconnected':
updateStatus('connecting');
break;
case 'failed':
updateStatus('disconnected');
showError('Connection failed');
cleanup();
break;
case 'closed':
updateStatus('disconnected');
cleanup();
break;
}
};
// Handle incoming tracks
peerConnection.ontrack = (event) => {
console.log('Received track:', event.track.kind);
if (event.track.kind === 'video') {
remoteVideo.srcObject = event.streams[0];
videoContainer.classList.remove('hidden');
connectionInfo.classList.remove('hidden');
controls.classList.remove('hidden');
configForm.classList.add('hidden');
}
};
// Create data channel for control messages
dataChannel = peerConnection.createDataChannel('control');
dataChannel.onopen = () => {
console.log('Data channel opened');
};
dataChannel.onmessage = (event) => {
console.log('Data channel message:', event.data);
};
return peerConnection;
}
// Send JSON over WebSocket
function sendJson(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
// Connect to server
async function connect() {
const url = serverUrlInput.value;
console.log('Connecting to:', url);
updateStatus('connecting');
connectBtn.disabled = true;
try {
// Create WebSocket connection
ws = new WebSocket(url);
ws.onopen = async () => {
console.log('WebSocket connected');
// Create peer connection
createPeerConnection();
// Create offer
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
// Send offer to server
sendJson({
type: 'offer',
sdp: offer
});
};
ws.onmessage = async (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
switch (message.type) {
case 'answer':
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
break;
case 'ice-candidate':
await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
break;
case 'error':
showError(message.error);
updateStatus('disconnected');
cleanup();
break;
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
showError('WebSocket error. Check server is running.');
updateStatus('disconnected');
connectBtn.disabled = false;
};
ws.onclose = () => {
console.log('WebSocket closed');
if (connectionStatus.classList.contains('connecting')) {
updateStatus('disconnected');
showError('Server closed the connection');
}
connectBtn.disabled = false;
};
} catch (error) {
console.error('Connection error:', error);
showError('Failed to connect: ' + error.message);
updateStatus('disconnected');
connectBtn.disabled = false;
}
}
// Disconnect from server
function disconnect() {
console.log('Disconnecting...');
cleanup();
configForm.classList.remove('hidden');
videoContainer.classList.add('hidden');
connectionInfo.classList.add('hidden');
controls.classList.add('hidden');
remoteVideo.srcObject = null;
connectBtn.disabled = false;
}
// Cleanup resources
function cleanup() {
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
if (rttInterval) {
clearInterval(rttInterval);
rttInterval = null;
}
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
if (ws) {
ws.close();
ws = null;
}
rttSamples = [];
}
// Start statistics monitoring
function startStatsMonitoring() {
statsInterval = setInterval(async () => {
if (!peerConnection || peerConnection.connectionState !== 'connected') {
return;
}
const stats = await peerConnection.getStats();
let bitrate = 0;
let width = 0;
let height = 0;
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
// Calculate bitrate
if (report.bytesReceived && report.bytesReceivedLast) {
const bytesPerSec = report.bytesReceived - report.bytesReceivedLast;
bitrate = (bytesPerSec * 8) / 1000; // kbps
}
report.bytesReceivedLast = report.bytesReceived;
// Resolution
width = report.frameWidth || 0;
height = report.frameHeight || 0;
// FPS
if (report.framesReceived && report.framesReceivedLast) {
const framesPerSec = report.framesReceived - report.framesReceivedLast;
fpsEl.textContent = framesPerSec.toFixed(1);
}
report.framesReceivedLast = report.framesReceived;
}
if (report.type === 'remote-candidate' && report.currentRoundTripTime) {
rttSamples.push(report.currentRoundTripTime * 1000); // Convert to ms
if (rttSamples.length > 10) rttSamples.shift();
const avgRtt = rttSamples.reduce((a, b) => a + b, 0) / rttSamples.length;
latencyEl.textContent = avgRtt.toFixed(0) + ' ms';
}
});
bitrateEl.textContent = bitrate.toFixed(0) + ' Kbps';
resolutionEl.textContent = `${width}x${height}`;
}, 1000);
}
// Toggle fullscreen
function toggleFullscreen() {
if (!document.fullscreenElement) {
remoteVideo.requestFullscreen().catch(err => {
showError(`Fullscreen error: ${err.message}`);
});
} else {
document.exitFullscreen();
}
}
// Event listeners
connectBtn.addEventListener('click', connect);
disconnectBtn.addEventListener('click', disconnect);
fullscreenBtn.addEventListener('click', toggleFullscreen);
// Handle window close
window.addEventListener('beforeunload', () => {
cleanup();
});
// Keyboard shortcuts
document.addEventListener('keydown', (event) => {
if (event.key === 'f' && event.altKey) {
toggleFullscreen();
}
if (event.key === 'Escape' && document.fullscreenElement) {
document.exitFullscreen();
}
});
// Update video stats on frame
remoteVideo.addEventListener('play', () => {
frameCount = 0;
lastFrameTime = Date.now();
});
remoteVideo.addEventListener('timeupdate', () => {
frameCount++;
});
</script>
</body>
</html>