mod capture;
mod h264;
mod server;
mod update_checker;
mod webrtc;
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use screencapturekit::cm::CMSampleBuffer;
use screencapturekit::prelude::*;
fn lock_mutex<'a, T>(m: &'a Mutex<T>) -> std::sync::MutexGuard<'a, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn main() {
update_checker::check();
let _ = rustls::crypto::CryptoProvider::install_default(
rustls::crypto::ring::default_provider(),
);
let args: Vec<String> = std::env::args().collect();
let mut wid: u32 = 0;
let mut window_name = String::from("mac-screen-cast");
let mut max_w: u32 = 1280;
let mut fps: u32 = 30;
let mut port: u16 = 8080;
let mut json_output = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-w" | "--window-id" => {
i += 1;
wid = args[i].parse().unwrap_or(0);
}
"--width" => {
i += 1;
max_w = args[i].parse().unwrap_or(1280);
}
"--fps" => {
i += 1;
fps = args[i].parse().unwrap_or(30).clamp(1, 60);
}
"--port" => {
i += 1;
port = args[i].parse().unwrap_or(8080);
}
"--json" => json_output = true,
"-l" | "--list" => {
if json_output {
println!("{}", server::list_windows_json());
} else {
for w in server::list_windows() {
println!("{:>5} | {} | {}", w.id, w.app, w.title);
}
}
return;
}
"-h" | "--help" => {
eprintln!("mac-screen-cast — stream macOS screen to browser over LAN");
eprintln!();
eprintln!("USAGE");
eprintln!(" mac-screen-cast [FLAGS] [OPTIONS]");
eprintln!(" mac-screen-cast -l [--json]");
eprintln!(" mac-screen-cast -w <id> [--width px] [--fps N] [--port N]");
eprintln!();
eprintln!("FLAGS");
eprintln!(" -l, --list List visible windows (human-readable)");
eprintln!(" --json JSON output for --list (machine-parseable)");
eprintln!(" -h, --help Show this help");
eprintln!();
eprintln!("OPTIONS");
eprintln!(" -w, --window-id Window ID to capture [env: none]");
eprintln!(" --width Max output width in pixels [default: 1280]");
eprintln!(" --fps Target frame rate (1-60) [default: 30]");
eprintln!(" --port HTTP server port [default: 8080]");
eprintln!();
eprintln!("EXAMPLES");
eprintln!(" mac-screen-cast -l --json List windows as JSON");
eprintln!(" mac-screen-cast -w 12345 --width 720 Stream at 720p");
eprintln!(" mac-screen-cast -w 12345 --fps 60 Stream at 60 fps");
eprintln!();
eprintln!("HTTP API");
eprintln!(" GET / HTML player page");
eprintln!(" GET /offer WebRTC SDP offer");
eprintln!(" POST /signal WebRTC answer + ICE candidates (JSON body)");
eprintln!(" GET /latency Current capture-to-send latency (ms)");
return;
}
_ => {}
}
i += 1;
}
if wid == 0 {
let wins = server::list_windows();
if wins.is_empty() {
eprintln!("No windows found");
return;
}
let mut seen = std::collections::HashSet::new();
let mut uq = Vec::new();
for w in &wins {
if seen.insert((&w.app, &w.title)) {
uq.push(w);
}
}
for (j, w) in uq.iter().enumerate() {
println!(
" [{:2}] {} - {}",
j + 1,
w.app,
if w.title.len() > 55 { &w.title[..55] } else { &w.title }
);
}
print!("Select window (1-{}): ", uq.len());
std::io::stdout().flush().ok();
let mut s = String::new();
std::io::stdin().read_line(&mut s).ok();
if let Ok(n) = s.trim().parse::<usize>() {
if n >= 1 && n <= uq.len() {
wid = uq[n - 1].id;
window_name = uq[n - 1].app.clone();
}
}
if wid == 0 {
return;
}
}
unsafe { screencapturekit::ffi::sc_initialize_core_graphics() }
let (out_w, out_h) = {
let content = SCShareableContent::get().unwrap_or_else(|e| {
eprintln!("ScreenCaptureKit error: {e}");
eprintln!(
"Grant Screen Recording permission in \
System Settings > Privacy & Security > Screen Recording."
);
std::process::exit(1);
});
let windows = content.windows();
let window = windows
.iter()
.find(|w| w.window_id() == wid)
.unwrap_or_else(|| {
eprintln!("Window {wid} not found — try --list to see available windows.");
std::process::exit(1);
});
let frame = window.frame();
let nw = frame.size().width as u32;
let nh = frame.size().height as u32;
let (ow, oh) = if nw > max_w {
let rnh = (nh * max_w) / nw;
(max_w, rnh)
} else {
(nw, nh)
};
(ow & !1, oh & !1)
};
eprintln!(
" Capture {}x{} @ {}fps, output to :{}",
out_w, out_h, fps, port
);
let encoder = match h264::VtEncoder::new(out_w, out_h, fps) {
Ok(e) => Arc::new(e),
Err(e) => {
eprintln!("Encoder init failed: {e}");
return;
}
};
let (frame_tx, frame_rx) = mpsc::sync_channel::<(h264::H264Frame, Instant)>(60);
let stop = Arc::new(AtomicBool::new(false));
let ctrlc_count = Arc::new(AtomicBool::new(false));
{
let c_stop = stop.clone();
let c_count = ctrlc_count.clone();
ctrlc::set_handler(move || {
if c_count.swap(true, Ordering::Relaxed) {
eprintln!("\nForce exit");
std::process::exit(1);
}
eprintln!("\nStopping (Ctrl+C again to force)...");
c_stop.store(true, Ordering::Relaxed);
})
.ok();
}
let latest_latency = Arc::new(AtomicU64::new(0));
let webrtc_rt = Arc::new(
tokio::runtime::Runtime::new().expect("create Tokio runtime for WebRTC"),
);
let wr_handle: Arc<Mutex<Option<webrtc::WebRtcHandle>>> = Arc::new(Mutex::new(None));
let webrtc_connected = Arc::new(AtomicBool::new(false));
let srv_wr = wr_handle.clone();
let srv_rt = webrtc_rt.clone();
let srv_wr_conn = webrtc_connected.clone();
let svr_s = stop.clone();
let srv_lat = latest_latency.clone();
let ip = server::get_ip();
eprintln!(" Initializing WebRTC...");
let srv = thread::spawn(move || {
use tiny_http::{Header, Response};
let server = match tiny_http::Server::http(format!("0.0.0.0:{}", port)) {
Ok(s) => s,
Err(e) => {
eprintln!("Port {} in use: {}", port, e);
return;
}
};
eprintln!("\n WebRTC stream → http://{}:{}", ip, port);
if let Ok(qr) = qrcode::QrCode::new(format!("http://{}:{}", ip, port)) {
let w = qr.width();
let mut out = String::new();
let mut y = 0usize;
out.push_str(" \n");
while y < w {
out.push_str(" ");
for x in 0..w {
let top = qr[(x, y)] != qrcode::Color::Light;
let bot = y + 1 < w && qr[(x, y + 1)] != qrcode::Color::Light;
out.push(match (top, bot) {
(true, true) => '█',
(true, false) => '▀',
(false, true) => '▄',
(false, false) => ' ',
});
}
out.push_str(" \n");
y += 2;
}
out.push_str(" \n");
eprintln!("{}", out);
}
loop {
if svr_s.load(Ordering::Relaxed) {
break;
}
let mut req = match server.recv_timeout(Duration::from_millis(500)) {
Ok(Some(r)) => r,
_ => continue,
};
let url = req.url();
let path = url.split('?').next().unwrap_or("/");
let resp = match path {
"/" => Response::from_data(server::html(fps, &window_name).into_bytes())
.with_header(
"Content-Type: text/html; charset=utf-8"
.parse::<Header>()
.unwrap(),
),
"/offer" => {
let was_connected = srv_wr_conn.swap(false, Ordering::Relaxed);
if was_connected {
{
let guard = lock_mutex(&srv_wr);
if let Some(ref wr) = *guard {
wr.close();
}
}
if let Ok(new_handle) =
webrtc::WebRtcHandle::new(srv_rt.handle().clone(), fps, svr_s.clone())
{
*lock_mutex(&srv_wr) = Some(new_handle);
eprintln!(" WebRTC offer recreated for refresh");
} else {
eprintln!(" WebRTC recreate failed");
}
}
match lock_mutex(&srv_wr).as_ref() {
Some(wr) => Response::from_data(wr.offer.clone().into_bytes())
.with_header(
"Content-Type: application/sdp"
.parse::<Header>()
.unwrap(),
),
None => Response::from_data(Vec::from("not ready")).with_status_code(503),
}
}
"/signal" => {
let mut body = String::new();
if req.as_reader().read_to_string(&mut body).is_ok() {
if let Some(ref wr) = *lock_mutex(&srv_wr) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(sdp) = v["sdp"].as_str() {
let _ = wr.set_answer(sdp.to_string());
if let Some(cands) = v["candidates"].as_array() {
for c in cands {
let cs = c["candidate"].as_str().unwrap_or("");
if !cs.is_empty() {
let sdp_mid =
c["sdpMid"].as_str().map(|s| s.to_string());
let sdp_mline_index =
c["sdpMLineIndex"].as_u64().map(|n| n as u16);
let _ = wr.add_candidate(
cs,
sdp_mid,
sdp_mline_index,
);
}
}
}
srv_wr_conn.store(true, Ordering::Relaxed);
eprintln!(" Signal exchange complete");
}
}
}
}
Response::from_data(Vec::from("{\"status\":\"ok\"}"))
.with_header("Content-Type: application/json".parse::<Header>().unwrap())
}
"/latency" => {
let ms = srv_lat.load(Ordering::Relaxed);
Response::from_data(format!("{}", ms).into_bytes())
}
_ => Response::from_data(Vec::from("{\"error\":\"not found\"}"))
.with_status_code(404)
.with_header("Content-Type: application/json".parse::<Header>().unwrap()),
};
req.respond(resp).ok();
}
});
{
match webrtc::WebRtcHandle::new(webrtc_rt.handle().clone(), fps, stop.clone()) {
Ok(handle) => {
eprintln!(" WebRTC offer ready");
*lock_mutex(&wr_handle) = Some(handle);
}
Err(e) => eprintln!(" WebRTC init failed: {}", e),
}
}
let encoder_cb = encoder.clone();
let frame_tx_cb = frame_tx.clone();
let frame_count = Arc::new(AtomicU64::new(0));
let frame_count_cb = frame_count.clone();
let mut capture_session = match capture::CaptureSession::new(
wid,
out_w,
out_h,
fps,
move |sample: CMSampleBuffer, _type: SCStreamOutputType| {
let n = frame_count_cb.fetch_add(1, Ordering::Relaxed);
if n < 10 || n % 150 == 0 {
eprint!("\r SCFrame #{}", n);
std::io::stderr().flush().ok();
}
if let Some(pb) = sample.image_buffer() {
if let Some(surface) = pb.io_surface() {
let cap_time = Instant::now();
match encoder_cb.encode(&surface, n, fps as i32) {
Ok(frame) => {
let _ = frame_tx_cb.send((frame, cap_time));
}
Err(e) => {
eprintln!("\n Encode error: {}", e);
}
}
} else {
eprint!("\r No IOSurface for frame #{}", n);
std::io::stderr().flush().ok();
}
}
},
) {
Ok(c) => c,
Err(e) => {
eprintln!("{e}");
return;
}
};
let mut h264_frame_count: u64 = 0;
let mut last_report = Instant::now();
let mut last_frame = Instant::now();
while !stop.load(Ordering::Relaxed) {
match frame_rx.recv_timeout(Duration::from_millis(100)) {
Ok((frame, cap_time)) => {
h264_frame_count += 1;
last_frame = Instant::now();
if let Some(ref wr) = *lock_mutex(&wr_handle) {
if let Err(e) = wr.send_frame(&frame) {
eprintln!(" WebRTC send error: {}", e);
}
}
latest_latency.store(cap_time.elapsed().as_millis() as u64, Ordering::Relaxed);
if h264_frame_count % 150 == 0 && last_report.elapsed() >= Duration::from_secs(3) {
print!("\r {} frames", h264_frame_count);
std::io::stdout().flush().ok();
last_report = Instant::now();
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if last_frame.elapsed() > Duration::from_secs(3) && h264_frame_count > 0 {
eprintln!("\n No frames for 3s — encoder may have failed");
last_frame = Instant::now();
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
eprintln!(" Encoder channel disconnected");
break;
}
}
}
capture_session.stop();
srv.join().ok();
}