Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FlexDisplay icon

FlexDisplay

Flexible HiDPI scaling and HDR control for macOS — free, open source, and small on purpose.

License: MIT Platform: macOS 14+ Swift 6 Status: core feature verified


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.

Table of contents


Why this exists

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.

How it works

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:

  1. Create a virtual display via the private CGVirtualDisplay CoreGraphics 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.
  2. 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.

Install

Download the latest .dmg from Releases, open it, drag FlexDisplay.app into /Applications, launch it.

Gatekeeper note (unsigned/ad-hoc builds)

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.app

Or: right-click the app → OpenOpen 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.

Launch at login

Open the FlexDisplay menu → Settings…GeneralLaunch at login.

Using it

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.

Revert confirmation dialog

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.

About window

Why "HiDPI" doesn't always look Retina-sharp

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.txt

Building from source

Requires 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.sh

run.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 build

Signing 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

Packaging a DMG

./Scripts/make-dmg.sh

Produces 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.

Command-line diagnostics

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 it

Some 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 stderr

Architecture

Sources/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.

Why SwiftPM, not an Xcode project

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-display gotchas (read before touching VirtualDisplayService)

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:

  1. The process must be a fully launched GUI app. Creating a CGVirtualDisplay from a headless CLI process half-works: the display registers, CGDisplayIsActive is true, CGDisplayBounds returns a size — but CGDisplayCopyAllDisplayModes returns zero modes and CGDisplayCopyDisplayMode returns nil. The class has _serverRPC_port, _proxyRPC_port, _clientHandler_port ivars; the handshake that publishes the mode table needs a real window-server connection. All virtual-display work happens inside the launched NSApplication — this is why the FLEXDISPLAY_SELFTEST_* env vars exist.
  2. Modes are logical sizes, not framebuffers. Put 1280×720 into CGVirtualDisplaySettings.modes and 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.
  3. maxPixelsWide/maxPixelsHigh must 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 the hiDPI flag being ignored:
    mode 1280x720, ceiling 1280x720   -> 1280x720 HiDPI present: no
    mode 1280x720, ceiling 2560x1440  -> 1280x720 HiDPI present: YES
    
  4. 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 CGConfigureDisplayWithDisplayMode onto the HiDPI mode explicitly before mirroring.
  5. 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.
  6. A mirrored panel is not its own NSScreen. When A mirrors B, only B appears in NSScreen.screens. Looking up A's NSScreen returns nil, and a naive frame/scale check reads 0×0/0.0 — which looks like total failure when everything actually worked. To verify what the real panel is being driven at, read CGDisplayCopyDisplayMode(realDisplayID) directly; for the desktop's backing scale, look up the virtual (source) display's NSScreen instead.
  7. Install terminationHandler before -initWithDescriptor:, not after. CGVirtualDisplay copies the handler out of the descriptor during init — it has its own _terminationHandler ivar. 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.
  8. Wait for modes, not just for the display to appear. Publishing happens in two stages — the display enters CGGetOnlineDisplayList first, its mode table lands later. Configuring in between silently no-ops, and the display keeps whatever default mode it came up in (often 1×). Poll CGDisplayCopyAllDisplayModes until non-empty.
  9. 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.
  10. 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).
  11. Filter your own virtual displays out of everything user-facing. They appear in CGGetOnlineDisplayList exactly like real hardware — stamp a recognizable vendor/product ID into the descriptor at creation and exclude anything matching it from your own UI.
  12. 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).
  13. Calling alloc/init through a cast objc_msgSend leaks under ARC — and here that means the display never goes away. ARC infers ownership from the selector family, which a cast to a plain id (*)(id, SEL) function pointer hides completely. It sees an unannotated id return, 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; for CGVirtualDisplay, whose dealloc is 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_unretained so ARC does not retain it on the way in), or annotate the cast ns_returns_retained.
  14. Only one CGVirtualDisplay seems 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.

Safety model

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:

  1. Session-scoped first, permanent second. Every risky change (CGCompleteDisplay- Configuration) applies with kCGConfigureForSession — which is undone by a logout — and is only written kCGConfigurePermanently after 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.
  2. 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.
  3. Every CGError is checked. Any failure inside a display-configuration transaction cancels the whole transaction (CGCancelDisplayConfiguration) rather than leaving a half-applied state.
  4. Teardown from four separate exit pathsapplicationWillTerminate, atexit, caught SIGINT/SIGTERM/SIGHUP, and the fact that a CGVirtualDisplay object deallocates (and its display vanishes) the moment its owning process dies, which is the one thing that survives even SIGKILL.
  5. No blind reconfiguration races. Restoring on launch and an explicit user request are mutually exclusive; applyScaling/teardown guard 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.
  6. Every private symbol is optional. NSClassFromString/dlsym plus a nil-check, with the corresponding feature disabling itself and reporting exactly why (visible in Settings → Diagnostics) rather than crashing.

Known limitations

  • 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 — DisplayServicesCanChangeBrightness reports 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.didWakeNotification instead. 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, and NSScreen is 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.

Roadmap

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 than DisplayServices alone 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 itselfCGVirtualDisplayMode's undocumented transferFunction parameter 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

Contributing

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/ or Services/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 DisplayServicesCanChangeBrightness returns true, testing and reporting on the HDR/ brightness paths (see Known limitations) is the single most useful contribution available right now.

FAQ

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.

License

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.

About

Flexible HiDPI scaling and HDR control for macOS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages