mirror of
https://github.com/morgan9e/VirtualDisplay
synced 2026-04-14 00:04:05 +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