Add local screenshare app

Signed-off-by: Marcel Müller <neikos@neikos.email>
This commit is contained in:
Marcel Müller 2026-02-04 12:18:48 +01:00
parent 527ed85dff
commit c23d7d06e0
8 changed files with 3552 additions and 0 deletions

1
lanshare/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

1857
lanshare/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
lanshare/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "lanshare"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures = "0.3"
dashmap = "6"
clap = { version = "4", features = ["derive"] }
local-ip-address = "0.6"

149
lanshare/README.md Normal file
View file

@ -0,0 +1,149 @@
# LAN Share
A peer-to-peer screen sharing application for local networks. No accounts, no cloud services — just open a browser and share.
## Features
- **WebRTC P2P**: Direct peer-to-peer connections, video never touches the server
- **Audio support**: Optionally share system audio with your screen
- **Multiple streams**: View multiple shared screens, select which one to focus on
- **Zero config**: Just run the server and open the URL in any browser
- **Keyboard shortcuts**: `F` for fullscreen, `M` for mute, `Escape` to expand sidebar
## Building
```bash
cargo build --release
```
The binary will be at `target/release/lanshare`.
## Running
```bash
./target/release/lanshare
```
Or with cargo:
```bash
cargo run --release
```
The server starts on `http://0.0.0.0:8080` by default.
### CLI Options
```
Usage: lanshare [OPTIONS]
Options:
-H, --host <IP> IP address to bind to [default: 0.0.0.0]
-p, --port <PORT> Port to listen on [default: 8080]
-v, --verbose Enable verbose/debug logging
-q, --quiet Quiet mode - only show errors
-h, --help Print help
-V, --version Print version
```
Examples:
```bash
# Listen on a specific interface
lanshare --host 192.168.1.100 --port 3000
# Localhost only (for testing)
lanshare -H 127.0.0.1
# Quiet mode
lanshare -q
```
### CLI Options
```
LAN Share - Peer-to-peer screen sharing for local networks
Usage: lanshare [OPTIONS]
Options:
-H, --host <HOST> Host address to bind to [default: 0.0.0.0]
-p, --port <PORT> Port to listen on [default: 8080]
-v, --verbose Enable verbose logging
-h, --help Print help
-V, --version Print version
```
### Examples
```bash
# Run on default settings (0.0.0.0:8080)
lanshare
# Run on a specific port
lanshare -p 3000
# Bind to localhost only
lanshare -H 127.0.0.1 -p 8080
# Run with verbose logging
lanshare -v
```
## Usage
1. Start the server on any machine in your LAN
2. Open `http://<server-ip>:8080` in a browser on each device
3. Enter your name when prompted
4. Click "Start Sharing" to share your screen
5. Select shared screens from the sidebar to view them
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Signaling Server │
│ (Rust/Axum/WebSocket) │
│ - Peer discovery │
│ - Relays SDP offers/answers │
│ - Relays ICE candidates │
└─────────────────────────────────────────────────────────────┘
│ │
│ WebSocket │ WebSocket
│ (signaling only) │ (signaling only)
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Peer A │◄────────────────────►│ Peer B │
│ (Browser) │ WebRTC P2P │ (Browser) │
└─────────────┘ (video/audio) └─────────────┘
```
The server only handles signaling — the actual video/audio streams flow directly between browsers via WebRTC.
## Browser Support
Tested on:
- Chrome/Chromium
- Firefox
- Edge
- Safari (macOS)
Note: Screen sharing with audio requires Chrome/Edge on Windows/macOS. Firefox and Linux may have limited audio capture support depending on the system.
## Configuration
Command-line options take precedence over defaults. See `lanshare --help` for all options.
Environment variables:
- `RUST_LOG`: Override log level (e.g., `RUST_LOG=lanshare=debug`)
## Security Notes
- This is designed for **trusted local networks only**
- No authentication — anyone with the URL can join
- No encryption beyond what WebRTC provides by default (DTLS-SRTP)
- Don't expose to the internet without additional security measures
## License
MIT

21
lanshare/lanshare.crt Normal file
View file

@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUQ9Xl2Hpk/6TLpoSPt9ZoOfJY7JwwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCRlIxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMDQxMTE2MzhaFw0yNjAy
MTQxMTE2MzhaMEUxCzAJBgNVBAYTAkZSMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCkgmUClyKPdgGGVeDjEAxBKxoB0foStA95lxh8pt7U
8k2hSbWc0kOlNqt0BgQjGPfQ7LPCc1hQwOv/7jz3zcC9xTi8uKRN5Q7sCqSFdQsa
jxbriWVXjDdCt+6L5ZKCQLQ3THZ1nIB6uq8j6Mal2MMwQjs5nhGYFRpfvMVcs4MR
KNHxe6rAWoUk5RYmefAdJjCLafruqO39HMgcGorPqkly12/v+d5G46gsgvkiXHCU
7qYa8MKC9YB4Oli4tp+2EFAna90ZEYXqImeBMHJnirNpsh0dgaqgBvfZif+B0FDe
anW6cSOcuWsX6zcP37DV3R/p7SDLY5GymMFE8m7HQ1+/AgMBAAGjUzBRMB0GA1Ud
DgQWBBRRv2p+x2aP66r8Mwh0syVEaB4MMzAfBgNVHSMEGDAWgBRRv2p+x2aP66r8
Mwh0syVEaB4MMzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAP
FYKgAEZtcHGZcefsQ5Vm3uYgMESe6wBdK6vE/fbbxIn9tzD05Yy7ZBJ0zQjhhYO0
WqRH21gWqQZn6x6sF+wjgIgqoKAOOZbZuQ1xHJtxXysbGioPKcg79+TH9Rgf64yG
SGPFsmy2rJx77FtQV6tUnusRMaz644zWuK6/rd580bpffdsLcY+3q6I8GvYmEDEh
zKb3tDEgwRDOs+gYm0vZLg7b9sy7O1Dd/FGZkqtMdkOGqbrz+5GpUViBknMZTlFb
PE/mEJmCxBZ0bpuSN+bJvIC3yFyQSbt/4xZs5p8qBr8bO7KD0c+wCMsqEGnmF2QI
ggMhQ8WnrzETpxZvFwXu
-----END CERTIFICATE-----

28
lanshare/lanshare.key Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCkgmUClyKPdgGG
VeDjEAxBKxoB0foStA95lxh8pt7U8k2hSbWc0kOlNqt0BgQjGPfQ7LPCc1hQwOv/
7jz3zcC9xTi8uKRN5Q7sCqSFdQsajxbriWVXjDdCt+6L5ZKCQLQ3THZ1nIB6uq8j
6Mal2MMwQjs5nhGYFRpfvMVcs4MRKNHxe6rAWoUk5RYmefAdJjCLafruqO39HMgc
GorPqkly12/v+d5G46gsgvkiXHCU7qYa8MKC9YB4Oli4tp+2EFAna90ZEYXqImeB
MHJnirNpsh0dgaqgBvfZif+B0FDeanW6cSOcuWsX6zcP37DV3R/p7SDLY5GymMFE
8m7HQ1+/AgMBAAECggEALX/bDCJc7qzGsy7haiuwF/4hzUsUDUQ723dM0H0euGrj
ya4nSt5k0zcRqJ9ZWZO4RtKQzUE1tfAF1d1Ag5Ems9XuYVP4LYsi22n+IuNCVPAq
eK6hltszFYLluU+fe+MFdR3yzYihiFBFzHq/JnOTWK+YzdDIMPX1O3FsbL1BjOb5
1I8gmaCEKk4HfVDHQNqE7bfIrLmTt1RsM6jzeOaVKGWzAmpVsg5fLRVQt3zIRwB1
sCm9UKKMN7IDNq/f3gjPGcOHr6ONPOYwK/NqqFqoXlYSOJLod+HRUMsim4+sMQ41
t8o4tffztU8zStoI0zBO/4UVBKdDeCceNhXweGOXMQKBgQDOmSNoxpexlaKAjyeA
9krXosM0MxasPkGWNWYa5zfFLZeVgfQyivhfA/7Is4ATmaI3owFv3XhvbvogkwGF
FaReR3b5Rm34+4eM4mH3Ot83WWroDGM9uZF5L5R3aJPtVZRFerKgn3b1zndneqS1
rvFMy+o9d3586NVgSkgZ/Gx31QKBgQDL2MwcAFrhpKPectoJ5Hrxv5lTR/tw/U7J
gAo8E84l8N1H0+OajfXyFNJLUAW/eyH+xa2LKa9j4Vz9aZjgsaSByKTnKuuuJNGG
Rg+qfbDtEI3t4EOrCCc8BXGgv4U2W5RrUBmScARDcSBOGWgCyiSZEpk1nTgnL1u8
ibucQHx3QwKBgQC/kU49HFCSkT6SWKt3sGjrlHfO0kSGyF+GidM4xQd4pXL2Zf7q
UuRFLm406gSrp/y7/EEb2k+PfGcgh0+UeOHlrfyK3hyhD5K8NzpBxewu5ZH0w2/O
T2Ct70mKg4UPQBhxaHlz6QmkmaMsZ5ONCD+lRzvXbRLzfe5FD/vVZLOrVQKBgQCi
N5dOHTY1ZGiHaEx9HNZ9tSRVsu20X9An5/29C2G8ra3aMBNq9den2svy5O5+D4Xh
EfxlxzlsuXXfr/3ZqWQpZ7tavrwoq+IVAYIMAdQfA1J+3z3aSDW4vPhMnLxsono8
39RJxVyPMuIrZGpx9d0j9zn3AXMjM7vEELM8x9CEswKBgDQJcTnQs+zJoLWHmxSl
qe7Zc3P3FmwT5jU2Yr/4ZfSSAIJezqI3PnQOsmx7pvo1DdI9nEZVF/s1U6eC9UwH
uEcuyyNwdydycejehQi6TwRU+XB6ZrHScDhkAYGvjGvv4O48D/lylqK2aYqm9h4m
sJLbJJfG4l1DQYsCs/S8qBDA
-----END PRIVATE KEY-----

406
lanshare/src/main.rs Normal file
View file

@ -0,0 +1,406 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::{Html, IntoResponse},
routing::get,
Router,
};
use clap::Parser;
use dashmap::DashMap;
use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use tokio::sync::broadcast;
use tower_http::cors::CorsLayer;
use tracing::{info, warn};
use uuid::Uuid;
use axum_server::tls_rustls::RustlsConfig;
/// LAN Share - Peer-to-peer screen sharing for local networks
///
/// A lightweight WebRTC-based screen sharing application that runs entirely
/// on your local network. No accounts, no cloud services — just open a
/// browser and share.
///
/// The server only handles signaling (peer discovery, connection setup).
/// Video/audio streams flow directly between browsers via WebRTC P2P.
#[derive(Parser, Debug)]
#[command(name = "lanshare")]
#[command(version, about, long_about = None)]
struct Args {
/// IP address to bind to
///
/// Use 0.0.0.0 to listen on all network interfaces (default),
/// 127.0.0.1 for localhost only, or a specific IP address.
#[arg(short = 'H', long, default_value = "0.0.0.0", value_name = "IP")]
host: String,
/// Port to listen on
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
port: u16,
/// Enable verbose/debug logging
#[arg(short, long)]
verbose: bool,
/// Quiet mode - only show errors
#[arg(short, long, conflicts_with = "verbose")]
quiet: bool,
#[arg(short, long)]
certificate: PathBuf,
#[arg(short, long)]
key: PathBuf,
}
/// Messages sent between peers via the signaling server
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum SignalMessage {
/// Server assigns an ID to a new peer
Welcome {
peer_id: String,
peers: Vec<PeerInfo>,
},
/// Peer announces themselves (with optional name)
Announce {
name: String,
},
/// Broadcast: a peer joined
PeerJoined {
peer_id: String,
name: String,
},
/// Broadcast: a peer left
PeerLeft {
peer_id: String,
},
/// Peer started sharing their screen
StartedSharing {
peer_id: String,
has_audio: bool,
},
/// Peer stopped sharing
StoppedSharing {
peer_id: String,
},
/// WebRTC offer (peer-to-peer, relayed by server)
Offer {
from: String,
to: String,
sdp: String,
},
/// WebRTC answer
Answer {
from: String,
to: String,
sdp: String,
},
/// ICE candidate
IceCandidate {
from: String,
to: String,
candidate: String,
},
/// Request to connect to a sharing peer
RequestStream {
from: String,
to: String,
},
/// Error message
Error {
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PeerInfo {
peer_id: String,
name: String,
is_sharing: bool,
has_audio: bool,
}
/// State for a connected peer
struct PeerState {
name: String,
is_sharing: bool,
has_audio: bool,
tx: broadcast::Sender<SignalMessage>,
}
/// Application state shared across all connections
struct AppState {
peers: DashMap<String, PeerState>,
}
impl AppState {
fn new() -> Self {
Self {
peers: DashMap::new(),
}
}
fn get_peer_list(&self) -> Vec<PeerInfo> {
self.peers
.iter()
.map(|entry| PeerInfo {
peer_id: entry.key().clone(),
name: entry.value().name.clone(),
is_sharing: entry.value().is_sharing,
has_audio: entry.value().has_audio,
})
.collect()
}
fn broadcast(&self, msg: SignalMessage, exclude: Option<&str>) {
for entry in self.peers.iter() {
if exclude.map_or(true, |ex| entry.key() != ex) {
let _ = entry.value().tx.send(msg.clone());
}
}
}
fn send_to(&self, peer_id: &str, msg: SignalMessage) {
if let Some(peer) = self.peers.get(peer_id) {
let _ = peer.tx.send(msg);
}
}
}
#[tokio::main]
async fn main() {
let args = Args::parse();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
)
.init();
let state = Arc::new(AppState::new());
let app = Router::new()
.route("/", get(serve_index))
.route("/ws", get(ws_handler))
.layer(CorsLayer::permissive())
.with_state(state);
let addr: SocketAddr = format!("{}:{}", args.host, args.port)
.parse()
.expect("Invalid host:port combination");
println!();
println!(" ╭─────────────────────────────────────────╮");
println!("\x1b[1;33mLAN Share\x1b[0m │");
println!(" │ P2P Screen Sharing for Local Networks │");
println!(" ╰─────────────────────────────────────────╯");
println!();
println!(" \x1b[1mServer running at:\x1b[0m");
println!(" → http://{}", addr);
if args.host == "0.0.0.0" {
if let Ok(local_ip) = local_ip_address::local_ip() {
println!(" → http://{}:{}", local_ip, args.port);
}
}
println!();
println!(" \x1b[90mOpen this URL in browsers on your local network.\x1b[0m");
println!(" \x1b[90mPress Ctrl+C to stop the server.\x1b[0m");
println!();
info!("Server starting on {}", addr);
let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap();
axum_server::bind_rustls(addr, config).serve(app.into_make_service()).await.unwrap();
}
async fn serve_index() -> impl IntoResponse {
Html(include_str!("../static/index.html"))
}
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
let peer_id = Uuid::new_v4().to_string();
let (tx, _) = broadcast::channel::<SignalMessage>(64);
// Add peer to state with default name
state.peers.insert(
peer_id.clone(),
PeerState {
name: format!("Peer {}", &peer_id[..8]),
is_sharing: false,
has_audio: false,
tx: tx.clone(),
},
);
let peers = state.get_peer_list();
info!("Peer {} connected ({} total peers)", &peer_id[..8], peers.len());
let (mut sender, mut receiver) = socket.split();
// Send welcome message with current peer list
let welcome = SignalMessage::Welcome {
peer_id: peer_id.clone(),
peers: peers.into_iter().filter(|p| p.peer_id != peer_id).collect(),
};
if sender
.send(Message::Text(serde_json::to_string(&welcome).unwrap().into()))
.await
.is_err()
{
state.peers.remove(&peer_id);
return;
}
// Subscribe to broadcast messages for this peer
let mut rx = tx.subscribe();
let peer_id_clone = peer_id.clone();
// Task to forward broadcast messages to this peer's websocket
let mut send_task = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await {
let text = serde_json::to_string(&msg).unwrap();
if sender.send(Message::Text(text.into())).await.is_err() {
break;
}
}
});
// Task to handle incoming messages from this peer
let state_clone = state.clone();
let peer_id_for_recv = peer_id.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
if let Message::Text(text) = msg {
match serde_json::from_str::<SignalMessage>(&text) {
Ok(signal) => {
handle_signal(&state_clone, &peer_id_for_recv, signal).await;
}
Err(e) => {
warn!("Failed to parse message from {}: {}", &peer_id_for_recv[..8], e);
}
}
}
}
});
// Wait for either task to complete (connection closed)
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
}
// Clean up: remove peer and notify others
if let Some((_, peer_state)) = state.peers.remove(&peer_id_clone) {
info!(
"Peer {} ({}) disconnected",
&peer_id_clone[..8],
peer_state.name
);
state.broadcast(
SignalMessage::PeerLeft {
peer_id: peer_id_clone,
},
None,
);
}
}
async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) {
match signal {
SignalMessage::Announce { name } => {
if let Some(mut peer) = state.peers.get_mut(from_peer) {
peer.name = name.clone();
}
state.broadcast(
SignalMessage::PeerJoined {
peer_id: from_peer.to_string(),
name,
},
Some(from_peer),
);
}
SignalMessage::StartedSharing { has_audio, .. } => {
if let Some(mut peer) = state.peers.get_mut(from_peer) {
peer.is_sharing = true;
peer.has_audio = has_audio;
}
state.broadcast(
SignalMessage::StartedSharing {
peer_id: from_peer.to_string(),
has_audio,
},
Some(from_peer),
);
}
SignalMessage::StoppedSharing { .. } => {
if let Some(mut peer) = state.peers.get_mut(from_peer) {
peer.is_sharing = false;
peer.has_audio = false;
}
state.broadcast(
SignalMessage::StoppedSharing {
peer_id: from_peer.to_string(),
},
Some(from_peer),
);
}
SignalMessage::Offer { to, sdp, .. } => {
state.send_to(
&to,
SignalMessage::Offer {
from: from_peer.to_string(),
to: to.clone(),
sdp,
},
);
}
SignalMessage::Answer { to, sdp, .. } => {
state.send_to(
&to,
SignalMessage::Answer {
from: from_peer.to_string(),
to: to.clone(),
sdp,
},
);
}
SignalMessage::IceCandidate { to, candidate, .. } => {
state.send_to(
&to,
SignalMessage::IceCandidate {
from: from_peer.to_string(),
to: to.clone(),
candidate,
},
);
}
SignalMessage::RequestStream { to, .. } => {
state.send_to(
&to,
SignalMessage::RequestStream {
from: from_peer.to_string(),
to: to.clone(),
},
);
}
_ => {}
}
}

1071
lanshare/static/index.html Normal file

File diff suppressed because it is too large Load diff