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

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(),
},
);
}
_ => {}
}
}