Flexible HiDPI scaling and HDR control for macOS — free, open source, and small on purpose.
FlexDisplay gives any monitor arbitrary "looks like" resolutions at true HiDPI — the same trick BetterDisplay uses, in a much smaller, single-purpose, free app.
On a 2560 × 1440 panel it will run a 1760 × 990 desktop rendered at 2× — a resolution the monitor's EDID never advertises and macOS will never offer you in System Settings.
Project status: flexible HiDPI scaling is implemented and verified on real hardware end-to-end (creation → mode selection → mirror → revert). HDR toggling and brightness control are implemented but have never run against capable hardware — see Caveats. Contributions from anyone with an HDR-capable display are very welcome.
- Why this exists
- How it works
- Install
- Using it
- Why "HiDPI" doesn't always look Retina-sharp
- Building from source
- Packaging a DMG
- Command-line diagnostics
- Architecture
- Safety model
- Known limitations
- Roadmap
- Contributing
- FAQ
- License
BetterDisplay is a great app, but its flexible-scaling feature — the actual reason most people install it — is a paid Pro upgrade, and the app itself is a large, dense surface covering DDC control, PIP, dummy displays, remote control, and more.
FlexDisplay does one thing: arbitrary HiDPI resolutions on any monitor, for free, with a menu that fits in one glance. If you only ever wanted the scaling slider, this is that slider and nothing else.
We're not trying to out-feature BetterDisplay — that's a multi-year effort. We're trying to be the tool you reach for when you just want your external monitor to look right.
A physical display only offers the modes its EDID declares, so you cannot ask a 1440p monitor for "looks like 1600 × 900 at 2×" — that mode does not exist on the panel. The trick, used by both BetterDisplay and FlexDisplay, is to stop asking the panel:
- Create a virtual display via the private
CGVirtualDisplayCoreGraphics API, with a framebuffer that's exactly 2× the logical size you want, HiDPI enabled. A virtual display has no EDID, so it can be any size you like. The desktop renders there, at true Retina density. - Mirror the real panel onto that virtual display. The panel now shows the virtual display's output, resampled by the GPU to fit its physical pixels.
You get an arbitrary logical resolution with genuinely 2×-rendered UI. The cost is one GPU scaling pass and, once the requested framebuffer exceeds the panel's native pixel count, some loss of sharpness (see below — this is physics, not a FlexDisplay limitation).
This relies on private CoreGraphics and DisplayServices APIs that Apple does not document or guarantee. Consequently:
- No App Store. Sandboxed apps cannot touch these APIs.
- No sandbox, ever. Hardened Runtime is supported for a signed/notarized build, but the app cannot be sandboxed and still work.
- Every private symbol is resolved at runtime via
NSClassFromString/dlsym, never linked. If Apple removes a symbol in a future macOS release, the corresponding feature disables itself with a plain-language message instead of crashing the app.
Download the latest .dmg from Releases, open it, drag
FlexDisplay.app into /Applications, launch it.
Prebuilt releases are ad-hoc signed, not notarized by Apple (that requires a paid Developer ID, which this project does not currently have). On first launch, Gatekeeper will refuse to open it. To run it anyway:
xattr -dr com.apple.quarantine /Applications/FlexDisplay.appOr: right-click the app → Open → Open in the dialog that appears (this bypasses the quarantine prompt without needing Terminal).
If a Developer ID–signed, notarized build becomes available, this section — and the need
for the workaround — goes away. See Scripts/make-dmg.sh for the
exact notarization command a maintainer with a Developer ID can run.
Open the FlexDisplay menu → Settings… → General → Launch at login.
Click the display icon in the menu bar. Each connected display gets its own submenu:
| Section | What it does |
|---|---|
| Flexible scaling | Aspect-locked sizes from half to full native width, each labelled with how sharp it will actually render (see below). This is the feature — picking one creates a virtual display and mirrors your panel onto it. |
| Built-in resolutions | The modes the panel actually advertises natively, badged HiDPI / Native / Supersampled. |
| HDR | Shown only on displays that report HDR capability. Toggling looks for a matching mode with a deeper pixel encoding at the same resolution/refresh. |
| Brightness | Shown only where DisplayServicesCanChangeBrightness reports true — typically built-in panels, not most external monitors over DisplayPort/HDMI. |
| Presets | Save the current configuration under a name, reapply it later, or cycle through all of a display's presets with ⌃⌥⌘S — works from anywhere, targets whichever display your pointer is currently over. |
Every change you make by hand goes through a 15-second revert-confirmation dialog: it applies the change immediately, then asks you to keep it. If you don't respond (or the change leaves you unable to see the dialog at all), it automatically reverts. Automatic restores — on launch, or when a display reconnects — skip the dialog, since you already confirmed that exact configuration once.
Quitting the app (or it crashing, or losing power) tears down every virtual display and
mirror pairing it created, so your panel returns to native. This is enforced from four
separate exit paths — applicationWillTerminate, atexit, SIGINT/SIGTERM/SIGHUP, and
the fact that a virtual display is deallocated (and therefore vanishes) the instant the
owning process dies, even on SIGKILL.
This surprises everyone the first time, and it's physics, not a bug — BetterDisplay hits the identical wall.
A HiDPI mode always renders at 2×, but your panel can only physically show so many pixels. The framebuffer FlexDisplay creates is always twice the logical size you ask for, so on a 2560 × 1440 monitor:
| You choose | Renders into | Panel shows | Effective sharpness |
|---|---|---|---|
| 1280 × 720 | 2560 × 1440 | all of it | true 2.0× — genuinely Retina |
| 1600 × 900 | 3200 × 1800 | 2560 × 1440 of it | 1.6× |
| 2048 × 1152 | 4096 × 2304 | 2560 × 1440 of it | 1.25× |
| 2560 × 1440 | 5120 × 2880 | 2560 × 1440 of it | 1.0× |
Only half of your panel's native width produces a framebuffer that fits inside its physical pixel count — that's the only size that's genuinely Retina. Everything above that is supersampled: clearly sharper than running at 1× would be, but not truly crisp. The menu prints the effective multiplier next to every size, so this is a deliberate choice rather than a surprise.
Generate this table for your own display:
FLEXDISPLAY_SELFTEST_SWEEP=/tmp/sweep.txt ./.build/debug/FlexDisplay
cat /tmp/sweep.txtRequires macOS 14+, Xcode 15+ (for the Swift 6 toolchain), and no other dependencies — this is a plain Swift Package Manager project, not an Xcode project. (See Architecture for why.)
git clone https://github.com/9Bit-Studio/FlexDisplay.git
cd FlexDisplay
./Scripts/run.shrun.sh builds, installs the app to ~/Applications, and launches it — this is the
fastest inner loop for development, since it kills any previously running copy first (via
SIGTERM, so its own teardown path runs cleanly).
If you only want the .app bundle without installing/launching it:
./Scripts/build.sh # debug build → build/FlexDisplay.app
./Scripts/build.sh release # optimized buildSigning is ad-hoc by default (codesign --sign -). To produce a hardened, Developer
ID–signed build:
FLEXDISPLAY_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./Scripts/build.sh release./Scripts/make-dmg.shProduces build/FlexDisplay-<version>.dmg — a release build, staged with an
/Applications symlink so installing is the usual drag-and-drop, then compressed with
hdiutil. With FLEXDISPLAY_IDENTITY set, it also signs the disk image and prints the
exact notarytool submit / stapler staple commands for full notarization.
The same binary that runs the menu bar app doubles as a diagnostic CLI — pass it any argument and it runs headless and exits instead of launching the GUI:
./.build/debug/FlexDisplay list # connected displays: identity, current + native mode, EDR
./.build/debug/FlexDisplay modes 0 # every mode display index 0 advertises
./.build/debug/FlexDisplay steps # offered scaling sizes, aspect drift, predicted sharpness
./.build/debug/FlexDisplay probe # which private APIs resolved on this exact macOS build
./.build/debug/FlexDisplay virtual-test 1280 720 # create a virtual display at a size, report what macOS did, remove itSome diagnostics (anything that actually creates and mirrors a virtual display) only behave correctly from inside a fully launched GUI process — see Architecture for why — and are instead exposed as environment-variable-gated self-tests the app runs on launch and then exits:
FLEXDISPLAY_SELFTEST=/tmp/report.txt ./.build/debug/FlexDisplay # creation + mode publishing
FLEXDISPLAY_SELFTEST_MIRROR=/tmp/report.txt ./.build/debug/FlexDisplay # full mirror, auto-reverts, no dialog
FLEXDISPLAY_SELFTEST_SERVICE=/tmp/report.txt ./.build/debug/FlexDisplay # exercises the real shipping code path
FLEXDISPLAY_SELFTEST_SWEEP=/tmp/report.txt ./.build/debug/FlexDisplay # sharpness table for every offered size
FLEXDISPLAY_VERBOSE=1 ./.build/debug/FlexDisplay # mirror all logs to stderrSources/CGVirtualDisplayPrivate/ Obj-C shim — the ONLY place private classes are named,
resolved via NSClassFromString + objc_msgSend, never
linked at compile time.
Sources/FlexDisplay/
App/ Entry point, menu bar controller, Settings window, composition root.
Core/ Display configuration transactions, the revert watchdog, teardown
coordination, the global hotkey.
Core/PrivateAPI/ Typed Swift wrappers over the private CoreGraphics + DisplayServices
APIs. Every symbol is optional; every failure degrades gracefully.
Models/ Display, DisplayMode, ScalingPreset — plain value types.
Services/ DisplayManager, ScalingService, VirtualDisplayService, HDRService,
PresetCoordinator — the orchestration layer. Depend on protocols
(`VirtualDisplayCreating`, `BrightnessControlling`), not concrete
private-API types, so the private-API layer can be swapped or mocked.
Persistence/ PresetStore — UserDefaults-backed, JSON-encoded.
CLI/ Diagnostic commands and the in-app self-tests described above.
Nothing outside Core/PrivateAPI/ and Sources/CGVirtualDisplayPrivate/ ever names a
private symbol. Everything above that layer works against protocols, so a future macOS
release that removes or changes a private API only requires changes in one place, and the
feature it backs disables itself instead of crashing the app.
An .xcodeproj's .pbxproj is a large, merge-hostile binary-adjacent format that's hard to
review in a diff and easy to corrupt by hand-editing. A plain SwiftPM package with a small
build script produces the exact same .app bundle, is trivially reviewable, and needs no
IDE to build — swift build is enough. Package.swift defines two targets: an Obj-C
target for the private-API shim, and the Swift executable that depends on it.
The private CGVirtualDisplay API accepts almost any configuration, reports success, and
then silently does something other than what you asked for. Every rule below was found the
hard way, with real test output backing it, while building this project:
- The process must be a fully launched GUI app. Creating a
CGVirtualDisplayfrom a headless CLI process half-works: the display registers,CGDisplayIsActiveis true,CGDisplayBoundsreturns a size — butCGDisplayCopyAllDisplayModesreturns zero modes andCGDisplayCopyDisplayModereturnsnil. The class has_serverRPC_port,_proxyRPC_port,_clientHandler_portivars; the handshake that publishes the mode table needs a real window-server connection. All virtual-display work happens inside the launchedNSApplication— this is why theFLEXDISPLAY_SELFTEST_*env vars exist. - Modes are logical sizes, not framebuffers. Put 1280×720 into
CGVirtualDisplaySettings.modesand macOS pairs it with a 2560×1440 framebuffer automatically. Put 2560×1440 in directly and you get a 2560×1440 desktop — the opposite of what you wanted. maxPixelsWide/maxPixelsHighmust be 2× the largest logical mode. The single costliest bug in this project. The descriptor's ceiling applies to the framebuffer, not the logical size. Pin it to the mode size and the display still creates successfully, still comes online, still reports OK — and silently publishes only 1× modes, because every HiDPI variant it tried to generate needed a framebuffer above the ceiling. Looks exactly like thehiDPIflag being ignored:mode 1280x720, ceiling 1280x720 -> 1280x720 HiDPI present: no mode 1280x720, ceiling 2560x1440 -> 1280x720 HiDPI present: YES- HiDPI is not always the default mode; select it explicitly. Even with the ceiling
correct, macOS may bring the display up in a 1× mode — it appears to pick the default by
pixel density against the reported physical size. Always
CGConfigureDisplayWithDisplayModeonto the HiDPI mode explicitly before mirroring. - macOS injects its own mode catalog alongside your requested ones — a standard set (800×600, 1024×768, 1280×720, 1920×1080…) plus halved HiDPI twins. Don't assume a mode in the published list is one you asked for.
- A mirrored panel is not its own
NSScreen. When A mirrors B, only B appears inNSScreen.screens. Looking up A'sNSScreenreturnsnil, and a naive frame/scale check reads0×0/0.0— which looks like total failure when everything actually worked. To verify what the real panel is being driven at, readCGDisplayCopyDisplayMode(realDisplayID)directly; for the desktop's backing scale, look up the virtual (source) display'sNSScreeninstead. - Install
terminationHandlerbefore-initWithDescriptor:, not after.CGVirtualDisplaycopies the handler out of the descriptor during init — it has its own_terminationHandlerivar. Setting it on the descriptor afterwards mutates an object nobody reads again, and the handler simply never fires, so a display CoreGraphics tears down on sleep/wake leaves a stale pair silently mirroring a dead display. - Wait for modes, not just for the display to appear. Publishing happens in two
stages — the display enters
CGGetOnlineDisplayListfirst, its mode table lands later. Configuring in between silently no-ops, and the display keeps whatever default mode it came up in (often 1×). PollCGDisplayCopyAllDisplayModesuntil non-empty. - Wait for a torn-down virtual display to actually disappear before creating a replacement. Releasing the handle only starts removal; creating a new display immediately after makes the new one never come online at all. Never let two code paths reconfigure the same display concurrently, either — a naive restore-on-launch racing an explicit user request produces exactly this failure.
- A mirrored display's human-readable name disappears too. The only API that knows a
monitor's name (
NSScreen.localizedName) stops reporting it the instant it's being mirrored, so "DELL U2720Q" becomes "Display 3" in your own UI unless you cache the name (keyed by the display's UUID, persisted, since a process that starts while a display is already mirrored never gets a chance to learn the name at all). - Filter your own virtual displays out of everything user-facing. They appear in
CGGetOnlineDisplayListexactly like real hardware — stamp a recognizable vendor/product ID into the descriptor at creation and exclude anything matching it from your own UI. - Order of operations on create/teardown matters. Set the virtual display's mode before mirroring it (mirroring copies whatever the source is currently showing), and break the mirror before releasing the virtual display (releasing first leaves the panel mirroring a display that no longer exists — a black screen until the window server notices).
- Calling
alloc/initthrough a castobjc_msgSendleaks under ARC — and here that means the display never goes away. ARC infers ownership from the selector family, which a cast to a plainid (*)(id, SEL)function pointer hides completely. It sees an unannotatedidreturn, assumes +0, and adds a retain of its own that nothing balances, so every object comes out over-retained by exactly one. For most classes that is a routine leak; forCGVirtualDisplay, whosedeallocis the display-removal mechanism, it means teardown silently never happens — the handle is released, the object survives, and the display stays online for the life of the process. Hand the raw +1 back with__bridge_transfer(keeping the intermediate__unsafe_unretainedso ARC does not retain it on the way in), or annotate the castns_returns_retained. - Only one
CGVirtualDisplayseems to exist at a time, system-wide. Observed on macOS 27.0, not documented anywhere: with one alive — in any process — creating another fails outright (-initWithDescriptor:returns nil), surfacing as a flat "macOS refused to create the virtual display" with nothing to distinguish it from a bad descriptor. Every creation tried here succeeded exactly when no other virtual display was online and failed exactly when one was, including across processes. This makes gotcha 13 far more damaging than an ordinary leak: leak one display and the feature is dead until the process exits. It also means anything sweeping several sizes in a loop is really testing that each display is genuinely released before the next is created.
FlexDisplay was built and tested on a machine with exactly one display, which is also the main display — meaning a bad configuration can genuinely leave the developer unable to see or click anything. Every mechanism below exists because of that constraint, and applies equally on any machine:
- Session-scoped first, permanent second. Every risky change (
CGCompleteDisplay- Configuration) applies withkCGConfigureForSession— which is undone by a logout — and is only writtenkCGConfigurePermanentlyafter a human explicitly confirms it. Automatic restores (launch, reconnect, hotkey preset cycling) are session-scoped too, deliberately: "the user confirmed this once" is weaker than it sounds, because a restore re-resolves the preset against the display's current mode table, and a different cable, port or dock can hand back a mode the panel cannot sync. Persistence across logins comes from this app reapplying the preset at launch, never from a permanent write that would outlive a black screen. - The revert watchdog. A 15-second countdown dialog with Keep / Revert buttons. No response, or the window being closed, means revert — closing the dialog is treated as the safe choice, never the risky one.
- Every
CGErroris checked. Any failure inside a display-configuration transaction cancels the whole transaction (CGCancelDisplayConfiguration) rather than leaving a half-applied state. - Teardown from four separate exit paths —
applicationWillTerminate,atexit, caughtSIGINT/SIGTERM/SIGHUP, and the fact that aCGVirtualDisplayobject deallocates (and its display vanishes) the moment its owning process dies, which is the one thing that survives evenSIGKILL. - No blind reconfiguration races. Restoring on launch and an explicit user request are
mutually exclusive;
applyScaling/teardownguard against re-entrant calls, since the private API's own async publishing makes a naive implementation racy in ways that leak virtual displays or corrupt internal tracking state. - Every private symbol is optional.
NSClassFromString/dlsymplus a nil-check, with the corresponding feature disabling itself and reporting exactly why (visible in Settings → Diagnostics) rather than crashing.
- HDR toggling is implemented but unverified on real hardware. Developed on a machine
whose only display is SDR (
maximumPotentialExtendedDynamicRangeColorComponentValue == 1.0). The capability-detection and graceful-degradation paths are exercised; the actual enable/disable path has never run against an HDR-capable panel. It fails safe — no matching mode means the toggle stays hidden rather than guessing — but treat it as unproven until someone confirms it on real hardware. If you have an HDR display, this is the single most valuable thing you could test and report on. - Brightness control is unverified for the same reason —
DisplayServicesCanChangeBrightnessreports false for the external SDR monitor this was built on. DDC/CI (real hardware brightness over DisplayPort/HDMI, not gamma tricks) is on the roadmap and would fix this independent of panel type. - Reconnect handling (unplug/replug restoring a preset) is implemented but untested — the development machine has one display and it cannot be physically unplugged as part of automated testing.
- Sleep/wake restore is implemented but untested. CoreGraphics tears virtual displays
down on sleep, and because the real panel never leaves the display list there is no
reconnect event to hang the recovery off — so what was lost is recorded and reapplied on
NSWorkspace.didWakeNotificationinstead. The recording and reapply paths are exercised; a full sleep/wake cycle has not been run as part of automated testing. - HDR state is unreadable while flexible scaling is active. A mirrored panel is not in
NSScreen.screens, andNSScreenis the only API that reports live EDR headroom. Rather than infer it from the mode's pixel encoding — which reports deep colour, not active HDR, so a 10-bit SDR mode would read as HDR-on — the state is treated as unknown: the HDR toggle says so instead of guessing, and captured presets omit the field rather than recording a value nobody observed. - Only one virtual display appears to exist system-wide (see gotcha 14), so flexible scaling cannot drive two monitors at once, and it will fail while any other app is using the same private API.
- Not notarized. See Install for the Gatekeeper workaround.
v1 differentiators (done or in progress):
- ✅ One slider per display, no dense settings surface
- ✅ Free — flexible scaling is a paid feature in BetterDisplay
- ✅ Global hotkey (⌃⌥⌘S) to cycle presets without touching the menu
v2 candidates:
- DDC/CI brightness + contrast for external monitors via
IOAVService(Apple Silicon) — real hardware brightness, not gamma tricks. Would also make brightness control testable and usable on far more external displays thanDisplayServicesalone covers. - Per-app / per-time auto-scaling rules ("Xcode frontmost → more space")
- EDR brightness boost via gamma-table or Metal overlay tricks, for sub-XDR displays
- HDR on a virtual display itself —
CGVirtualDisplayMode's undocumentedtransferFunctionparameter is already plumbed through the bridge layer but unused. If it works, this is something BetterDisplay does not expose at all. - Developer ID signing + notarization, so the Gatekeeper workaround above stops being necessary
Issues and pull requests are welcome. A few things that make review faster:
- Read the private-display gotchas
above before touching anything in
Core/PrivateAPI/orServices/VirtualDisplayService.swift— it documents everything already discovered the hard way about how the private display APIs actually behave. Re-deriving a rule that's already documented wastes your time. - If you're changing behavior around virtual displays, run the relevant
FLEXDISPLAY_SELFTEST_*self-test (see Command-line diagnostics) and paste the output in your PR description. This codebase's hardest bugs are all in the "reports success, silently does something else" category, so a config change that looks correct by inspection is not enough — it needs to be run. - Anything that lands in
Core/PrivateAPI/must resolve its symbol at runtime (NSClassFromString/dlsym) and degrade gracefully if the symbol is missing. Never link against a private symbol. - If you have access to an HDR-capable display or a display where
DisplayServicesCanChangeBrightnessreturns true, testing and reporting on the HDR/ brightness paths (see Known limitations) is the single most useful contribution available right now.
Does this work on Intel Macs? It should — nothing here is architecture-specific — but it has only been built and tested on Apple Silicon (M2). Reports from Intel welcome.
Will this get me banned from anything, void a warranty, damage my display? No. This only changes software-level display configuration (modes, mirroring) using APIs Apple itself uses internally for the same purpose (System Settings' own scaling UI is built on the same mechanism). Nothing here touches firmware, EDID data on the physical monitor, or anything persistent outside this Mac's own display preferences.
Why not just use BetterDisplay? You should, if you want its other features (DDC control, PIP, dummy displays, remote control). FlexDisplay exists for people who only ever wanted the free version of the scaling slider, and would rather have that in a smaller, free, open-source app.
Can I use this at work / commercially / in a fork? Yes — MIT license, no restrictions beyond keeping the copyright notice.
MIT © 9Bit Studio
This project uses Apple's private CoreGraphics and DisplayServices APIs, which are undocumented and not covered by any public API stability guarantee. It is not affiliated with, endorsed by, or associated with Apple Inc. or with BetterDisplay/waydabber.

