From 5822d9b3a158b016c4b6dd25c86f488314e0f716 Mon Sep 17 00:00:00 2001 From: "Morgan J." Date: Sat, 11 Apr 2026 03:42:31 +0900 Subject: [PATCH] Add source --- Makefile | 42 ++++++++ src/AppDelegate.swift | 165 ++++++++++++++++++++++++++++++++ src/BridgingHeader.h | 1 + src/CGVirtualDisplayPrivate.h | 61 ++++++++++++ src/DisplayManager.swift | 61 ++++++++++++ src/Info.plist | 30 ++++++ src/Resolution.swift | 40 ++++++++ src/VirtualDisplay.entitlements | 6 ++ src/main.swift | 7 ++ 9 files changed, 413 insertions(+) create mode 100644 Makefile create mode 100644 src/AppDelegate.swift create mode 100644 src/BridgingHeader.h create mode 100644 src/CGVirtualDisplayPrivate.h create mode 100644 src/DisplayManager.swift create mode 100644 src/Info.plist create mode 100644 src/Resolution.swift create mode 100644 src/VirtualDisplay.entitlements create mode 100644 src/main.swift diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..21401af --- /dev/null +++ b/Makefile @@ -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) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift new file mode 100644 index 0000000..55807a5 --- /dev/null +++ b/src/AppDelegate.swift @@ -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() + } +} diff --git a/src/BridgingHeader.h b/src/BridgingHeader.h new file mode 100644 index 0000000..ae381ca --- /dev/null +++ b/src/BridgingHeader.h @@ -0,0 +1 @@ +#import "CGVirtualDisplayPrivate.h" diff --git a/src/CGVirtualDisplayPrivate.h b/src/CGVirtualDisplayPrivate.h new file mode 100644 index 0000000..848411b --- /dev/null +++ b/src/CGVirtualDisplayPrivate.h @@ -0,0 +1,61 @@ +// +// CGVirtualDisplayPrivate.h +// + +#import +#import + +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 *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 diff --git a/src/DisplayManager.swift b/src/DisplayManager.swift new file mode 100644 index 0000000..111cbfb --- /dev/null +++ b/src/DisplayManager.swift @@ -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) + } +} diff --git a/src/Info.plist b/src/Info.plist new file mode 100644 index 0000000..f7d00d6 --- /dev/null +++ b/src/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleExecutable + VirtualDisplay + CFBundleIdentifier + local.virtualdisplay + CFBundleName + VirtualDisplay + CFBundleDisplayName + VirtualDisplay + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1 + CFBundleVersion + 1 + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/src/Resolution.swift b/src/Resolution.swift new file mode 100644 index 0000000..24ae5de --- /dev/null +++ b/src/Resolution.swift @@ -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 + } +} diff --git a/src/VirtualDisplay.entitlements b/src/VirtualDisplay.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/src/VirtualDisplay.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main.swift b/src/main.swift new file mode 100644 index 0000000..01a189e --- /dev/null +++ b/src/main.swift @@ -0,0 +1,7 @@ +import Cocoa + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run()