diff --git a/FreeDisplay/App/AppDelegate.swift b/FreeDisplay/App/AppDelegate.swift index 3692955..bc48c50 100644 --- a/FreeDisplay/App/AppDelegate.swift +++ b/FreeDisplay/App/AppDelegate.swift @@ -21,6 +21,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Start intercepting brightness keys to route them to the display under the cursor. BrightnessKeyService.shared.start() + // 若上次退出时「竖屏模式」处于开启状态,重新武装自动启动。 + // 这里只是武装:Sidecar 通常在登录几秒后才连上,且每次 displayID 都会变, + // 真正的启动发生在检测到 Sidecar 显示器出现时。 + RotatedSidecarService.shared.restoreIfEnabled() + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, diff --git a/FreeDisplay/Services/DisplayManager.swift b/FreeDisplay/Services/DisplayManager.swift index 2b7281b..b013c92 100644 --- a/FreeDisplay/Services/DisplayManager.swift +++ b/FreeDisplay/Services/DisplayManager.swift @@ -215,8 +215,15 @@ class DisplayManager: ObservableObject { func arrangeExternalAboveBuiltin() { guard UserDefaults.standard.bool(forKey: "fd.arrangement.externalAbove") else { return } + // 「竖屏模式」运行时,它管理的两块屏由 RotatedSidecarService 独占摆位: + // 物理 Sidecar 屏被停在只有一个角接触的位置,光标才进不去。 + // 而本方法会在任意显示器变更后 500ms 触发 —— 包括停放动作本身造成的变更、 + // 以及用户手动拖动 —— 不排除的话会立刻把摆位覆盖掉, + // 表现为「过几秒又跳回去」。 + let sidecarOwned = RotatedSidecarService.shared.managedDisplayIDs + guard let builtin = displays.first(where: { $0.isBuiltin }) else { return } - let externals = displays.filter { !$0.isBuiltin } + let externals = displays.filter { !$0.isBuiltin && !sidecarOwned.contains($0.displayID) } guard !externals.isEmpty else { return } let builtinX = Int(builtin.bounds.origin.x) diff --git a/FreeDisplay/Services/RotatedSidecarService.swift b/FreeDisplay/Services/RotatedSidecarService.swift new file mode 100644 index 0000000..d851d96 --- /dev/null +++ b/FreeDisplay/Services/RotatedSidecarService.swift @@ -0,0 +1,522 @@ +import AppKit +import CoreGraphics +import Foundation + +/// Drives a rotated (portrait) Sidecar iPad display. +/// +/// ## Why this is not "rotate the display" +/// +/// A Sidecar display cannot be rotated. `CGDisplayRotation` is read-only, macOS exposes +/// no Rotation control for Sidecar in System Settings, and the old `IOFBTransform` IOKit +/// path does not exist on Apple Silicon (there are zero `IODisplayConnect` services). +/// Physically turning the iPad makes iPadOS reorient while macOS keeps sending a landscape +/// framebuffer, which crops the image — a longstanding iPadOS bug. +/// +/// ## What this does instead +/// +/// The Sidecar display is never rotated. Instead: +/// +/// 1. Create a **virtual** display whose dimensions are the Sidecar display's, flipped +/// (a landscape 1280x854 Sidecar gets an 854x1280 portrait virtual display). +/// 2. Capture that virtual display with ScreenCaptureKit. +/// 3. Rotate the captured frames 90° with CoreImage. +/// 4. Render them into a borderless full-screen window pinned to the Sidecar display. +/// +/// The iPad stays locked in landscape the whole time and simply shows a full-screen stream +/// of already-rotated content. The user drags windows onto the *virtual* portrait display; +/// the Sidecar display becomes a dumb output surface. +/// +/// This mirrors BetterDisplay's approach: +/// https://github.com/waydabber/BetterDisplay/wiki/Rotated-Sidecar +@MainActor +final class RotatedSidecarService: ObservableObject, @unchecked Sendable { + static let shared = RotatedSidecarService() + + private init() { + // Sidecar displays get a NEW CGDirectDisplayID on every reconnect, and locking + // the iPad's orientation or toggling mirroring counts as a reconnect. Without + // this, the service stays "active" pointing at a display that no longer exists, + // its window is pinned to a dead NSScreen, and Start becomes a silent no-op. + NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.handleScreenChange() + } + } + isSidecarConnected = !Self.connectedSidecarDisplays().isEmpty + } + + /// Tears down if the display we were driving has gone away; otherwise takes the chance + /// to auto-start, since Sidecar typically connects seconds AFTER login. + private func handleScreenChange() { + isSidecarConnected = !Self.connectedSidecarDisplays().isEmpty + guard isActive, let target = targetDisplayID else { + attemptAutoStart() + return + } + let stillPresent = Self.connectedSidecarDisplays().contains(target) + guard !stillPresent else { return } + Task { @MainActor in + await teardown() + errorMessage = "Sidecar 显示器已断开,请重新开启。" + } + } + + // MARK: - Restore across launches + + /// Arms auto-start if the feature was left enabled. Called once at launch. + /// + /// Cannot simply start here: Sidecar reconnects a few seconds after login, with a new + /// display ID, so there is usually nothing to attach to yet. `handleScreenChange` + /// retries when a display actually appears. + func restoreIfEnabled() { + if let raw = UserDefaults.standard.object(forKey: Keys.orientation) as? Int, + let stored = Orientation(rawValue: raw) { + orientation = stored + } + guard UserDefaults.standard.bool(forKey: Keys.enabled) else { return } + autoStartArmed = true + attemptAutoStart() + } + + /// One-shot: on any failure it disarms rather than retrying, so a broken prerequisite + /// is reported once and stays reported. + private func attemptAutoStart() { + guard autoStartArmed, !isActive else { return } + guard let target = Self.connectedSidecarDisplays().first else { return } + + guard Self.hasScreenRecordingPermission else { + autoStartArmed = false + autoStartFailed = true + errorMessage = "需要屏幕录制权限。授权后请重新开启。" + FDLog.sidecar.error("auto-start aborted: no Screen Recording permission") + return + } + + autoStartArmed = false // one attempt only + Task { @MainActor in + await start(sidecarDisplayID: target, orientation: orientation, isAutoStart: true) + if !isActive { + autoStartFailed = true + FDLog.sidecar.error("auto-start failed; not retrying until the user presses Start") + } + } + } + + // MARK: - Types + + /// Which way the iPad is physically turned. Determines the frame rotation applied + /// to the virtual display's content. + enum Orientation: Int, CaseIterable, Identifiable { + /// iPad turned counter-clockwise (USB-C port at the top). + case portraitCounterClockwise = 270 + /// iPad turned clockwise (USB-C port ends up at the bottom on most iPads). + case portraitClockwise = 90 + + var id: Int { rawValue } + + var label: String { + switch self { + case .portraitCounterClockwise: return "上" + case .portraitClockwise: return "下" + } + } + } + + // MARK: - State + + @Published private(set) var isActive = false + @Published private(set) var errorMessage: String? + /// Display ID of the Sidecar display currently being driven. + @Published private(set) var targetDisplayID: CGDirectDisplayID? + /// Set when an automatic start failed. Auto-start does not run again until the user + /// presses Start, so a broken prerequisite (revoked permission, say) surfaces once + /// instead of retrying on every screen change forever. + @Published private(set) var autoStartFailed = false + /// Whether any Sidecar display is currently connected. The menu hides the whole + /// section when false — the feature is meaningless without an iPad attached. + @Published private(set) var isSidecarConnected = false + + /// Orientation the user last chose; restored across launches. + @Published var orientation: Orientation = .portraitClockwise { + didSet { + guard orientation != oldValue else { return } + UserDefaults.standard.set(orientation.rawValue, forKey: Keys.orientation) + } + } + + private var virtualConfigID: UUID? + private var viewModel: StreamViewModel? + private var windowController: StreamWindowController? + /// Whether an auto-start is still permitted this session. + private var autoStartArmed = false + + private enum Keys { + static let enabled = "fd.rotatedSidecar.enabled" + static let orientation = "fd.rotatedSidecar.orientation" + /// The Sidecar display's origin BEFORE we parked it, so Stop can put it back even + /// after a relaunch or a crash. In memory only, this was lost on quit and the + /// user's layout could never be restored. + static let originalOrigin = "fd.rotatedSidecar.originalOrigin" + /// Where we parked the Sidecar display and placed the virtual one last time, so a + /// restored session reproduces the same layout instead of recomputing it. + static let parkedOrigin = "fd.rotatedSidecar.parkedOrigin" + static let virtualOrigin = "fd.rotatedSidecar.virtualOrigin" + } + + /// Displays whose position this service owns while running. DisplayManager's + /// auto-arrange must leave these alone or it will undo the parking. + var managedDisplayIDs: Set { + guard isActive else { return [] } + var ids = Set() + if let t = targetDisplayID { ids.insert(t) } + if let cfg = virtualConfigID, let v = VirtualDisplayService.shared.displayID(for: cfg) { + ids.insert(v) + } + return ids + } + + // MARK: - Prerequisites + + /// Whether Screen Recording has been granted. ScreenCaptureKit cannot enumerate or + /// capture displays without it, and the failure looks like a missing display. + nonisolated static var hasScreenRecordingPermission: Bool { + CGPreflightScreenCaptureAccess() + } + + /// Triggers the system's Screen Recording prompt (only ever shown once per app; + /// afterwards the user must grant it in System Settings by hand). + nonisolated static func requestScreenRecordingPermission() { + _ = CGRequestScreenCaptureAccess() + } + + // MARK: - Persistence helpers + + private static func point(forKey key: String) -> CGPoint? { + guard let s = UserDefaults.standard.string(forKey: key) else { return nil } + let parts = s.split(separator: ",").compactMap { Double($0) } + guard parts.count == 2 else { return nil } + return CGPoint(x: parts[0], y: parts[1]) + } + + private static func set(_ p: CGPoint?, forKey key: String) { + guard let p else { + UserDefaults.standard.removeObject(forKey: key) + return + } + UserDefaults.standard.set("\(Int(p.x)),\(Int(p.y))", forKey: key) + } + + /// CoreGraphics reports Sidecar displays with these ASCII-encoded IDs: + /// vendor 0x6161706c == "aapl", model 0x69506164 == "iPad". + nonisolated private static let sidecarVendorID: UInt32 = 0x6161706c + nonisolated private static let sidecarModelID: UInt32 = 0x69506164 + + // MARK: - Detection + + /// True if `displayID` is an iPad connected over Sidecar. + nonisolated static func isSidecarDisplay(_ displayID: CGDirectDisplayID) -> Bool { + CGDisplayVendorNumber(displayID) == sidecarVendorID + && CGDisplayModelNumber(displayID) == sidecarModelID + } + + /// Every active display ID. + static func activeDisplayIDs() -> [CGDirectDisplayID] { + var count: UInt32 = 0 + guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return [] } + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return [] } + return ids + } + + /// All currently connected Sidecar displays. + static func connectedSidecarDisplays() -> [CGDirectDisplayID] { + var count: UInt32 = 0 + guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return [] } + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return [] } + return ids.filter { isSidecarDisplay($0) } + } + + // MARK: - Start + + /// Sets up the virtual display, capture, and output window. + /// + /// - Parameters: + /// - sidecarDisplayID: the physical Sidecar display to drive. + /// - orientation: which way the iPad is physically turned. + /// - Parameter isAutoStart: true when restoring at launch. Only a user-initiated + /// start clears `autoStartFailed`, so a failed restore stays visible. + func start(sidecarDisplayID: CGDirectDisplayID, + orientation: Orientation, + isAutoStart: Bool = false) async { + guard !isActive else { return } + errorMessage = nil + if !isAutoStart { autoStartFailed = false } + self.orientation = orientation + + guard Self.hasScreenRecordingPermission else { + errorMessage = "需要屏幕录制权限。授权后请重新开启。" + return + } + + // The Sidecar display's landscape size in points. + let bounds = CGDisplayBounds(sidecarDisplayID) + let landscapeW = Int(bounds.width.rounded()) + let landscapeH = Int(bounds.height.rounded()) + guard landscapeW > 0, landscapeH > 0 else { + errorMessage = "无法读取 Sidecar 显示器尺寸" + return + } + + // The virtual display is the same size with width/height swapped, so that once + // its content is rotated 90° it exactly fills the Sidecar display with no scaling. + let config = VirtualDisplayService.VirtualDisplayConfig( + name: "Sidecar(虚拟)", + width: landscapeH, + height: landscapeW, + refreshRate: 60.0, + // 1:1 pixel mapping — a HiDPI backing store would be rescaled on the way out + // and cost GPU for no visible gain, since the Sidecar output is fixed size. + hiDPI: false, + autoCreate: false + ) + + guard await VirtualDisplayService.shared.create(config: config) else { + errorMessage = "创建虚拟显示器失败" + return + } + virtualConfigID = config.id + + FDLog.sidecar.info(""" + created virtual display config \ + \(config.width, privacy: .public)x\(config.height, privacy: .public) \ + for sidecar \(sidecarDisplayID, privacy: .public) \ + (\(landscapeW, privacy: .public)x\(landscapeH, privacy: .public)) + """) + + guard let virtualID = VirtualDisplayService.shared.displayID(for: config.id) else { + errorMessage = "虚拟显示器已创建,但没有 display ID" + await teardown() + return + } + + // Give WindowServer a moment to register the new display. + try? await Task.sleep(nanoseconds: 700_000_000) + + // macOS PERSISTS mirror arrangements. If the user has ever mirrored a display + // onto this Sidecar display, the newly created virtual display gets folded into + // that remembered mirror set automatically. A mirrored secondary is *online but + // not active*, never appears in SCShareableContent, and therefore cannot be + // captured — which surfaces as a baffling "target display not found". + // + // Break the mirroring explicitly. This is the one legitimate use of + // CGConfigureDisplayMirrorOfDisplay in this codebase: turning mirroring OFF. + // (Using it to turn mirroring ON for HiDPI is banned — see docs/lessons.) + if MirrorService.shared.isMirroring(virtualID) { + FDLog.sidecar.info(""" + virtual display \(virtualID, privacy: .public) was auto-mirrored to \ + \(MirrorService.shared.mirrorSource(for: virtualID) ?? 0, privacy: .public) — unmirroring + """) + _ = await MirrorService.shared.disableMirror(displayID: virtualID) + // Let the reconfiguration settle before checking for the display again. + try? await Task.sleep(nanoseconds: 700_000_000) + } + + let vm = StreamViewModel(displayID: virtualID) + vm.config.rotation = orientation.rawValue + // The cursor MUST be captured. This is a display the user works on, not a passive + // mirror — the real pointer lives on the virtual display, which is never visible + // directly, so without the captured cursor there is no pointer on the iPad at all. + vm.config.showCursor = true + viewModel = vm + + await vm.service.startCapture(showCursor: vm.config.showCursor) + guard vm.service.isCapturing else { + // Most likely cause: Screen Recording permission has not been granted. + errorMessage = vm.service.errorMessage + ?? "屏幕捕获启动失败。请在「系统设置 › 隐私与安全性 › 屏幕录制」中授权。" + await teardown() + return + } + vm.isCapturing = true + + // Arrange before showing the window: repositioning displays invalidates NSScreen + // frames, and the window must be sized to the Sidecar screen's final position. + await arrangeForRotatedSidecar(virtualID: virtualID, sidecarID: sidecarDisplayID) + + // Don't guess with a fixed sleep: NSScreen.screens refreshes asynchronously after + // a display reconfiguration, so a window built from a stale frame lands off-screen. + // Wait until AppKit's geometry agrees with CoreGraphics' before placing the window. + await waitForScreenGeometry(displayID: sidecarDisplayID) + + guard let arrangedScreen = NSScreen.screen(for: sidecarDisplayID) else { + errorMessage = "找不到 Sidecar 显示器对应的屏幕" + await teardown() + return + } + + let controller = StreamWindowController(viewModel: vm) + controller.showFullScreen(on: arrangedScreen) + windowController = controller + + targetDisplayID = sidecarDisplayID + isActive = true + // Only now, with everything running, is it safe to ask for this to be restored. + UserDefaults.standard.set(true, forKey: Keys.enabled) + } + + // MARK: - Stop + + func stop() async { + // Explicit stop means "don't bring this back next launch", and forgets the parked + // layout so a later start recomputes it rather than reusing a stale corner. + UserDefaults.standard.set(false, forKey: Keys.enabled) + Self.set(nil, forKey: Keys.parkedOrigin) + Self.set(nil, forKey: Keys.virtualOrigin) + autoStartFailed = false + await teardown() + } + + private func teardown() async { + windowController?.close() + windowController = nil + + if let vm = viewModel { + await vm.service.stopCapture() + } + viewModel = nil + + if let id = virtualConfigID { + _ = VirtualDisplayService.shared.destroy(configID: id) + VirtualDisplayService.shared.removeConfig(id: id) + } + virtualConfigID = nil + + await restoreOriginalArrangement(sidecarID: targetDisplayID) + + targetDisplayID = nil + isActive = false + } + + // MARK: - Layout + + /// Arranges the layout so the cursor can reach the portrait virtual display but not + /// the physical Sidecar display behind it. + /// + /// The problem: one physical screen now has two entries in the arrangement — the + /// virtual display the user works on, and the real Sidecar display that merely shows + /// our output window. If the cursor wanders onto the latter it appears to vanish, + /// because that display's content is a static mirror of the former. + /// + /// The fix: macOS only lets the cursor cross between displays that share an **edge**. + /// Placing the Sidecar display so it touches the layout at a single **corner** keeps + /// the arrangement contiguous (macOS rejects disconnected layouts) while leaving no + /// edge to cross. The virtual display takes the natural spot beside the main display. + private func arrangeForRotatedSidecar(virtualID: CGDirectDisplayID, + sidecarID: CGDirectDisplayID) async { + // Remember where the user had the Sidecar display BEFORE we move it, so Stop can + // put it back. Persisted, not just in memory: a quit or crash while active would + // otherwise strand the display in the parking corner permanently. + // Only recorded on a fresh arrangement — re-recording after a restore would save + // the parked position as if it were the user's own. + if Self.point(forKey: Keys.originalOrigin) == nil { + Self.set(CGDisplayBounds(sidecarID).origin, forKey: Keys.originalOrigin) + } + + // Reuse the previous layout when there is one, so a restored session reproduces + // the arrangement the user already accepted rather than recomputing (and possibly + // landing somewhere else, since the anchor display can change). + let storedVirtual = Self.point(forKey: Keys.virtualOrigin) + let storedParked = Self.point(forKey: Keys.parkedOrigin) + + let vx: Int, vy: Int + if let sv = storedVirtual { + vx = Int(sv.x); vy = Int(sv.y) + } else { + // Virtual display: immediately to the right of the main display, tops aligned. + let mainBounds = CGDisplayBounds(CGMainDisplayID()) + vx = Int(mainBounds.maxX) + vy = Int(mainBounds.minY) + } + _ = await ArrangementService.shared.setPosition(x: vx, y: vy, for: virtualID) + + let sx: Int, sy: Int + if let sp = storedParked { + sx = Int(sp.x); sy = Int(sp.y) + } else { + // Corner-park beyond the OUTERMOST display so it sits at the far end of the + // layout rather than wedged between displays the user works on. + // + // macOS only lets the cursor cross between displays sharing an EDGE, so a + // single-point corner contact keeps the arrangement contiguous (macOS rejects + // disconnected layouts) while leaving nothing to cross. + // + // Anchor on a real display's corner, not the bounding box's: the box corner + // may be empty space, and a display touching nothing gets shoved back adjacent. + let sidecarSize = CGDisplayBounds(sidecarID).size + let sw = Int(sidecarSize.width) + let sh = Int(sidecarSize.height) + let anchor = Self.activeDisplayIDs() + .filter { $0 != sidecarID && $0 != virtualID } + .map { ($0, CGDisplayBounds($0)) } + .min { ($0.1.minX + $0.1.minY) < ($1.1.minX + $1.1.minY) } + if let (anchorID, anchorBounds) = anchor { + sx = Int(anchorBounds.minX) - sw + sy = Int(anchorBounds.minY) - sh + FDLog.sidecar.info("parking sidecar off display \(anchorID, privacy: .public)'s top-left corner") + } else { + let vb = CGDisplayBounds(virtualID) + sx = vx + Int(vb.width) + sy = vy + Int(vb.height) + } + } + _ = await ArrangementService.shared.setPosition(x: sx, y: sy, for: sidecarID) + + // Record the layout so the next start reproduces it exactly. + Self.set(CGPoint(x: vx, y: vy), forKey: Keys.virtualOrigin) + Self.set(CGPoint(x: sx, y: sy), forKey: Keys.parkedOrigin) + + FDLog.sidecar.info(""" + arranged: virtual \(virtualID, privacy: .public) at \(vx, privacy: .public),\(vy, privacy: .public) — \ + sidecar \(sidecarID, privacy: .public) parked at \(sx, privacy: .public),\(sy, privacy: .public) \ + \(storedParked == nil ? "(computed)" : "(restored)", privacy: .public) + """) + } + + /// Puts the Sidecar display back where the user had it before we parked it. + /// Reads the persisted origin, so this still works after a relaunch. + private func restoreOriginalArrangement(sidecarID: CGDirectDisplayID?) async { + guard let origin = Self.point(forKey: Keys.originalOrigin) else { return } + if let id = sidecarID, CGDisplayIsOnline(id) != 0 { + _ = await ArrangementService.shared.setPosition( + x: Int(origin.x), y: Int(origin.y), for: id + ) + } + Self.set(nil, forKey: Keys.originalOrigin) + } + + // MARK: - Helpers + + /// Waits until AppKit's `NSScreen` for `displayID` reports the same size CoreGraphics + /// does, meaning the reconfiguration has propagated. Gives up after ~2s and lets the + /// caller proceed with whatever AppKit currently reports. + private func waitForScreenGeometry(displayID: CGDirectDisplayID) async { + let expected = CGDisplayBounds(displayID).size + for _ in 0..<20 { + if let s = NSScreen.screen(for: displayID), + Int(s.frame.width) == Int(expected.width), + Int(s.frame.height) == Int(expected.height) { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + FDLog.sidecar.error(""" + NSScreen geometry never settled for display \(displayID, privacy: .public); \ + expected \(Int(expected.width), privacy: .public)x\(Int(expected.height), privacy: .public) + """) + } +} diff --git a/FreeDisplay/Services/ScreenCaptureService.swift b/FreeDisplay/Services/ScreenCaptureService.swift new file mode 100644 index 0000000..715c5fa --- /dev/null +++ b/FreeDisplay/Services/ScreenCaptureService.swift @@ -0,0 +1,146 @@ +@preconcurrency import ScreenCaptureKit +import CoreMedia +import CoreImage +import Foundation + +/// Captures a single display using ScreenCaptureKit (macOS 14+). +/// All state mutations happen on @MainActor; SCStream callbacks are bridged via Task. +@MainActor +final class ScreenCaptureService: NSObject, @unchecked Sendable, ObservableObject { + let displayID: CGDirectDisplayID + @Published private(set) var latestFrame: CIImage? + @Published private(set) var isCapturing = false + @Published private(set) var errorMessage: String? + + private var stream: SCStream? + + init(displayID: CGDirectDisplayID) { + self.displayID = displayID + super.init() + } + + // MARK: - Start + + func startCapture(showCursor: Bool) async { + guard !isCapturing else { return } + errorMessage = nil + do { + // A just-created virtual display does not appear in SCShareableContent + // immediately — the list is refreshed asynchronously by the window server, + // so a single lookup right after creation reliably misses it. Poll instead. + var scDisplay: SCDisplay? + var lastSeen: [UInt32] = [] + for attempt in 0..<12 { + let content = try await SCShareableContent.current + lastSeen = content.displays.map { $0.displayID } + if let match = content.displays.first(where: { $0.displayID == displayID }) { + scDisplay = match + break + } + if attempt < 11 { + try? await Task.sleep(nanoseconds: 400_000_000) + } + } + guard let scDisplay else { + // Log what SCShareableContent actually reported vs. what CoreGraphics + // thinks is active — the two disagreeing is the whole diagnosis here. + var cgCount: UInt32 = 0 + CGGetActiveDisplayList(0, nil, &cgCount) + var cgIDs = [CGDirectDisplayID](repeating: 0, count: Int(cgCount)) + CGGetActiveDisplayList(cgCount, &cgIDs, &cgCount) + + // Online but NOT active means the display exists yet isn't drawing — + // the classic cause being that it has been folded into a mirror set, + // where only the master is active and capturable. + var onCount: UInt32 = 0 + CGGetOnlineDisplayList(0, nil, &onCount) + var onIDs = [CGDirectDisplayID](repeating: 0, count: Int(onCount)) + CGGetOnlineDisplayList(onCount, &onIDs, &onCount) + + let target = self.displayID + let mirrorInfo = onIDs.contains(target) + ? "online=YES mirrors=\(CGDisplayMirrorsDisplay(target)) inMirrorSet=\(CGDisplayIsInMirrorSet(target) != 0)" + : "online=NO" + + FDLog.capture.error(""" + target display not found. \ + want=\(target, privacy: .public) \ + SCShareableContent=\(lastSeen.map(String.init).joined(separator: ","), privacy: .public) \ + CGActive=\(cgIDs.map(String.init).joined(separator: ","), privacy: .public) \ + CGOnline=\(onIDs.map(String.init).joined(separator: ","), privacy: .public) \ + \(mirrorInfo, privacy: .public) + """) + errorMessage = "找不到目标显示器" + return + } + FDLog.capture.info("capturing display \(self.displayID, privacy: .public)") + let filter = SCContentFilter(display: scDisplay, excludingWindows: []) + let config = SCStreamConfiguration() + config.width = scDisplay.width + config.height = scDisplay.height + config.minimumFrameInterval = CMTime(value: 1, timescale: 60) + config.pixelFormat = kCVPixelFormatType_32BGRA + config.showsCursor = showCursor + config.capturesAudio = false + let s = SCStream(filter: filter, configuration: config, delegate: self) + try s.addStreamOutput(self, type: .screen, sampleHandlerQueue: .global(qos: .userInteractive)) + try await s.startCapture() + stream = s + isCapturing = true + } catch { + errorMessage = "串流启动失败:\(error.localizedDescription)" + } + } + + // MARK: - Stop + + func stopCapture() async { + guard let s = stream else { return } + do { + try await s.stopCapture() + } catch { + // Ignore stop errors + } + stream = nil + isCapturing = false + latestFrame = nil + } + + // MARK: - Restart with new options + + func restart(showCursor: Bool) { + Task { + await stopCapture() + await startCapture(showCursor: showCursor) + } + } +} + +// MARK: - SCStreamOutput + +extension ScreenCaptureService: SCStreamOutput { + nonisolated func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + guard type == .screen, + let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + Task { @MainActor [weak self] in + self?.latestFrame = ciImage + } + } +} + +// MARK: - SCStreamDelegate + +extension ScreenCaptureService: SCStreamDelegate { + nonisolated func stream(_ stream: SCStream, didStopWithError error: Error) { + Task { @MainActor [weak self] in + self?.isCapturing = false + self?.stream = nil + self?.errorMessage = "串流中断:\(error.localizedDescription)" + } + } +} diff --git a/FreeDisplay/Services/VirtualDisplayService.swift b/FreeDisplay/Services/VirtualDisplayService.swift index 374333f..a1f7e4e 100644 --- a/FreeDisplay/Services/VirtualDisplayService.swift +++ b/FreeDisplay/Services/VirtualDisplayService.swift @@ -68,6 +68,14 @@ final class VirtualDisplayService: ObservableObject, @unchecked Sendable { activeDisplayObjects.values.contains { $0.displayID == displayID } } + /// 指定 config 对应的虚拟显示器的实时 `CGDirectDisplayID`;未激活时返回 nil。 + func displayID(for configID: UUID) -> CGDirectDisplayID? { + guard let vd = activeDisplayObjects[configID], vd.displayID != kCGNullDirectDisplay else { + return nil + } + return vd.displayID + } + // MARK: - Create / Destroy /// Creates a virtual display from the given config using CGVirtualDisplay private API. @@ -90,7 +98,9 @@ final class VirtualDisplayService: ObservableObject, @unchecked Sendable { ) descriptor.maxPixelsWide = UInt32(w) descriptor.maxPixelsHigh = UInt32(h) - descriptor.name = "FreeDisplay Virtual" + // 用 config 自己的名字:NSScreen.localizedName 取的就是这个值, + // 也就是用户在「排列显示器」和系统设置里看到的名称。 + descriptor.name = config.name descriptor.vendorID = 0xEEEE // non-zero required — 0 causes CGVirtualDisplay(descriptor:) to return nil descriptor.productID = 0x0001 descriptor.serialNum = 0x0001 diff --git a/FreeDisplay/Utilities/FDLog.swift b/FreeDisplay/Utilities/FDLog.swift new file mode 100644 index 0000000..7dff98b --- /dev/null +++ b/FreeDisplay/Utilities/FDLog.swift @@ -0,0 +1,13 @@ +import OSLog + +/// Shared os_log loggers. Read them with: +/// +/// log stream --predicate 'subsystem == "com.freedisplay.app"' --style compact +/// log show --predicate 'subsystem == "com.freedisplay.app"' --last 5m --style compact +/// +/// Use `privacy: .public` on interpolated values — os_log redacts dynamic values by +/// default and they show up as `` otherwise. +enum FDLog { + static let capture = Logger(subsystem: "com.freedisplay.app", category: "capture") + static let sidecar = Logger(subsystem: "com.freedisplay.app", category: "sidecar") +} diff --git a/FreeDisplay/ViewModels/StreamViewModel.swift b/FreeDisplay/ViewModels/StreamViewModel.swift new file mode 100644 index 0000000..db4e19c --- /dev/null +++ b/FreeDisplay/ViewModels/StreamViewModel.swift @@ -0,0 +1,129 @@ +import CoreImage +import Foundation + +/// Options used to configure screen capture and apply transforms/filters to frames. +struct StreamConfig { + var showCursor: Bool = true + var scale: Double = 1.0 // Display scale multiplier + var rotation: Int = 0 // 0 / 90 / 180 / 270 (degrees clockwise) + var flipH: Bool = false + var flipV: Bool = false + var cropEnabled: Bool = false + var cropInset: Double = 0.0 // % cropped from each edge (0..40) + var filterName: String = "none" // "none" | "grayscale" | "blur" | "sharpen" | "invert" + var alphaValue: Double = 1.0 // Window opacity + var autoRestore: Bool = false // Re-open on display reconnect +} + +/// Manages stream state, options, and frame processing for one display. +@MainActor +final class StreamViewModel: ObservableObject { + @Published var config = StreamConfig() + @Published var isCapturing = false + + let service: ScreenCaptureService + + init(displayID: CGDirectDisplayID) { + service = ScreenCaptureService(displayID: displayID) + } + + // MARK: - Capture control + + func startCapture() { + Task { + await service.startCapture(showCursor: config.showCursor) + isCapturing = service.isCapturing + } + } + + func stopCapture() { + Task { + await service.stopCapture() + isCapturing = false + } + } + + // MARK: - Frame processing + + /// Applies rotation, flip, crop, and filter to a raw captured CIImage. + func processedImage(_ raw: CIImage) -> CIImage { + var image = raw + + // 1. Crop + if config.cropEnabled && config.cropInset > 0 { + let ext = image.extent + let pct = config.cropInset / 100.0 + let cropped = CGRect( + x: ext.width * pct, + y: ext.height * pct, + width: ext.width * (1 - 2 * pct), + height: ext.height * (1 - 2 * pct) + ) + image = image.cropped(to: cropped) + image = image.transformed(by: CGAffineTransform(translationX: -image.extent.minX, y: -image.extent.minY)) + } + + // 2. Rotation (clockwise) + if config.rotation != 0 { + image = applyRotation(image, degreesCW: config.rotation) + } + + // 3. Flip + if config.flipH { + let t = CGAffineTransform(scaleX: -1, y: 1) + .concatenating(CGAffineTransform(translationX: image.extent.width, y: 0)) + image = image.transformed(by: t) + } + if config.flipV { + let t = CGAffineTransform(scaleX: 1, y: -1) + .concatenating(CGAffineTransform(translationX: 0, y: image.extent.height)) + image = image.transformed(by: t) + } + + // 4. Video filter + switch config.filterName { + case "grayscale": + if let f = CIFilter(name: "CIColorControls") { + f.setValue(image, forKey: kCIInputImageKey) + f.setValue(0.0, forKey: kCIInputSaturationKey) + image = f.outputImage ?? image + } + case "blur": + if let f = CIFilter(name: "CIGaussianBlur") { + f.setValue(image, forKey: kCIInputImageKey) + f.setValue(4.0, forKey: kCIInputRadiusKey) + image = (f.outputImage ?? image).cropped(to: image.extent) + } + case "sharpen": + if let f = CIFilter(name: "CIUnsharpMask") { + f.setValue(image, forKey: kCIInputImageKey) + f.setValue(1.5, forKey: kCIInputIntensityKey) + f.setValue(2.0, forKey: kCIInputRadiusKey) + image = (f.outputImage ?? image).cropped(to: image.extent) + } + case "invert": + if let f = CIFilter(name: "CIColorInvert") { + f.setValue(image, forKey: kCIInputImageKey) + image = f.outputImage ?? image + } + default: + break + } + + return image + } + + // MARK: - Rotation helper + + /// Rotates a CIImage by `degrees` clockwise, then normalizes extent to origin (0,0). + private func applyRotation(_ image: CIImage, degreesCW: Int) -> CIImage { + // Negative radians = clockwise in standard math coordinate system + let radians = -CGFloat(degreesCW) * .pi / 180.0 + let rotated = image.transformed(by: CGAffineTransform(rotationAngle: radians)) + let norm = CGAffineTransform( + translationX: -rotated.extent.minX, + y: -rotated.extent.minY + ) + return rotated.transformed(by: norm) + } +} diff --git a/FreeDisplay/Views/ArrangementView.swift b/FreeDisplay/Views/ArrangementView.swift index fd20653..a3a5391 100644 --- a/FreeDisplay/Views/ArrangementView.swift +++ b/FreeDisplay/Views/ArrangementView.swift @@ -5,12 +5,24 @@ import SwiftUI /// Supports drag-to-reposition and "Set as main display" button for secondary displays. struct ArrangementView: View { @EnvironmentObject var displayManager: DisplayManager + @ObservedObject private var rotatedSidecar = RotatedSidecarService.shared @State private var draggedID: CGDirectDisplayID? @State private var dragOffset: CGSize = .zero @State private var dragError: String? private let canvasHeight: CGFloat = 160 + /// 要绘制的显示器。「竖屏模式」运行时隐藏物理 Sidecar 屏: + /// 它只是被停在角落的被动输出面,用户不会去操作它, + /// 拖动它反而会破坏「只有一角接触」这一防止光标跑进去的前提。 + /// 由竖屏的虚拟显示器代表它。 + private var arrangeableDisplays: [DisplayInfo] { + guard rotatedSidecar.isActive, let hidden = rotatedSidecar.targetDisplayID else { + return displayManager.displays + } + return displayManager.displays.filter { $0.displayID != hidden } + } + var body: some View { VStack(alignment: .leading, spacing: 6) { // Visual canvas @@ -40,12 +52,12 @@ struct ArrangementView: View { } // "Set as main display" for non-main displays - ForEach(displayManager.displays.filter { !$0.isMain }) { display in + ForEach(arrangeableDisplays.filter { !$0.isMain }) { display in Button(action: { Task { @MainActor in let ok = await ArrangementService.shared.setAsMainDisplay( display.displayID, - among: displayManager.displays + among: arrangeableDisplays ) if ok { displayManager.refreshDisplays() } } @@ -69,7 +81,7 @@ struct ArrangementView: View { @ViewBuilder private func thumbnails(canvasSize: CGSize) -> some View { let layout = computeLayout(canvasSize: canvasSize) - ForEach(displayManager.displays) { display in + ForEach(arrangeableDisplays) { display in let rect = layout[display.displayID] ?? CGRect(x: canvasSize.width / 2, y: canvasSize.height / 2, width: 60, height: 40) let isDragged = draggedID == display.displayID DisplayThumbnailView(display: display, isDragged: isDragged) @@ -96,7 +108,7 @@ struct ArrangementView: View { /// Computes the canvas-space rect for each display, scaled to fit the canvas. private func computeLayout(canvasSize: CGSize) -> [CGDirectDisplayID: CGRect] { - let displays = displayManager.displays + let displays = arrangeableDisplays guard !displays.isEmpty else { return [:] } let allBounds = displays.map { CGDisplayBounds($0.displayID) } @@ -133,7 +145,7 @@ struct ArrangementView: View { /// Converts the drag translation to screen coordinates and applies the new position. private func applyDrag(for display: DisplayInfo, translation: CGSize, layout: [CGDirectDisplayID: CGRect], canvasSize: CGSize) { - let displays = displayManager.displays + let displays = arrangeableDisplays guard !displays.isEmpty else { return } let allBounds = displays.map { CGDisplayBounds($0.displayID) } @@ -212,11 +224,14 @@ private struct DisplayThumbnailView: View { // 显示器名称 + 主显示标记 VStack(spacing: 2) { + // 换行而不是截断:竖屏缩略图很窄,单行会把名称截成「Fre…rtual」。 Text(display.name) .font(.system(size: 8, weight: .medium)) .foregroundColor(display.isBuiltin ? .white : .primary) - .lineLimit(1) - .truncationMode(.middle) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.75) + .fixedSize(horizontal: false, vertical: true) if display.isMain { HStack(spacing: 2) { Image(systemName: "star.fill") diff --git a/FreeDisplay/Views/MenuBarView.swift b/FreeDisplay/Views/MenuBarView.swift index 41887f4..e7cfe1b 100644 --- a/FreeDisplay/Views/MenuBarView.swift +++ b/FreeDisplay/Views/MenuBarView.swift @@ -67,9 +67,11 @@ struct MenuBarView: View { @ObservedObject private var updateService = UpdateService.shared @ObservedObject private var settings = SettingsService.shared @ObservedObject private var virtualDisplayService = VirtualDisplayService.shared + @ObservedObject private var rotatedSidecar = RotatedSidecarService.shared @State private var expandedDisplayIDs: Set = [] @State private var showArrangement: Bool = false @State private var showVirtualDisplays: Bool = false + @State private var showRotatedSidecar: Bool = false @State private var showAutoBrightness: Bool = false @State private var showSettings: Bool = false @State private var quitHovered = false @@ -165,6 +167,22 @@ struct MenuBarView: View { .transition(.opacity.combined(with: .move(edge: .top))) } + // Sidecar 竖屏入口 —— 仅在连接了 Sidecar 显示器时显示 + if rotatedSidecar.isSidecarConnected { + ExpandableRow( + icon: "ipad.landscape.badge.play", + iconColor: .indigo, + label: "Sidecar", + isExpanded: $showRotatedSidecar + ) + + if showRotatedSidecar { + RotatedSidecarView() + .padding(.leading, 8) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + // 自动亮度入口 (Phase 11) ExpandableRow( icon: "sun.and.horizon.fill", @@ -226,7 +244,12 @@ struct MenuBarView: View { } } + // 给滚动内容一个确定的宽度,ScrollView 才能算出高度: + // MenuBarExtra(.window) 给的是不确定尺寸,未加约束的 ScrollView 会塌成 0 高。 + .frame(width: 340, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } + .frame(minHeight: 60, maxHeight: 640) Divider().opacity(0.3) @@ -262,7 +285,7 @@ struct MenuBarView: View { } // end VStack .frame(width: 340) - .frame(maxHeight: 700) + .fixedSize(horizontal: false, vertical: true) .padding(.vertical, 8) .onReceive(displayManager.$displays) { newDisplays in let validIDs = Set(newDisplays.map { $0.displayID }) diff --git a/FreeDisplay/Views/RotatedSidecarView.swift b/FreeDisplay/Views/RotatedSidecarView.swift new file mode 100644 index 0000000..a9eeaa9 --- /dev/null +++ b/FreeDisplay/Views/RotatedSidecarView.swift @@ -0,0 +1,200 @@ +import SwiftUI + +/// "Sidecar" section — drives a portrait iPad by rotating a virtual display and streaming +/// it full-screen to the (landscape-locked) Sidecar display. +/// See `RotatedSidecarService` for why it works this way. +struct RotatedSidecarView: View { + @ObservedObject private var service = RotatedSidecarService.shared + @State private var sidecarDisplays: [CGDirectDisplayID] = [] + @State private var hasScreenRecording = true + @State private var isWorking = false + @State private var isHovered = false + + private var canEnable: Bool { !sidecarDisplays.isEmpty && hasScreenRecording } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Prerequisites — spelled out, because each one fails in a way that looks like + // something else (a missing display, a black screen, a cropped image). + VStack(alignment: .leading, spacing: 4) { + RequirementRow( + ok: hasScreenRecording, + text: "屏幕录制权限", + fixLabel: hasScreenRecording ? nil : "打开设置", + fix: { + RotatedSidecarService.requestScreenRecordingPermission() + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } + ) + // Not a check — macOS cannot see the iPad's rotation lock — so it sits + // with the prerequisites as guidance rather than as a pass/fail row. + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundColor(.blue) + .font(.caption) + .accessibilityHidden(true) + Text("请在 iPad 仍为横屏时打开「旋转锁定」,然后再转动 iPad。") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + .padding(.horizontal, 12) + } + .padding(.bottom, 6) + + // Main toggle — mirrors AutoBrightnessView's row. + HStack { + MenuItemIcon( + systemName: "rotate.right.fill", + color: service.isActive ? .indigo : .secondary + ) + VStack(alignment: .leading, spacing: 2) { + Text("竖屏模式") + .font(.body) + Text("竖向使用 iPad") + .font(.caption2) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if isWorking { + ProgressView() + .controlSize(.small) + .scaleEffect(0.6) + .frame(width: 24) + } else { + Toggle("", isOn: Binding( + get: { service.isActive }, + set: { _ in toggle() } + )) + .toggleStyle(.switch) + .labelsHidden() + .controlSize(.small) + .disabled(!canEnable && !service.isActive) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.primary.opacity(isHovered ? 0.06 : 0)) + .onHover { isHovered = $0 } + .contentShape(Rectangle()) + + // Orientation only matters once it is running. + if service.isActive { + HStack(spacing: 6) { + Text("方向") + .font(.caption) + Spacer() + Picker("", selection: Binding( + get: { service.orientation }, + set: { setOrientation($0) } + )) { + ForEach(RotatedSidecarService.Orientation.allCases) { o in + Text(o.label).tag(o) + } + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .fixedSize() + .disabled(isWorking) + .help("与 iPad 实际摆放方向保持一致") + } + .padding(.horizontal, 12) + .padding(.top, 2) + .padding(.bottom, 6) + } + + if let err = service.errorMessage { + HStack(spacing: 5) { + Image(systemName: service.autoStartFailed + ? "exclamationmark.octagon.fill" : "exclamationmark.triangle.fill") + .foregroundColor(service.autoStartFailed ? .red : .orange) + .font(.caption2) + .accessibilityHidden(true) + Text(err) + .font(.caption2) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.bottom, 4) + } + } + .padding(.vertical, 4) + .onAppear { refresh() } + .onReceive(NotificationCenter.default.publisher( + for: NSApplication.didChangeScreenParametersNotification + )) { _ in + refresh() + } + } + + private func refresh() { + sidecarDisplays = RotatedSidecarService.connectedSidecarDisplays() + hasScreenRecording = RotatedSidecarService.hasScreenRecordingPermission + } + + private func toggle() { + guard let target = sidecarDisplays.first else { return } + isWorking = true + Task { @MainActor in + if service.isActive { + await service.stop() + } else { + await service.start(sidecarDisplayID: target, orientation: service.orientation) + } + isWorking = false + } + } + + /// Changing orientation while running has to restart the pipeline — the rotation is + /// baked into the capture chain when it starts. + private func setOrientation(_ new: RotatedSidecarService.Orientation) { + guard new != service.orientation else { return } + guard service.isActive, let target = sidecarDisplays.first else { + service.orientation = new + return + } + isWorking = true + Task { @MainActor in + await service.stop() + await service.start(sidecarDisplayID: target, orientation: new) + isWorking = false + } + } +} + +// MARK: - RequirementRow + +/// One prerequisite line: green tick when satisfied, red cross when not. +private struct RequirementRow: View { + let ok: Bool + let text: LocalizedStringKey + var fixLabel: LocalizedStringKey? = nil + var fix: (() -> Void)? = nil + + var body: some View { + HStack(spacing: 6) { + Image(systemName: ok ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(ok ? .green : .red) + .font(.caption) + .accessibilityHidden(true) + Text(text) + .font(.caption) + .foregroundColor(ok ? .secondary : .primary) + Spacer() + if let fixLabel, let fix { + Button(fixLabel, action: fix) + .buttonStyle(.plain) + .font(.caption) + .foregroundColor(.blue) + } + } + .padding(.horizontal, 12) + .accessibilityElement(children: .combine) + } +} diff --git a/FreeDisplay/Views/StreamWindow.swift b/FreeDisplay/Views/StreamWindow.swift new file mode 100644 index 0000000..e416763 --- /dev/null +++ b/FreeDisplay/Views/StreamWindow.swift @@ -0,0 +1,225 @@ +import AppKit +import SwiftUI +import Metal + +// MARK: - StreamWindowController + +/// Creates and manages an NSWindow that displays a live stream from one display. +@MainActor +final class StreamWindowController: NSObject { + private(set) var window: NSWindow? + let viewModel: StreamViewModel + + /// Display the full-screen window is pinned to, so it can be re-framed whenever the + /// screen layout changes. Repositioning displays invalidates NSScreen frames; without + /// this the window keeps its old frame and ends up off-screen or on the wrong display. + private var pinnedDisplayID: CGDirectDisplayID? + private var screenObserver: NSObjectProtocol? + + init(viewModel: StreamViewModel) { + self.viewModel = viewModel + super.init() + } + + /// Re-applies the window frame to the pinned display's current geometry. + private func repinToDisplay() { + guard let win = window, + let id = pinnedDisplayID, + let screen = NSScreen.screen(for: id) else { return } + if win.frame != screen.frame { + win.setFrame(screen.frame, display: true) + } + } + + var isVisible: Bool { window?.isVisible ?? false } + + func show() { + if let win = window { + win.makeKeyAndOrderFront(nil) + return + } + let hosting = NSHostingController(rootView: + StreamContentView(viewModel: viewModel) + .frame(minWidth: 320, minHeight: 180) + ) + let win = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 360), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + win.title = "屏幕串流" + win.contentViewController = hosting + win.center() + win.isReleasedWhenClosed = false + win.alphaValue = viewModel.config.alphaValue + win.makeKeyAndOrderFront(nil) + window = win + } + + /// Opens a borderless, full-screen window pinned to `screen`, showing the stream + /// with no chrome. Used by the rotated-Sidecar feature: the physical display shows + /// nothing but the (already rotated) content of the associated virtual display. + /// + /// The window deliberately does NOT become key — clicking through to it would steal + /// focus from whatever the user is doing on their primary display. + func showFullScreen(on screen: NSScreen) { + close() + pinnedDisplayID = screen.displayID + // Registered after close() — close() unregisters, so doing this in init would + // leave the observer dangling the first time a window is shown. + screenObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.repinToDisplay() } + } + + let hosting = NSHostingController(rootView: StreamContentView(viewModel: viewModel)) + let win = NSWindow( + contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false, + screen: screen + ) + win.contentViewController = hosting + win.setFrame(screen.frame, display: true) + win.isReleasedWhenClosed = false + win.backgroundColor = .black + win.hasShadow = false + win.isMovable = false + // Sit ABOVE the menu bar. The target display is a pure output surface — its own + // menu bar and Dock belong to the physical (landscape) display, so once the iPad + // is turned they appear along a vertical edge, on top of the rotated content. + // Covering them leaves only the streamed virtual display's own menu bar visible, + // which is correctly oriented. + win.level = .init(Int(CGWindowLevelForKey(.mainMenuWindow)) + 1) + // Show on the target display even when the user switches Spaces on the main one. + win.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + win.ignoresMouseEvents = false + win.orderFrontRegardless() + window = win + } + + func close() { + window?.close() + window = nil + pinnedDisplayID = nil + // Removed here rather than in deinit: a nonisolated deinit can't touch + // @MainActor state under Swift concurrency checking. + if let obs = screenObserver { + NotificationCenter.default.removeObserver(obs) + screenObserver = nil + } + } + + func updateAlpha(_ value: Double) { + window?.alphaValue = value + } +} + +// MARK: - StreamContentView + +struct StreamContentView: View { + @ObservedObject var viewModel: StreamViewModel + /// Observed separately: `latestFrame` is @Published on the *service*, and nested + /// ObservableObjects do not propagate through their parent. Observing only the + /// view model means the view never re-renders and the window stays black. + @ObservedObject var service: ScreenCaptureService + + init(viewModel: StreamViewModel) { + self.viewModel = viewModel + self.service = viewModel.service + } + + var body: some View { + ZStack { + Color.black + if let frame = service.latestFrame { + CIImageDisplayView(ciImage: viewModel.processedImage(frame)) + } else if service.isCapturing { + ProgressView("正在获取画面…") + .progressViewStyle(.circular) + .tint(.white) + } else if let err = service.errorMessage { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(.orange) + Text(err) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } else { + VStack(spacing: 8) { + Image(systemName: "display.trianglebadge.exclamationmark") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text("串流未启动") + .foregroundColor(.secondary) + } + } + } + .ignoresSafeArea() + } +} + +// MARK: - CIImageDisplayView + +/// NSViewRepresentable wrapper that renders a CIImage into a layer-backed NSView. +struct CIImageDisplayView: NSViewRepresentable { + let ciImage: CIImage + + func makeNSView(context: Context) -> StreamNSView { + StreamNSView() + } + + func updateNSView(_ nsView: StreamNSView, context: Context) { + nsView.ciImage = ciImage + } +} + +// MARK: - StreamNSView + +/// Layer-backed NSView that renders CIImage efficiently using a Metal-backed CIContext. +final class StreamNSView: NSView { + var ciImage: CIImage? { + didSet { needsDisplay = true } + } + + /// Shared Metal-backed CIContext for efficient GPU rendering. + private static let ciContext: CIContext = { + if let device = MTLCreateSystemDefaultDevice() { + return CIContext(mtlDevice: device, options: [.useSoftwareRenderer: false]) + } + return CIContext(options: [.useSoftwareRenderer: false]) + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = CGColor.black + } + + required init?(coder: NSCoder) { fatalError() } + + override var wantsUpdateLayer: Bool { true } + + override func updateLayer() { + guard let ciImage = ciImage else { + layer?.contents = nil + return + } + let extent = ciImage.extent + guard !extent.isEmpty, !extent.isInfinite else { return } + if let cgImage = Self.ciContext.createCGImage(ciImage, from: extent) { + layer?.contents = cgImage + layer?.contentsGravity = .resizeAspect + layer?.backgroundColor = CGColor.black + } + } +} diff --git a/docs/codemap/file-tree.md b/docs/codemap/file-tree.md index 8a19de0..214a61c 100644 --- a/docs/codemap/file-tree.md +++ b/docs/codemap/file-tree.md @@ -60,7 +60,11 @@ FreeDisplay/ │ │ ├── SettingsService.swift # UserDefaults + JSON 文件持久化全局和每显示器设置;改动需注意 key 命名(必须 fd. 前缀)和向后兼容 │ │ ├── UpdateService.swift # GitHub Releases API 检查新版本,语义化版本比较;改动影响更新检查逻辑 │ │ ├── VirtualDisplayService.swift # 虚拟显示器创建/销毁:CGVirtualDisplay 私有 API(vendorID 必须非零如 0xEEEE,主线程创建),HiDPI via 镜像模式,CGHelpers.runWithTimeout 超时保护,hiDPILog 文件调试日志,ObjC 类型 Sendable 扩展;HiDPI 配置仅运行时生效不持久化;改动影响虚拟显示器和 HiDPI 一键预设功能 +│ │ ├── RotatedSidecarService.swift # 旋转 Sidecar:不旋转 Sidecar 屏本身(CGDisplayRotation 只读、Apple Silicon 无 IODisplayConnect),而是建宽高对调的虚拟屏 → SCStream 抓取 → CoreImage 旋转 90° → 无边框全屏窗口投到 Sidecar;Sidecar 检测靠 ASCII ID(vendor 0x6161706c "aapl" / model 0x69506164 "iPad");需要屏幕录制权限 +│ │ ├── ScreenCaptureService.swift # ScreenCaptureKit SCStream 单屏抓取,输出 CIImage 帧;Phase 21 删除后为旋转 Sidecar 功能恢复 │ │ └── PresetService.swift # 预设管理:保存/加载/应用显示器配置预设;使用 DisplayManagerAccessor 读取当前显示器状态;presets.json 存储在 ~/Library/Application Support/FreeDisplay/ +│ ├── ViewModels/ # 视图模型 +│ │ └── StreamViewModel.swift # 串流帧处理:旋转/翻转/裁剪/滤镜(CoreImage)+ 抓取开关;旋转 Sidecar 用其 config.rotation;Phase 21 删除后恢复 │ ├── Utilities/ # 工具扩展 │ │ └── NSScreenExtension.swift # NSScreen 扩展:按 CGDirectDisplayID 查找 NSScreen,获取 displayID;被 NotchView、NotchOverlayManager 依赖 │ ├── FreeDisplay-Bridging-Header.h # 私有 API 声明:CGVirtualDisplay(macOS 14+)和 IOAVService(Apple Silicon DDC);属性名已对照 Chromium 源码验证(maxPixelsWide/maxPixelsHigh 非 maxPixelSize) @@ -79,6 +83,8 @@ FreeDisplay/ │ ├── SystemColorView.swift # 系统取色器(NSColorSampler)+ HEX/RGB/HSB 显示 + 历史记录;依赖 SettingsService 持久化颜色历史 │ ├── HiDPIView.swift # HiDPI Override 状态行(plist 方案)+ 写入/还原按钮;依赖 HiDPIService │ ├── VirtualDisplayView.swift # 虚拟显示器配置列表 + 创建表单(预设分辨率)+ HiDPI 一键预设;依赖 VirtualDisplayService +│ ├── RotatedSidecarView.swift # Sidecar 竖屏模式开关 + 方向选择(上/下);依赖 RotatedSidecarService +│ ├── StreamWindow.swift # 串流输出窗口:StreamWindowController.showFullScreen(on:) 无边框全屏窗口 + Metal CIContext 渲染 CIImage;Phase 21 删除后为旋转 Sidecar 功能恢复 │ └── SavePresetView.swift # 保存当前显示器状态为预设;内联表单(名称 + 图标选择器);调用 PresetService.captureCurrentState + addPreset ├── FreeDisplay.xcodeproj/ # Xcode 项目文件(由 xcodegen 生成,不要手动编辑) ├── .gitignore # Git 忽略规则 diff --git a/docs/lessons/iokit.md b/docs/lessons/iokit.md index 2f063f5..c5867f5 100644 --- a/docs/lessons/iokit.md +++ b/docs/lessons/iokit.md @@ -14,13 +14,47 @@ - `IOFBCopyI2CInterfaceForBus(framebuffer, busIndex, &interface)` 是比手动查 IOFramebufferI2CInterface 子节点更干净的 API,推荐使用 - `BrightnessService` 方法若要访问 `@MainActor` 隔离的 `DisplayInfo` 属性,需标记为 `@MainActor`;实际 DDC I/O 由 DDCService 内部的 ddcQueue 异步执行,不阻塞 MainActor -## IOKit / 屏幕旋转(Phase 4) +## IOKit / 屏幕旋转(Phase 4)— ⚠️ 已失效,不要照做 + +> **2026-08-12 更新:下面这套 `IOFBTransform` 旋转方案在 Apple Silicon 上完全不可用。** +> 在 M4 Pro / macOS 26.4 上实测: +> +> ``` +> ioreg -rc IODisplayConnect → 0 个服务 +> ioreg -l | grep IOFBTransform → 0 个匹配 +> ``` +> +> 显示器由 DCP 驱动,根本不存在 IOFramebuffer 可写。这影响机器上**所有**显示器, +> 不只是 Sidecar。(与"IOFramebuffer I2C DDC 在 Apple Silicon 上不工作、必须走 +> IOAVService"是同一个根因。)旋转代码已在 Phase 21 删除,**不要恢复**。 +> +> - `CGDisplayRotation()` 只读,没有公开 setter +> - 但**旋转本身并非做不到** — BetterDisplay 有可用的旋转功能,说明存在别的机制, +> 本次未查明。若将来要做旋转,先去查 BetterDisplay 怎么实现,❌ 不要从下面这段开始 +> - Sidecar 显示器在系统设置里**根本没有 Rotation 选项** → 系统层面就不支持, +> 任何 API 都救不了;竖屏 iPad 的正解见 `RotatedSidecarService`(旋转虚拟屏 + 串流) + +历史记录(Intel 时代有效,Apple Silicon 无效): - `CGDisplayIOServicePort` 在最新 macOS SDK 中已彻底 **unavailable**(非 deprecated),直接报错,必须用 IOKit registry 遍历代替 - 替代方式:遍历 `IODisplayConnect` → 用 vendor/model 匹配 → `IORegistryEntryGetParentEntry(service, kIOServicePlane, &parent)` 得到 IOFramebuffer(与 DDCService.framebufferService 完全相同的模式) - 屏幕旋转:`IORegistryEntrySetCFProperty(fb, "IOFBTransform", NSNumber(value: index))` + `IOServiceRequestProbe(fb, 0x00000400)` 触发;旋转 index = 0/1/2/3 对应 0°/90°/180°/270° - `import IOKit.graphics` 对于 `IOServiceRequestProbe` 所需的图形常量是必要的 +## Sidecar 显示器(2026-08-12) + +- Sidecar 的 CoreGraphics ID 是 ASCII 编码:vendor `0x6161706c` = "aapl",model `0x69506164` = "iPad" +- **`CGDirectDisplayID` 每次重连都会变**(一次会话内观察到 7→8→9→12→19→24→30→32); + 锁定 iPad 方向、开关镜像都算重连 → ❌ 不要跨重连缓存 displayID,要监听 + `NSApplication.didChangeScreenParametersNotification` +- `/Library/Displays/.../Overrides` 的 plist **对 Sidecar 有效**(能注入分辨率), + 但 iPad 端不能正确呈现竖屏 framebuffer(画面只占一角或 3/4),所以这条路走不通 +- **macOS 会持久化镜像配置**:用户曾把某个显示器镜像到 Sidecar 后,之后新建的虚拟 + 显示器会被自动并入那个镜像组。被镜像的从属显示器是 **online 但不 active**, + 不会出现在 `SCShareableContent` 里,报错表现为莫名其妙的"找不到目标显示器"。 + 排查要点:对比 `CGGetOnlineDisplayList` 和 `CGGetActiveDisplayList`,两者不一致就是它 +- 新建的虚拟显示器不会立刻出现在 `SCShareableContent` 里(窗口服务器异步刷新)→ 要轮询 + ## IOKit / 环境光传感器(Phase 11) - `AppleLMUController` 是 IOKit 服务,通过 `IOServiceGetMatchingService` 获取;`IOServiceOpen` 打开连接后用 `IOConnectCallMethod(port, 0, nil, 0, nil, 0, &output, &outputCount, nil, &outputStructSize)` 读取两通道(左/右)传感器 UInt64 值