Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Verify

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: verify-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-test:
runs-on: macos-26
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Use newest installed Xcode
run: sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" && swift --version
- name: Install XcodeGen
run: brew list xcodegen >/dev/null 2>&1 || brew install xcodegen
- name: Build DDC helper from source
run: ./scripts/build-ddc.sh
- name: Verify live DDC scheduling without hardware
run: ./tools/test-tern-ddc.sh
- name: Generate Xcode project
run: xcodegen generate
- name: Build and run tests
run: xcodebuild -project XDR.xcodeproj -scheme XDR -destination 'platform=macOS' -derivedDataPath build CODE_SIGNING_ALLOWED=NO test
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,12 @@ build/
.DS_Store
xcuserdata/
*.xcuserstate

# Generated DDC helper and local package caches
XDR/Resources/m1ddc
Vendor/m1ddc/.objects/
Vendor/m1ddc/m1ddc
Vendor/m1ddc/library/
.build/
DerivedData/
.swiftpm/
41 changes: 27 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
# XDR

Mac menu-bar brightness for HDR and Pro Display XDR. Goes brighter than the system slider. Drag it, the screen follows, color gamut stays put.
A native Mac menu-bar app for display brightness, MacBook XDR boost and Night Vision.

## What It Does
## Controls

XDR gives you real-time brightness control for high-dynamic-range displays. Click the menu bar icon, adjust the slider, and your display brightness changes immediately while keeping the color gamut intact. No fiddling through system settings. No overshooting.
- Built-in XDR displays retain their native brightness and boost controls.
- The identified Tern 16-inch 4K IPS monitor uses a hardware brightness slider over USB-C DDC. It does not use gamma dimming or simulate XDR boost.
- Other unsupported external displays retain the software-dimming route.
- Night Vision provides a red viewing mode. Boost and Night Vision change appearance; neither is a colour-calibration tool.

Real example: You're editing photos in Final Cut Pro on a ProDisplay XDR in a bright room. Your edit suite is getting washed out. You tap the menu bar, bump the brightness up 20%, colors stay perfect, you're back to work in two seconds.
The Tern slider updates its saved reconnect preset after the monitor confirms the value. The optional local reconnect helper is independent of this app; configuring it is not required to use the slider. Its saved preset is stored in `~/Library/Application Support/Tern Controls/defaults.json`.

## Why This Matters
## Build

macOS gives you global brightness control, but HDR displays need something smarter. They use display gamma tables to adjust intensity while preserving the wide color gamut that makes them worth owning. XDR handles this directly, so you get precise control without losing the color accuracy that HDR is for.
Requires macOS 14 or later, Xcode and XcodeGen. Tern hardware control currently targets Apple Silicon Macs and the explicitly identified monitor.

The slider is live. No confirmation dialogs. No lag. Adjust and see.
```sh
./scripts/build-ddc.sh
xcodegen generate
xcodebuild -project XDR.xcodeproj -scheme XDR -configuration Release \
-derivedDataPath build CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual build
```

## How It Works
The DDC helper is built from the MIT-licensed source in `Vendor/m1ddc`; its executable is generated, not committed. KeyboardShortcuts and LaunchAtLogin are resolved through Swift Package Manager.

- **SwiftUI menu bar** for the UI
- **macOS display gamma tables** for the actual brightness adjustment
- **Real-time response** as you drag
- **Persistent settings** that survive a restart
## Verify

## Get Started
The fast DDC scheduler regression uses a fake monitor process, so it can run without a physical display.

Launch it, click the menu bar icon, adjust the slider. Your brightness setting is saved automatically.
```sh
./tools/test-tern-ddc.sh
./scripts/build-ddc.sh
xcodegen generate
xcodebuild -project XDR.xcodeproj -scheme XDR -destination 'platform=macOS' \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO test
```

Automated tests do not establish physical brightness, speaker playback, colour accuracy or persistence after a monitor loses power. Hardware changes should be checked against monitor readback and the actual display.
146 changes: 146 additions & 0 deletions Tests/NightVisionMathTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import XCTest
@testable import XDR

