Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ It also keeps the established multi-Mac workspace features:

- Sender can stream either a mirrored desktop or an extended virtual display. See [Display Modes](docs/Features.md#display-modes).
- One sender can drive multiple receiver Macs over separate cables. See [Multi-Receiver Workflows](docs/Features.md#multi-receiver-workflows).
- Stream profiles range from `2560 x 1440` to `5120 x 2880` with H.264/HEVC selection based on capability. `5K 60` is an experimental profile for recent Apple Silicon; `5K 48` remains recommended for reliable daily work. See [Display Modes](docs/Features.md#display-modes).
- Stream profiles range from `1920 x 1080` to `5120 x 2880` with H.264/HEVC selection based on capability. The `Full HD` profiles match the native panel of 21.5-inch non-Retina iMacs, so those receivers decode at panel resolution instead of downscaling. `5K 60` is an experimental profile for recent Apple Silicon; `5K 48` remains recommended for reliable daily work. See [Display Modes](docs/Features.md#display-modes).
- Receiver discovery is automatic over Bonjour. Extended-display arrangement is remembered per receiver when possible. See [Display Modes](docs/Features.md#display-modes).
- Thunderbolt Bridge remains the primary low-latency path, with `Network Link` available as an experimental addon-gated transport. See [Network Link](docs/Features.md#network-link-experimental).
- The Sender menu can control a connected receiver's brightness, volume, Night Shift, and True Tone when supported. See [Receiver Device Controls](docs/Features.md#receiver-device-controls).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,24 @@ extension CGVirtualDisplaySettings: @unchecked @retroactive Sendable {}
/// Pixel size of the mode handed to CGVirtualDisplay. With `settings.hiDPI = true`
/// macOS synthesises a strictly 2x backing store, so a mode of (w, h) renders the
/// desktop into a (2w, 2h) framebuffer and reports "looks like w x h" in Displays.
///
/// `isHiDPI == false` asks for a 1x mode instead: the framebuffer equals the mode,
/// and the desktop reports its true size. That is the right choice when the stream
/// already matches a non-Retina receiver panel pixel for pixel, where a HiDPI mode
/// would render every control at 2x on a panel that has no extra pixels to show it.
struct TBVirtualDisplayModeSize: Equatable {
let width: Int
let height: Int
let isHiDPI: Bool

var backingWidth: Int { width * 2 }
var backingHeight: Int { height * 2 }
init(width: Int, height: Int, isHiDPI: Bool = true) {
self.width = width
self.height = height
self.isHiDPI = isHiDPI
}

var backingWidth: Int { isHiDPI ? width * 2 : width }
var backingHeight: Int { isHiDPI ? height * 2 : height }
}

struct TBVirtualDisplayIdentity {
Expand Down Expand Up @@ -84,7 +96,8 @@ final class ReceiverBackedVirtualDisplaySession {
// stream exactly, so capture is 1:1 and only the panel-side scale remains.
var resolvedMode = modeOverride ?? TBVirtualDisplayModeSize(
width: profile.modeWidth,
height: profile.modeHeight
height: profile.modeHeight,
isHiDPI: profile.hiDPI
)

// macOS refuses a HiDPI mode whose backing store exceeds the advertised panel.
Expand All @@ -95,7 +108,11 @@ final class ReceiverBackedVirtualDisplaySession {
resolvedMode.backingWidth, resolvedMode.backingHeight,
profile.panelWidth, profile.panelHeight
)
resolvedMode = TBVirtualDisplayModeSize(width: profile.modeWidth, height: profile.modeHeight)
resolvedMode = TBVirtualDisplayModeSize(
width: profile.modeWidth,
height: profile.modeHeight,
isHiDPI: profile.hiDPI
)
}

let descriptor = CGVirtualDisplayDescriptor()
Expand Down Expand Up @@ -128,7 +145,7 @@ final class ReceiverBackedVirtualDisplaySession {
}

let settings = CGVirtualDisplaySettings()
settings.hiDPI = profile.hiDPI
settings.hiDPI = resolvedMode.isHiDPI
guard let mode = CGVirtualDisplayMode(
width: UInt(resolvedMode.width),
height: UInt(resolvedMode.height),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ enum TBSenderAutomation {
static func parsePreset(_ value: String) -> TBDisplayCapturePreset? {
if let preset = TBDisplayCapturePreset(rawValue: value) { return preset }
switch value.lowercased() {
case "1080p", "1080", "fullhd", "1920x1080": return .standard1080p
case "1080p60", "fullhd60", "smooth1080": return .smooth1080p60
case "1440p", "1440", "standard": return .standard1440p
case "1440p60", "smooth", "smooth1440": return .smooth1440p60
case "1800p", "1800p60", "smooth1800": return .smooth1800p60
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,11 @@ private struct TBDisplaySenderSessionSettingsSheet: View {
.disabled(session.isConnected || session.isStreaming)
}

if session.captureSource == .extendedDesktop {
// A native-scale profile already renders the desktop 1:1 with the
// stream, so render matching has nothing left to do and its HiDPI
// wording would be wrong. Hide the row rather than show a toggle
// that silently does nothing.
if session.captureSource == .extendedDesktop, !session.capturePreset.rendersAtNativeScale {
settingRow(renderMatchingTitle, details: renderMatchingDetails) {
Toggle("", isOn: $session.matchRenderToStream)
.labelsHidden()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,10 @@ enum TBDisplaySenderL10n {
extension TBDisplayCapturePreset {
func title(_ language: TBDisplaySenderLanguage) -> String {
switch self {
case .standard1080p:
return TBDisplaySenderL10n.text("sender.profile.full_hd", language)
case .smooth1080p60:
return TBDisplaySenderL10n.text("sender.profile.full_hd_60", language)
case .standard1440p:
return TBDisplaySenderL10n.text("sender.profile.standard", language)
case .smooth1440p60:
Expand Down
103 changes: 86 additions & 17 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import Network
import VideoToolbox

enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {
case standard1080p
case smooth1080p60
case standard1440p
case smooth1440p60
case smooth1800p60
Expand All @@ -24,6 +26,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var title: String {
switch self {
case .standard1080p:
return "Full HD"
case .smooth1080p60:
return "Full HD 60"
case .standard1440p:
return "Standard"
case .smooth1440p60:
Expand All @@ -43,6 +49,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var description: String {
switch self {
case .standard1080p:
return "1920 × 1080"
case .smooth1080p60:
return "1920 × 1080 @ 60"
case .standard1440p:
return "2560 × 1440"
case .smooth1440p60:
Expand All @@ -62,6 +72,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var width: Int {
switch self {
case .standard1080p, .smooth1080p60:
return 1920
case .standard1440p, .smooth1440p60:
return 2560
case .smooth1800p60:
Expand All @@ -77,6 +89,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var height: Int {
switch self {
case .standard1080p, .smooth1080p60:
return 1080
case .standard1440p, .smooth1440p60:
return 1440
case .smooth1800p60:
Expand All @@ -92,6 +106,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var averageBitRate: Int {
switch self {
case .standard1080p:
return 20_000_000
case .smooth1080p60:
return 30_000_000
case .standard1440p:
return 36_000_000
case .smooth1440p60:
Expand All @@ -111,7 +129,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var codecName: String {
switch self {
case .standard1440p, .smooth1440p60, .smooth1800p60:
case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60:
return "H.264"
case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
return "HEVC"
Expand All @@ -120,7 +138,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var codecType: CMVideoCodecType {
switch self {
case .standard1440p, .smooth1440p60, .smooth1800p60:
case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60:
return kCMVideoCodecType_H264
case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
return kCMVideoCodecType_HEVC
Expand All @@ -132,9 +150,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {
return parsed
}
switch self {
case .standard1440p:
case .standard1080p, .standard1440p:
return 3
case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60,
case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60,
.native5k, .native5k60Experimental:
// Five surfaces protect WindowServer from starvation during
// high-frame-rate 4K capture while the serial pipeline prevents an
Expand All @@ -145,6 +163,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var expectedFrameRate: Int {
switch self {
case .standard1080p:
return 30
case .smooth1080p60:
return 60
case .standard1440p:
return 30
case .smooth1440p60:
Expand All @@ -169,6 +191,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var maxKeyFrameInterval: Int {
switch self {
case .standard1080p, .smooth1080p60:
return 60
case .standard1440p:
return 60
case .smooth1440p60:
Expand All @@ -186,6 +210,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var maxKeyFrameIntervalDuration: Int {
switch self {
case .standard1080p:
return 2
case .smooth1080p60:
return 1
case .standard1440p:
return 2
case .smooth1440p60:
Expand All @@ -199,9 +227,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var prioritizeSpeed: Bool {
switch self {
case .standard1440p:
case .standard1080p, .standard1440p:
return false
case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
return true
}
}
Expand All @@ -215,18 +243,18 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var maxFrameDelayCount: Int {
switch self {
case .standard1440p:
case .standard1080p, .standard1440p:
return 1
case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
return 0
}
}

var dropsBeforeEncodeWhenBacklogged: Bool {
switch self {
case .standard1440p:
case .standard1080p, .standard1440p:
return false
case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
return true
}
}
Expand All @@ -240,7 +268,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var captureResolution: SCCaptureResolutionType {
switch self {
case .standard1440p, .smooth1440p60, .smooth1800p60:
case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60:
return .nominal
case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental:
return .best
Expand All @@ -249,6 +277,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {

var virtualDisplayRefreshRate: Double {
switch self {
case .standard1080p, .smooth1080p60:
return 60
case .standard1440p:
return 60
case .smooth1440p60, .smooth1800p60:
Expand Down Expand Up @@ -276,6 +306,33 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable {
var renderMatchedDesktopDescription: String {
"\(width / 2) × \(height / 2)"
}

/// Virtual display mode for a receiver whose panel already matches the stream
/// pixel for pixel. Rendering 1x keeps the desktop at its true size and makes
/// capture 1:1; the HiDPI variant would halve the logical desktop and draw every
/// control at 2x on a panel with no extra pixels to resolve it.
var nativeScaleDisplayMode: TBVirtualDisplayModeSize {
TBVirtualDisplayModeSize(width: width, height: height, isHiDPI: false)
}

/// True for profiles that target a receiver panel of exactly this resolution,
/// with no extra pixels for a HiDPI backing store.
///
/// The receiver advertises a fixed 5120x2880 panel and a 2560x1440 HiDPI mode
/// no matter what it is actually plugged into, so its profile cannot be used to
/// detect a non-Retina panel; the capture profile is the signal instead. A
/// 21.5-inch non-Retina iMac running the 1080p profiles wants a 1920x1080 1x
/// desktop: HiDPI would render the desktop at 960x540 and draw every control at
/// 2x, and the receiver's own 1440p default would rescale twice on the way out.
var rendersAtNativeScale: Bool {
switch self {
case .standard1080p, .smooth1080p60:
return true
case .standard1440p, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60,
.native5k, .native5k60Experimental:
return false
}
}
}

/// A zero-buffer frame-rate ceiling for capture callbacks. Deadlines advance on
Expand Down Expand Up @@ -1346,7 +1403,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u

private func resolvedCodecType(for preset: TBDisplayCapturePreset, profile: TBMonitorDisplayProfile?) -> CMVideoCodecType {
switch preset {
case .standard1440p, .smooth1440p60, .smooth1800p60:
case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60:
let receiverSupportsHEVC = profile?.supportsHEVCDecode ?? receiverSupportsHEVCDecodeHint ?? false
if receiverSupportsHEVC, Self.probeHEVCHardwareEncoderSupport() {
return kCMVideoCodecType_HEVC
Expand Down Expand Up @@ -2430,15 +2487,27 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u
? await self.fetchShareableDisplayIDs()
: []
let receiverKey = self.extendedDisplayIdentityKey(for: profile)
let modeOverride: TBVirtualDisplayModeSize? = (self.matchRenderToStream && self.captureSource == .extendedDesktop)
? self.capturePreset.renderMatchedDisplayMode
: nil
// A native-scale profile targets a panel with exactly this many pixels, so
// render the desktop 1x at the stream size: capture is 1:1 and the panel
// shows it at its true size. This takes priority over render matching,
// whose 2x mode would halve the logical desktop for no gain on a panel with
// no extra pixels to resolve it.
let modeOverride: TBVirtualDisplayModeSize?
if self.capturePreset.rendersAtNativeScale {
modeOverride = self.capturePreset.nativeScaleDisplayMode
} else if self.matchRenderToStream && self.captureSource == .extendedDesktop {
modeOverride = self.capturePreset.renderMatchedDisplayMode
} else {
modeOverride = nil
}
if let modeOverride {
NSLog(
"TargetBridge: render matching on, virtual display mode %dx%d (backing %dx%d) for %dx%d stream",
"TargetBridge: virtual display mode %dx%d %@ (backing %dx%d) for %dx%d stream, panel %dx%d",
modeOverride.width, modeOverride.height,
modeOverride.isHiDPI ? "HiDPI" : "1x",
modeOverride.backingWidth, modeOverride.backingHeight,
self.capturePreset.width, self.capturePreset.height
self.capturePreset.width, self.capturePreset.height,
profile.panelWidth, profile.panelHeight
)
}
guard self.session.create(
Expand Down
Loading