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
Original file line number Diff line number Diff line change
Expand Up @@ -97,30 +97,49 @@ final class ReceiverBackedVirtualDisplaySession {
return false
}

activatePreferredMode(for: display.displayID, profile: profile, refreshRate: preferredRefreshRate)
// Restore the user's previously chosen mode for this receiver if we have
// one; otherwise fall back to the receiver-advertised profile default.
let preferenceKey = TBVirtualDisplayModeMemory.preferenceKey(for: identity)
let savedChoice = TBVirtualDisplayModeMemory.shared.load(forKey: preferenceKey)
activatePreferredMode(for: display.displayID,
profile: profile,
refreshRate: preferredRefreshRate,
savedChoice: savedChoice)

virtualDisplay = display
displayID = display.displayID
displayName = profile.receiverName
identityDescription = "vendor=0x\(String(descriptor.vendorID, radix: 16)) product=0x\(String(identity.productID, radix: 16)) serial=0x\(String(identity.serialNumber, radix: 16))"

// Remember any manual resolution change the user makes from now on, so it
// sticks across reconnects for this receiver.
TBVirtualDisplayModeMemory.shared.track(displayID: display.displayID, key: preferenceKey)
return true
}

func destroy() {
if displayID != kCGNullDirectDisplay {
TBVirtualDisplayModeMemory.shared.untrack(displayID: displayID)
}
virtualDisplay = nil
displayID = kCGNullDirectDisplay
displayName = ""
identityDescription = ""
}