final class NightVisionMathTests: XCTestCase {

// MARK: - Channel ceilings (gamma LUT model)

func testCeilings_endpoints() {
// Zero intensity is identity: every channel at full ceiling.
XCTAssertEqual(NightVisionCurve.redMax(forIntensity: 0), 1.0, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.greenMax(forIntensity: 0), 1.0, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.blueMax(forIntensity: 0), 1.0, accuracy: 0.0001)
// Full intensity hits the floors.
XCTAssertEqual(NightVisionCurve.redMax(forIntensity: 1), NightVisionCurve.minRed, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.greenMax(forIntensity: 1), NightVisionCurve.minGreen, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.blueMax(forIntensity: 1), NightVisionCurve.minBlue, accuracy: 0.0001)
}

func testGreenAndBlue_monotonicallyDecrease() {
var lastG = 2.0, lastB = 2.0
for t in stride(from: 0.0, through: 1.0, by: 0.05) {
let g = NightVisionCurve.greenMax(forIntensity: t)
let b = NightVisionCurve.blueMax(forIntensity: t)
XCTAssertLessThanOrEqual(g, lastG + 0.0001, "green must not increase as intensity rises")
XCTAssertLessThanOrEqual(b, lastB + 0.0001, "blue must not increase as intensity rises")
lastG = g; lastB = b
}
}

func testBlueSuppressedHarderThanGreen() {
// Blue is the worst offender for dark adaptation, so it is always the
// most suppressed channel.
for t in stride(from: 0.1, through: 1.0, by: 0.1) {
XCTAssertLessThan(NightVisionCurve.blueMax(forIntensity: t),
NightVisionCurve.greenMax(forIntensity: t) + 0.0001,
"blue ceiling should be <= green at intensity \(t)")
}
}

func testRed_staysDominant() {
// Red is the surviving channel: once the shift is meaningfully engaged its
// ceiling sits well above green/blue. (The low end is intentionally gentle —
// a "warm Night Shift" — so dominance is asserted from mid-intensity up.)
for t in stride(from: 0.6, through: 1.0, by: 0.1) {
XCTAssertGreaterThan(NightVisionCurve.redMax(forIntensity: t),
NightVisionCurve.greenMax(forIntensity: t) + 0.2)
XCTAssertGreaterThan(NightVisionCurve.redMax(forIntensity: t),
NightVisionCurve.blueMax(forIntensity: t) + 0.2)
}
}

func testRed_onlyDimsAtTop() {
// The lower two thirds keep full red brightness; only the deep end dims.
XCTAssertEqual(NightVisionCurve.redMax(forIntensity: 0.3), 1.0, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.redMax(forIntensity: 0.7), 1.0, accuracy: 0.0001)
XCTAssertLessThan(NightVisionCurve.redMax(forIntensity: 1.0), 1.0)
XCTAssertGreaterThanOrEqual(NightVisionCurve.redMax(forIntensity: 1.0), 0.75) // never too dark
}

func testReadabilityFloors_atFullIntensity() {
// The contract that prevents an illegible result: at maximum, green and
// blue keep a trace so edges stay distinguishable, red stays bright.
XCTAssertGreaterThanOrEqual(NightVisionCurve.greenMax(forIntensity: 1.0), 0.09)
XCTAssertGreaterThanOrEqual(NightVisionCurve.blueMax(forIntensity: 1.0), 0.025)
XCTAssertGreaterThanOrEqual(NightVisionCurve.redMax(forIntensity: 1.0), 0.78)
}

func testWarmZone_isGentle() {
// At 35% (the first-enable default) the shift is mild: green and blue
// ceilings stay high so the screen reads like a warm Night Shift.
XCTAssertGreaterThan(NightVisionCurve.greenMax(forIntensity: 0.35), 0.70)
XCTAssertGreaterThan(NightVisionCurve.blueMax(forIntensity: 0.35), 0.55)
XCTAssertEqual(NightVisionCurve.redMax(forIntensity: 0.35), 1.0, accuracy: 0.0001)
}

func testInputsAreClamped() {
XCTAssertEqual(NightVisionCurve.greenMax(forIntensity: -1), 1.0, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.greenMax(forIntensity: 2), NightVisionCurve.minGreen, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.blueMax(forIntensity: 2), NightVisionCurve.minBlue, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.redMax(forIntensity: 2), NightVisionCurve.minRed, accuracy: 0.0001)
}

// MARK: - Overlay fallback values

func testWashAlpha_endpointsAndMonotonic() {
XCTAssertEqual(NightVisionCurve.washAlpha(forIntensity: 0), 0, accuracy: 0.0001)
XCTAssertGreaterThan(NightVisionCurve.washAlpha(forIntensity: 1), 0.4)
var last = -1.0
for t in stride(from: 0.0, through: 1.0, by: 0.05) {
let v = NightVisionCurve.washAlpha(forIntensity: t)
XCTAssertGreaterThanOrEqual(v, last)
last = v
}
}

func testDimAlpha_belowThreshold_isZero() {
XCTAssertEqual(NightVisionCurve.dimAlpha(forIntensity: 0.0), 0, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.dimAlpha(forIntensity: 0.6), 0, accuracy: 0.0001)
}

func testDimAlpha_atFull_isModest() {
let d = NightVisionCurve.dimAlpha(forIntensity: 1.0)
XCTAssertGreaterThan(d, 0)
XCTAssertLessThanOrEqual(d, 0.35) // dims, never blacks out
}

// MARK: - Engage ramp

func testRampProgress_endpointsAndEased() {
XCTAssertEqual(NightVisionCurve.rampProgress(0), 0, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.rampProgress(1), 1, accuracy: 0.0001)
XCTAssertLessThan(NightVisionCurve.rampProgress(0.1), 0.1)
XCTAssertGreaterThan(NightVisionCurve.rampProgress(0.9), 0.9)
var last = -1.0
for t in stride(from: 0.0, through: 1.0, by: 0.05) {
let v = NightVisionCurve.rampProgress(t)
XCTAssertGreaterThanOrEqual(v, last)
last = v
}
}

// MARK: - Safe restore rule

func testSafeRestore_disabledStaysDisabled() {
let r = NightVision.safeRestore(enabled: false, intensity: 0.5)
XCTAssertFalse(r.enabled)
XCTAssertEqual(r.intensity, 0.5, accuracy: 0.0001)
}

func testSafeRestore_withinCeiling_staysEnabled() {
XCTAssertTrue(NightVision.safeRestore(enabled: true, intensity: 0.5).enabled)
XCTAssertTrue(NightVision.safeRestore(enabled: true, intensity: NightVision.safeRestoreCeiling).enabled)
}

func testSafeRestore_extremeRestoresOffButKeepsIntensity() {
let r = NightVision.safeRestore(enabled: true, intensity: 0.9)
XCTAssertFalse(r.enabled)
XCTAssertEqual(r.intensity, 0.9, accuracy: 0.0001)
XCTAssertFalse(NightVision.safeRestore(enabled: true, intensity: 1.0).enabled)
}

func testFirstEnableDefault_isWarmZone() {
XCTAssertEqual(NightVision.firstEnableIntensity, 0.35, accuracy: 0.0001)
XCTAssertEqual(NightVisionCurve.redMax(forIntensity: NightVision.firstEnableIntensity), 1.0, accuracy: 0.0001)
}
}
21 changes: 21 additions & 0 deletions Vendor/m1ddc/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 waydabber

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
128 changes: 128 additions & 0 deletions Vendor/m1ddc/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# -- VARIABLES

