Add source

This commit is contained in:
2026-04-11 03:42:31 +09:00
parent b321703dd9
commit 5822d9b3a1
9 changed files with 413 additions and 0 deletions

42
Makefile Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
#import "CGVirtualDisplayPrivate.h"

View 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
View 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
View 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
View 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
}
}

View 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
View File

@@ -0,0 +1,7 @@
import Cocoa
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()