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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions FreeDisplay/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion FreeDisplay/Services/DisplayManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
522 changes: 522 additions & 0 deletions FreeDisplay/Services/RotatedSidecarService.swift

Large diffs are not rendered by default.

146 changes: 146 additions & 0 deletions FreeDisplay/Services/ScreenCaptureService.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
}
12 changes: 11 additions & 1 deletion FreeDisplay/Services/VirtualDisplayService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions FreeDisplay/Utilities/FDLog.swift
Original file line number Diff line number Diff line change
@@ -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 `<private>` otherwise.
enum FDLog {
static let capture = Logger(subsystem: "com.freedisplay.app", category: "capture")
static let sidecar = Logger(subsystem: "com.freedisplay.app", category: "sidecar")
}
129 changes: 129 additions & 0 deletions FreeDisplay/ViewModels/StreamViewModel.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading