Skip to content

Instantly share code, notes, and snippets.

@mxvsh
Last active August 14, 2025 20:27
Show Gist options
  • Select an option

  • Save mxvsh/aef0c3bf1f72a96455b5a6161bb3efbe to your computer and use it in GitHub Desktop.

Select an option

Save mxvsh/aef0c3bf1f72a96455b5a6161bb3efbe to your computer and use it in GitHub Desktop.
macOS WebRTC Screen Stream
import SwiftUI
import Cocoa
import AVFoundation
import CoreGraphics
import CoreVideo
import Swifter
import WebRTC
import ScreenCaptureKit
// MARK: - App Delegate for Clean Shutdown
final class AppTermDelegate: NSObject, NSApplicationDelegate {
static weak var manager: StreamManager?
func applicationWillTerminate(_ notification: Notification) {
Task { await AppTermDelegate.manager?.stopStreaming() }
}
}
// MARK: - SwiftUI App Structure
@main
struct JammerApp: App {
@NSApplicationDelegateAdaptor(AppTermDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
.windowResizability(.contentSize)
}
}
// MARK: - SwiftUI ContentView
struct ContentView: View {
@StateObject private var streamManager = StreamManager()
var body: some View {
VStack(spacing: 20) {
Text("WebRTC Screen Stream")
.font(.title2)
.fontWeight(.medium)
HStack {
Button(streamManager.isStreaming ? "Stop Streaming" : "Start Streaming") {
Task {
if streamManager.isStreaming {
await streamManager.stopStreaming()
} else {
await streamManager.startStreaming()
}
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Text(streamManager.status)
.foregroundColor(.secondary)
.font(.caption)
}
if streamManager.isStreaming && !streamManager.serverURL.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Open in browser:")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Text(streamManager.serverURL)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(streamManager.serverURL, forType: .string)
}
.buttonStyle(.borderless)
.controlSize(.small)
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
}
.padding(30)
.frame(width: 460, height: 220)
.onAppear {
AppTermDelegate.manager = streamManager
}
}
}
// MARK: - Stream Manager (Observable Object)
@MainActor
class StreamManager: ObservableObject {
@Published var isStreaming = false
@Published var status = "Idle"
@Published var serverURL = ""
// HTTP
private let server = HttpServer()
private let httpPort: in_port_t = 8080
// WebRTC
private var factory: RTCPeerConnectionFactory!
private var pc: RTCPeerConnection?
private var videoSource: RTCVideoSource!
private var videoTrack: RTCVideoTrack!
private var dummyCapturer: RTCCameraVideoCapturer!
// Capture
private var screenCapturer: ScreenCapturer?
init() {
setupWebRTCFactory()
}
// MARK: - Public Methods
func startStreaming() async {
guard !isStreaming else { return }
do {
pc = makePeerConnection()
setupHTTPServer()
try await startCapture()
isStreaming = true
status = "Capturing…"
serverURL = "http://localhost:\(httpPort)/"
} catch {
status = "Capture failed: \(error.localizedDescription)"
}
}
func stopStreaming() async {
guard isStreaming else { return }
stopCapture()
pc?.close()
pc = nil
// Stop and restart server to clear connections
server.stop()
setupHTTPServer()
isStreaming = false
status = "Stopped"
serverURL = ""
}
// MARK: - WebRTC Setup
private func setupWebRTCFactory() {
RTCInitializeSSL()
let encoder = RTCDefaultVideoEncoderFactory()
let decoder = RTCDefaultVideoDecoderFactory()
factory = RTCPeerConnectionFactory(encoderFactory: encoder, decoderFactory: decoder)
}
private func makePeerConnection() -> RTCPeerConnection {
let config = RTCConfiguration()
config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
config.sdpSemantics = .unifiedPlan
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
let pc = factory.peerConnection(with: config, constraints: constraints, delegate: nil)!
// Create video track
videoSource = factory.videoSource()
videoTrack = factory.videoTrack(with: videoSource, trackId: "screen0")
dummyCapturer = RTCCameraVideoCapturer(delegate: videoSource)
// Attach the track to the PC (Unified Plan)
pc.add(videoTrack, streamIds: ["screen"])
pc.connectionStateChanged = { [weak self] state in
Task { @MainActor in
self?.status = "WebRTC: \(state.rawValue)"
}
}
return pc
}
// MARK: - Capture Control
private func startCapture() async throws {
let capturer = ScreenCapturer()
capturer.onPixelBuffer = { [weak self] pixelBuffer, time in
guard let self = self else { return }
let rtcPixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
let frame = RTCVideoFrame(buffer: rtcPixelBuffer, rotation: ._0, timeStampNs: Int64(time.seconds * 1_000_000_000))
self.videoSource.capturer(self.dummyCapturer, didCapture: frame)
}
try await capturer.start()
screenCapturer = capturer
}
private func stopCapture() {
screenCapturer?.stop()
screenCapturer = nil
}
// MARK: - HTTP Server Setup
private func setupHTTPServer() {
// Viewer page
server["/"] = { [weak self] _ in
guard let html = self?.viewerHTML else { return .internalServerError }
return .ok(.html(html))
}
// Offer endpoint (browser -> app)
server["/offer"] = { [weak self] req in
guard let self = self else { return .internalServerError }
do {
let body = Data(req.body)
guard let dict = try JSONSerialization.jsonObject(with: body) as? [String: Any],
let sdp = dict["sdp"] as? String else {
return .badRequest(nil)
}
// Always create a fresh peer connection for each offer
self.pc = self.makePeerConnection()
let remoteDesc = RTCSessionDescription(type: .offer, sdp: sdp)
let sem = DispatchSemaphore(value: 0)
var answerSDP: String?
self.pc!.setRemoteDescription(remoteDesc) { err in
if let err = err { print("setRemoteDescription error:", err) }
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
self.pc!.answer(for: constraints) { answer, err in
if let err = err {
print("answer error:", err)
sem.signal(); return
}
guard let answer = answer else { sem.signal(); return }
self.pc!.setLocalDescription(answer) { err in
if let err = err { print("setLocalDescription error:", err) }
self.waitForIceComplete(self.pc!) {
answerSDP = self.pc!.localDescription?.sdp
sem.signal()
}
}
}
}
_ = sem.wait(timeout: .now() + 5)
let response: [String: Any] = ["sdp": answerSDP ?? (self.pc!.localDescription?.sdp ?? "")]
let data = try JSONSerialization.data(withJSONObject: response)
return .raw(200, "OK", ["Content-Type": "application/json"]) { writer in
try writer.write(data)
}
} catch {
return .internalServerError
}
}
do {
try server.start(httpPort, forceIPv4: true)
} catch {
print("HTTP server failed:", error)
}
}
private func waitForIceComplete(_ pc: RTCPeerConnection, completion: @escaping () -> Void) {
func check() {
if pc.iceGatheringState == .complete {
completion()
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
check()
}
}
}
check()
}
private var viewerHTML: String {
return """
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>WebRTC Screen Viewer</title></head>
<body style="margin:0;background:#10131c;color:#cfe3ff;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif">
<div style="padding:10px">WebRTC Screen Viewer</div>
<video id="v" autoplay playsinline controls style="width:100vw;height:calc(100vh - 48px);background:#000"></video>
<script>
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
pc.ontrack = ev => { document.getElementById('v').srcObject = ev.streams[0]; };
(async () => {
const offer = await pc.createOffer({offerToReceiveVideo: true, offerToReceiveAudio: false});
await pc.setLocalDescription(offer);
const res = await fetch('/offer', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({sdp: offer.sdp})});
const answer = await res.json();
await pc.setRemoteDescription({type:'answer', sdp: answer.sdp});
})().catch(e => alert(e));
</script></body></html>
"""
}
}
// MARK: - Screen Capturer using ScreenCaptureKit
@available(macOS 12.3, *)
final class ScreenCapturer: NSObject, SCStreamDelegate, SCStreamOutput {
private var stream: SCStream?
private let queue = DispatchQueue(label: "ScreenCapturer.queue")
var onPixelBuffer: ((CVPixelBuffer, CMTime) -> Void)?
func start() async throws {
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
guard let display = content.displays.first else {
throw NSError(domain: "ScreenCapturer", code: -1, userInfo: [NSLocalizedDescriptionKey: "No displays found"])
}
let filter = SCContentFilter(display: display, excludingWindows: [])
let config = SCStreamConfiguration()
config.width = Int(display.width)
config.height = Int(display.height)
config.pixelFormat = kCVPixelFormatType_32BGRA
config.minimumFrameInterval = CMTime(value: 1, timescale: 30) // 30 fps
config.showsCursor = true
stream = SCStream(filter: filter, configuration: config, delegate: self)
try stream?.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
try await stream?.startCapture()
}
func stop() {
Task {
try? await stream?.stopCapture()
stream = nil
}
}
// MARK: - SCStreamOutput
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard type == .screen,
let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let time = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
onPixelBuffer?(pixelBuffer, time)
}
// MARK: - SCStreamDelegate
func stream(_ stream: SCStream, didStopWithError error: Error) {
print("Screen capture stopped with error: \(error)")
}
}
// MARK: - RTCPeerConnection Extension
private extension RTCPeerConnection {
var connectionStateChanged: ((RTCPeerConnectionState) -> Void)? {
get { objc_getAssociatedObject(self, "_conncb") as? (RTCPeerConnectionState) -> Void }
set {
objc_setAssociatedObject(self, "_conncb", newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
let delegate = ConnectionDelegate(cb: newValue)
objc_setAssociatedObject(self, "_conndelegate", delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
self.delegate = delegate
}
}
class ConnectionDelegate: NSObject, RTCPeerConnectionDelegate {
let cb: ((RTCPeerConnectionState) -> Void)?
init(cb: ((RTCPeerConnectionState) -> Void)?) { self.cb = cb }
func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCPeerConnectionState) { cb?(stateChanged) }
// Unused delegate methods for brevity:
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCSignalingState) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd rtpReceiver: RTCRtpReceiver, streams: [RTCMediaStream]) {}
}
}
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment