mirror of
https://github.com/morgan9e/VirtualDisplay
synced 2026-04-13 15:55:02 +09:00
Add source
This commit is contained in:
42
Makefile
Normal file
42
Makefile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
APP_NAME := VirtualDisplay
|
||||||
|
SRC_DIR := src
|
||||||
|
BUILD_DIR := build
|
||||||
|
APP_BUNDLE := $(BUILD_DIR)/$(APP_NAME).app
|
||||||
|
MACOS_DIR := $(APP_BUNDLE)/Contents/MacOS
|
||||||
|
BIN := $(MACOS_DIR)/$(APP_NAME)
|
||||||
|
|
||||||
|
INFO_PLIST := $(SRC_DIR)/Info.plist
|
||||||
|
ENTITLEMENTS := $(SRC_DIR)/VirtualDisplay.entitlements
|
||||||
|
BRIDGING := $(SRC_DIR)/BridgingHeader.h
|
||||||
|
PRIVATE_H := $(SRC_DIR)/CGVirtualDisplayPrivate.h
|
||||||
|
|
||||||
|
SWIFT_SOURCES := $(wildcard $(SRC_DIR)/*.swift)
|
||||||
|
|
||||||
|
SWIFTC := swiftc
|
||||||
|
SWIFTFLAGS := -O \
|
||||||
|
-import-objc-header $(BRIDGING) \
|
||||||
|
-framework Cocoa \
|
||||||
|
-framework CoreGraphics \
|
||||||
|
-framework IOKit
|
||||||
|
|
||||||
|
.PHONY: all run kill clean rebuild
|
||||||
|
|
||||||
|
all: $(APP_BUNDLE)
|
||||||
|
|
||||||
|
$(APP_BUNDLE): $(SWIFT_SOURCES) $(BRIDGING) $(PRIVATE_H) $(INFO_PLIST) $(ENTITLEMENTS)
|
||||||
|
@mkdir -p $(MACOS_DIR) $(APP_BUNDLE)/Contents/Resources
|
||||||
|
cp $(INFO_PLIST) $(APP_BUNDLE)/Contents/Info.plist
|
||||||
|
$(SWIFTC) $(SWIFTFLAGS) -o $(BIN) $(SWIFT_SOURCES)
|
||||||
|
codesign --force --sign - --entitlements $(ENTITLEMENTS) $(APP_BUNDLE)
|
||||||
|
@echo "Built $(APP_BUNDLE)"
|
||||||
|
|
||||||
|
run: $(APP_BUNDLE)
|
||||||
|
open $(APP_BUNDLE)
|
||||||
|
|
||||||
|
kill:
|
||||||
|
-pkill -f $(APP_NAME)
|
||||||
|
|
||||||
|
rebuild: clean all
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
165
src/AppDelegate.swift
Normal file
165
src/AppDelegate.swift
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import Cocoa
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
||||||
|
|
||||||
|
private var statusItem: NSStatusItem!
|
||||||
|
private let menu = NSMenu()
|
||||||
|
|
||||||
|
private struct Physical {
|
||||||
|
let name: String
|
||||||
|
let pixelWidth: Int
|
||||||
|
let pixelHeight: Int
|
||||||
|
let refreshRate: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastPhysical: Physical?
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
if let button = statusItem.button {
|
||||||
|
if let img = NSImage(systemSymbolName: "rectangle.on.rectangle",
|
||||||
|
accessibilityDescription: "VirtualDisplay") {
|
||||||
|
img.isTemplate = true
|
||||||
|
button.image = img
|
||||||
|
} else {
|
||||||
|
button.title = "VD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.delegate = self
|
||||||
|
menu.autoenablesItems = false
|
||||||
|
statusItem.menu = menu
|
||||||
|
|
||||||
|
ProcessInfo.processInfo.disableAutomaticTermination("VirtualDisplay running")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
DisplayManager.shared.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NSMenuDelegate
|
||||||
|
|
||||||
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
|
guard menu === self.menu else { return }
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuild() {
|
||||||
|
menu.removeAllItems()
|
||||||
|
|
||||||
|
guard let phys = physicalDisplay() else {
|
||||||
|
addDisabled("No display detected")
|
||||||
|
menu.addItem(.separator())
|
||||||
|
addQuit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addDisabled("\(phys.name) (\(phys.pixelWidth)×\(phys.pixelHeight) @ \(phys.refreshRate)Hz)")
|
||||||
|
menu.addItem(.separator())
|
||||||
|
|
||||||
|
let activeScale = DisplayManager.shared.currentScale
|
||||||
|
for s in availableScales(physicalPixelWidth: phys.pixelWidth,
|
||||||
|
physicalPixelHeight: phys.pixelHeight) {
|
||||||
|
let item = NSMenuItem(title: "\(s)%",
|
||||||
|
action: #selector(chooseScale(_:)),
|
||||||
|
keyEquivalent: "")
|
||||||
|
item.target = self
|
||||||
|
item.tag = s
|
||||||
|
item.state = (activeScale == s) ? .on : .off
|
||||||
|
menu.addItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if activeScale != nil {
|
||||||
|
menu.addItem(.separator())
|
||||||
|
addAction("Open Display Settings…", #selector(openDisplaySettings(_:)))
|
||||||
|
menu.addItem(.separator())
|
||||||
|
addAction("Disable", #selector(disable(_:)))
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.addItem(.separator())
|
||||||
|
addQuit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu helpers
|
||||||
|
|
||||||
|
private func addDisabled(_ title: String) {
|
||||||
|
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
||||||
|
item.isEnabled = false
|
||||||
|
menu.addItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addAction(_ title: String, _ action: Selector) {
|
||||||
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||||
|
item.target = self
|
||||||
|
menu.addItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addQuit() {
|
||||||
|
menu.addItem(NSMenuItem(title: "Quit",
|
||||||
|
action: #selector(NSApplication.terminate(_:)),
|
||||||
|
keyEquivalent: "q"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physical display detection
|
||||||
|
|
||||||
|
private func physicalDisplay() -> Physical? {
|
||||||
|
if let live = liveLookup() { lastPhysical = live }
|
||||||
|
return lastPhysical
|
||||||
|
}
|
||||||
|
|
||||||
|
private func liveLookup() -> Physical? {
|
||||||
|
let ourID = DisplayManager.shared.virtualDisplayID
|
||||||
|
|
||||||
|
var count: UInt32 = 0
|
||||||
|
guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids = [CGDirectDisplayID](repeating: 0, count: Int(count))
|
||||||
|
guard CGGetActiveDisplayList(count, &ids, &count) == .success,
|
||||||
|
let id = ids.first(where: { $0 != ourID }),
|
||||||
|
let mode = CGDisplayCopyDisplayMode(id) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = NSDeviceDescriptionKey("NSScreenNumber")
|
||||||
|
let name = NSScreen.screens.first {
|
||||||
|
($0.deviceDescription[key] as? NSNumber)?.uint32Value == id
|
||||||
|
}?.localizedName ?? lastPhysical?.name ?? "Display"
|
||||||
|
let hz = mode.refreshRate > 0 ? Int(mode.refreshRate.rounded()) : 60
|
||||||
|
|
||||||
|
return Physical(
|
||||||
|
name: name,
|
||||||
|
pixelWidth: mode.pixelWidth,
|
||||||
|
pixelHeight: mode.pixelHeight,
|
||||||
|
refreshRate: hz
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
@objc private func chooseScale(_ sender: NSMenuItem) {
|
||||||
|
guard let phys = physicalDisplay() else { return }
|
||||||
|
let cfg = vdConfig(physicalPixelWidth: phys.pixelWidth,
|
||||||
|
physicalPixelHeight: phys.pixelHeight,
|
||||||
|
refreshRate: phys.refreshRate,
|
||||||
|
scalePercent: sender.tag)
|
||||||
|
DisplayManager.shared.setScale(sender.tag, config: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openDisplaySettings(_ sender: NSMenuItem) {
|
||||||
|
let urls = [
|
||||||
|
"x-apple.systempreferences:com.apple.Displays-Settings.extension",
|
||||||
|
"x-apple.systempreferences:com.apple.preference.displays",
|
||||||
|
]
|
||||||
|
for str in urls {
|
||||||
|
if let url = URL(string: str), NSWorkspace.shared.open(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func disable(_ sender: NSMenuItem) {
|
||||||
|
DisplayManager.shared.disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/BridgingHeader.h
Normal file
1
src/BridgingHeader.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import "CGVirtualDisplayPrivate.h"
|
||||||
61
src/CGVirtualDisplayPrivate.h
Normal file
61
src/CGVirtualDisplayPrivate.h
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// CGVirtualDisplayPrivate.h
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Cocoa/Cocoa.h>
|
||||||
|
#import <CoreGraphics/CoreGraphics.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@class CGVirtualDisplayDescriptor;
|
||||||
|
|
||||||
|
@interface CGVirtualDisplayMode : NSObject
|
||||||
|
@property(readonly, nonatomic) CGFloat refreshRate;
|
||||||
|
@property(readonly, nonatomic) NSUInteger width;
|
||||||
|
@property(readonly, nonatomic) NSUInteger height;
|
||||||
|
- (instancetype)initWithWidth:(NSUInteger)arg1 height:(NSUInteger)arg2 refreshRate:(CGFloat)arg3;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface CGVirtualDisplaySettings : NSObject
|
||||||
|
@property(nonatomic) unsigned int hiDPI;
|
||||||
|
@property(retain, nonatomic) NSArray<CGVirtualDisplayMode *> *modes;
|
||||||
|
- (instancetype)init;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface CGVirtualDisplay : NSObject
|
||||||
|
@property(readonly, nonatomic) NSArray *modes;
|
||||||
|
@property(readonly, nonatomic) unsigned int hiDPI;
|
||||||
|
@property(readonly, nonatomic) CGDirectDisplayID displayID;
|
||||||
|
@property(readonly, nonatomic) id terminationHandler;
|
||||||
|
@property(readonly, nonatomic) dispatch_queue_t queue;
|
||||||
|
@property(readonly, nonatomic) unsigned int maxPixelsHigh;
|
||||||
|
@property(readonly, nonatomic) unsigned int maxPixelsWide;
|
||||||
|
@property(readonly, nonatomic) CGSize sizeInMillimeters;
|
||||||
|
@property(readonly, nonatomic) NSString *name;
|
||||||
|
@property(readonly, nonatomic) unsigned int serialNum;
|
||||||
|
@property(readonly, nonatomic) unsigned int productID;
|
||||||
|
@property(readonly, nonatomic) unsigned int vendorID;
|
||||||
|
- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)arg1;
|
||||||
|
- (BOOL)applySettings:(CGVirtualDisplaySettings *)arg1;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface CGVirtualDisplayDescriptor : NSObject
|
||||||
|
@property(retain, nonatomic) dispatch_queue_t queue;
|
||||||
|
@property(retain, nonatomic) NSString *name;
|
||||||
|
@property(nonatomic) CGPoint whitePoint;
|
||||||
|
@property(nonatomic) CGPoint bluePrimary;
|
||||||
|
@property(nonatomic) CGPoint greenPrimary;
|
||||||
|
@property(nonatomic) CGPoint redPrimary;
|
||||||
|
@property(nonatomic) unsigned int maxPixelsHigh;
|
||||||
|
@property(nonatomic) unsigned int maxPixelsWide;
|
||||||
|
@property(nonatomic) CGSize sizeInMillimeters;
|
||||||
|
@property(nonatomic) unsigned int serialNum;
|
||||||
|
@property(nonatomic) unsigned int productID;
|
||||||
|
@property(nonatomic) unsigned int vendorID;
|
||||||
|
@property(copy, nonatomic) void (^terminationHandler)(id, CGVirtualDisplay*);
|
||||||
|
- (instancetype)init;
|
||||||
|
- (nullable dispatch_queue_t)dispatchQueue;
|
||||||
|
- (void)setDispatchQueue:(dispatch_queue_t)arg1;
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
61
src/DisplayManager.swift
Normal file
61
src/DisplayManager.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import Cocoa
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
final class DisplayManager {
|
||||||
|
static let shared = DisplayManager()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private(set) var currentScale: Int?
|
||||||
|
private var display: CGVirtualDisplay?
|
||||||
|
|
||||||
|
private let workQueue = DispatchQueue(label: "VirtualDisplay.work",
|
||||||
|
qos: .userInitiated)
|
||||||
|
|
||||||
|
var virtualDisplayID: CGDirectDisplayID? {
|
||||||
|
return display?.displayID
|
||||||
|
}
|
||||||
|
|
||||||
|
func setScale(_ scalePercent: Int, config: VDConfig) {
|
||||||
|
let vd = display ?? makeDisplay()
|
||||||
|
display = vd
|
||||||
|
|
||||||
|
let settings = CGVirtualDisplaySettings()
|
||||||
|
settings.hiDPI = 1
|
||||||
|
settings.modes = [
|
||||||
|
CGVirtualDisplayMode(
|
||||||
|
width: UInt(config.logicalWidth),
|
||||||
|
height: UInt(config.logicalHeight),
|
||||||
|
refreshRate: CGFloat(config.refreshRate)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
workQueue.async {
|
||||||
|
let ok = vd.apply(settings)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if ok { self.currentScale = scalePercent }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disable() {
|
||||||
|
display = nil
|
||||||
|
currentScale = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDisplay() -> CGVirtualDisplay {
|
||||||
|
let desc = CGVirtualDisplayDescriptor()
|
||||||
|
desc.setDispatchQueue(workQueue)
|
||||||
|
desc.name = "VirtualDisplay"
|
||||||
|
desc.maxPixelsWide = UInt32(maxFramebufferPixelWidth)
|
||||||
|
desc.maxPixelsHigh = UInt32(maxFramebufferPixelHeight)
|
||||||
|
desc.sizeInMillimeters = CGSize(
|
||||||
|
width: Double(maxFramebufferPixelWidth) / 100.0 * 25.4,
|
||||||
|
height: Double(maxFramebufferPixelHeight) / 100.0 * 25.4
|
||||||
|
)
|
||||||
|
desc.vendorID = 0xBEEF
|
||||||
|
desc.productID = 0xCAFE
|
||||||
|
desc.serialNum = 1
|
||||||
|
desc.terminationHandler = { _, _ in }
|
||||||
|
return CGVirtualDisplay(descriptor: desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Info.plist
Normal file
30
src/Info.plist
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>VirtualDisplay</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>local.virtualdisplay</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>VirtualDisplay</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>VirtualDisplay</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>11.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
40
src/Resolution.swift
Normal file
40
src/Resolution.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct VDConfig: Equatable {
|
||||||
|
let pixelWidth: Int
|
||||||
|
let pixelHeight: Int
|
||||||
|
let logicalWidth: Int
|
||||||
|
let logicalHeight: Int
|
||||||
|
let refreshRate: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
let scalePercents: [Int] = [100, 125, 133, 150, 166, 175, 200]
|
||||||
|
|
||||||
|
let maxFramebufferPixelWidth = 6144
|
||||||
|
let maxFramebufferPixelHeight = 3456
|
||||||
|
|
||||||
|
func vdConfig(physicalPixelWidth pw: Int,
|
||||||
|
physicalPixelHeight ph: Int,
|
||||||
|
refreshRate: Int,
|
||||||
|
scalePercent s: Int) -> VDConfig {
|
||||||
|
let lw = max(1, Int((Double(pw) * 100.0 / Double(s)).rounded()))
|
||||||
|
let lh = max(1, Int((Double(ph) * 100.0 / Double(s)).rounded()))
|
||||||
|
return VDConfig(
|
||||||
|
pixelWidth: lw * 2,
|
||||||
|
pixelHeight: lh * 2,
|
||||||
|
logicalWidth: lw,
|
||||||
|
logicalHeight: lh,
|
||||||
|
refreshRate: refreshRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableScales(physicalPixelWidth pw: Int, physicalPixelHeight ph: Int) -> [Int] {
|
||||||
|
return scalePercents.filter { s in
|
||||||
|
let cfg = vdConfig(physicalPixelWidth: pw,
|
||||||
|
physicalPixelHeight: ph,
|
||||||
|
refreshRate: 60,
|
||||||
|
scalePercent: s)
|
||||||
|
return cfg.pixelWidth <= maxFramebufferPixelWidth
|
||||||
|
&& cfg.pixelHeight <= maxFramebufferPixelHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/VirtualDisplay.entitlements
Normal file
6
src/VirtualDisplay.entitlements
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
7
src/main.swift
Normal file
7
src/main.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Cocoa
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
app.run()
|
||||||
Reference in New Issue
Block a user