diff --git a/FreeDisplay.xcodeproj/project.pbxproj b/FreeDisplay.xcodeproj/project.pbxproj index 1c98fd6..d033e89 100644 --- a/FreeDisplay.xcodeproj/project.pbxproj +++ b/FreeDisplay.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 4AA949AC237EE0320969D676 /* DisplayModeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F049322038220CBB75B6985 /* DisplayModeListView.swift */; }; 4C8A0C530FDFF72F78F5DFD3 /* BrightnessHUDService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50B61D6248DB61E562B4E3D /* BrightnessHUDService.swift */; }; 4E6DA9CD1781AAB2A814E56A /* ArrangementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6804E6622DE956669AE0DBCC /* ArrangementView.swift */; }; + 4F57F12852AE8C6A0E98B51D /* DisplayConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6669E8E7560CCDA207539E /* DisplayConnectionService.swift */; }; 5E6835D82CA20AB0673F102F /* NotchOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3739D4F9F8A958DE959C133E /* NotchOverlayManager.swift */; }; 611792FF4019DE0B3D2D8091 /* HiDPIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CFC4A7F963D418082D6B61 /* HiDPIView.swift */; }; 63E0F610C2DB27B05C23D665 /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63BF0C6897ED7891956F2715 /* UpdateService.swift */; }; @@ -38,12 +39,14 @@ 80F888F001C7F53E4FA362AD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 90F55D10A5241A8DFA941E4C /* Assets.xcassets */; }; 87A763D8B6A6F9D9D9351AB7 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA0E0202B5E5AC58680DDDB /* MenuBarView.swift */; }; 8BD7199384F977B50A5EAB5B /* MirrorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754025E6D6152C81F145C63C /* MirrorService.swift */; }; + 8EF0216DF111267D59FB4785 /* DisplayPowerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4765F29B2E36F40EDD0FE52 /* DisplayPowerView.swift */; }; 94A44781BBE01DF170DF6F64 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11B24C0698CE807BA1E28D0 /* AppDelegate.swift */; }; B170F04C4FDA2E9A8D0AD6B0 /* BrightnessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6ED9828FF4F2823DDB03014 /* BrightnessService.swift */; }; B3452C06010BEFEEBF0FD1FC /* ResolutionSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603658292A824E3A3968A4D7 /* ResolutionSliderView.swift */; }; CBC8B0900A104181893B69B2 /* ImageAdjustmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22B3A7EF51E72B078901699 /* ImageAdjustmentView.swift */; }; CD3C6687C2B23F46A3678E94 /* NotchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2419A06679F0A55C454EF1F /* NotchView.swift */; }; D14D979F3149D232244DD701 /* MainDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4720147B44C6328686440A16 /* MainDisplayView.swift */; }; + D4A9A35703129085CA5A6EFA /* DisplayPowerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4AAB1941D982CA85BFA199 /* DisplayPowerService.swift */; }; DEFA538D0EED4A16A0DC987D /* NSScreenExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F1FD9EEC72051EBC3473C1 /* NSScreenExtension.swift */; }; E061011F318208D7FF94D963 /* CGHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5BF8AFF876572E97D370A0 /* CGHelpers.swift */; }; E201AC4CE5BCEC009C926206 /* ArrangementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22030BFC8A680A596A5764C9 /* ArrangementService.swift */; }; @@ -54,6 +57,7 @@ /* Begin PBXFileReference section */ 0509F1D7107310B84B34B825 /* LaunchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchService.swift; sourceTree = ""; }; + 0C4AAB1941D982CA85BFA199 /* DisplayPowerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPowerService.swift; sourceTree = ""; }; 0C8DE3994AD8E543BA765829 /* VirtualDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualDisplayView.swift; sourceTree = ""; }; 181D9C66D6F3D332AAC9AC0B /* SystemColorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemColorView.swift; sourceTree = ""; }; 1C7E2B33E54A6BAC64A84990 /* DisplayDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayDetailView.swift; sourceTree = ""; }; @@ -62,6 +66,7 @@ 3739D4F9F8A958DE959C133E /* NotchOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOverlayManager.swift; sourceTree = ""; }; 3B6EC27CA822E1D2F38769E0 /* FreeDisplay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FreeDisplay.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4720147B44C6328686440A16 /* MainDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainDisplayView.swift; sourceTree = ""; }; + 4E6669E8E7560CCDA207539E /* DisplayConnectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayConnectionService.swift; sourceTree = ""; }; 4F6823DA8C05CB0B6070AA74 /* ColorProfileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorProfileService.swift; sourceTree = ""; }; 58474F9C406F992241963112 /* SavePresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePresetView.swift; sourceTree = ""; }; 598AEDB5733E6B0E108E9EB5 /* AutoBrightnessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBrightnessView.swift; sourceTree = ""; }; @@ -86,6 +91,7 @@ BEC02E3AAD0509479979CBEA /* FreeDisplay.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FreeDisplay.entitlements; sourceTree = ""; }; C11B24C0698CE807BA1E28D0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C2419A06679F0A55C454EF1F /* NotchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchView.swift; sourceTree = ""; }; + C4765F29B2E36F40EDD0FE52 /* DisplayPowerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPowerView.swift; sourceTree = ""; }; C6ED9828FF4F2823DDB03014 /* BrightnessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessService.swift; sourceTree = ""; }; C76FBEDC2A57F5FBBEE11AA0 /* PresetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetService.swift; sourceTree = ""; }; C79B61823CE0057EF1E64215 /* ColorProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorProfileView.swift; sourceTree = ""; }; @@ -137,7 +143,9 @@ DB5BF8AFF876572E97D370A0 /* CGHelpers.swift */, 4F6823DA8C05CB0B6070AA74 /* ColorProfileService.swift */, 719FFBCF3D3A63A6FFFC7172 /* DDCService.swift */, + 4E6669E8E7560CCDA207539E /* DisplayConnectionService.swift */, C8919AFF5445E0571C06B148 /* DisplayManager.swift */, + 0C4AAB1941D982CA85BFA199 /* DisplayPowerService.swift */, 7419D1AC41D1C369FFDCD09D /* GammaService.swift */, 819474C0E46B105CBBFE7A7F /* HiDPIService.swift */, 0509F1D7107310B84B34B825 /* LaunchService.swift */, @@ -204,6 +212,7 @@ C79B61823CE0057EF1E64215 /* ColorProfileView.swift */, 1C7E2B33E54A6BAC64A84990 /* DisplayDetailView.swift */, 6F049322038220CBB75B6985 /* DisplayModeListView.swift */, + C4765F29B2E36F40EDD0FE52 /* DisplayPowerView.swift */, D2CFC4A7F963D418082D6B61 /* HiDPIView.swift */, F22B3A7EF51E72B078901699 /* ImageAdjustmentView.swift */, 4720147B44C6328686440A16 /* MainDisplayView.swift */, @@ -254,7 +263,6 @@ }; }; buildConfigurationList = 8EA410054BD32F42B7BB9BC3 /* Build configuration list for PBXProject "FreeDisplay" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -264,6 +272,7 @@ mainGroup = AD8C2B4D7F682DFFA53EF6B7; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; + productRefGroup = 4D2DDE7731949C0697A6167C /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -302,11 +311,14 @@ 6C97C09AAFC84C7DF9FFFC4F /* ColorProfileService.swift in Sources */, 2A82397FAC6CCCA0966AAED5 /* ColorProfileView.swift in Sources */, 80CA5725D4B2AA1D35E1C527 /* DDCService.swift in Sources */, + 4F57F12852AE8C6A0E98B51D /* DisplayConnectionService.swift in Sources */, 67C5BDA583DF55CAE1C63822 /* DisplayDetailView.swift in Sources */, 20BC0BD119FFB0F3BAC751FD /* DisplayInfo.swift in Sources */, 65CDC231F2FF743ACA68C862 /* DisplayManager.swift in Sources */, 17F9F1E3464F57FEFF92591C /* DisplayMode.swift in Sources */, 4AA949AC237EE0320969D676 /* DisplayModeListView.swift in Sources */, + D4A9A35703129085CA5A6EFA /* DisplayPowerService.swift in Sources */, + 8EF0216DF111267D59FB4785 /* DisplayPowerView.swift in Sources */, 1E5F0FF7497392061D4444CA /* DisplayPreset.swift in Sources */, 3EB262F0A45A2A8F1E9FBCE9 /* FreeDisplayApp.swift in Sources */, 69413272AA7D6B777D028855 /* GammaService.swift in Sources */, diff --git a/FreeDisplay/App/FreeDisplayApp.swift b/FreeDisplay/App/FreeDisplayApp.swift index 8be0b5b..dae3538 100644 --- a/FreeDisplay/App/FreeDisplayApp.swift +++ b/FreeDisplay/App/FreeDisplayApp.swift @@ -31,7 +31,7 @@ struct FreeDisplayApp: App { // Give WindowServer 2 seconds to stabilize after wake before // touching display state. try? await Task.sleep(nanoseconds: 2_000_000_000) - dm.refreshDisplays() + dm.refreshDisplays(invalidateTransportCaches: true) try? await Task.sleep(nanoseconds: 500_000_000) for display in dm.displays { // Apply software brightness factor first so GammaService @@ -45,7 +45,7 @@ struct FreeDisplayApp: App { } } } label: { - Image(systemName: "display") + Label("FreeDisplay", systemImage: "display") } .menuBarExtraStyle(.window) } diff --git a/FreeDisplay/Services/BrightnessService.swift b/FreeDisplay/Services/BrightnessService.swift index c8a73bc..700ef60 100644 --- a/FreeDisplay/Services/BrightnessService.swift +++ b/FreeDisplay/Services/BrightnessService.swift @@ -2,6 +2,7 @@ import Foundation import IOKit import IOKit.graphics import CoreGraphics +import AppKit @_silgen_name("CGDisplayIOServicePort") private func CGDisplayIOServicePort(_ display: CGDirectDisplayID) -> io_service_t @@ -141,6 +142,73 @@ final class BrightnessService: @unchecked Sendable { /// Used to denormalize 0–100% into the display's native DDC range. private var ddcMaxBrightness: [CGDirectDisplayID: UInt16] = [:] + private func displayUUIDString(for displayID: CGDirectDisplayID) -> String? { + guard let cfUUID = CGDisplayCreateUUIDFromDisplayID(displayID), + let uuid = CFUUIDCreateString(nil, cfUUID.takeRetainedValue()) else { + return nil + } + return uuid as String + } + + private func physicalDDCIdentity(for displayID: CGDirectDisplayID) -> String { + "v\(CGDisplayVendorNumber(displayID))-m\(CGDisplayModelNumber(displayID))-s\(CGDisplaySerialNumber(displayID))" + } + + private func ddcDisabledKeys(for displayID: CGDirectDisplayID) -> [String] { + let physical = physicalDDCIdentity(for: displayID) + var keys = [ + "fd.ddc.disabled.physical.\(physical)", + "fd.power.powerVCPUnsafe.physical.\(physical)" + ] + if let uuid = displayUUIDString(for: displayID) { + keys.append("fd.ddc.disabled.\(uuid)") + keys.append("fd.power.powerVCPUnsafe.\(uuid)") + keys.append("fd.power.wakeFailedAfterStandby.\(uuid)") + } + return keys + } + + func isHardwareDDCDisabled(for displayID: CGDirectDisplayID) -> Bool { + let defaults = UserDefaults.standard + return ddcDisabledKeys(for: displayID).contains { defaults.bool(forKey: $0) } + } + + func markHardwareDDCDisabled(for displayID: CGDirectDisplayID, reason: String) { + let defaults = UserDefaults.standard + let physical = physicalDDCIdentity(for: displayID) + defaults.set(true, forKey: "fd.ddc.disabled.physical.\(physical)") + if let uuid = displayUUIDString(for: displayID) { + defaults.set(true, forKey: "fd.ddc.disabled.\(uuid)") + } + ddcAvailableLock.withLock { + ddcAvailable[displayID] = false + ddcMaxBrightness.removeValue(forKey: displayID) + } + PowerDiagnostics.log("ddc-disabled mark displayID=\(displayID) physical=\(physical) reason=\(reason)") + } + + @MainActor + @discardableResult + func markHardwareDDCDisabledIfKnownHighRisk(display: DisplayInfo) -> Bool { + guard isKnownHighRiskDDCDisplay(display) else { return false } + if isHardwareDDCDisabled(for: display.displayID) { return true } + markHardwareDDCDisabled( + for: display.displayID, + reason: "known-high-risk-display-\(display.name)" + ) + return true + } + + @MainActor + private func isKnownHighRiskDDCDisplay(_ display: DisplayInfo) -> Bool { + let normalizedName = display.name + .lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + return normalizedName.contains("s27h85") + } + // MARK: - Public API @MainActor @@ -158,6 +226,15 @@ final class BrightnessService: @unchecked Sendable { display.brightness = b } } else { + if isHardwareDDCDisabled(for: displayID) { + ddcAvailableLock.withLock { + ddcAvailable[displayID] = false + ddcMaxBrightness.removeValue(forKey: displayID) + } + PowerDiagnostics.log("brightness-refresh skip-ddc-disabled displayID=\(displayID)") + return + } + // First check if DDC is already known to be unavailable; if so skip the // async DDC call and just read the current gamma-derived brightness. let knownUnavailable: Bool = ddcAvailableLock.withLock { @@ -210,6 +287,19 @@ final class BrightnessService: @unchecked Sendable { self?.setInternalBrightness(value) } } else { + if isHardwareDDCDisabled(for: displayID) { + ddcAvailableLock.withLock { + ddcAvailable[displayID] = false + ddcMaxBrightness.removeValue(forKey: displayID) + } + display.brightness = clamped + PowerDiagnostics.log("brightness-set software-only displayID=\(displayID) reason=ddc-disabled") + queue.async { [weak self] in + self?.setSoftwareBrightness(clamped, for: displayID) + } + return + } + // Check current DDC availability status let currentStatus: Bool? = ddcAvailableLock.withLock { ddcAvailable[displayID] } @@ -289,7 +379,7 @@ final class BrightnessService: @unchecked Sendable { self.queue.async { self.setInternalBrightness(floatVal) } } } else { - let currentStatus: Bool? = ddcAvailableLock.withLock { ddcAvailable[displayID] } + let currentStatus: Bool? = isDDCAvailable(for: displayID) if currentStatus == false { // Software (gamma) path: 8 steps over 200ms @@ -390,7 +480,53 @@ final class BrightnessService: @unchecked Sendable { /// Returns whether DDC is available for the given display. /// nil means not yet determined (first use). func isDDCAvailable(for displayID: CGDirectDisplayID) -> Bool? { - ddcAvailableLock.withLock { ddcAvailable[displayID] } + if isHardwareDDCDisabled(for: displayID) { + return false + } + return ddcAvailableLock.withLock { ddcAvailable[displayID] } + } + + /// Determines whether DDC is available using the brightness VCP, which is safer + /// than probing power state directly. Existing false values are respected unless + /// `forceProbe` is true, which is used by the explicit refresh button. + func ensureDDCAvailability(for displayID: CGDirectDisplayID, forceProbe: Bool = false) async -> Bool { + if isHardwareDDCDisabled(for: displayID) { + ddcAvailableLock.withLock { + ddcAvailable[displayID] = false + ddcMaxBrightness.removeValue(forKey: displayID) + } + PowerDiagnostics.log("ddc-availability skip-disabled displayID=\(displayID) force=\(forceProbe)") + return false + } + + if !forceProbe, let known = isDDCAvailable(for: displayID) { + PowerDiagnostics.log("ddc-availability cached displayID=\(displayID) available=\(known)") + return known + } + + PowerDiagnostics.log("ddc-availability probe-start displayID=\(displayID) vcp=0x10 force=\(forceProbe)") + let result = await withCheckedContinuation { (continuation: CheckedContinuation<(current: UInt16, max: UInt16)?, Never>) in + DDCService.shared.readAsync( + displayID: displayID, + command: DDCService.brightnessVCP + ) { result in + continuation.resume(returning: result) + } + } + + return ddcAvailableLock.withLock { + if let result, result.max > 0 { + ddcAvailable[displayID] = true + ddcMaxBrightness[displayID] = result.max + PowerDiagnostics.log("ddc-availability probe-ok displayID=\(displayID) vcp=0x10 current=\(result.current) max=\(result.max)") + return true + } + + ddcAvailable[displayID] = false + ddcMaxBrightness.removeValue(forKey: displayID) + PowerDiagnostics.log("ddc-availability probe-failed displayID=\(displayID) vcp=0x10") + return false + } } /// Clears DDC availability and max brightness cache for a disconnected display. diff --git a/FreeDisplay/Services/DDCService.swift b/FreeDisplay/Services/DDCService.swift index f8cf785..84df559 100644 --- a/FreeDisplay/Services/DDCService.swift +++ b/FreeDisplay/Services/DDCService.swift @@ -39,31 +39,49 @@ final class DDCService: ObservableObject, @unchecked Sendable { #if arch(arm64) private var avServiceCache: [CGDirectDisplayID: IOAVServiceRef] = [:] + private var avServiceUnavailableIDs: Set = [] private let avServiceLock = NSLock() - /// Ordered list of all working external AVServices found during last enumeration. - private var allExternalAVServices: [IOAVServiceRef] = [] #endif private init() {} + private func shouldLogPowerDiagnostic(for command: UInt8) -> Bool { + command == Self.brightnessVCP || command == Self.powerVCP + } + + private func vcpCode(_ command: UInt8) -> String { + "0x\(String(command, radix: 16, uppercase: true))" + } + // MARK: - ARM64 IOAVService Path #if arch(arm64) // MARK: - ARM64 IORegistry-based AVService matching - /// Mapping warning exposed to UI when more than one external display is connected - /// and we fall back to index-based AVService assignment. + /// Mapping warning exposed to UI when one or more AVService entries cannot be + /// verified against a concrete CoreGraphics display. @Published var mappingWarning: String? = nil + private struct FramebufferDisplayMatch { + let endpointName: String + let vendor: UInt32 + let product: UInt32 + let serial: UInt32? + let productName: String? + } + /// Attempts to match an IOAVService (DCPAVServiceProxy) to a CGDirectDisplayID by /// comparing IORegistry properties against CoreGraphics display attributes. /// - /// Matching strategy (in order of reliability): - /// 1. Walk up the IORegistry parent chain from the DCPAVServiceProxy node to find a node - /// that has both "DisplayVendorID" and "DisplayProductID", then compare against - /// CGDisplayVendorNumber / CGDisplayModelNumber for each external display. - /// 2. If no vendor/product match is found, fall back to sorted-index assignment and - /// emit a console warning (and set mappingWarning if >1 external display). + /// Matching strategy: + /// 1. Walk up the IORegistry parent chain from the DCPAVServiceProxy node to find + /// DisplayVendorID / DisplayProductID and compare against CoreGraphics. + /// 2. Match the DCPAVServiceProxy's `dispextN` endpoint to the sibling + /// IOMobileFramebufferShim, then compare its ProductAttributes. + /// + /// If neither strategy verifies the identity, the service is intentionally left + /// unmapped. DCPAVServiceProxy enumeration order is not stable across sleep / + /// reconnect, so index fallback can send DDC writes to the wrong monitor. /// /// Returns a dictionary mapping each matched external CGDirectDisplayID to its AVService. private func buildAVServiceMap( @@ -81,44 +99,39 @@ final class DDCService: ObservableObject, @unchecked Sendable { guard !externalIDs.isEmpty else { return [:] } var result: [CGDirectDisplayID: IOAVServiceRef] = [:] - var unmatchedServices: [(service: IOAVServiceRef, ioEntry: io_service_t)] = [] + var unmatchedCount = 0 + let framebufferMatches = framebufferDisplayMatches() - // Strategy 1: IORegistry property matching + // Verified IORegistry matching only. Never assign by display order. for entry in workingServices { - guard let matched = matchAVServiceToDisplay( + let matched = matchAVServiceToFramebufferEndpoint( + ioEntry: entry.ioEntry, + candidates: externalIDs, + alreadyMapped: Set(result.keys), + framebufferMatches: framebufferMatches + ) ?? matchAVServiceToDisplay( ioEntry: entry.ioEntry, candidates: externalIDs, alreadyMapped: Set(result.keys) - ) else { - unmatchedServices.append(entry) + ) + + guard let matched else { + unmatchedCount += 1 continue } + result[matched] = entry.service #if DEBUG - print("[DDCService] ARM64: IORegistry matched AVService to display \(matched) (vendor/product)") + print("[DDCService] ARM64: verified AVService match for display \(matched)") #endif } - // Strategy 2: Index fallback for any remaining unmatched services/displays - let unmappedIDs = externalIDs.filter { result[$0] == nil }.sorted() - if !unmatchedServices.isEmpty && !unmappedIDs.isEmpty { - if unmappedIDs.count > 1 { - let warning = "Multiple external displays: DDC may target wrong monitor (IORegistry matching failed)" - #if DEBUG - print("[DDCService] WARNING: \(warning)") - #endif - DispatchQueue.main.async { self.mappingWarning = warning } - } else { - DispatchQueue.main.async { self.mappingWarning = nil } - } - for (idx, extID) in unmappedIDs.enumerated() { - if idx < unmatchedServices.count { - result[extID] = unmatchedServices[idx].service - #if DEBUG - print("[DDCService] ARM64: index fallback mapped AVService[\(idx)] to display \(extID)") - #endif - } - } + if unmatchedCount > 0 { + let warning = "无法可靠匹配部分 DDC 通道,已阻止未验证的电源命令以避免误控其他显示器。" + #if DEBUG + print("[DDCService] WARNING: \(warning)") + #endif + DispatchQueue.main.async { self.mappingWarning = warning } } else { DispatchQueue.main.async { self.mappingWarning = nil } } @@ -168,18 +181,137 @@ final class DDCService: ObservableObject, @unchecked Sendable { guard let vendor = nodeVendor, let product = nodeProduct else { continue } - // Find a candidate display whose vendor+model matches + var matches: [CGDirectDisplayID] = [] for dispID in candidates { guard !alreadyMapped.contains(dispID) else { continue } if CGDisplayVendorNumber(dispID) == vendor && CGDisplayModelNumber(dispID) == product { - return dispID + matches.append(dispID) } } + if matches.count == 1 { return matches[0] } } return nil } + private func matchAVServiceToFramebufferEndpoint( + ioEntry: io_service_t, + candidates: [CGDirectDisplayID], + alreadyMapped: Set, + framebufferMatches: [String: FramebufferDisplayMatch] + ) -> CGDirectDisplayID? { + guard let path = registryPath(for: ioEntry), + let endpointName = endpointName(fromRegistryPath: path), + let framebuffer = framebufferMatches[endpointName] else { + return nil + } + + var matches: [CGDirectDisplayID] = [] + for dispID in candidates { + guard !alreadyMapped.contains(dispID) else { continue } + guard CGDisplayVendorNumber(dispID) == framebuffer.vendor, + CGDisplayModelNumber(dispID) == framebuffer.product else { continue } + + let displaySerial = CGDisplaySerialNumber(dispID) + if let serial = framebuffer.serial, + serial != 0, + displaySerial != 0, + serial != displaySerial { + continue + } + + matches.append(dispID) + } + + guard matches.count == 1 else { + #if DEBUG + let name = framebuffer.productName ?? endpointName + print("[DDCService] ARM64: ambiguous endpoint match for \(name), candidates=\(matches)") + #endif + return nil + } + + #if DEBUG + print("[DDCService] ARM64: endpoint \(endpointName) matched \(framebuffer.productName ?? "unknown") to display \(matches[0])") + #endif + return matches[0] + } + + private func framebufferDisplayMatches() -> [String: FramebufferDisplayMatch] { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceMatching("IOMobileFramebufferShim"), + &iterator + ) == KERN_SUCCESS else { return [:] } + defer { IOObjectRelease(iterator) } + + var matches: [String: FramebufferDisplayMatch] = [:] + var service = IOIteratorNext(iterator) + while service != IO_OBJECT_NULL { + let currentService = service + service = IOIteratorNext(iterator) + defer { IOObjectRelease(currentService) } + + guard let path = registryPath(for: currentService), + let endpointName = endpointName(fromRegistryPath: path), + let props = ioRegistryEntryProperties(currentService)?.takeRetainedValue() as? [String: Any], + let displayAttributes = dictionaryValue(props["DisplayAttributes"]), + let productAttributes = dictionaryValue(displayAttributes["ProductAttributes"]), + let vendor = uint32Value(productAttributes["LegacyManufacturerID"]), + let product = uint32Value(productAttributes["ProductID"]) else { + continue + } + + matches[endpointName] = FramebufferDisplayMatch( + endpointName: endpointName, + vendor: vendor, + product: product, + serial: uint32Value(productAttributes["SerialNumber"]), + productName: productAttributes["ProductName"] as? String + ) + } + + return matches + } + + private func registryPath(for entry: io_service_t) -> String? { + var path = [CChar](repeating: 0, count: 512) + let result = path.withUnsafeMutableBufferPointer { buffer in + IORegistryEntryGetPath(entry, kIOServicePlane, buffer.baseAddress) + } + guard result == KERN_SUCCESS else { return nil } + let bytes = path.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } + + private func endpointName(fromRegistryPath path: String) -> String? { + guard let start = path.range(of: "dispext") else { return nil } + var end = start.upperBound + while end < path.endIndex, path[end].isNumber { + path.formIndex(after: &end) + } + guard end > start.upperBound else { return nil } + return String(path[start.lowerBound.. [String: Any]? { + if let dictionary = value as? [String: Any] { + return dictionary + } + if let dictionary = value as? NSDictionary { + return dictionary as? [String: Any] + } + return nil + } + + private func uint32Value(_ value: Any?) -> UInt32? { + if let value = value as? UInt32 { return value } + if let value = value as? Int { return UInt32(bitPattern: Int32(truncatingIfNeeded: value)) } + if let value = value as? NSNumber { return value.uint32Value } + return nil + } + /// Wraps IORegistryEntryCreateCFProperties to return an optional Unmanaged. private func ioRegistryEntryProperties(_ entry: io_service_t) -> Unmanaged? { var props: Unmanaged? = nil @@ -190,18 +322,29 @@ final class DDCService: ObservableObject, @unchecked Sendable { } /// Finds the IOAVService for the given display. Caches the result per display. - /// Returns nil if no working AVService is found (built-in displays, or displays + /// Returns nil if no verified AVService is found (built-in displays, or displays /// that don't support DDC over the Apple Silicon AV path). /// - /// Matching strategy: IORegistry vendor/product property matching first, - /// falling back to sorted-index assignment if properties are unavailable. - private func findAVService(for displayID: CGDirectDisplayID) -> IOAVServiceRef? { + /// Matching strategy: verified IORegistry identity matching only. Ambiguous + /// services are left unavailable instead of using display order as a guess. + /// + /// When `allowingUnreadableService` is true, identity-verified services are accepted + /// even if a probe read currently fails. This is used only for wake writes because + /// some displays stop answering reads while in standby but still accept 0xD6=0x01. + private func findAVService( + for displayID: CGDirectDisplayID, + allowingUnreadableService: Bool = false + ) -> IOAVServiceRef? { // Fast path: return cached service if present avServiceLock.lock() if let cached = avServiceCache[displayID] { avServiceLock.unlock() return cached } + if avServiceUnavailableIDs.contains(displayID) && !allowingUnreadableService { + avServiceLock.unlock() + return nil + } avServiceLock.unlock() // Slow path: enumerate all DCPAVServiceProxy nodes in the IOKit registry. @@ -240,10 +383,11 @@ final class DDCService: ObservableObject, @unchecked Sendable { continue } - // Verify the service responds to I2C reads (confirms it's a usable DDC path) + // Verify the service responds to I2C reads for normal DDC reads/writes. + // Wake writes may need to use the identity-verified path even after reads stop. var testBuf = [UInt8](repeating: 0, count: 32) let ret = IOAVServiceReadI2C(avService, 0x37, 0x51, &testBuf, 32) - if ret == kIOReturnSuccess { + if ret == kIOReturnSuccess || allowingUnreadableService { // Retain io_service_t so we can walk its parent chain in buildAVServiceMap IOObjectRetain(service) workingPairs.append((service: avService, ioEntry: service)) @@ -253,6 +397,11 @@ final class DDCService: ObservableObject, @unchecked Sendable { } guard !workingPairs.isEmpty else { + if !allowingUnreadableService { + avServiceLock.lock() + avServiceUnavailableIDs.insert(displayID) + avServiceLock.unlock() + } #if DEBUG print("[DDCService] ARM64: no IOAVService found for display \(displayID)") #endif @@ -274,11 +423,16 @@ final class DDCService: ObservableObject, @unchecked Sendable { avServiceLock.unlock() return cached } - allExternalAVServices = workingPairs.map { $0.service } + avServiceCache.removeAll() + avServiceUnavailableIDs.removeAll() for (extID, avService) in serviceMap { avServiceCache[extID] = avService + avServiceUnavailableIDs.remove(extID) } let result = avServiceCache[displayID] + if result == nil && !allowingUnreadableService { + avServiceUnavailableIDs.insert(displayID) + } avServiceLock.unlock() #if DEBUG @@ -295,7 +449,18 @@ final class DDCService: ObservableObject, @unchecked Sendable { func invalidateAVServiceCache(for displayID: CGDirectDisplayID) { avServiceLock.lock() avServiceCache.removeValue(forKey: displayID) + avServiceUnavailableIDs.remove(displayID) + avServiceLock.unlock() + } + + /// Invalidates all AVService identity caches. Use after display topology changes + /// because Apple Silicon endpoint ordering can change across sleep/reconnect. + func invalidateAllAVServiceCaches() { + avServiceLock.lock() + avServiceCache.removeAll() + avServiceUnavailableIDs.removeAll() avServiceLock.unlock() + DispatchQueue.main.async { self.mappingWarning = nil } } /// ARM64 DDC write: send a Set VCP command via IOAVService. @@ -303,7 +468,11 @@ final class DDCService: ObservableObject, @unchecked Sendable { /// [0x84, 0x03, vcpCode, valueHigh, valueLow, checksum] /// Checksum = XOR of 0x50 (0x51 XOR 0x01) with all preceding buffer bytes. private func arm64Write(displayID: CGDirectDisplayID, command: UInt8, value: UInt16) -> Bool { - guard let avService = findAVService(for: displayID) else { return false } + let isWakeWrite = command == Self.powerVCP && value == 0x01 + guard let avService = findAVService( + for: displayID, + allowingUnreadableService: isWakeWrite + ) else { return false } let valueHigh = UInt8((value >> 8) & 0xFF) let valueLow = UInt8(value & 0xFF) @@ -314,7 +483,13 @@ final class DDCService: ObservableObject, @unchecked Sendable { for b in payload { checksum ^= b } var buf: [UInt8] = payload + [checksum] + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-arm64-set-request displayID=\(displayID) vcp=\(vcpCode(command)) value=0x\(String(value, radix: 16, uppercase: true)) bytes=\(buf.map { String(format: "%02X", $0) }.joined(separator: " "))") + } let ret = IOAVServiceWriteI2C(avService, 0x37, 0x51, &buf, UInt32(buf.count)) + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-arm64-set-result displayID=\(displayID) vcp=\(vcpCode(command)) value=0x\(String(value, radix: 16, uppercase: true)) ioReturn=\(ret)") + } #if DEBUG if ret == kIOReturnSuccess { print("[DDCService] ARM64 write VCP 0x\(String(command, radix: 16)) = \(value) OK") @@ -337,8 +512,14 @@ final class DDCService: ObservableObject, @unchecked Sendable { for b in requestPayload { requestChecksum ^= b } var requestBuf: [UInt8] = requestPayload + [requestChecksum] + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-arm64-get-request displayID=\(displayID) vcp=\(vcpCode(command)) bytes=\(requestBuf.map { String(format: "%02X", $0) }.joined(separator: " "))") + } let writeRet = IOAVServiceWriteI2C(avService, 0x37, 0x51, &requestBuf, UInt32(requestBuf.count)) guard writeRet == kIOReturnSuccess else { + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-arm64-get-request-failed displayID=\(displayID) vcp=\(vcpCode(command)) ioReturn=\(writeRet)") + } #if DEBUG print("[DDCService] ARM64 read request failed for VCP 0x\(String(command, radix: 16)): \(writeRet)") #endif @@ -352,6 +533,9 @@ final class DDCService: ObservableObject, @unchecked Sendable { var replyBuf = [UInt8](repeating: 0, count: 12) let readRet = IOAVServiceReadI2C(avService, 0x37, 0x51, &replyBuf, UInt32(replyBuf.count)) guard readRet == kIOReturnSuccess else { + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-arm64-get-reply-failed displayID=\(displayID) vcp=\(vcpCode(command)) ioReturn=\(readRet)") + } #if DEBUG print("[DDCService] ARM64 read reply failed for VCP 0x\(String(command, radix: 16)): \(readRet)") #endif @@ -374,6 +558,9 @@ final class DDCService: ObservableObject, @unchecked Sendable { let maxVal = (UInt16(replyBuf[6]) << 8) | UInt16(replyBuf[7]) let curVal = (UInt16(replyBuf[8]) << 8) | UInt16(replyBuf[9]) + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-arm64-get-reply-ok displayID=\(displayID) vcp=\(vcpCode(command)) current=0x\(String(curVal, radix: 16, uppercase: true)) max=0x\(String(maxVal, radix: 16, uppercase: true)) bytes=\(replyBuf.map { String(format: "%02X", $0) }.joined(separator: " "))") + } #if DEBUG print("[DDCService] ARM64 read VCP 0x\(String(command, radix: 16)): cur=\(curVal) max=\(maxVal)") #endif @@ -605,16 +792,34 @@ final class DDCService: ObservableObject, @unchecked Sendable { // MARK: - Cache Cleanup - /// Removes all cached VCP entries for a display that is no longer connected. - func clearCache(for displayID: CGDirectDisplayID) { + /// Removes cached VCP values for a display while preserving the verified + /// AVService identity mapping. This keeps wake commands usable after a + /// display enters standby and stops responding to read requests. + func clearVCPValueCache(for displayID: CGDirectDisplayID) { cacheLock.lock() vcpCache.removeValue(forKey: displayID) cacheLock.unlock() + } + + /// Removes all cached VCP entries for a display that is no longer connected. + func clearCache(for displayID: CGDirectDisplayID) { + clearVCPValueCache(for: displayID) #if arch(arm64) invalidateAVServiceCache(for: displayID) #endif } + /// Clears all DDC caches. Display topology changes can invalidate both VCP + /// values and the Apple Silicon AVService-to-display mapping. + func clearAllCaches() { + cacheLock.lock() + vcpCache.removeAll() + cacheLock.unlock() +#if arch(arm64) + invalidateAllAVServiceCaches() +#endif + } + // MARK: - Public Async API (with retry) /// Asynchronously write a VCP value, retrying up to 3 times. @@ -625,6 +830,9 @@ final class DDCService: ObservableObject, @unchecked Sendable { value: UInt16, completion: ((Bool) -> Void)? = nil ) { + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-write-start displayID=\(displayID) vcp=\(vcpCode(command)) value=0x\(String(value, radix: 16, uppercase: true))") + } ddcQueue.async { for attempt in 0..<3 { if self.writeSynchronous(displayID: displayID, command: command, value: value) { @@ -632,11 +840,17 @@ final class DDCService: ObservableObject, @unchecked Sendable { self.cacheLock.lock() self.vcpCache[displayID]?[command] = nil self.cacheLock.unlock() + if self.shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-write-ok displayID=\(displayID) vcp=\(self.vcpCode(command)) value=0x\(String(value, radix: 16, uppercase: true)) attempt=\(attempt + 1)") + } completion?(true) return } if attempt < 2 { Thread.sleep(forTimeInterval: 0.05) } } + if self.shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-write-failed displayID=\(displayID) vcp=\(self.vcpCode(command)) value=0x\(String(value, radix: 16, uppercase: true))") + } completion?(false) } } @@ -652,11 +866,17 @@ final class DDCService: ObservableObject, @unchecked Sendable { cacheLock.lock() if let entry = vcpCache[displayID]?[command], !entry.isExpired { cacheLock.unlock() + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-read-cache-hit displayID=\(displayID) vcp=\(vcpCode(command)) current=0x\(String(entry.current, radix: 16, uppercase: true)) max=0x\(String(entry.max, radix: 16, uppercase: true))") + } completion((current: entry.current, max: entry.max)) return } cacheLock.unlock() + if shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-read-start displayID=\(displayID) vcp=\(vcpCode(command))") + } ddcQueue.async { for attempt in 0..<3 { if let r = self.readSynchronous(displayID: displayID, command: command) { @@ -666,11 +886,17 @@ final class DDCService: ObservableObject, @unchecked Sendable { current: r.current, max: r.max, timestamp: Date() ) self.cacheLock.unlock() + if self.shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-read-ok displayID=\(displayID) vcp=\(self.vcpCode(command)) current=0x\(String(r.current, radix: 16, uppercase: true)) max=0x\(String(r.max, radix: 16, uppercase: true)) attempt=\(attempt + 1)") + } completion(r) return } if attempt < 2 { Thread.sleep(forTimeInterval: 0.05) } } + if self.shouldLogPowerDiagnostic(for: command) { + PowerDiagnostics.log("ddc-read-failed displayID=\(displayID) vcp=\(self.vcpCode(command))") + } completion(nil) } } diff --git a/FreeDisplay/Services/DisplayConnectionService.swift b/FreeDisplay/Services/DisplayConnectionService.swift new file mode 100644 index 0000000..6ba6194 --- /dev/null +++ b/FreeDisplay/Services/DisplayConnectionService.swift @@ -0,0 +1,368 @@ +import Foundation +import CoreGraphics +import Darwin + +enum DisplayConnectionAction: String, Sendable { + case disconnect + case reconnect + + var title: String { + switch self { + case .disconnect: return "断开" + case .reconnect: return "重连" + } + } +} + +struct DisplayConnectionSnapshot: Codable, Identifiable, Sendable { + let displayUUID: String + let name: String + let displayID: CGDirectDisplayID + let vendorNumber: UInt32 + let modelNumber: UInt32 + let serialNumber: UInt32 + let isMain: Bool + let pixelWidth: Int + let pixelHeight: Int + let boundsX: Double + let boundsY: Double + let boundsWidth: Double + let boundsHeight: Double + let timestamp: Date + + var id: String { + "v\(vendorNumber)-m\(modelNumber)-s\(serialNumber)" + } + + var resolutionText: String { + "\(pixelWidth)x\(pixelHeight)" + } +} + +struct DisplayConnectionResult: Sendable { + let success: Bool + let userMessage: String +} + +private enum DisplayTopologyApplyResult: Sendable { + case applied + case unavailable + case timedOut + case failed(stage: String, code: Int32) +} + +private final class DisplayTopologyPrivateBridge: @unchecked Sendable { + static let shared = DisplayTopologyPrivateBridge() + + private typealias ConfigureDisplayEnabledFn = @convention(c) ( + CGDisplayConfigRef, + CGDirectDisplayID, + Int32 + ) -> CGError + + private let configureDisplayEnabled: ConfigureDisplayEnabledFn? + let symbolName: String? + let frameworkPath: String? + + var isAvailable: Bool { + configureDisplayEnabled != nil + } + + private init() { + let frameworkPaths = [ + "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", + "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight" + ] + let symbolNames = [ + "CGSConfigureDisplayEnabled", + "SLSConfigureDisplayEnabled" + ] + + var loadedFunction: ConfigureDisplayEnabledFn? + var loadedSymbolName: String? + var loadedFrameworkPath: String? + + for path in frameworkPaths { + guard let handle = dlopen(path, RTLD_LAZY) else { continue } + for symbolName in symbolNames { + guard let symbol = dlsym(handle, symbolName) else { continue } + loadedFunction = unsafeBitCast(symbol, to: ConfigureDisplayEnabledFn.self) + loadedSymbolName = symbolName + loadedFrameworkPath = path + break + } + if loadedFunction != nil { break } + } + + configureDisplayEnabled = loadedFunction + symbolName = loadedSymbolName + frameworkPath = loadedFrameworkPath + } + + func setDisplay(_ displayID: CGDirectDisplayID, enabled: Bool) async -> DisplayTopologyApplyResult { + guard let configureDisplayEnabled else { + return .unavailable + } + + let enabledFlag: Int32 = enabled ? 1 : 0 + return await CGHelpers.runWithTimeout(seconds: 8, fallback: .timedOut) { + var config: CGDisplayConfigRef? + let begin = CGBeginDisplayConfiguration(&config) + guard begin == .success, let cfg = config else { + return .failed(stage: "begin", code: Int32(begin.rawValue)) + } + + let configure = configureDisplayEnabled(cfg, displayID, enabledFlag) + guard configure == .success else { + CGCancelDisplayConfiguration(cfg) + return .failed(stage: "configure", code: Int32(configure.rawValue)) + } + + let complete = CGCompleteDisplayConfiguration(cfg, .forSession) + guard complete == .success else { + CGCancelDisplayConfiguration(cfg) + return .failed(stage: "complete", code: Int32(complete.rawValue)) + } + + return .applied + } + } +} + +@MainActor +final class DisplayConnectionService: ObservableObject, @unchecked Sendable { + static let shared = DisplayConnectionService() + + private let topologyBridge = DisplayTopologyPrivateBridge.shared + + var isTopologyControlAvailable: Bool { + topologyBridge.isAvailable + } + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private init() { + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + func remember(display: DisplayInfo) { + guard !display.isBuiltin, display.isOnline, display.isEnabled else { return } + save(snapshot: snapshot(for: display), for: display) + } + + func snapshot(for display: DisplayInfo) -> DisplayConnectionSnapshot { + DisplayConnectionSnapshot( + displayUUID: display.displayUUID, + name: display.name, + displayID: display.displayID, + vendorNumber: display.vendorNumber, + modelNumber: display.modelNumber, + serialNumber: display.serialNumber, + isMain: display.isMain, + pixelWidth: display.pixelWidth, + pixelHeight: display.pixelHeight, + boundsX: display.bounds.origin.x, + boundsY: display.bounds.origin.y, + boundsWidth: display.bounds.size.width, + boundsHeight: display.bounds.size.height, + timestamp: Date() + ) + } + + func savedSnapshot(for display: DisplayInfo) -> DisplayConnectionSnapshot? { + let defaults = UserDefaults.standard + let keys = [ + snapshotKey(for: display), + physicalSnapshotKey(for: display) + ] + for key in keys { + guard let data = defaults.data(forKey: key), + let snapshot = try? decoder.decode(DisplayConnectionSnapshot.self, from: data) else { + continue + } + return snapshot + } + return nil + } + + func offlineSnapshots( + activeDisplayIDs: Set, + activePhysicalIDs: Set + ) -> [DisplayConnectionSnapshot] { + var latestByPhysicalID: [String: DisplayConnectionSnapshot] = [:] + let values = UserDefaults.standard.dictionaryRepresentation() + + for (key, value) in values where key.hasPrefix("fd.displayConnection.snapshot.") { + guard let data = value as? Data, + let snapshot = try? decoder.decode(DisplayConnectionSnapshot.self, from: data), + !activeDisplayIDs.contains(snapshot.displayID), + !activePhysicalIDs.contains(snapshot.id) else { + continue + } + + if let existing = latestByPhysicalID[snapshot.id], + existing.timestamp >= snapshot.timestamp { + continue + } + + latestByPhysicalID[snapshot.id] = snapshot + } + + let snapshots = latestByPhysicalID.values.sorted { $0.timestamp > $1.timestamp } + PowerDiagnostics.log("display-connection offline-snapshots count=\(snapshots.count) ids=\(snapshots.map { "\($0.name)#\($0.displayID)#\($0.id)" }.joined(separator: ","))") + return snapshots + } + + func requestDisconnect(display: DisplayInfo, activePhysicalDisplayCount: Int) async -> DisplayConnectionResult { + let snapshot = snapshot(for: display) + save(snapshot: snapshot, for: display) + PowerDiagnostics.log("display-connection request action=disconnect \(identity(for: display)) activePhysicalDisplayCount=\(activePhysicalDisplayCount)") + + guard !display.isBuiltin else { + return blocked("内建显示屏不能使用拓扑断开。") + } + guard activePhysicalDisplayCount > 1 else { + return blocked("为避免断开最后一个可用显示器,FreeDisplay 已阻止这次操作。") + } + guard isTopologyControlAvailable else { + return blocked("安全检查和状态快照已就绪,但当前系统没有导出可用的拓扑断开私有符号。") + } + + return await applyTopologyChange( + displayID: display.displayID, + action: .disconnect, + expectedEnabled: false + ) + } + + func requestReconnect(display: DisplayInfo, activePhysicalDisplayCount: Int? = nil) async -> DisplayConnectionResult { + let snapshot = savedSnapshot(for: display) + PowerDiagnostics.log("display-connection request action=reconnect \(identity(for: display)) hasSnapshot=\(snapshot != nil)") + + guard !display.isBuiltin else { + return blocked("内建显示屏不能使用拓扑重连。") + } + guard snapshot != nil else { + return blocked("还没有这台显示器的连接快照;请先在显示器在线时刷新或执行一次断开准备。") + } + guard isTopologyControlAvailable else { + return blocked("已找到这台显示器的连接记录,但当前系统没有导出可用的拓扑重连私有符号。") + } + + if display.isOnline && display.isEnabled { + if let activePhysicalDisplayCount, activePhysicalDisplayCount <= 1 { + return blocked("为避免断开最后一个可用显示器,FreeDisplay 已阻止这次重连。") + } + + PowerDiagnostics.log("display-connection reconnect-cycle-start \(identity(for: display))") + let disconnect = await applyTopologyChange( + displayID: display.displayID, + action: .disconnect, + expectedEnabled: false + ) + guard disconnect.success else { return disconnect } + + try? await Task.sleep(nanoseconds: 900_000_000) + } + + return await applyTopologyChange( + displayID: display.displayID, + action: .reconnect, + expectedEnabled: true + ) + } + + func requestReconnect(snapshot: DisplayConnectionSnapshot) async -> DisplayConnectionResult { + PowerDiagnostics.log("display-connection request action=reconnect snapshot name=\"\(snapshot.name)\" displayID=\(snapshot.displayID) uuid=\(snapshot.displayUUID) physical=\(snapshot.id)") + + guard isTopologyControlAvailable else { + return blocked("已找到这台显示器的离线记录,但当前系统没有导出可用的拓扑重连私有符号。") + } + + return await applyTopologyChange( + displayID: snapshot.displayID, + action: .reconnect, + expectedEnabled: true + ) + } + + private func save(snapshot: DisplayConnectionSnapshot, for display: DisplayInfo) { + guard let data = try? encoder.encode(snapshot) else { return } + UserDefaults.standard.set(data, forKey: snapshotKey(for: display)) + UserDefaults.standard.set(data, forKey: physicalSnapshotKey(for: display)) + } + + private func snapshotKey(for display: DisplayInfo) -> String { + "fd.displayConnection.snapshot.\(display.displayUUID)" + } + + private func physicalSnapshotKey(for display: DisplayInfo) -> String { + "fd.displayConnection.snapshot.physical.v\(display.vendorNumber)-m\(display.modelNumber)-s\(display.serialNumber)" + } + + private func applyTopologyChange( + displayID: CGDirectDisplayID, + action: DisplayConnectionAction, + expectedEnabled: Bool + ) async -> DisplayConnectionResult { + PowerDiagnostics.log("display-connection apply-start action=\(action.rawValue) displayID=\(displayID) symbol=\(topologyBridge.symbolName ?? "nil")") + + let result = await topologyBridge.setDisplay(displayID, enabled: expectedEnabled) + switch result { + case .applied: + DDCService.shared.clearAllCaches() + BrightnessService.shared.invalidateDDCState(for: displayID) + + try? await Task.sleep(nanoseconds: 600_000_000) + let isActive = CGDisplayIsActive(displayID) != 0 + let isOnline = CGDisplayIsOnline(displayID) != 0 + PowerDiagnostics.log("display-connection apply-ok action=\(action.rawValue) displayID=\(displayID) active=\(isActive) online=\(isOnline)") + + if !expectedEnabled && isActive { + return DisplayConnectionResult( + success: false, + userMessage: "系统已接受断开请求,但这台显示器仍处于启用状态。请刷新后再试。" + ) + } + + if expectedEnabled && !isActive { + return DisplayConnectionResult( + success: false, + userMessage: "系统已接受重连请求,但这台显示器还没有恢复为可用状态。请稍等后刷新;若仍无画面,可能需要重新插拔线缆。" + ) + } + + return DisplayConnectionResult( + success: true, + userMessage: action == .disconnect + ? "已发送拓扑断开请求,FreeDisplay 没有触碰 DDC 电源通道。" + : "已发送拓扑重连请求,FreeDisplay 没有触碰 DDC 电源通道。" + ) + case .unavailable: + return blocked("当前系统没有导出可用的拓扑控制符号,无法执行断开/重连。") + case .timedOut: + PowerDiagnostics.log("display-connection apply-timeout action=\(action.rawValue) displayID=\(displayID)") + return DisplayConnectionResult( + success: false, + userMessage: "WindowServer 没有及时响应拓扑\(action.title)请求,FreeDisplay 已停止等待。请刷新显示器列表确认当前状态。" + ) + case .failed(let stage, let code): + PowerDiagnostics.log("display-connection apply-failed action=\(action.rawValue) displayID=\(displayID) stage=\(stage) code=\(code)") + return DisplayConnectionResult( + success: false, + userMessage: "拓扑\(action.title)失败(\(stage),错误码 \(code))。" + ) + } + } + + private func identity(for display: DisplayInfo) -> String { + "name=\"\(display.name)\" displayID=\(display.displayID) uuid=\(display.displayUUID) vendor=\(display.vendorNumber) model=\(display.modelNumber) serial=\(display.serialNumber)" + } + + private func blocked(_ message: String) -> DisplayConnectionResult { + PowerDiagnostics.log("display-connection blocked message=\"\(message)\"") + return DisplayConnectionResult(success: false, userMessage: message) + } +} diff --git a/FreeDisplay/Services/DisplayManager.swift b/FreeDisplay/Services/DisplayManager.swift index 2b7281b..11c6a5d 100644 --- a/FreeDisplay/Services/DisplayManager.swift +++ b/FreeDisplay/Services/DisplayManager.swift @@ -23,7 +23,7 @@ private func displayReconfigCallback( // Mode or main-display change: refresh mode info for existing displays only. manager.refreshExistingDisplayModes() } else { - manager.refreshDisplays() + manager.refreshDisplays(invalidateTransportCaches: true) } // Auto-rearrange after any display config change completes (debounced 500 ms). @@ -34,6 +34,8 @@ private func displayReconfigCallback( @MainActor class DisplayManager: ObservableObject { @Published var displays: [DisplayInfo] = [] + @Published var disconnectedDisplaySnapshots: [DisplayConnectionSnapshot] = [] + @Published private(set) var displayConfigurationRevision = 0 // nonisolated(unsafe) allows deinit (which is nonisolated in Swift 6) to access this value. nonisolated(unsafe) private var callbackContext: UnsafeMutableRawPointer? @@ -53,7 +55,7 @@ class DisplayManager: ObservableObject { } } - func refreshDisplays() { + func refreshDisplays(invalidateTransportCaches: Bool = false) { var displayCount: UInt32 = 0 CGGetOnlineDisplayList(0, nil, &displayCount) var displayIDs = [CGDirectDisplayID](repeating: 0, count: Int(displayCount)) @@ -62,6 +64,14 @@ class DisplayManager: ObservableObject { let currentIDs = Set(displays.map { $0.displayID }) let newIDSet = Set((0.. String { + "v\(display.vendorNumber)-m\(display.modelNumber)-s\(display.serialNumber)" + } + + private func identityKeys(for display: DisplayInfo) -> [String] { + [ + "uuid.\(display.displayUUID)", + "physical.\(physicalIdentity(for: display))" + ] + } + + private func d6ReadUnreliableKeys(for display: DisplayInfo) -> [String] { + [ + "fd.power.d6ReadUnreliable.\(display.displayUUID)", + "fd.power.d6ReadUnreliable.physical.\(physicalIdentity(for: display))" + ] + } + + private func topologyPowerPreferredKeys(for display: DisplayInfo) -> [String] { + [ + "fd.power.topologyPreferred.\(display.displayUUID)", + "fd.power.topologyPreferred.physical.\(physicalIdentity(for: display))" + ] + } + + private func localPowerRecord(for display: DisplayInfo) -> LocalPowerRecord? { + for key in identityKeys(for: display) { + if let record = localPowerRecords[key] { + return record + } + } + return nil + } + + func hasLocalPowerDownRequest(for display: DisplayInfo) -> Bool { + guard let record = localPowerRecord(for: display) else { return false } + return record.mode != .on + } + + func localPowerDownStatus(for display: DisplayInfo) -> DisplayPowerStatus? { + guard let record = localPowerRecord(for: display), + record.mode != .on else { + return nil + } + return DisplayPowerStatus( + mode: record.mode, + rawValue: record.mode.rawValue, + source: isD6ReadUnreliable(for: display) ? .localCommandReadUnreliable : .localCommand + ) + } + + func isD6ReadUnreliable(for display: DisplayInfo) -> Bool { + let defaults = UserDefaults.standard + return d6ReadUnreliableKeys(for: display).contains { defaults.bool(forKey: $0) } + } + + func prefersTopologyPowerControl(for display: DisplayInfo) -> Bool { + let defaults = UserDefaults.standard + return isKnownDDCWakeUnsafeDisplay(display) + || topologyPowerPreferredKeys(for: display).contains { defaults.bool(forKey: $0) } + } + + func markTopologyPowerPreferred(for display: DisplayInfo, reason: String) { + let defaults = UserDefaults.standard + let keys = topologyPowerPreferredKeys(for: display) + let wasMarked = keys.contains { defaults.bool(forKey: $0) } + keys.forEach { defaults.set(true, forKey: $0) } + if !wasMarked { + PowerDiagnostics.log("power-topology-preferred mark name=\"\(display.name)\" displayID=\(display.displayID) reason=\(reason)") + } + stateRevision &+= 1 + } + + func markD6ReadUnreliable(for display: DisplayInfo, reason: String) { + let defaults = UserDefaults.standard + let keys = d6ReadUnreliableKeys(for: display) + let wasMarked = keys.contains { defaults.bool(forKey: $0) } + keys.forEach { defaults.set(true, forKey: $0) } + if !wasMarked { + PowerDiagnostics.log("power-d6-read-unreliable mark name=\"\(display.name)\" displayID=\(display.displayID) reason=\(reason)") + } + stateRevision &+= 1 + } + + func clearLocalPowerDownState(for display: DisplayInfo) { + var didRemove = false + for key in identityKeys(for: display) { + if localPowerRecords.removeValue(forKey: key) != nil { + didRemove = true + } + } + if didRemove { + PowerDiagnostics.log("power-local-state clear name=\"\(display.name)\" displayID=\(display.displayID)") + stateRevision &+= 1 + } + } + + private func recordLocalPowerMode(_ mode: DisplayPowerMode, for display: DisplayInfo) { + if mode == .on { + clearLocalPowerDownState(for: display) + return + } + + let record = LocalPowerRecord(mode: mode, date: Date()) + identityKeys(for: display).forEach { + localPowerRecords[$0] = record + } + PowerDiagnostics.log("power-local-state record name=\"\(display.name)\" displayID=\(display.displayID) mode=\(mode.title)") + stateRevision &+= 1 + } + + private func reconcileHardwareStatus( + _ status: DisplayPowerStatus, + maxValue: UInt16, + for display: DisplayInfo + ) -> DisplayPowerStatus { + if maxValue == 0xFF { + markD6ReadUnreliable(for: display, reason: "power-vcp-max-0xFF") + } + + if let localStatus = localPowerDownStatus(for: display) { + if status.mode == .on { + markD6ReadUnreliable( + for: display, + reason: "local-power-down-read-back-on" + ) + return DisplayPowerStatus( + mode: localStatus.mode, + rawValue: localStatus.rawValue, + source: .localCommandReadUnreliable + ) + } + return localStatus + } + + if isD6ReadUnreliable(for: display), status.mode == .on { + return DisplayPowerStatus( + mode: status.mode, + rawValue: status.rawValue, + source: .hardwareReadUnreliable + ) + } + + return status + } + + private func isKnownDDCWakeUnsafeDisplay(_ display: DisplayInfo) -> Bool { + let normalizedName = display.name + .lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + return normalizedName == "mimonitor" + && display.vendorNumber == 25_001 + && display.modelNumber == 10_164 + } + + func currentStatus(for displayID: CGDirectDisplayID, forceDDCProbe: Bool = false) async -> DisplayPowerStatus? { + (await readHardwareStatus(for: displayID, forceDDCProbe: forceDDCProbe))?.status + } + + func currentStatus(for display: DisplayInfo, forceDDCProbe: Bool = false) async -> DisplayPowerStatus? { + guard let hardware = await readHardwareStatus( + for: display.displayID, + forceDDCProbe: forceDDCProbe + ) else { + if let localStatus = localPowerDownStatus(for: display) { + PowerDiagnostics.log("power-status local-fallback name=\"\(display.name)\" displayID=\(display.displayID) mode=\(localStatus.title)") + return localStatus + } + return nil + } + + return reconcileHardwareStatus( + hardware.status, + maxValue: hardware.maxValue, + for: display + ) + } + + private func readHardwareStatus( + for displayID: CGDirectDisplayID, + forceDDCProbe: Bool = false + ) async -> (status: DisplayPowerStatus, maxValue: UInt16)? { + if forceDDCProbe { + PowerDiagnostics.log("power-status probe-only displayID=\(displayID) action=read-brightness-vcp-0x10 skip=power-vcp-0xD6") + _ = await BrightnessService.shared.ensureDDCAvailability( + for: displayID, + forceProbe: true + ) + return nil + } + + guard await BrightnessService.shared.ensureDDCAvailability( + for: displayID, + forceProbe: forceDDCProbe + ) else { + PowerDiagnostics.log("power-status unavailable displayID=\(displayID) reason=ddc-brightness-probe-failed") + return nil + } + + PowerDiagnostics.log("power-status read displayID=\(displayID) vcp=0xD6") + return await withCheckedContinuation { (continuation: CheckedContinuation<(status: DisplayPowerStatus, maxValue: UInt16)?, Never>) in + DDCService.shared.readAsync( + displayID: displayID, + command: DDCService.powerVCP + ) { result in + guard let result else { + PowerDiagnostics.log("power-status read-failed displayID=\(displayID) vcp=0xD6") + continuation.resume(returning: nil) + return + } + let mode = DisplayPowerMode(rawDDCValue: result.current) + PowerDiagnostics.log("power-status read-ok displayID=\(displayID) vcp=0xD6 current=0x\(String(result.current, radix: 16, uppercase: true)) max=0x\(String(result.max, radix: 16, uppercase: true))") + continuation.resume(returning: ( + status: DisplayPowerStatus(mode: mode, rawValue: result.current), + maxValue: result.max + )) + } + } + } + + func setPowerMode(_ mode: DisplayPowerMode, for displayID: CGDirectDisplayID) async -> Bool { + guard !BrightnessService.shared.isHardwareDDCDisabled(for: displayID) else { + PowerDiagnostics.log("power-command skipped displayID=\(displayID) reason=ddc-disabled") + return false + } + + DDCService.shared.clearCache(for: displayID) + PowerDiagnostics.log("power-command send displayID=\(displayID) vcp=0xD6 value=0x\(String(mode.rawValue, radix: 16, uppercase: true)) title=\(mode.title)") + return await withCheckedContinuation { continuation in + DDCService.shared.writeAsync( + displayID: displayID, + command: DDCService.powerVCP, + value: mode.rawValue + ) { success in + PowerDiagnostics.log("power-command result displayID=\(displayID) vcp=0xD6 value=0x\(String(mode.rawValue, radix: 16, uppercase: true)) success=\(success)") + continuation.resume(returning: success) + } + } + } + + func setPowerMode(_ mode: DisplayPowerMode, for display: DisplayInfo) async -> Bool { + let success = await setPowerMode(mode, for: display.displayID) + if success { + recordLocalPowerMode(mode, for: display) + } + return success + } + + func reinitializeDisplayLink(for displayID: CGDirectDisplayID) async -> Bool { + PowerDiagnostics.log("display-link reinitialize-start displayID=\(displayID)") + DDCService.shared.clearCache(for: displayID) + BrightnessService.shared.invalidateDDCState(for: displayID) + + guard CGDisplayIsOnline(displayID) != 0, + CGDisplayIsActive(displayID) != 0, + let mode = CGDisplayCopyDisplayMode(displayID) else { + PowerDiagnostics.log("display-link reinitialize-failed displayID=\(displayID) reason=display-not-active") + return false + } + + let success = await ResolutionService.applyModeSync(mode, on: displayID) + + try? await Task.sleep(nanoseconds: 500_000_000) + DDCService.shared.clearCache(for: displayID) + BrightnessService.shared.invalidateDDCState(for: displayID) + + PowerDiagnostics.log("display-link reinitialize-result displayID=\(displayID) success=\(success)") + return success + } +} diff --git a/FreeDisplay/Views/DisplayDetailView.swift b/FreeDisplay/Views/DisplayDetailView.swift index b7b991f..4a21f9c 100644 --- a/FreeDisplay/Views/DisplayDetailView.swift +++ b/FreeDisplay/Views/DisplayDetailView.swift @@ -35,6 +35,10 @@ struct DisplayDetailView: View { // HiDPI toggle — before mode list (natural workflow: enable HiDPI → pick resolution) HiDPIRowView(display: display) + // DDC power controls (external displays only) + DisplayPowerView(display: display) + .environmentObject(displayManager) + // Display mode list toggle row ExpandableRow( icon: "rectangle.on.rectangle", @@ -123,4 +127,3 @@ struct DisplayDetailView: View { } } } - diff --git a/FreeDisplay/Views/DisplayPowerView.swift b/FreeDisplay/Views/DisplayPowerView.swift new file mode 100644 index 0000000..99f87c9 --- /dev/null +++ b/FreeDisplay/Views/DisplayPowerView.swift @@ -0,0 +1,786 @@ +import SwiftUI + +struct DisplayPowerView: View { + @ObservedObject var display: DisplayInfo + @EnvironmentObject var displayManager: DisplayManager + @ObservedObject private var powerService = DisplayPowerService.shared + @ObservedObject private var ddcService = DDCService.shared + @ObservedObject private var connectionService = DisplayConnectionService.shared + @State private var status: DisplayPowerStatus? + @State private var didLoadStatus = false + @State private var isLoading = false + @State private var pendingCommand: DisplayPowerCommand? + @State private var errorMessage: String? + @State private var wakeFailedAfterStandby = false + @State private var powerVCPUnsafe = false + @State private var didSkipPowerStatusRead = false + + private var activePhysicalDisplayCount: Int { + displayManager.displays.filter { + $0.isOnline + && $0.isEnabled + && !VirtualDisplayService.shared.isVirtualDisplay($0.displayID) + }.count + } + + private var statusText: String { + if isLoading { return "处理中" } + if usesTopologyConnection { + return connectionService.isTopologyControlAvailable ? "拓扑控制" : "拓扑待接入" + } + guard let status else { return didLoadStatus ? "不可读取" : "DDC 电源" } + return status.title + } + + private var statusColor: Color { + if usesTopologyConnection { return .blue } + guard let mode = status?.mode else { return .secondary } + switch mode { + case .on: return .green + case .standby, .suspend, .off: return .orange + case .hardOff: return .red + } + } + + private var isDDCUnavailable: Bool { + BrightnessService.shared.isDDCAvailable(for: display.displayID) == false + } + + private var isPowerStatusUnreadable: Bool { + didLoadStatus && status == nil + } + + private var hasLocalPowerDownRequest: Bool { + powerService.hasLocalPowerDownRequest(for: display) + } + + private var hasD6ReadUnreliable: Bool { + powerService.isD6ReadUnreliable(for: display) + } + + private var hasUnsafeDDCMapping: Bool { + isPowerStatusUnreadable && ddcService.mappingWarning != nil + } + + private var usesTopologyConnection: Bool { + powerVCPUnsafe + || BrightnessService.shared.isHardwareDDCDisabled(for: display.displayID) + || powerService.prefersTopologyPowerControl(for: display) + } + + private var shouldUseReconnectAction: Bool { + isPowerStatusUnreadable && !hasLocalPowerDownRequest + } + + private var isReconnectDisabled: Bool { + isLoading || display.isBuiltin + } + + private var wakeFailureKey: String { + "fd.power.wakeFailedAfterStandby.\(display.displayUUID)" + } + + private var powerUnsafeKey: String { + "fd.power.powerVCPUnsafe.\(display.displayUUID)" + } + + private var physicalPowerUnsafeKey: String { + "fd.power.powerVCPUnsafe.physical.v\(display.vendorNumber)-m\(display.modelNumber)-s\(display.serialNumber)" + } + + private var diagnosticIdentity: String { + "name=\"\(display.name)\" displayID=\(display.displayID) uuid=\(display.displayUUID) vendor=\(display.vendorNumber) model=\(display.modelNumber) serial=\(display.serialNumber)" + } + + private func reloadPowerSafetyFlags() { + _ = BrightnessService.shared.markHardwareDDCDisabledIfKnownHighRisk(display: display) + + let defaults = UserDefaults.standard + let wakeFailed = defaults.bool(forKey: wakeFailureKey) + if wakeFailed { + defaults.set(true, forKey: powerUnsafeKey) + defaults.set(true, forKey: physicalPowerUnsafeKey) + } + let unsafeByUUID = defaults.bool(forKey: powerUnsafeKey) + let unsafeByPhysicalID = defaults.bool(forKey: physicalPowerUnsafeKey) + wakeFailedAfterStandby = wakeFailed + powerVCPUnsafe = wakeFailed || unsafeByUUID || unsafeByPhysicalID + if powerVCPUnsafe { + BrightnessService.shared.markHardwareDDCDisabled( + for: display.displayID, + reason: "power-vcp-unsafe" + ) + } + PowerDiagnostics.log("power-view safety \(diagnosticIdentity) wakeFailed=\(wakeFailed) unsafeUUID=\(unsafeByUUID) unsafePhysical=\(unsafeByPhysicalID)") + } + + private func markPowerVCPUnsafe(reason: String) { + powerVCPUnsafe = true + UserDefaults.standard.set(true, forKey: powerUnsafeKey) + UserDefaults.standard.set(true, forKey: physicalPowerUnsafeKey) + BrightnessService.shared.markHardwareDDCDisabled( + for: display.displayID, + reason: reason + ) + PowerDiagnostics.log("power-view mark-unsafe \(diagnosticIdentity) reason=\(reason)") + } + + private func isActionDisabled(_ command: DisplayPowerCommand) -> Bool { + if isLoading { return true } + if isDDCUnavailable && !(command == .wake && hasLocalPowerDownRequest) { return true } + if hasUnsafeDDCMapping { return true } + if powerVCPUnsafe { return true } + if command == .wake { + if hasLocalPowerDownRequest { return false } + guard let mode = status?.mode else { + return true + } + return mode == .on + } + if command == .sleep { + if wakeFailedAfterStandby { return true } + if hasLocalPowerDownRequest { return true } + return status?.mode != .on + } + if command == .hardOff { + if hasD6ReadUnreliable { return true } + return status?.mode != .on + } + return isPowerStatusUnreadable + } + + private var automaticMessage: String? { + guard errorMessage == nil else { return nil } + if wakeFailedAfterStandby { + return "这台显示器待机后没有响应软件唤醒,已禁用睡眠以避免再次进入不可恢复状态。" + } + if usesTopologyConnection { + if connectionService.isTopologyControlAvailable { + if powerService.prefersTopologyPowerControl(for: display) { + return "这台显示器的 DDC 待机可以生效,但 DDC 唤醒不能可靠恢复画面;FreeDisplay 已改用拓扑断开/重连,不影响亮度等普通 DDC 控制。" + } + return "这台显示器已切换到拓扑断开/重连模式,FreeDisplay 不会发送任何硬件 DDC 指令。" + } + if powerService.prefersTopologyPowerControl(for: display) { + return "这台显示器的 DDC 唤醒不可靠;当前系统没有可用的拓扑控制符号,建议避免使用 DDC 睡眠。" + } + return "这台显示器的 DDC 通道已标记为高风险;现在只保留软件亮度。断开/重连的安全壳已就绪,真正控制需要下一步接入 CoreDisplay/WindowServer 私有 API。" + } + if hasD6ReadUnreliable { + if hasLocalPowerDownRequest { + return "这台显示器的 VCP 0xD6 读值不可靠;FreeDisplay 会保留本次待机后的唤醒按钮,并暂时禁用硬件关机。" + } + return "这台显示器的 VCP 0xD6 读值不可靠;睡眠仍可用,但硬件关机已禁用以降低恢复风险。" + } + if hasLocalPowerDownRequest { + return "FreeDisplay 已记录本次电源命令;如果显示器未自动恢复,唤醒按钮会继续保留。" + } + if isDDCUnavailable { + return "未检测到这台显示器的 DDC/CI 硬件通道,当前只能使用软件亮度。可以先尝试重连显示链路;若仍不可用,请检查显示器菜单里的 DDC/CI 或连接方式。" + } + if didLoadStatus && status == nil { + if ddcService.mappingWarning != nil { + return "无法可靠确认这台显示器对应的 DDC 通道,已阻止电源控制以避免误控其他显示器。" + } + if didSkipPowerStatusRead { + return "刷新只重新检测了 DDC 通道,没有发送 VCP 0xD6 电源状态请求;为避免触发异常待机,睡眠、唤醒和关机会暂时禁用。" + } + if hasLocalPowerDownRequest { + return "无法读取 VCP 0xD6 电源状态;已保留本次睡眠后的唤醒按钮,睡眠和关机会暂时禁用。" + } + return "无法读取 VCP 0xD6 电源状态;请先尝试重连显示链路,DDC 睡眠、唤醒和关机会暂时禁用。" + } + return nil + } + + var body: some View { + if display.isBuiltin { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + MenuItemIcon(systemName: "power.circle.fill", color: .red) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text("显示器电源") + .font(.body) + Text(statusText) + .font(.caption) + .foregroundColor(statusColor) + } + + Spacer() + + if isLoading { + ProgressView() + .scaleEffect(0.6) + .frame(width: 18, height: 18) + } else { + Button { + refreshStatus() + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(usesTopologyConnection ? "刷新显示连接状态" : "重新检测 DDC 通道") + } + } + + if usesTopologyConnection { + HStack(spacing: 6) { + DisplayPowerActionButton( + title: "断开", + systemImage: "rectangle.slash", + tint: .indigo, + isDisabled: isLoading + ) { + performTopologyAction(.disconnect) + } + + DisplayPowerActionButton( + title: "重连", + systemImage: "arrow.triangle.2.circlepath", + tint: .blue, + isDisabled: isLoading + ) { + performTopologyAction(.reconnect) + } + } + } else { + HStack(spacing: 6) { + DisplayPowerActionButton( + title: "睡眠", + systemImage: "moon.zzz.fill", + tint: .indigo, + isDisabled: isActionDisabled(.sleep) + ) { + request(.sleep) + } + + DisplayPowerActionButton( + title: shouldUseReconnectAction ? "重连" : "唤醒", + systemImage: shouldUseReconnectAction ? "arrow.triangle.2.circlepath" : "sun.max.fill", + tint: shouldUseReconnectAction ? .blue : .orange, + isDisabled: shouldUseReconnectAction ? isReconnectDisabled : isActionDisabled(.wake) + ) { + if shouldUseReconnectAction { + performReinitialize() + } else { + perform(.wake) + } + } + + DisplayPowerActionButton( + title: "关机", + systemImage: "power", + tint: .red, + isDisabled: isActionDisabled(.hardOff) + ) { + request(.hardOff) + } + } + } + + if let pendingCommand { + DisplayPowerConfirmRow( + command: pendingCommand, + message: confirmMessage(for: pendingCommand), + onCancel: { + self.pendingCommand = nil + }, + onConfirm: { + perform(pendingCommand) + } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + if let errorMessage { + DisplayPowerMessageRow( + systemImage: "exclamationmark.triangle.fill", + message: errorMessage, + tint: .orange, + onDismiss: { + self.errorMessage = nil + } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } else if let automaticMessage { + DisplayPowerMessageRow( + systemImage: "info.circle.fill", + message: automaticMessage, + tint: .blue, + onDismiss: nil + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .task(id: display.displayID) { + reloadPowerSafetyFlags() + await loadStatus() + } + .onChange(of: displayManager.displayConfigurationRevision) { _, _ in + pendingCommand = nil + errorMessage = nil + status = nil + didLoadStatus = false + didSkipPowerStatusRead = false + reloadPowerSafetyFlags() + Task { + try? await Task.sleep(nanoseconds: 300_000_000) + await loadStatus() + } + } + } + } + + private func request(_ command: DisplayPowerCommand) { + guard !isDDCUnavailable else { + pendingCommand = nil + errorMessage = "这台显示器当前没有可用的 DDC/CI 硬件通道,无法发送睡眠或关机命令。" + return + } + guard !isPowerStatusUnreadable else { + pendingCommand = nil + errorMessage = "无法读取这台显示器的电源状态。为避免误操作,FreeDisplay 已暂时阻止睡眠和关机。" + return + } + guard !(command == .sleep && wakeFailedAfterStandby) else { + pendingCommand = nil + errorMessage = "这台显示器待机后无法通过 FreeDisplay 唤醒,已阻止再次发送睡眠命令。" + return + } + guard command != .sleep || status?.mode == .on else { + pendingCommand = nil + errorMessage = "这台显示器当前不是开机状态,FreeDisplay 已阻止重复发送睡眠命令。" + return + } + guard command != .hardOff || status?.mode == .on else { + pendingCommand = nil + errorMessage = "这台显示器当前不是开机状态,FreeDisplay 已阻止发送硬件关机命令。" + return + } + guard command != .hardOff || !hasD6ReadUnreliable else { + pendingCommand = nil + errorMessage = "这台显示器的 DDC 电源读值不可靠,FreeDisplay 已禁用硬件关机以降低无法恢复的风险。" + return + } + guard !powerVCPUnsafe else { + pendingCommand = nil + errorMessage = "这台显示器的 DDC 电源控制已标记为高风险,FreeDisplay 已阻止这次操作。" + return + } + guard activePhysicalDisplayCount > 1 else { + pendingCommand = nil + errorMessage = "为避免关掉最后一个可用显示器,FreeDisplay 已阻止这次操作。" + return + } + errorMessage = nil + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + pendingCommand = command + } + } + + private func perform(_ command: DisplayPowerCommand) { + guard !isLoading else { return } + guard !isDDCUnavailable || (command == .wake && hasLocalPowerDownRequest) else { + pendingCommand = nil + errorMessage = "这台显示器当前没有可用的 DDC/CI 硬件通道,无法发送电源命令。" + return + } + guard !(command != .wake && isPowerStatusUnreadable) else { + pendingCommand = nil + errorMessage = "无法读取这台显示器的电源状态。为避免误操作,FreeDisplay 已暂时阻止睡眠和关机。" + return + } + guard command == .wake || status?.mode == .on else { + pendingCommand = nil + errorMessage = "这台显示器当前不是开机状态,FreeDisplay 已阻止发送新的关机/睡眠命令。" + return + } + guard command != .hardOff || !hasD6ReadUnreliable else { + pendingCommand = nil + errorMessage = "这台显示器的 DDC 电源读值不可靠,FreeDisplay 已禁用硬件关机以降低无法恢复的风险。" + return + } + guard !hasUnsafeDDCMapping else { + pendingCommand = nil + errorMessage = "这台显示器的 DDC 通道没有通过身份校验,FreeDisplay 已阻止发送命令。" + return + } + guard !powerVCPUnsafe else { + pendingCommand = nil + errorMessage = "这台显示器的 DDC 电源控制已标记为高风险,FreeDisplay 已阻止发送命令。" + return + } + pendingCommand = nil + errorMessage = nil + isLoading = true + + Task { + let success = await powerService.setPowerMode(command.mode, for: display) + await MainActor.run { + isLoading = false + if success { + didLoadStatus = true + didSkipPowerStatusRead = false + status = DisplayPowerStatus( + mode: command.mode, + rawValue: command.mode.rawValue, + source: .localCommand + ) + if command == .wake { + wakeFailedAfterStandby = false + UserDefaults.standard.removeObject(forKey: wakeFailureKey) + } + } else { + if command == .wake && (isPowerStatusUnreadable || hasLocalPowerDownRequest) { + wakeFailedAfterStandby = true + UserDefaults.standard.set(true, forKey: wakeFailureKey) + markPowerVCPUnsafe(reason: "wake-failed-after-standby") + } + errorMessage = errorText(forFailedCommand: command) + } + } + } + } + + private func refreshStatus() { + guard !isLoading else { return } + PowerDiagnostics.log("power-refresh click \(diagnosticIdentity) powerUnsafe=\(powerVCPUnsafe) wakeFailed=\(wakeFailedAfterStandby)") + if usesTopologyConnection { + refreshTopologyStatus() + return + } + DDCService.shared.clearCache(for: display.displayID) + errorMessage = nil + Task { await loadStatus(forceDDCProbe: true) } + } + + private func loadStatus(forceDDCProbe: Bool = false) async { + let preservedStatus = await MainActor.run { () -> DisplayPowerStatus? in + isLoading = true + return status + } + + if usesTopologyConnection { + PowerDiagnostics.log("topology-status skipped-ddc \(diagnosticIdentity)") + await MainActor.run { + status = nil + didSkipPowerStatusRead = true + didLoadStatus = true + isLoading = false + } + return + } + + if forceDDCProbe { + let available = await BrightnessService.shared.ensureDDCAvailability( + for: display.displayID, + forceProbe: true + ) + PowerDiagnostics.log("power-refresh probe-result \(diagnosticIdentity) ddcAvailable=\(available) skippedPowerVCP=0xD6") + await MainActor.run { + if let localStatus = powerService.localPowerDownStatus(for: display) { + status = localStatus + } else if !available { + status = nil + } else { + status = preservedStatus + } + didSkipPowerStatusRead = true + didLoadStatus = true + isLoading = false + } + return + } + + if powerVCPUnsafe { + PowerDiagnostics.log("power-status skipped-unsafe \(diagnosticIdentity)") + await MainActor.run { + status = nil + didSkipPowerStatusRead = true + didLoadStatus = true + isLoading = false + } + return + } + + let newStatus = await powerService.currentStatus( + for: display, + forceDDCProbe: forceDDCProbe + ) + await MainActor.run { + status = newStatus + didSkipPowerStatusRead = false + didLoadStatus = true + isLoading = false + } + } + + private func refreshTopologyStatus() { + pendingCommand = nil + errorMessage = nil + isLoading = true + PowerDiagnostics.log("topology-refresh click \(diagnosticIdentity)") + + Task { + await MainActor.run { + displayManager.refreshDisplays(invalidateTransportCaches: false) + status = nil + didSkipPowerStatusRead = true + didLoadStatus = true + isLoading = false + } + } + } + + private func performTopologyAction(_ action: DisplayConnectionAction) { + guard !isLoading else { return } + pendingCommand = nil + errorMessage = nil + isLoading = true + + Task { + let result: DisplayConnectionResult + switch action { + case .disconnect: + result = await connectionService.requestDisconnect( + display: display, + activePhysicalDisplayCount: activePhysicalDisplayCount + ) + case .reconnect: + result = await connectionService.requestReconnect( + display: display, + activePhysicalDisplayCount: activePhysicalDisplayCount + ) + } + + await MainActor.run { + if result.success { + if case .reconnect = action { + powerService.clearLocalPowerDownState(for: display) + } + displayManager.refreshDisplays(invalidateTransportCaches: true) + } else { + errorMessage = result.userMessage + } + status = nil + didSkipPowerStatusRead = true + didLoadStatus = true + isLoading = false + } + } + } + + private func performReinitialize() { + guard !isLoading else { return } + pendingCommand = nil + errorMessage = nil + isLoading = true + + let displayID = display.displayID + Task { + let success = await powerService.reinitializeDisplayLink(for: displayID) + await MainActor.run { + displayManager.refreshDisplays(invalidateTransportCaches: true) + } + try? await Task.sleep(nanoseconds: 500_000_000) + let ddcAvailable = await BrightnessService.shared.ensureDDCAvailability( + for: displayID, + forceProbe: true + ) + + await MainActor.run { + status = success && ddcAvailable + ? DisplayPowerStatus(mode: .on, rawValue: DisplayPowerMode.on.rawValue) + : nil + didSkipPowerStatusRead = true + didLoadStatus = true + isLoading = false + if success && ddcAvailable { + powerService.clearLocalPowerDownState(for: display) + wakeFailedAfterStandby = false + UserDefaults.standard.removeObject(forKey: wakeFailureKey) + } + if !success { + errorMessage = "公开重连没有完成;如果显示器仍黑屏,请先用 BetterDisplay 的 reconnect 或实体按键恢复。" + } + } + } + } + + private func confirmMessage(for command: DisplayPowerCommand) -> String { + switch command { + case .sleep: + if display.isMain { + return "这台显示器当前是主屏。继续后会发送 DDC 待机命令,若显示器支持,屏幕会变黑。" + } + return "继续后会发送 DDC 待机命令;如果这台显示器不支持,命令可能不会生效。" + case .hardOff: + if display.isMain { + return "这台显示器当前是主屏。硬件关机后,部分显示器只能通过实体按键重新打开。" + } + return "部分显示器在硬件关机后只能通过实体按键重新打开。" + case .wake: + return "" + } + } + + private func errorText(forFailedCommand command: DisplayPowerCommand) -> String { + if command == .wake { + return "显示器处于待机,但没有响应软件唤醒;这台显示器可能需要实体电源键恢复。" + } + return "显示器没有响应 DDC 电源命令,可能是不支持 VCP 0xD6,或当前连接链路不允许 DDC 写入。" + } +} + +private struct DisplayPowerConfirmRow: View { + let command: DisplayPowerCommand + let message: String + let onCancel: () -> Void + let onConfirm: () -> Void + @State private var cancelHovered = false + @State private var confirmHovered = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: command == .hardOff ? "power" : "moon.zzz.fill") + .font(.caption) + .foregroundColor(command == .hardOff ? .red : .indigo) + .frame(width: 16) + .accessibilityHidden(true) + Text(command.title) + .font(.caption) + .fontWeight(.semibold) + Spacer() + } + + Text(message) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Button(action: onCancel) { + Text("取消") + .font(.caption) + .frame(maxWidth: .infinity, minHeight: 26) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(cancelHovered ? 0.18 : 0.12)) + ) + } + .buttonStyle(.plain) + .onHover { cancelHovered = $0 } + + Button(action: onConfirm) { + Text("继续") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: 26) + .background( + RoundedRectangle(cornerRadius: 6) + .fill((command == .hardOff ? Color.red : Color.indigo).opacity(confirmHovered ? 0.9 : 0.78)) + ) + } + .buttonStyle(.plain) + .onHover { confirmHovered = $0 } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke((command == .hardOff ? Color.red : Color.indigo).opacity(0.22), lineWidth: 1) + ) + } +} + +private struct DisplayPowerMessageRow: View { + let systemImage: String + let message: String + let tint: Color + let onDismiss: (() -> Void)? + @State private var closeHovered = false + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: systemImage) + .font(.caption) + .foregroundColor(tint) + .frame(width: 16) + .accessibilityHidden(true) + Text(message) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + if let onDismiss { + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.caption2) + .foregroundColor(closeHovered ? .primary : .secondary) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { closeHovered = $0 } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(tint.opacity(0.08)) + ) + } +} + +private struct DisplayPowerActionButton: View { + let title: String + let systemImage: String + let tint: Color + let isDisabled: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: systemImage) + .font(.caption) + .accessibilityHidden(true) + Text(title) + .font(.caption) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .foregroundColor(isDisabled ? .secondary : tint) + .frame(maxWidth: .infinity, minHeight: 26) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(tint.opacity(isHovered && !isDisabled ? 0.14 : 0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(tint.opacity(isDisabled ? 0.1 : 0.2), lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .onHover { isHovered = $0 } + .help(title) + } +} diff --git a/FreeDisplay/Views/MenuBarView.swift b/FreeDisplay/Views/MenuBarView.swift index 41887f4..422a8e9 100644 --- a/FreeDisplay/Views/MenuBarView.swift +++ b/FreeDisplay/Views/MenuBarView.swift @@ -103,6 +103,11 @@ struct MenuBarView: View { } } + ForEach(displayManager.disconnectedDisplaySnapshots) { snapshot in + DisconnectedDisplayRowView(snapshot: snapshot) + .environmentObject(displayManager) + } + // 预设列表 (Phase 19) Divider() .opacity(0.3) @@ -261,8 +266,7 @@ struct MenuBarView: View { .padding(.vertical, 6) } // end VStack - .frame(width: 340) - .frame(maxHeight: 700) + .frame(width: 340, height: 700) .padding(.vertical, 8) .onReceive(displayManager.$displays) { newDisplays in let validIDs = Set(newDisplays.map { $0.displayID }) @@ -440,3 +444,84 @@ struct DisplayRowView: View { .accessibilityAddTraits(.isButton) } } + +private struct DisconnectedDisplayRowView: View { + let snapshot: DisplayConnectionSnapshot + @EnvironmentObject var displayManager: DisplayManager + @State private var isLoading = false + @State private var errorMessage: String? + @State private var isHovered = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "display.trianglebadge.exclamationmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 20, height: 20) + .background(RoundedRectangle(cornerRadius: 5).fill(Color.orange)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text(snapshot.name) + .font(.body) + .lineLimit(1) + Text("\(snapshot.resolutionText) · 已断开") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + + if isLoading { + ProgressView() + .scaleEffect(0.6) + .frame(width: 18, height: 18) + } else { + Button { + reconnect() + } label: { + Label("重连", systemImage: "arrow.triangle.2.circlepath") + .font(.caption) + } + .buttonStyle(.borderless) + .help("用保存的拓扑快照重新启用这台显示器") + } + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.orange) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 28) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.primary.opacity(isHovered ? 0.06 : 0)) + .onHover { isHovered = $0 } + } + + private func reconnect() { + guard !isLoading else { return } + errorMessage = nil + isLoading = true + + Task { + let result = await DisplayConnectionService.shared.requestReconnect(snapshot: snapshot) + await MainActor.run { + displayManager.refreshDisplays(invalidateTransportCaches: true) + } + try? await Task.sleep(nanoseconds: 800_000_000) + await MainActor.run { + displayManager.refreshDisplays(invalidateTransportCaches: true) + isLoading = false + if !result.success { + errorMessage = result.userMessage + } + } + } + } +} diff --git a/docs/codemap/CLAUDE.md b/docs/codemap/CLAUDE.md index fc283ec..8688a35 100644 --- a/docs/codemap/CLAUDE.md +++ b/docs/codemap/CLAUDE.md @@ -48,6 +48,7 @@ | 新增菜单栏工具入口(非显示器相关) | `Views/MenuBarView.swift`(工具区添加入口)→ 新建 View/Service → `docs/codemap/CLAUDE.md` | | 修改分辨率切换逻辑 | `Services/ResolutionService.swift`、`Models/DisplayMode.swift`,测试影响 `Views/ResolutionSliderView.swift` 和 `Views/DisplayModeListView.swift` | | 修改亮度读写 | `Services/BrightnessService.swift`,外接屏同步检查 `Services/DDCService.swift` | +| 修改显示器电源控制 | `Services/DisplayPowerService.swift`、`Services/DisplayConnectionService.swift`、`Views/DisplayPowerView.swift`,外接屏同步检查 `Services/DDCService.swift` 的 VCP 0xD6 | | 修改图像调整效果 | `Services/GammaService.swift`(公式/Table 计算)、`Views/ImageAdjustmentView.swift`(UI 滑块映射) | | 添加新的持久化设置项 | `Services/SettingsService.swift`(Keys + @Published 属性 + loadAll/persist)→ 相关 View | | 修改通知/热插拔响应 | `Services/DisplayManager.swift`(displayReconfigCallback + refreshDisplays) | diff --git a/docs/codemap/file-tree.md b/docs/codemap/file-tree.md index 8a19de0..8f0d687 100644 --- a/docs/codemap/file-tree.md +++ b/docs/codemap/file-tree.md @@ -50,7 +50,9 @@ FreeDisplay/ │ │ ├── CGHelpers.swift # 共享 CG 阻塞调用工具:CGHelpers.runWithTimeout(seconds:fallback:operation:) 在后台线程以超时保护运行 WindowServer IPC 阻塞操作;被 ArrangementService、MirrorService、ResolutionService、VirtualDisplayService 使用 │ │ ├── ColorProfileService.swift # ICC Profile 枚举(扫描 3 个系统目录)和切换(ColorSync API);改动影响色彩描述文件列表和切换 │ │ ├── DDCService.swift # ⚠️ IOKit I2C DDC/CI 通信核心:IOFramebuffer 查找、VCP 读写、5 秒 TTL 缓存、3 次重试;几乎所有外接显示器功能的底层依赖,改动需极谨慎 -│ │ ├── DisplayManager.swift # ⚠️ 显示器枚举(CGGetOnlineDisplayList)+ CGDisplay 热插拔回调 + arrangeExternalAboveBuiltin() 自动外接屏定位;@Published displays 被全局注入,改动影响整个显示器列表数据流 +│ │ ├── DisplayConnectionService.swift # 显示器拓扑断开/重连:动态加载 CGSConfigureDisplayEnabled,在配置事务中启停外接屏;含离线快照、最后显示器保护、超时和日志 +│ │ ├── DisplayPowerService.swift # DDC VCP 0xD6 电源模式语义层:仅对未禁用 DDC 的显示器读取/写入电源状态;高风险设备改走 DisplayConnectionService +│ │ ├── DisplayManager.swift # ⚠️ 显示器枚举(CGGetOnlineDisplayList)+ CGDisplay 热插拔回调 + 离线拓扑快照列表 + arrangeExternalAboveBuiltin() 自动外接屏定位;@Published displays 被全局注入,改动影响整个显示器列表数据流 │ │ ├── GammaService.swift # 软件 Gamma 调整:CGSetDisplayTransferByFormula/Table,支持对比度/增益/色温/量化/反色;所有 gamma/软件亮度写入的唯一入口;改动影响图像调整效果 │ │ ├── HiDPIService.swift # 写 /Library/Displays/...plist 注入 HiDPI 缩放模式,需管理员权限;改动影响 HiDPI override 生成逻辑 │ │ ├── LaunchService.swift # SMAppService 管理开机自启动(macOS 13+);改动仅影响 Launch at Login 功能 @@ -70,6 +72,7 @@ FreeDisplay/ │ ├── BrightnessSliderView.swift # 单显示器亮度滑块(200ms 去抖)+ 全局组合亮度控制;依赖 BrightnessService + DDCService │ ├── ColorProfileView.swift # ICC Profile 列表(推荐/全部分组)和切换;依赖 ColorProfileService │ ├── DisplayDetailView.swift # ⚠️ 每显示器展开面板,可折叠 Section 的容器(三组分组);新增/删除 Section 都要改此文件,且需同步 MenuBarView +│ ├── DisplayPowerView.swift # 外接显示器电源控制行:普通显示器用 DDC,已知高风险显示器用拓扑断开/重连;依赖 DisplayPowerService + DisplayConnectionService │ ├── DisplayModeListView.swift # 分辨率模式列表(HiDPI/原生/其他分组)、收藏置顶星标、点击切换;依赖 ResolutionService │ ├── ImageAdjustmentView.swift # 11 个图像调整滑块(对比度/Gamma/增益/色温/各通道/量化/反色);依赖 GammaService │ ├── MainDisplayView.swift # "设为主显示屏"行,当前已是主屏时显示状态标签;依赖 ArrangementService diff --git a/docs/lessons/CLAUDE.md b/docs/lessons/CLAUDE.md index 4186783..044e307 100644 --- a/docs/lessons/CLAUDE.md +++ b/docs/lessons/CLAUDE.md @@ -1,13 +1,13 @@ # 踩坑经验索引 — FreeDisplay -> 更新: 2026-03-05 +> 更新: 2026-05-23 ## 主题索引 | 主题 | 文件 | 条数 | |------|------|------| -| IOKit / DDC / 旋转 / 环境光 / 显示器匹配 / Apple Silicon | [iokit.md](iokit.md) | L-003, L-004, L-005 + 通用条目 | -| CoreGraphics / HiDPI / CGVirtualDisplay | [coregraphics.md](coregraphics.md) | L-006, L-007, L-020 ~ L-027 + 通用条目 | +| IOKit / DDC / 旋转 / 环境光 / 显示器匹配 / Apple Silicon | [iokit.md](iokit.md) | L-003, L-004, L-005, L-028 ~ L-035 + 通用条目 | +| CoreGraphics / HiDPI / CGVirtualDisplay | [coregraphics.md](coregraphics.md) | L-006, L-007, L-020 ~ L-027, L-035, L-036 + 通用条目 | | SwiftUI / MenuBarExtra / UI 动画 / DDC 缓存 / 性能 | [swiftui.md](swiftui.md) | 通用条目 | | 跨 Service 协作 / 并发 / 资源管理 | [services.md](services.md) | L-008 ~ L-019 | | Xcode 构建 / Phase 收尾 | [build.md](build.md) | 通用条目 | diff --git a/docs/lessons/coregraphics.md b/docs/lessons/coregraphics.md index 1fd0cc7..848e991 100644 --- a/docs/lessons/coregraphics.md +++ b/docs/lessons/coregraphics.md @@ -1,6 +1,6 @@ # 踩坑经验 — CoreGraphics -> 更新: 2026-03-05 +> 更新: 2026-05-23 ## CoreGraphics / 显示器 @@ -93,3 +93,17 @@ - **原因**: 用 display.pixelWidth/pixelHeight(来自 CGDisplayPixelsWide/High)作为原生分辨率传入,但显示器当前可能在非原生分辨率 - **解法**: 用 display.availableModes.max(by: width*height) 获取最大可用模式作为原生分辨率 - **教训**: CGDisplayPixelsWide/High 返回的是当前模式的像素尺寸,不是面板物理分辨率 + +### L-035: CGSConfigureDisplayEnabled 必须放在 CGDisplayConfiguration 事务中 +- **现象**: 用 `CGSMainConnectionID()` 作为第一个参数调用 `CGSConfigureDisplayEnabled` 会直接崩在 SkyLight +- **原因**: 该符号虽然是 CGS/SLS 前缀,但实际调用形态是 `CGSConfigureDisplayEnabled(CGDisplayConfigRef, CGDirectDisplayID, enabled)`,第一个参数必须来自 `CGBeginDisplayConfiguration` +- **解法**: 用 `dlopen` + `dlsym` 动态加载 `CGSConfigureDisplayEnabled`/`SLSConfigureDisplayEnabled`,在 `CGBeginDisplayConfiguration` → configure enabled → `CGCompleteDisplayConfiguration(.forSession)` 事务中执行,并用 `CGHelpers.runWithTimeout` 包住 WindowServer IPC +- **教训**: 私有 WindowServer API 不能只凭命名前缀猜签名;先用无副作用参数验证 ABI,再接入真实路径 +- **日期**: 2026-05-23 + +### L-036: 拓扑断开后必须保留离线重连入口 +- **现象**: S27H85x 执行 `CGSConfigureDisplayEnabled(false)` 后,系统显示 `online=false active=false`,FreeDisplay 菜单里不再出现这台显示器,导致用户无法从 UI 重连 +- **原因**: UI 只渲染 `CGGetOnlineDisplayList` 返回的在线显示器;拓扑断开会把目标从在线列表移除,但同一个 `CGDirectDisplayID` 仍可用 `CGSConfigureDisplayEnabled(true)` 恢复 +- **解法**: 断开前保存 `DisplayConnectionSnapshot`,`DisplayManager` 在刷新时计算 `disconnectedDisplaySnapshots`,`MenuBarView` 渲染“已断开”行并调用快照重连;日志记录 online IDs、离线快照和每次拓扑调用结果 +- **教训**: 任何“禁用/断开”功能都必须先做可见的恢复路径;不能把恢复入口挂在会被断开的对象自身上 +- **日期**: 2026-05-23 diff --git a/docs/lessons/iokit.md b/docs/lessons/iokit.md index 2f063f5..390ce5a 100644 --- a/docs/lessons/iokit.md +++ b/docs/lessons/iokit.md @@ -1,6 +1,6 @@ # 踩坑经验 — IOKit -> 更新: 2026-03-05 +> 更新: 2026-05-23 ## IOKit / DDC @@ -52,3 +52,59 @@ - **解法**: 使用 IOAVService 私有 API(`IOAVServiceCreateWithService` + `IOAVServiceWriteI2C` / `IOAVServiceReadI2C`),通过 DCPAVServiceProxy IOKit 服务查找外接显示器 - **教训**: 不同 CPU 架构的 macOS 使用完全不同的显示器通信 API。MonitorControl、BetterDisplay 都用 IOAVService。参考 alinpanaitiu.com/blog/journey-to-ddc-on-m1-macs/ - **日期**: 2026-03-03 + +### L-028: DCPAVServiceProxy 顺序不能当作显示器顺序 +- **现象**: 多台外接显示器时,点击 27G1G4 的 DDC 电源睡眠,实际进入睡眠的是 S27H85x +- **原因**: Apple Silicon 上 `DCPAVServiceProxy` 的枚举顺序会随睡眠、唤醒、重连变化;用“剩余服务按顺序分配给剩余显示器”的兜底策略会把 DDC 写命令发到错误硬件 +- **解法**: 只接受可验证的映射:先尝试 DCPAVServiceProxy 父链 vendor/product;失败时用 registry path 中的 `dispextN` 对齐 `IOMobileFramebufferShim`,再用 `DisplayAttributes.ProductAttributes` 的 vendor/product/serial 与 CoreGraphics displayID 校验 +- **教训**: DDC 写命令有真实硬件副作用,宁可把通道标记为不可用,也不要用顺序、索引或数组位置猜测目标显示器 +- **日期**: 2026-05-23 + +### L-029: DDC `0xD6=0x04` 不是安全的“睡眠” +- **现象**: S27H85x 点击“睡眠”后从面板消失,并且疑似需要实体电源键才能回来 +- **原因**: 当前“睡眠”按钮发送的是 VCP `0xD6=0x04`(Off / DPMS 关闭),部分显示器会断开信号链路甚至进入深度关闭;macOS 会把它从在线显示器列表移除,DDC 唤醒也随之失去通道 +- **解法**: “睡眠”只发送更保守的 `0xD6=0x02`(Standby);会断连的 Off / Hard Off 必须作为破坏性操作单独呈现并明确提示 +- **教训**: UI 文案不能把 DDC Off 包装成普通睡眠。显示器电源命令应按最保守语义暴露,避免用户误触后失去软件控制路径 +- **日期**: 2026-05-23 + +### L-030: 待机后读不到 VCP 状态不代表不能唤醒 +- **现象**: S27H85x 点击“睡眠”后仍在面板里,但电源状态显示“不可读取”,唤醒按钮也被禁用 +- **原因**: 待机后的显示器可能停止响应 VCP `0xD6` 读取,但仍接受 `0xD6=0x01` 写入;如果 UI 把“状态不可读”当成“所有命令不可用”,就会把用户卡在不能唤醒的状态 +- **解法**: 本次会话内刚发送过睡眠/关机命令时,可以保留唤醒;但刷新、系统唤醒、显示器重连后必须重建 AVService 映射,避免使用旧通道;若唤醒失败,按显示器 UUID 记录 `fd.power.wakeFailedAfterStandby.*` 并禁用后续睡眠 +- **教训**: DDC 读通道和写通道不能等价处理。对唤醒这类恢复命令,应尽量保留安全且已验证的写入路径 +- **日期**: 2026-05-23 + +### L-031: BetterDisplay 重连后旧 AVService 缓存会变危险 +- **现象**: S27H85x 经 BetterDisplay disconnect/reconnect 后重新显示,但 FreeDisplay 刷新电源状态仍不可读;此时点击“唤醒”反而可能让屏幕再次进入待机 +- **原因**: BetterDisplay 的 reconnect 会重建显示链路,`CGDirectDisplayID` 可能保持不变,但底层 `DCPAVServiceProxy` / `IOAVService` 引用和 endpoint 映射已经失效。继续使用旧缓存做 DDC 写入,有概率把命令送到错误或陈旧的通道 +- **解法**: 刷新电源状态、发送 DDC 电源命令、系统显示器 add/remove、系统 wake 后都必须清掉 AVService 缓存并重新做 IORegistry 身份匹配;状态不可读且不是本次 FreeDisplay 刚发送睡眠后的场景时,不再提供盲目 DDC 唤醒,改提供“重连”动作 +- **教训**: `CGDirectDisplayID` 稳定不代表 IOKit 通信通道稳定。显示链路被第三方工具或系统重初始化后,所有 DDC 写入前都要重新校验目标硬件身份 +- **日期**: 2026-05-23 + +### L-032: 电源状态读取必须先用安全 VCP 确认可用性 +- **现象**: S27H85x 通过 BetterDisplay reconnect 恢复后,FreeDisplay 显示软件亮度;点击“显示器电源”的刷新按钮,显示器又进入待机/黑屏 +- **原因**: 电源刷新先调用 `BrightnessService.invalidateDDCState` 清掉“DDC 不可用”缓存,再立即读取 VCP `0xD6`。这会绕过软件亮度/无 DDC 的保护,对本来已经判定为 DDC 不可靠的显示器发送 power 读取请求。后续若把读取门槛写成 `isDDCAvailable == true`,又会让刚启动时状态为 nil 的正常 DDC 显示器也无法读取电源状态 +- **解法**: `DisplayPowerService.currentStatus` 调用 `BrightnessService.ensureDDCAvailability`,先用亮度 VCP `0x10` 确认 DDC 可用;只有确认成功才读取 power VCP `0xD6`。电源刷新只清 DDC transport/VCP 缓存,不清 DDC 可用性缓存;已知 DDC 不可用的显示器只允许重连/显示链路恢复动作 +- **教训**: DDC 电源控制必须建立在“该显示器 DDC 已被其他安全 VCP 验证可用”的前提上。不要用 power VCP 本身来探测 DDC 是否可用 +- **日期**: 2026-05-23 + +### L-033: DDC 电源高风险必须按显示器隔离 +- **现象**: 某一台显示器的 DDC 电源命令会误控或影响其他屏,容易想到“只要有一台高风险,就禁用所有显示器的睡眠/关机” +- **原因**: 全局禁用虽然安全,但破坏了正常显示器的能力边界,也掩盖了真正的问题:DDC 通道映射没有做到足够可靠的一对一校验 +- **解法**: 高风险标记必须按 `displayUUID` 持久化,只禁用该显示器自己的 DDC 睡眠/唤醒/关机;同时加强 AVService 匹配,优先使用 `dispextN` endpoint 对齐 `IOMobileFramebufferShim`,再考虑父链 vendor/product +- **教训**: 电源命令的安全边界是“目标显示器 + 已验证通信通道”,不是“当前机器所有外接显示器”。单台设备异常不能变成全局功能退化 +- **日期**: 2026-05-23 + +### L-034: S27H85x 必须在首次 DDC 探测前进入高风险名单 +- **现象**: 清空设置或首次启动后,S27H85x 仍可能在电源状态加载时触发 VCP `0x10` 亮度探测,随后才进入保护分支 +- **原因**: “失败后打标”的保护只对已经发生过异常的设备有效;对已知高风险型号,第一次探测本身就是风险 +- **解法**: 枚举显示器后按显示器名称识别 `S27H85x` 系列,立即写入 `fd.ddc.disabled.*` 禁用硬件 DDC,使亮度、电源刷新和强制探测都直接走软件亮度/拓扑控制路径 +- **教训**: 已知高风险硬件不能依赖运行时探测得出结论;应在任何 VCP 读写前进入 denylist +- **日期**: 2026-05-23 + +### L-035: Mi Monitor 的 DDC 电源写入有效但读值不可信 +- **现象**: Mi Monitor 发送 `0xD6=0x02` 后屏幕确实进入待机,且没有影响其他显示器;但随后读取 `0xD6` 又返回 `0x01`(开机),并且 `max=0xFF`,不像标准枚举范围 `0x01...0x05`。继续发送 `0xD6=0x01` 时 I2C 写入也返回成功,但面板没有实际恢复画面 +- **原因**: 该显示器固件接受 DDC Power Mode 写入,但 `0xD6` 读回值和 wake 写入确认都不能可靠表达面板/背光实际状态。报告里 EDID 层声明 `Supports Standby: 1`,但不支持 Suspend/Active Off,因此只能把 Standby 当作可写动作,不能把后续 `0x01` 或 wake 写入 ACK 当成高可信状态 +- **解法**: 引入电源状态来源和可信度:正常 DDC、DDC 读值不可靠、DDC 高风险拓扑三档。对 Mi Monitor 这类 wake ACK 不可信设备,电源控制改用拓扑断开/重连;普通 DDC 亮度不禁用。拓扑重连若发现显示器仍在线启用,先执行一次断开再重连,强制重建显示链路 +- **教训**: DDC 写成功、DDC 读成功、显示器真实视觉状态是三类信号。Power VCP 必须带来源和可信度建模,不能用单个 `status.mode == .on` 决定 UI 和恢复路径 +- **日期**: 2026-05-23 diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index f4d2094..93ef835 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -6,9 +6,13 @@ APP_NAME="FreeDisplay" SCHEME="FreeDisplay" BUILD_DIR="$(pwd)/build" DMG_NAME="${APP_NAME}.dmg" +STAGING_DIR="$BUILD_DIR/dmg-staging" echo "=== Building ${APP_NAME} Release ===" +# Remove stale staging first so find(1) never picks a previous packaged app. +rm -rf "$STAGING_DIR" + # Clean and build Release (skip Xcode's codesign; we'll sign manually after stripping xattrs) xcodebuild -scheme "$SCHEME" -configuration Release \ -derivedDataPath "$BUILD_DIR" \ @@ -16,7 +20,7 @@ xcodebuild -scheme "$SCHEME" -configuration Release \ clean build 2>&1 | tail -20 # Find the .app -APP_PATH=$(find "$BUILD_DIR" -name "${APP_NAME}.app" -type d | head -1) +APP_PATH=$(find "$BUILD_DIR/Build/Products/Release" -name "${APP_NAME}.app" -type d | head -1) if [ -z "$APP_PATH" ]; then echo "ERROR: ${APP_NAME}.app not found in build output" exit 1 @@ -32,8 +36,6 @@ echo "=== Signing ===" codesign --force --deep --sign - "$APP_PATH" # Create staging directory for DMG -STAGING_DIR="$BUILD_DIR/dmg-staging" -rm -rf "$STAGING_DIR" mkdir -p "$STAGING_DIR" cp -R "$APP_PATH" "$STAGING_DIR/" ln -s /Applications "$STAGING_DIR/Applications"