@discardableResult
private func activatePreferredMode(for displayID: CGDirectDisplayID, profile: TBMonitorDisplayProfile, refreshRate: Double) -> Bool {
private func activatePreferredMode(for displayID: CGDirectDisplayID,
profile: TBMonitorDisplayProfile,
refreshRate: Double,
savedChoice: TBVirtualDisplayModeMemory.Choice?) -> Bool {
let timeout = Date().addingTimeInterval(2.0)
while Date() < timeout {
var success = false
autoreleasepool {
if let preferredMode = preferredMode(for: displayID, profile: profile, refreshRate: refreshRate) {
success = CGDisplaySetDisplayMode(displayID, preferredMode, nil) == .success
let mode = savedChoice.flatMap { savedMode(for: displayID, choice: $0) }
?? preferredMode(for: displayID, profile: profile, refreshRate: refreshRate)
if let mode {
success = CGDisplaySetDisplayMode(displayID, mode, nil) == .success
}
}
if success {
Expand All @@ -131,6 +150,27 @@ final class ReceiverBackedVirtualDisplaySession {
return false
}

/// Find the display mode matching a saved choice. Matches on pixel size as
/// well as point size so a HiDPI mode is not confused with its 1× ("Standard")
/// counterpart. The low-resolution-duplicates option ensures both variants are
/// enumerated.
private func savedMode(for displayID: CGDirectDisplayID, choice: TBVirtualDisplayModeMemory.Choice) -> CGDisplayMode? {
let options = [kCGDisplayShowDuplicateLowResolutionModes: kCFBooleanTrue] as CFDictionary
guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, options) else {
return nil
}
let modes = modesCF as? [CGDisplayMode] ?? []

let candidates = modes.filter { mode in
mode.width == choice.pointWidth && mode.height == choice.pointHeight &&
mode.pixelWidth == choice.pixelWidth && mode.pixelHeight == choice.pixelHeight
}
if let exact = candidates.first(where: { abs($0.refreshRate - choice.refreshRate) < 0.5 }) {
return exact
}
return candidates.first
}

private func preferredMode(for displayID: CGDirectDisplayID, profile: TBMonitorDisplayProfile, refreshRate: Double) -> CGDisplayMode? {
guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, nil) else {
return nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import CoreGraphics
import Foundation

/// Remembers the user's chosen display mode per virtual-display identity and
/// restores it on reconnect.
///
/// macOS does not reliably persist the resolution of these hot-plugged virtual
/// displays, and the session otherwise re-imposes the receiver-advertised
/// default (e.g. 2560×1440 HiDPI) on every connect. We capture the user's manual
/// resolution change via a CoreGraphics display-reconfiguration callback and
/// re-apply it the next time the same receiver connects.
///
/// All access happens on the main thread: the session (which is `@MainActor`)
/// drives `track`/`untrack`/`load`, and the reconfiguration callback is invoked
/// on the run loop of the registering (main) thread. Hence `@unchecked Sendable`.
final class TBVirtualDisplayModeMemory: @unchecked Sendable {
/// A persisted mode choice. `pixelWidth`/`pixelHeight` capture the backing
/// resolution, which is what distinguishes a HiDPI mode from its 1× ("Standard")
/// counterpart at the same point size.
struct Choice: Codable {
var pointWidth: Int
var pointHeight: Int
var pixelWidth: Int
var pixelHeight: Int
var refreshRate: Double
}

static let shared = TBVirtualDisplayModeMemory()
private init() {}

private let defaultsPrefix = "tb.displayMode."
private var tracked: [CGDirectDisplayID: String] = [:]
private var registered = false

private func defaultsKey(_ key: String) -> String { defaultsPrefix + key }

/// The stable preference key for a virtual-display identity.
static func preferenceKey(for identity: TBVirtualDisplayIdentity) -> String {
"\(identity.productID)-\(identity.serialNumber)"
}

func load(forKey key: String) -> Choice? {
guard let data = UserDefaults.standard.data(forKey: defaultsKey(key)) else { return nil }
return try? JSONDecoder().decode(Choice.self, from: data)
}

/// Begin remembering mode changes for `displayID` under `key`, so the user's
/// subsequent manual resolution changes are persisted.
func track(displayID: CGDirectDisplayID, key: String) {
ensureRegistered()
tracked[displayID] = key
}

func untrack(displayID: CGDirectDisplayID) {
tracked.removeValue(forKey: displayID)
}

private func ensureRegistered() {
guard !registered else { return }
registered = true
CGDisplayRegisterReconfigurationCallback(tbVirtualDisplayReconfigurationCallback, nil)
}

fileprivate func handleReconfiguration(_ displayID: CGDirectDisplayID,
_ flags: CGDisplayChangeSummaryFlags) {
guard flags.contains(.setModeFlag) else { return }
guard let key = tracked[displayID] else { return }
guard let mode = CGDisplayCopyDisplayMode(displayID) else { return }
let choice = Choice(
pointWidth: mode.width,
pointHeight: mode.height,
pixelWidth: mode.pixelWidth,
pixelHeight: mode.pixelHeight,
refreshRate: mode.refreshRate
)
if let data = try? JSONEncoder().encode(choice) {
UserDefaults.standard.set(data, forKey: defaultsKey(key))
}
}
}

private func tbVirtualDisplayReconfigurationCallback(
_ displayID: CGDirectDisplayID,
_ flags: CGDisplayChangeSummaryFlags,
_ userInfo: UnsafeMutableRawPointer?
) {
TBVirtualDisplayModeMemory.shared.handleReconfiguration(displayID, flags)
}
4 changes: 4 additions & 0 deletions TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
337BD561622AA19F43D3B95B /* ReceiverBackedVirtualDisplaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */; };
4290B6572B8CA96E01C56425 /* TBDisplaySenderBuildInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */; };
4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */; };
58980FE95A48D8F8EDB42055 /* TBVirtualDisplayModeMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */; };
5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */; };
6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; };
7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; };
Expand Down Expand Up @@ -49,6 +50,7 @@
4C932E960346E674A0659624 /* TBLocalizationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalizationStore.swift; sourceTree = "<group>"; };
59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonManifest.swift; sourceTree = "<group>"; };
6579B499CBC11196966318EA /* network-link.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "network-link.json"; sourceTree = "<group>"; };
6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBVirtualDisplayModeMemory.swift; sourceTree = "<group>"; };
6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderStatusItemController.swift; sourceTree = "<group>"; };
7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputRelayController.swift; sourceTree = "<group>"; };
80543A7A5A3B583C1543ABC7 /* TBInputDebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputDebugLog.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -95,6 +97,7 @@
DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */,
7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */,
06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */,
6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */,
);
path = TBDisplaySender;
sourceTree = "<group>";
Expand Down Expand Up @@ -274,6 +277,7 @@
8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */,
C2CCAB017BC3CAB1DCFA5CCC /* TBMonitorProtocol.swift in Sources */,
06E4F7B33C77762A5E2CB616 /* TBReceiverDiscovery.swift in Sources */,
58980FE95A48D8F8EDB42055 /* TBVirtualDisplayModeMemory.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down