# Project name
NAME = m1ddc
LIB = lib$(NAME)

# Compiler
CC = clang
CFLAGS = -Wall -Werror -Wextra -fmodules
SDKROOT ?= $(shell xcrun --sdk macosx --show-sdk-path)
CPPFLAGS = -I $(INC_DIR) -isysroot "$(SDKROOT)"
DEPFLAGS = -MMD

# Libraries
LDLIBS = -framework CoreDisplay -isysroot "$(SDKROOT)"

# Commands
RM = rm -f
RMDIR = rm -rf
MKDIR = mkdir -p
MAKE = make -C
AR = ar -rcs

# Paths
INC_DIR = headers
SRC_DIR = sources
LIB_DIR = library
BIN_DIR = /usr/local/bin

# Sources & Objects - Binary
SOURCES = i2c \
ioregistry \
m1ddc \

OBJ_DIR = .objects
OBJECTS = $(patsubst %,$(OBJ_DIR)/%,$(SOURCES:=.o))

# Sources & Objects - Library
LIB_SRCS = $(filter-out m1ddc, $(SOURCES))
LIB_OBJS = $(patsubst %,$(OBJ_DIR)/%,$(LIB_SRCS:=.o))
LIB_HDRS = $(patsubst %,$(INC_DIR)/%,$(LIB_SRCS:=.h))

