From c10021af199b6071adbb3758d3025dfc37086895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20G=C3=B6nnenwein?= Date: Thu, 18 Jun 2026 16:54:49 +0200 Subject: [PATCH] feat(sender): remember the virtual display resolution per receiver macOS does not reliably persist resolution for these hot-plugged virtual displays, and the session re-imposed the receiver-advertised default each connect. Capture the user's chosen mode via a display-reconfiguration callback (keyed by virtual-display identity) and re-apply it on reconnect, matching pixel size so HiDPI vs Standard is preserved. --- .../ReceiverBackedVirtualDisplaySession.swift | 48 +++++++++- .../TBVirtualDisplayModeMemory.swift | 88 +++++++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 4 + 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 TargetBridge-Sender/TBDisplaySender/TBVirtualDisplayModeMemory.swift diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index e3afe45..e7c1f1a 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -97,16 +97,30 @@ 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 = "" @@ -114,13 +128,18 @@ final class ReceiverBackedVirtualDisplaySession { } @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 { @@ -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 diff --git a/TargetBridge-Sender/TBDisplaySender/TBVirtualDisplayModeMemory.swift b/TargetBridge-Sender/TBDisplaySender/TBVirtualDisplayModeMemory.swift new file mode 100644 index 0000000..d11b1f0 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBVirtualDisplayModeMemory.swift @@ -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) +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 7a3bdb3..7e9f484 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -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 */; }; @@ -49,6 +50,7 @@ 4C932E960346E674A0659624 /* TBLocalizationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalizationStore.swift; sourceTree = ""; }; 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonManifest.swift; sourceTree = ""; }; 6579B499CBC11196966318EA /* network-link.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "network-link.json"; sourceTree = ""; }; + 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBVirtualDisplayModeMemory.swift; sourceTree = ""; }; 6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderStatusItemController.swift; sourceTree = ""; }; 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputRelayController.swift; sourceTree = ""; }; 80543A7A5A3B583C1543ABC7 /* TBInputDebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputDebugLog.swift; sourceTree = ""; }; @@ -95,6 +97,7 @@ DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */, 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */, 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */, + 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */, ); path = TBDisplaySender; sourceTree = ""; @@ -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; };