Last active
August 14, 2025 20:27
-
-
Save mxvsh/aef0c3bf1f72a96455b5a6161bb3efbe to your computer and use it in GitHub Desktop.
macOS WebRTC Screen Stream
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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