# -- IMPLICIT RULES / LINKING

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.m Makefile
@$(CC) -c $< -o $@ $(CPPFLAGS) $(CFLAGS) $(DEPFLAGS)

$(OBJ_DIR):
@$(MKDIR) $(OBJ_DIR)

$(LIB_DIR):
@$(MKDIR) $(LIB_DIR)

$(NAME): $(OBJ_DIR) $(OBJECTS)
@$(CC) $(LDLIBS) $(OBJECTS) -o $@
@printf "Created binary \"$(NAME)\"\n"

$(LIB).a: $(LIB_DIR) $(OBJ_DIR) $(LIB_OBJS)
@$(AR) $(LIB_DIR)/$@ $(LIB_OBJS)
@printf "Created library \"$(LIB_DIR)/$@\"\n"

# For each header file, we do the following, using regular expressions:
# - Ignore the #ifndef _FILE/# define _FILE/#endif directives that begin/end the file
# - Extract the #import directives
# - Extract all the remaining # directives
# - Extract the rest: types and functions declarations.
# All the extracted lines are then appendend to the final header file.
$(LIB).h: $(LIB_DIR) $(LIB_HDRS)
@imports=""; \
directives=""; \
declarations=""; \
for file in $(LIB_HDRS); do \
declarations="$$declarations\n\n/*\n * -- $$(basename $$file .h | tr '[:lower:]' '[:upper:]')\n*/\n"; \
fileguard=$$(echo "_$$(basename $$file .h | tr '[:lower:]' '[:upper:]')_H"); \
if [ "$$(head -n 1 $$file)" = "#ifndef $$fileguard" ]; then \
filecontent=$$(sed -e '1,2d' -e '$$d' $$file); \
else \
filecontent=$$(cat $$file); \
fi; \
imports="$$imports\n$$(echo "$$filecontent" | grep -E "^#\s*import" | sort | uniq)"; \
directives="$$directives\n$$(echo "$$filecontent" | grep -E "^#" | grep -vE "^#\s*import" | grep -vE "^#\s*include\s*\".*.h\"$$" )"; \
declarations="$$declarations\n$$(echo "$$filecontent" | grep -vE "^#" )"; \
done; \
guard=$$(echo "_$(LIB)_H" | tr '[:lower:]' '[:upper:]'); \
printf "#ifndef $$guard\n# define $$guard\n\n" > $(LIB_DIR)/$@; \
printf "$$directives\n\n" >> $(LIB_DIR)/$@; \
printf "$$imports\n\n" >> $(LIB_DIR)/$@; \
printf "$$declarations\n\n" >> $(LIB_DIR)/$@; \
printf "#endif" >> $(LIB_DIR)/$@; \
sed -i '' -e '/^$$/N;/^\n$$/D' $(LIB_DIR)/$@; \

@printf "Created header \"$(LIB_DIR)/$@\"\n"

# -- RULES

.DEFAULT_GOAL := binary

all: binary lib

binary: $(NAME)

lib: $(LIB).a $(LIB).h

clean:
@if [ -e $(OBJ_DIR) ]; then \
$(RMDIR) $(OBJ_DIR); \
printf "Objects deleted\n"; \
fi;

fclean: clean
@if [ -e $(NAME) ]; then \
$(RM) $(NAME); \
printf "Binary deleted\n"; \
fi;
@if [ -e $(LIB_DIR) ]; then \
$(RMDIR) $(LIB_DIR); \
printf "Library deleted\n"; \
fi;

re: fclean all

install:
/bin/mkdir -p $(BIN_DIR)
sudo /usr/bin/install -s -m 0755 $(NAME) $(BIN_DIR)

.PHONY: all lib clean fclean re install

-include $(OBJECTS:.o=.d)
Loading
Loading