commit 5733747618335531bc5860334847cdf7f1b37874
parent 7feda948f29af1c3618c82d5020e993fa4a1168e
Author: umhau <umhau@example.invalid>
Date: Wed, 21 Oct 2026 12:00:23 +0000
Render headset without exclusive capture or desktop focus interference
Diffstat:
8 files changed, 136 insertions(+), 28 deletions(-)
diff --git a/NOTES.md b/NOTES.md
@@ -146,3 +146,7 @@ The optical tuning shader passes float-coordinate comparisons including independ
the end-to-end quantized sampling check. Screenshots show pre-compensated output before the physical lenses, so they
cannot establish the blur/fringe that reaches a user's eye. Software rendering, optical compensation, and physical
lens/eye placement are separate contributors; do not infer a hardware defect from the remaining residuals.
+
+## Desktop coexistence
+
+The demo uses a non-activating headset panel without exclusive display capture. Its session-only display layout places the headset to the right of other displays. The application does not change Dock preferences. Native 90 Hz rendering and clean shutdown were checked; see evidence/desktop-coexistence.txt.
diff --git a/SETUP.md b/SETUP.md
@@ -90,7 +90,7 @@ zsh tools/build.sh # builds the app + the signe
- Calibrated rendering passed a 180 s trial: 89.996 presented fps over the settled health interval, no display drops,
0 GPU errors, approximately 5.97 ms GPU/frame, clean shutdown. See `evidence/calibrated-90hz-trial.json`.
Software validation is complete; physical optical validation remains separate from software/model checks.
-- The app captures the headset display (no desktop on it) and uses its native unscaled 4320x2160 @ 90 Hz mode.
+- The app covers the headset with a non-activating borderless panel, without exclusive display capture, at native unscaled 4320x2160 @ 90 Hz.
If macOS changes the mode by itself, the app restores 90 Hz (also checked every 10 s, CoreGraphics only).
- Rendering runs on a dedicated "Headset Metal rendering" thread: a `CAMetalDisplayLink` on that thread's run loop
hands each frame a ready drawable (no `nextDrawable` calls, nothing drawn on the main thread). The main thread only
@@ -108,7 +108,7 @@ zsh tools/build.sh # builds the app + the signe
- Forced-outage duration (display lost -> back): 14.4 s before the recovery queue, 4.4 s with it
(= 2 s grace + ~2.2 s reset/retrain/re-add). Releasing the capture inside the callback did not help
(CGDisplayRelease returns 1001 = kCGErrorIllegalArgument there; outages 4.6 s) and was removed.
- - Every DRST is followed ~5.7 s later by a brief lane-sync blip (IRQ_HPD/INTERLANE retrain) that the app recaptures.
+ - Every DRST is followed ~5.7 s later by a brief lane-sync blip (IRQ_HPD/INTERLANE retrain) that the app handles automatically.
- **Reliability milestone PASSED** (`evidence/verified-90hz-soak.json`, `evidence/verified-90hz-soak.txt`):
no-fault soak at native 4320x2160 @ 90 Hz, exit 0, 649.965 s settled interval, 58,496 presented frames =
89.9987 fps, skipped +3, 0 GPU errors, no render-worker join timeout, zero DP resets. One 0.637 s self-heal at
@@ -156,3 +156,5 @@ sharpness grid. Red correction and separate blue horizontal/vertical correction
factory compensation. Changes save automatically to `~/Library/Application Support/VRWorkstation/optics-adjustments.json`.
The factory reset button restores 100% for all three controls. Color controls are disabled for the green-only pattern.
The floor now uses a filtered procedural grid to avoid subpixel line aliasing at distance.
+
+The headset uses a non-activating panel and is placed to the right of other displays for this session. The Mac controls remain a normal window with a Quit button. No Dock preferences are changed.
diff --git a/Sources/VRWorkstation/App.swift b/Sources/VRWorkstation/App.swift
@@ -1,4 +1,4 @@
-// The app owns the headset: it wakes it over HID, captures its display (no desktop, no other windows),
+// The app wakes the headset over HID and covers its display with a non-activating surface,
// renders the stereo scene fullscreen at 4320x2160 @ 90 Hz, and re-takes control the moment the display returns.
//
// Health is watched PASSIVELY only (macOS display callbacks + the headset's own 0x05 status reports).
@@ -8,11 +8,10 @@ import AppKit
import Metal
import QuartzCore
-final class HeadsetWindow: NSWindow {
- override var canBecomeKey: Bool { true }
- override func keyDown(with e: NSEvent) {
- if e.charactersIgnoringModifiers == "q" || e.keyCode == 53 { NSApp.terminate(nil) }
- }
+/// A display surface, never the keyboard focus or an exclusive display owner.
+final class HeadsetWindow: NSPanel {
+ override var canBecomeKey: Bool { false }
+ override var canBecomeMain: Bool { false }
}
/// The headset's display: vendor 0x220E (8718).
@@ -33,7 +32,7 @@ private func isNativeHeadsetMode(_ mode: CGDisplayMode) -> Bool {
private let displayChanged: CGDisplayReconfigurationCallBack = { id, flags, ctx in
guard !flags.contains(.beginConfigurationFlag), let ctx else { return }
- guard flags.contains(.removeFlag) || flags.contains(.addFlag) || flags.contains(.setModeFlag) else { return }
+ guard flags.contains(.removeFlag) || flags.contains(.addFlag) || flags.contains(.setModeFlag) || flags.contains(.movedFlag) else { return }
let enteredAt = Date()
log("[display callback] id=\(id) flags=\(flags.rawValue) main=\(Thread.isMainThread)")
let session = Unmanaged<HeadsetSession>.fromOpaque(ctx).takeUnretainedValue()
@@ -47,6 +46,7 @@ private let displayChanged: CGDisplayReconfigurationCallBack = { id, flags, ctx
if flags.contains(.removeFlag) { session.displayLost(id) }
if flags.contains(.addFlag), isHeadsetDisplay(id) { session.displayArrived(id) }
if flags.contains(.setModeFlag) { session.displayModeChanged(id) }
+ if flags.contains(.movedFlag) { session.displayMoved(id) }
}
}
@@ -155,25 +155,23 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
hmd.stop()
window?.orderOut(nil)
opticsControls?.close(); opticsControls = nil
- if let display { CGDisplayRelease(display) }
display = nil
}
- /// Takes exclusive control of the headset display and puts the renderer on it.
+ /// Put the render surface on the headset without exclusive display capture.
func displayArrived(_ id: CGDirectDisplayID) {
guard !stopping else { return }
if display == id { displayModeChanged(id); return }
if let previous = display { displayLost(previous) }
if let t = lostAt { log(String(format: "headset display back after %.0f ms", Date().timeIntervalSince(t) * 1000)) }
lostAt = nil
- let r = CGDisplayCapture(id)
- log("captured display \(id): \(r == .success ? "ok" : "error \(r.rawValue)")")
- guard r == .success else { recover(); return }
+ log("attaching renderer to headset display \(id)")
display = id
- recovery.capturedDisplay(id)
+ placeHeadsetToRight(id)
+ recovery.trackDisplay(id)
recoveryTimer?.invalidate(); recoveryTimer = nil
guard setNativeMode(id) else {
- CGDisplayRelease(id); display = nil
+ display = nil
log("cannot render at required native mode")
recover()
return
@@ -181,6 +179,28 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
configureOutput(id)
}
+ /// Keep the Mac's left desktop edge available for its left-positioned Dock.
+ private func placeHeadsetToRight(_ id: CGDirectDisplayID) {
+ var ids = [CGDirectDisplayID](repeating: 0, count: 16), count: UInt32 = 0
+ guard CGGetOnlineDisplayList(16, &ids, &count) == .success else { return }
+ let other = ids.prefix(Int(count)).filter { $0 != id }
+ guard let right = other.map({ CGDisplayBounds($0).maxX }).max() else { return }
+ let y = CGDisplayBounds(CGMainDisplayID()).minY
+ let current = CGDisplayBounds(id)
+ guard current.minX != right || current.minY != y else { return }
+ var configuration: CGDisplayConfigRef?
+ guard CGBeginDisplayConfiguration(&configuration) == .success, let configuration else { return }
+ let moved = CGConfigureDisplayOrigin(configuration, id, Int32(right), Int32(y))
+ guard moved == .success else { CGCancelDisplayConfiguration(configuration); return }
+ let result = CGCompleteDisplayConfiguration(configuration, .forSession)
+ log("headset desktop placement to right: \(result.rawValue)")
+ }
+
+ func displayMoved(_ id: CGDirectDisplayID) {
+ guard display == id, !stopping else { return }
+ configureOutput(id)
+ }
+
func displayModeChanged(_ id: CGDirectDisplayID) {
guard !stopping, display == id, let current = CGDisplayCopyDisplayMode(id) else { return }
let changed = !isNativeHeadsetMode(current)
@@ -198,16 +218,18 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
private func configureOutput(_ id: CGDirectDisplayID) {
guard !stopping, display == id else { return }
- // A captured display is covered by the shielding window; ours must sit above it.
+ // Keep this surface above the headset desktop without taking over macOS display capture.
let bounds = CGDisplayBounds(id)
let mainH = CGDisplayBounds(CGMainDisplayID()).height
let frame = NSRect(x: bounds.minX, y: mainH - bounds.maxY, width: bounds.width, height: bounds.height)
- let win = window ?? HeadsetWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false)
- win.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1)
- win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
+ let win = window ?? HeadsetWindow(contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
+ win.level = .statusBar
+ win.hidesOnDeactivate = false
+ win.ignoresMouseEvents = true
+ win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
win.contentView = view
win.setFrame(frame, display: true)
- win.makeKeyAndOrderFront(nil)
+ win.orderFrontRegardless()
window = win
view.metalLayer.drawableSize = CGSize(width: 4320, height: 2160)
outputConfigured = true
@@ -222,7 +244,6 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
outputConfigured = false
renderLoop.setPaused(true)
window?.orderOut(nil)
- CGDisplayRelease(id)
log("headset display LOST")
recover()
}
@@ -247,7 +268,7 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
self.recoveryTimer = nil
guard !self.stopping, self.display == nil else { return }
if let id = onlineHeadsetDisplay() {
- log("headset display online; retrying capture without a DP reset")
+ log("headset display online; retrying headset attachment without a DP reset")
self.displayArrived(id)
} else { self.recovery.requestRecovery() }
}
diff --git a/Sources/VRWorkstation/LinkRecovery.swift b/Sources/VRWorkstation/LinkRecovery.swift
@@ -19,7 +19,7 @@ final class LinkRecovery {
self.helper = helper; self.wake = wake
}
- func capturedDisplay(_ id: UInt32) {
+ func trackDisplay(_ id: UInt32) {
queue.async { self.target = id; self.cancelWaiting() }
}
diff --git a/Sources/VRWorkstation/OpticsControls.swift b/Sources/VRWorkstation/OpticsControls.swift
@@ -68,7 +68,9 @@ final class OpticsControls: NSObject {
}
stack.addArrangedSubview(NSTextField(labelWithString: "Color correction: 0% = off, 100% = factory"))
let reset = NSButton(title: "Reset colors to factory", target: self, action: #selector(resetColors))
- stack.addArrangedSubview(reset)
+ let buttons = NSStackView(views: [reset, NSButton(title: "Quit", target: NSApp, action: #selector(NSApplication.terminate(_:)))])
+ buttons.spacing = 12
+ stack.addArrangedSubview(buttons)
let settings = renderer.opticalSettings()
pattern.selectItem(at: settings.pattern.rawValue)
red.floatValue = settings.redGain
diff --git a/Tests/LinkRecoveryChecks.swift b/Tests/LinkRecoveryChecks.swift
@@ -41,7 +41,7 @@ struct LinkRecoveryChecks {
let missing = LinkRecovery(helper: directory.appendingPathComponent("missing"))
precondition(!attempt(missing), "a missing helper must fail")
- // A headset ADD must cancel startup recovery even before capture binds an ID.
+ // A headset ADD must cancel startup recovery even before the session binds an ID.
let marker = directory.appendingPathComponent("ran")
let recording = try helper("recording", "#!/bin/sh\n/usr/bin/touch \(marker.path)\n")
let startup = LinkRecovery(helper: recording)
@@ -49,7 +49,7 @@ struct LinkRecoveryChecks {
startup.displayAdded(42)
Thread.sleep(forTimeInterval: 2.3) // deliberately do not pump main
precondition(!FileManager.default.fileExists(atPath: marker.path), "startup ADD must cancel reset")
- startup.capturedDisplay(42)
+ startup.trackDisplay(42)
startup.displayRemoved(42)
startup.displayAdded(43)
Thread.sleep(forTimeInterval: 2.3)
diff --git a/evidence/desktop-coexistence.txt b/evidence/desktop-coexistence.txt
@@ -0,0 +1,79 @@
+8711.769422 [hmd] serial REDACTED-DEVICE-SERIAL
+8711.871422 [hmd] wake commands accepted; waiting for IMU and video evidence
+8711.872422 attaching renderer to headset display 2
+8712.151422 [display callback] id=2 flags=4098 main=true
+8712.152422 headset desktop placement to right: 0
+8712.724422 [display callback] id=2 flags=8 main=true
+8712.726422 mode 4320x2160@90 (ui 4320x2160): ok
+8712.750422 rendering on headset (4320.0, 2160.0), drawable (4320.0, 2160.0); dedicated Metal display loop at 90 Hz
+8712.755422 render worker main=false
+frame 1 drawable 4320x2160
+8712.848422 [display callback] handled after 697.5 ms
+8712.849422 rendering on headset (4320.0, 2160.0), drawable (4320.0, 2160.0); dedicated Metal display loop at 90 Hz
+8712.849422 [display callback] handled after 124.5 ms
+8712.868422 [hmd] display ready=false; raw 05 00 01 01 00 3b 00 00 00 0a 27
+8712.868422 headset reports display not ready
+8714.042422 [hmd] display ready=true; raw 05 01 01 01 01 3b 00 00 00 0a 27
+frame 300 drawable 4320x2160
+frame 600 drawable 4320x2160
+8721.873422 health display=2 submitted=815 presented=807 skipped=5 fps=77.09 presentAge=0.00s GPUms=6.53 GPUerrors=0 IMU=9188 lastIMU=0.0s status=2
+frame 900 drawable 4320x2160
+frame 1200 drawable 4320x2160
+frame 1500 drawable 4320x2160
+8731.871422 health display=2 submitted=1715 presented=1706 skipped=6 fps=89.91 presentAge=0.00s GPUms=6.57 GPUerrors=0 IMU=19188 lastIMU=0.0s status=2
+frame 1800 drawable 4320x2160
+frame 2100 drawable 4320x2160
+frame 2400 drawable 4320x2160
+8741.872422 health display=2 submitted=2615 presented=2603 skipped=9 fps=89.69 presentAge=0.00s GPUms=6.56 GPUerrors=0 IMU=29192 lastIMU=0.0s status=2
+frame 2700 drawable 4320x2160
+frame 3000 drawable 4320x2160
+frame 3300 drawable 4320x2160
+8751.872422 health display=2 submitted=3515 presented=3503 skipped=9 fps=90.00 presentAge=0.00s GPUms=6.57 GPUerrors=0 IMU=39196 lastIMU=0.0s status=2
+frame 3600 drawable 4320x2160
+frame 3900 drawable 4320x2160
+frame 4200 drawable 4320x2160
+8761.872422 health display=2 submitted=4415 presented=4403 skipped=9 fps=90.00 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=49196 lastIMU=0.0s status=2
+frame 4500 drawable 4320x2160
+frame 4800 drawable 4320x2160
+frame 5100 drawable 4320x2160
+8771.871422 health display=2 submitted=5314 presented=5295 skipped=16 fps=89.20 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=59200 lastIMU=0.0s status=2
+frame 5400 drawable 4320x2160
+frame 5700 drawable 4320x2160
+frame 6000 drawable 4320x2160
+8781.871422 health display=2 submitted=6214 presented=6195 skipped=16 fps=90.00 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=69204 lastIMU=0.0s status=2
+frame 6300 drawable 4320x2160
+frame 6600 drawable 4320x2160
+frame 6900 drawable 4320x2160
+8791.871422 health display=2 submitted=7114 presented=7095 skipped=16 fps=90.00 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=79204 lastIMU=0.0s status=2
+frame 7200 drawable 4320x2160
+frame 7500 drawable 4320x2160
+frame 7800 drawable 4320x2160
+8801.871422 health display=2 submitted=8014 presented=7995 skipped=16 fps=90.00 presentAge=0.00s GPUms=6.57 GPUerrors=0 IMU=89208 lastIMU=0.0s status=2
+frame 8100 drawable 4320x2160
+frame 8400 drawable 4320x2160
+frame 8700 drawable 4320x2160
+8811.871422 health display=2 submitted=8914 presented=8890 skipped=21 fps=89.50 presentAge=0.00s GPUms=6.56 GPUerrors=0 IMU=99208 lastIMU=0.0s status=2
+frame 9000 drawable 4320x2160
+frame 9300 drawable 4320x2160
+frame 9600 drawable 4320x2160
+8821.871422 health display=2 submitted=9814 presented=9790 skipped=21 fps=90.00 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=109212 lastIMU=0.0s status=2
+frame 9900 drawable 4320x2160
+frame 10200 drawable 4320x2160
+frame 10500 drawable 4320x2160
+8831.871422 health display=2 submitted=10714 presented=10689 skipped=22 fps=89.90 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=119216 lastIMU=0.0s status=2
+frame 10800 drawable 4320x2160
+frame 11100 drawable 4320x2160
+frame 11400 drawable 4320x2160
+8841.869422 health display=2 submitted=11614 presented=11588 skipped=23 fps=89.91 presentAge=0.00s GPUms=6.56 GPUerrors=0 IMU=129216 lastIMU=0.0s status=2
+frame 11700 drawable 4320x2160
+frame 12000 drawable 4320x2160
+frame 12300 drawable 4320x2160
+8851.870422 health display=2 submitted=12514 presented=12487 skipped=24 fps=89.89 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=139220 lastIMU=0.0s status=2
+frame 12600 drawable 4320x2160
+frame 12900 drawable 4320x2160
+frame 13200 drawable 4320x2160
+8861.870422 health display=2 submitted=13414 presented=13387 skipped=24 fps=90.00 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=149220 lastIMU=0.0s status=2
+frame 13500 drawable 4320x2160
+frame 13800 drawable 4320x2160
+frame 14100 drawable 4320x2160
+8871.870422 health display=2 submitted=14314 presented=14286 skipped=24 fps=89.90 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=159224 lastIMU=0.0s status=2
diff --git a/evidence/helper-safety-checks.txt b/evidence/helper-safety-checks.txt
@@ -7,4 +7,4 @@ recovery helper failed (exit 9)
recovery helper missing: run tools/build.sh (expected /tmp/REDACTED-TEST-DIRECTORY)
recovery: display absent after grace period; resetting DP adapter
recovery helper accepted DRST; waiting for display ADD
-PASS: three resets/minute; stuck helper stopped in 3.0318520069122314s; missing helper rejected
+PASS: three resets/minute; stuck helper stopped in 3.0193029642105103s; missing helper rejected