mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Mac in the headset: stream Mac windows and screens into the Frame as panels
Frame Control can now show any Mac window, or a whole screen, as its own SteamVR panel on the Steam Frame (Tools -> Mac in the headset, macOS only). Place it with the SteamVR dashboard; the laser clicks and scrolls, and the Mac's own keyboard types. - mac/frame-mac-view (Swift, no dependencies): ScreenCaptureKit capture per window or display, VideoToolbox H.264 with low-latency rate control (JPEG fallback), a loopback HTTP/WebSocket server, CGEvent/AX input playback, and a display-awake assertion while anyone watches. - ui/frame_macview.py: starts the agent, runs an ssh -R tunnel with a supervisor that reopens it on the same port, and launches a Chromium app window per stream on gamescope's :0, tagged with STEAM_GAME for its own panel. - Frame Control's key never leaves the Mac: viewers get single-use, per-source tickets and reconnect keys that Stop revokes. - ui/mac-view.html: WebCodecs decode, keyframe recovery, pointer/wheel/keys back. - Bundled in the Mac app build; tests/test_macview.py builds and drives the agent on macOS. Verified on the Frame (build 20260925.6191901) with the test pattern: panel in about 1.5 s, about 60 fps, Mac-to-window about 11-17 ms, tunnel recovery in 4 s. Laser input and real window capture still need a person in the headset. Also commits the other thread's first-party rule (steam-frame skill) and the first-party options table in docs/streaming.md. Reviewed by GPT-6 Astra (xhigh, read-only) over six rounds; all findings fixed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0016d9200c
commit
a3c6e5c984
20 files changed
+2708
-6
No files matched your search
@@ -0,0 +1,339 @@
|
||||
// Where pictures come from: one Mac window or one display (ScreenCaptureKit),
|
||||
// or a generated test pattern that needs no permissions and shows the input
|
||||
// the viewer sends, for checking the whole path without touching the Mac.
|
||||
import AppKit
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import ScreenCaptureKit
|
||||
|
||||
enum Source: Equatable {
|
||||
case window(CGWindowID)
|
||||
case display(CGDirectDisplayID)
|
||||
case test
|
||||
|
||||
init?(_ s: String) {
|
||||
let parts = s.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
switch (parts.first, parts.count > 1 ? UInt32(parts[1]) : nil) {
|
||||
case ("window", let id?): self = .window(id)
|
||||
case ("display", let id?): self = .display(id)
|
||||
case ("test", _): self = .test
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var key: String {
|
||||
switch self {
|
||||
case .window(let id): return "window:\(id)"
|
||||
case .display(let id): return "display:\(id)"
|
||||
case .test: return "test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Size to capture at: the source's pixel size, shrunk to fit a box whose
|
||||
/// long side is `maxLong` (default 1920), even numbers for the encoder.
|
||||
func fitSize(width: Double, height: Double, maxLong: Int) -> (Int, Int) {
|
||||
let scale = min(1, Double(maxLong) / max(width, height, 1))
|
||||
let w = max(16, Int((width * scale / 2).rounded()) * 2), h = max(16, Int((height * scale / 2).rounded()) * 2)
|
||||
return (w, h)
|
||||
}
|
||||
|
||||
/// What CGWindowList says about a window right now (no permission needed for
|
||||
/// bounds; titles need Screen Recording).
|
||||
struct WindowInfo {
|
||||
let id: CGWindowID
|
||||
let pid: pid_t
|
||||
let app: String
|
||||
let title: String
|
||||
let bounds: CGRect
|
||||
let layer: Int
|
||||
let onScreen: Bool
|
||||
|
||||
static func all(onScreenOnly: Bool = true) -> [WindowInfo] {
|
||||
let opts: CGWindowListOption = onScreenOnly ? [.optionOnScreenOnly, .excludeDesktopElements] : [.excludeDesktopElements]
|
||||
let list = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[CFString: Any]] ?? []
|
||||
return list.compactMap(WindowInfo.init)
|
||||
}
|
||||
|
||||
static func find(_ id: CGWindowID) -> WindowInfo? {
|
||||
let list = CGWindowListCopyWindowInfo(.optionIncludingWindow, id) as? [[CFString: Any]] ?? []
|
||||
return list.compactMap(WindowInfo.init).first { $0.id == id }
|
||||
}
|
||||
|
||||
init?(_ d: [CFString: Any]) {
|
||||
guard let id = d[kCGWindowNumber] as? CGWindowID, let pid = d[kCGWindowOwnerPID] as? pid_t,
|
||||
let b = d[kCGWindowBounds] as? [String: Any], let rect = CGRect(dictionaryRepresentation: b as CFDictionary)
|
||||
else { return nil }
|
||||
self.id = id
|
||||
self.pid = pid
|
||||
app = d[kCGWindowOwnerName] as? String ?? ""
|
||||
title = d[kCGWindowName] as? String ?? ""
|
||||
bounds = rect
|
||||
layer = d[kCGWindowLayer] as? Int ?? 0
|
||||
onScreen = d[kCGWindowIsOnscreen] as? Bool ?? false
|
||||
}
|
||||
}
|
||||
|
||||
protocol CaptureSource: AnyObject {
|
||||
var onFrame: ((CVPixelBuffer, CMTime) -> Void)? { get set }
|
||||
var onEnded: ((String) -> Void)? { get set }
|
||||
/// Points on the Mac's global display space that the picture covers.
|
||||
var frameRect: CGRect { get }
|
||||
var title: String { get }
|
||||
var app: String { get }
|
||||
var pid: pid_t? { get }
|
||||
func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void)
|
||||
func stop()
|
||||
func pointer(x: Double, y: Double, text: String?) // for the test pattern
|
||||
}
|
||||
|
||||
extension CaptureSource {
|
||||
func pointer(x: Double, y: Double, text: String?) {}
|
||||
}
|
||||
|
||||
/// ScreenCaptureKit, for a window or a display.
|
||||
final class SCKSource: NSObject, CaptureSource, SCStreamOutput, SCStreamDelegate {
|
||||
let source: Source
|
||||
var onFrame: ((CVPixelBuffer, CMTime) -> Void)?
|
||||
var onEnded: ((String) -> Void)?
|
||||
private(set) var frameRect = CGRect.zero
|
||||
private(set) var title = ""
|
||||
private(set) var app = ""
|
||||
private(set) var pid: pid_t?
|
||||
private var stream: SCStream?
|
||||
private var config = SCStreamConfiguration()
|
||||
private var maxLong = 1920
|
||||
private var scale = 2.0
|
||||
private var poll: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "frame-mac-view.capture", qos: .userInteractive)
|
||||
/// Set by stop(), on `queue`; a start still enumerating windows checks it
|
||||
/// before it starts capturing, so a viewer that left early leaves nothing on.
|
||||
private var cancelled = false
|
||||
/// The window or display no longer exists, so retrying can't help.
|
||||
private(set) var gone = false
|
||||
var onChange: (() -> Void)? // title or size changed
|
||||
|
||||
init(_ source: Source) { self.source = source }
|
||||
|
||||
func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void) {
|
||||
self.maxLong = maxLong
|
||||
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { [self] content, error in
|
||||
queue.async { self.begin(content, error, fps: fps, completion: completion) }
|
||||
}
|
||||
}
|
||||
|
||||
private func begin(_ content: SCShareableContent?, _ error: Error?, fps: Int, completion: @escaping (String?) -> Void) {
|
||||
if cancelled { return }
|
||||
guard let content else {
|
||||
return completion("Screen Recording isn't allowed for Frame Control (\(error?.localizedDescription ?? "no content"))")
|
||||
}
|
||||
let filter: SCContentFilter
|
||||
switch source {
|
||||
case .window(let id):
|
||||
guard let w = content.windows.first(where: { $0.windowID == id }) else {
|
||||
gone = true
|
||||
return completion("that window has closed")
|
||||
}
|
||||
filter = SCContentFilter(desktopIndependentWindow: w)
|
||||
title = w.title ?? ""
|
||||
app = w.owningApplication?.applicationName ?? ""
|
||||
pid = w.owningApplication?.processID
|
||||
frameRect = w.frame
|
||||
case .display(let id):
|
||||
guard let d = content.displays.first(where: { $0.displayID == id }) else {
|
||||
gone = true
|
||||
return completion("that display isn't connected")
|
||||
}
|
||||
filter = SCContentFilter(display: d, excludingWindows: [])
|
||||
title = displayName(id)
|
||||
app = "Mac"
|
||||
frameRect = CGDisplayBounds(id)
|
||||
case .test:
|
||||
return completion("not a ScreenCaptureKit source")
|
||||
}
|
||||
scale = Double(filter.pointPixelScale)
|
||||
let (w, h) = fitSize(width: frameRect.width * scale, height: frameRect.height * scale, maxLong: maxLong)
|
||||
config.width = w
|
||||
config.height = h
|
||||
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
|
||||
config.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
|
||||
config.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2
|
||||
config.colorSpaceName = CGColorSpace.sRGB
|
||||
config.showsCursor = true
|
||||
config.queueDepth = 5
|
||||
config.scalesToFit = true
|
||||
config.preservesAspectRatio = true
|
||||
config.capturesAudio = false
|
||||
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
||||
do {
|
||||
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
|
||||
} catch {
|
||||
return completion("couldn't capture: \(error.localizedDescription)")
|
||||
}
|
||||
self.stream = stream
|
||||
stream.startCapture { error in
|
||||
self.queue.async {
|
||||
if self.cancelled {
|
||||
stream.stopCapture { _ in }
|
||||
return
|
||||
}
|
||||
if let error { return completion("couldn't capture: \(error.localizedDescription)") }
|
||||
self.startPolling()
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async {
|
||||
self.cancelled = true
|
||||
self.poll?.cancel()
|
||||
self.poll = nil
|
||||
self.stream?.stopCapture { _ in }
|
||||
self.stream = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows move, resize, retitle and close; ScreenCaptureKit doesn't say.
|
||||
private func startPolling() {
|
||||
guard case .window(let id) = source else { return }
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now() + 1, repeating: 1)
|
||||
t.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let info = WindowInfo.find(id) else {
|
||||
self.stop()
|
||||
self.onEnded?("the window closed")
|
||||
return
|
||||
}
|
||||
var changed = false
|
||||
if !info.title.isEmpty, info.title != self.title { self.title = info.title; changed = true }
|
||||
let old = self.frameRect
|
||||
self.frameRect = info.bounds
|
||||
if abs(old.width - info.bounds.width) > 1 || abs(old.height - info.bounds.height) > 1 {
|
||||
let (w, h) = fitSize(width: info.bounds.width * self.scale, height: info.bounds.height * self.scale, maxLong: self.maxLong)
|
||||
self.config.width = w
|
||||
self.config.height = h
|
||||
self.stream?.updateConfiguration(self.config) { _ in }
|
||||
changed = true
|
||||
}
|
||||
if changed { self.onChange?() }
|
||||
}
|
||||
t.resume()
|
||||
poll = t
|
||||
}
|
||||
|
||||
func stream(_ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType) {
|
||||
guard type == .screen, sample.isValid, let pb = CMSampleBufferGetImageBuffer(sample) else { return }
|
||||
// Only complete frames carry new pixels; idle and blank ones don't.
|
||||
if let atts = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
|
||||
let raw = atts.first?[.status] as? Int, let status = SCFrameStatus(rawValue: raw), status != .complete {
|
||||
return
|
||||
}
|
||||
onFrame?(pb, CMSampleBufferGetPresentationTimeStamp(sample))
|
||||
}
|
||||
|
||||
func stream(_ stream: SCStream, didStopWithError error: Error) {
|
||||
onEnded?("capture stopped: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func displayName(_ id: CGDirectDisplayID) -> String {
|
||||
for screen in NSScreen.screens {
|
||||
if (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == id {
|
||||
return screen.localizedName
|
||||
}
|
||||
}
|
||||
return "Display \(id)"
|
||||
}
|
||||
|
||||
/// A moving test card: bars, a clock and a frame counter, plus a dot where the
|
||||
/// viewer's pointer is and the last key it sent, so input can be checked too.
|
||||
final class TestSource: CaptureSource {
|
||||
var onFrame: ((CVPixelBuffer, CMTime) -> Void)?
|
||||
var onEnded: ((String) -> Void)?
|
||||
let frameRect = CGRect(x: 0, y: 0, width: 1280, height: 720)
|
||||
let title = "Test pattern"
|
||||
let app = "Frame Control"
|
||||
let pid: pid_t? = nil
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var pool: CVPixelBufferPool?
|
||||
private var n = 0
|
||||
private var dot: (Double, Double)?
|
||||
private var lastText = "Point or type in the headset"
|
||||
private let queue = DispatchQueue(label: "frame-mac-view.test")
|
||||
private var size = (1280, 720)
|
||||
|
||||
func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void) {
|
||||
size = fitSize(width: 1280, height: 720, maxLong: maxLong)
|
||||
let attrs: [CFString: Any] = [kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey: size.0, kCVPixelBufferHeightKey: size.1,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary]
|
||||
CVPixelBufferPoolCreate(nil, nil, attrs as CFDictionary, &pool)
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now(), repeating: 1.0 / Double(fps))
|
||||
t.setEventHandler { [weak self] in self?.draw() }
|
||||
t.resume()
|
||||
timer = t
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.cancel()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
func pointer(x: Double, y: Double, text: String?) {
|
||||
queue.async {
|
||||
if x >= 0 { self.dot = (x, y) }
|
||||
if let text { self.lastText = text }
|
||||
}
|
||||
}
|
||||
|
||||
private func draw() {
|
||||
guard let pool else { return }
|
||||
var out: CVPixelBuffer?
|
||||
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &out)
|
||||
guard let pb = out else { return }
|
||||
CVPixelBufferLockBaseAddress(pb, [])
|
||||
defer { CVPixelBufferUnlockBaseAddress(pb, []) }
|
||||
let (w, h) = size
|
||||
guard let ctx = CGContext(data: CVPixelBufferGetBaseAddress(pb), width: w, height: h, bitsPerComponent: 8,
|
||||
bytesPerRow: CVPixelBufferGetBytesPerRow(pb), space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue)
|
||||
else { return }
|
||||
let colors: [(CGFloat, CGFloat, CGFloat)] = [(0.75, 0.75, 0.75), (0.75, 0.75, 0), (0, 0.75, 0.75), (0, 0.75, 0),
|
||||
(0.75, 0, 0.75), (0.75, 0, 0), (0, 0, 0.75)]
|
||||
let bw = CGFloat(w) / CGFloat(colors.count)
|
||||
for (i, c) in colors.enumerated() {
|
||||
ctx.setFillColor(red: c.0, green: c.1, blue: c.2, alpha: 1)
|
||||
ctx.fill(CGRect(x: CGFloat(i) * bw, y: CGFloat(h) * 0.35, width: bw + 1, height: CGFloat(h) * 0.65))
|
||||
}
|
||||
ctx.setFillColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1)
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: w, height: Int(Double(h) * 0.35)))
|
||||
// A bar sweeping once a second shows motion and dropped frames.
|
||||
let x = CGFloat(n % 60) / 60 * CGFloat(w)
|
||||
ctx.setFillColor(red: 1, green: 1, blue: 1, alpha: 1)
|
||||
ctx.fill(CGRect(x: x, y: CGFloat(h) * 0.35, width: max(4, CGFloat(w) / 120), height: CGFloat(h) * 0.65))
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss.SSS"
|
||||
let label = "Frame Control test pattern \(f.string(from: Date())) frame \(n)\n\(lastText)"
|
||||
let text = NSAttributedString(string: label, attributes: [
|
||||
.font: NSFont.monospacedSystemFont(ofSize: CGFloat(h) / 24, weight: .medium),
|
||||
.foregroundColor: NSColor.white,
|
||||
])
|
||||
let ns = NSGraphicsContext(cgContext: ctx, flipped: false)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = ns
|
||||
text.draw(at: CGPoint(x: CGFloat(w) * 0.03, y: CGFloat(h) * 0.08))
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
if let (px, py) = dot {
|
||||
let r = CGFloat(h) / 40
|
||||
ctx.setFillColor(red: 1, green: 0.2, blue: 0.2, alpha: 1)
|
||||
ctx.fillEllipse(in: CGRect(x: CGFloat(px) * CGFloat(w) - r, y: (1 - CGFloat(py)) * CGFloat(h) - r, width: 2 * r, height: 2 * r))
|
||||
}
|
||||
n += 1
|
||||
onFrame?(pb, CMClockGetTime(CMClockGetHostTimeClock()))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user