Skip to content
Open
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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,14 @@ jobs:
test -f docs/project-overview.md
test -f docs/architecture.md
test -f docs/operations.md
- name: Check example configuration
run: python3 -m json.tool examples/sample-config.json >/dev/null
- name: Run smart smoke tests
run: make test-smart

macos-build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build app bundle
run: make clean all
818 changes: 795 additions & 23 deletions AppDelegate.m

Large diffs are not rendered by default.

23 changes: 11 additions & 12 deletions Brightness.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
* Brightness.h — Unified brightness control for built-in and external displays.
* Part of DisplayDisabler v3.0
*
* Internal panels use the private DisplayServices framework (same path the F1/F2
* keys go through). External DisplayPort/HDMI/USB-C panels use DDC/CI over
* Apple Silicon's IOAVService.
* System-managed panels use the private DisplayServices framework (same path
* the F1/F2 keys go through). Other external DisplayPort/HDMI/USB-C panels use
* DDC/CI over Apple Silicon's IOAVService.
*/

#import <Foundation/Foundation.h>
Expand All @@ -17,22 +17,21 @@ NS_ASSUME_NONNULL_BEGIN
+ (instancetype)shared;

// Whether this display exposes a settable brightness through either path.
// Built-in: queried via DisplayServicesCanChangeBrightness (not merely
// "DisplayServices loaded" — some panels advertise the framework but refuse
// writes). External: DDC/CI resolution via IOAVService.
// DisplayServices is preferred when macOS says it can change this display.
// Other external panels fall back to DDC/CI resolution via IOAVService.
- (BOOL)supportsBrightness:(CGDirectDisplayID)displayID;

// Write a 0–100 brightness value. Built-in uses DisplayServicesSetBrightness
// Smooth for the same fade animation Apple's F1/F2 path produces; external
// uses DDC VCP "Set Feature" 0x03 on code 0x10.
// Write a 0–100 brightness value. DisplayServicesSetBrightness is used when
// available; unsupported external panels use DDC VCP "Set Feature" 0x03 on
// code 0x10.
- (BOOL)setBrightnessPercent:(uint8_t)percent
forDisplay:(CGDirectDisplayID)displayID
error:(NSError **)error;

// Read the display's current brightness as a 0–100 percent. Returns -1 on
// failure (capability query failed, DDC read timed out, etc). Built-in
// path uses DisplayServicesGetBrightness; DDC externals are unsupported
// here because VCP reads over IOAVService are fragile across panels.
// failure. DisplayServicesGetBrightness is used when available; DDC externals
// are unsupported here because VCP reads over IOAVService are fragile across
// panels.
- (int)brightnessPercentForDisplay:(CGDirectDisplayID)displayID;

// Drop the cached IOAVService handles. Call when displays come and go so
Expand Down
47 changes: 25 additions & 22 deletions Brightness.m
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* Derived from the m1ddc reverse-engineering work (MIT licensed).
*
* Internal path (DisplayServices private framework):
* DisplayServicesSetBrightness(displayID, 0.0..1.0) int (0 = success).
* DisplayServicesSetBrightness(displayID, 0.0..1.0) -> int (0 = success).
* Resolved at runtime with dlsym so a missing framework degrades gracefully.
*/

Expand Down Expand Up @@ -45,14 +45,9 @@ extern CFDictionaryRef CoreDisplay_DisplayCreateInfoDictionary(CGDirectDisplayID
typedef int (*DSCanChangeFn)(CGDirectDisplayID);

// Resolves the whole DisplayServices brightness surface in one dispatch_once
// so the dlopen + dlsym cost is paid at most once per process. The smooth
// variant is preferred for -set because it reproduces the fade animation
// Apple's F1/F2 keys drive (instant SetBrightness is visually jarring at
// large deltas). If SetBrightnessSmooth is missing on some future macOS,
// we gracefully fall back to SetBrightness.
// so the dlopen + dlsym cost is paid at most once per process.
typedef struct {
DSSetFn set;
DSSetFn setSmooth;
DSGetFn get;
DSCanChangeFn canChange;
} DSBrightnessFns;
Expand All @@ -66,7 +61,6 @@ static DSBrightnessFns dsBrightness(void) {
RTLD_LAZY);
if (!h) return;
f.set = (DSSetFn)dlsym(h, "DisplayServicesSetBrightness");
f.setSmooth = (DSSetFn)dlsym(h, "DisplayServicesSetBrightnessSmooth");
f.get = (DSGetFn)dlsym(h, "DisplayServicesGetBrightness");
f.canChange = (DSCanChangeFn)dlsym(h, "DisplayServicesCanChangeBrightness");
});
Expand Down Expand Up @@ -216,14 +210,13 @@ - (BOOL)setBrightnessViaDisplayServices:(uint8_t)percent
forDisplay:(CGDirectDisplayID)displayID
error:(NSError **)error {
DSBrightnessFns f = dsBrightness();
DSSetFn setFn = f.setSmooth ?: f.set;
if (!setFn) {
if (!f.set) {
if (error) *error = brightnessError(-1,
@"DisplayServices is unavailable on this macOS version.");
return NO;
}

int rc = setFn(displayID, percent / 100.0f);
int rc = f.set(displayID, percent / 100.0f);
if (rc != 0) {
if (error) *error = brightnessError(rc,
[NSString stringWithFormat:@"DisplayServices rejected the brightness (rc=%d).", rc]);
Expand All @@ -232,22 +225,22 @@ - (BOOL)setBrightnessViaDisplayServices:(uint8_t)percent
return YES;
}

- (BOOL)canChangeViaDisplayServices:(CGDirectDisplayID)displayID {
DSBrightnessFns f = dsBrightness();
if (!f.set) return NO;
if (f.canChange && f.canChange(displayID) != 0) return YES;
return CGDisplayIsBuiltin(displayID);
}

// ── Public API ──────────────────────────────────────────────────────────────

- (BOOL)supportsBrightness:(CGDirectDisplayID)displayID {
if (CGDisplayIsBuiltin(displayID)) {
DSBrightnessFns f = dsBrightness();
if (!f.set && !f.setSmooth) return NO;
// CanChangeBrightness is the authoritative capability check; some
// panels (e.g. external Apple-branded displays) advertise
// DisplayServices but refuse writes.
return f.canChange ? (f.canChange(displayID) != 0) : YES;
}
if ([self canChangeViaDisplayServices:displayID]) return YES;
if (CGDisplayIsBuiltin(displayID)) return NO;
return [self serviceFor:displayID] != NULL;
}

- (int)brightnessPercentForDisplay:(CGDirectDisplayID)displayID {
if (!CGDisplayIsBuiltin(displayID)) return -1;
DSBrightnessFns f = dsBrightness();
if (!f.get) return -1;
float v = -1;
Expand All @@ -266,9 +259,19 @@ - (BOOL)setBrightnessPercent:(uint8_t)percent
error:(NSError **)error {
if (percent > 100) percent = 100;

if (CGDisplayIsBuiltin(displayID)) {
return [self setBrightnessViaDisplayServices:percent forDisplay:displayID error:error];
if ([self canChangeViaDisplayServices:displayID]) {
NSError *displayServicesError = nil;
if ([self setBrightnessViaDisplayServices:percent
forDisplay:displayID
error:&displayServicesError]) {
return YES;
}
if (CGDisplayIsBuiltin(displayID)) {
if (error) *error = displayServicesError;
return NO;
}
}

return [self setBrightnessViaDDC:percent forDisplay:displayID error:error];
}

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@
- Added architecture and operations notes.
- Added repository validation workflow.
- Added sample configuration and smoke validation notes.

## 2026-06-08

- Added a shared smart-script parser library, safe-disable wrapper, status/doctor command, idempotent alias block management, installer repair/dry-run options, and smoke parser fixtures.
- Added app-first smart safety: trusted external displays in the menu-bar UI, event-driven built-in recovery, safe built-in disable, and System Status / Doctor actions.
- Added unified installer profiles for menu-bar app only, CLI only, or full app plus CLI fallback installation.
- Added matching uninstaller profiles for app-only, CLI-only, and full removal.
43 changes: 4 additions & 39 deletions DisplayManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -926,47 +926,12 @@ - (void)forceHiDPIForDisplay:(CGDirectDisplayID)displayID
CGDisplayModeRelease(curMode);
}

// Resolve the panel mode the force will switch to. Single picker for both
// panel-derived and synthetic targets, hard aspect constraint (mismatched
// aspect → mirror bars). Within the surviving candidates the picker
// prefers an exact 2× pixel match (true 1:1 mirror downsample), then
// falls within a single sweep to the smallest scale deviation, with a
// mild bias toward Standard variants.
//
// Note on notched panels: macOS's mirror compositor unconditionally
// auto-switches the destination panel to a runtime mode that matches
// the source virtual's logical dimensions and shifts content below the
// notch line — overriding whichever mode this picker selects. The
// strip beside the camera notch ends up dark regardless. This is
// destination-driven OS behavior with no override path; Crisp HiDPI
// (panel-native plist injection) is the only architecture that can
// render at custom logical sizes without that dead strip.
// Keep the panel mode list around only to capture the pre-force mode for
// restore-on-stop. The old "pick a switch mode before mirroring" path is
// intentionally gone: macOS immediately substitutes a mirror-runtime mode
// for the destination panel, so explicit panel switches do not stick.
NSArray<DDDisplayMode *> *panelModes = [self modesForDisplay:displayID];

size_t wantPW = targetLogicalWidth * 2;
size_t wantPH = targetLogicalHeight * 2;
double targetAspect = (double)wantPW / (double)wantPH;

CGDisplayModeRef switchMode = NULL;
double bestScore = INFINITY;
for (DDDisplayMode *m in panelModes) {
if (!m.modeRef) continue;
double mAspect = (double)m.pixelWidth / (double)m.pixelHeight;
if (fabs(mAspect - targetAspect) / targetAspect > kDDAspectTolerance) continue;

double score;
if (m.pixelWidth == wantPW && m.pixelHeight == wantPH) {
// Exact 2× pixel match — pure 1:1 mirror, supersample-free path.
score = m.isHiDPI ? -0.5 : -1.0;
} else {
double rw = (double)m.pixelWidth / (double)wantPW;
double rh = (double)m.pixelHeight / (double)wantPH;
score = MAX(fabs(1.0 - rw), fabs(1.0 - rh));
if (!m.isHiDPI) score *= 0.95; // mild Standard bias
}
if (score < bestScore) { bestScore = score; switchMode = m.modeRef; }
}

// Capture the current panel mode for restore-on-stop. Captured before
// mirror so it reflects the user-visible pre-force mode, not the runtime
// mirror-destination mode macOS substitutes once mirroring engages.
Expand Down
21 changes: 16 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ OBJECTS = $(SOURCES:.m=.o)
DEPS = $(SOURCES:.m=.d)
EXECUTABLE = $(APP_NAME)

.PHONY: all clean bundle sign install uninstall icon
.PHONY: all clean bundle sign install uninstall icon test-smart

all: bundle sign

Expand All @@ -30,18 +30,29 @@ $(EXECUTABLE): $(OBJECTS)
$(CC) $(CFLAGS) $(FRAMEWORKS) $(OBJECTS) -o $@

# Render AppIcon.icns from the "display" SF Symbol on a dark rounded-rect
# background. One-shot build-time helper; the .icns is committed to the
# repo so CI / downstream builders don't need to re-run it.
# background. The helper also leaves an inspectable AppIcon.iconset while
# writing the .icns directly, avoiding iconutil's runner-specific validation.
AppIcon.icns: build_icon.m
@$(CC) -fobjc-arc -O0 -mmacosx-version-min=14.0 -framework Cocoa \
build_icon.m -o /tmp/dd-build-icon
@/tmp/dd-build-icon AppIcon.iconset
@iconutil -c icns AppIcon.iconset -o AppIcon.icns
@/tmp/dd-build-icon AppIcon.iconset AppIcon.icns
@rm -rf AppIcon.iconset /tmp/dd-build-icon
@echo "Built AppIcon.icns"

icon: AppIcon.icns

test-smart:
zsh -n scripts/*.sh scripts/lib/*.sh tests/smoke/test_smart_parsers.sh tests/smoke/test_uninstall_smart.sh tests/smoke/test_smart_status_doctor.sh
zsh tests/smoke/test_smart_parsers.sh
zsh tests/smoke/test_smart_status_doctor.sh
zsh tests/smoke/test_uninstall_smart.sh
zsh scripts/install_smart.sh --dry-run --app --yes >/dev/null
zsh scripts/install_smart.sh --dry-run --cli --no-download --yes --no-watchdog >/dev/null
zsh scripts/install_smart.sh --dry-run --full --no-download --yes --no-watchdog >/dev/null
zsh scripts/uninstall_smart.sh --dry-run --app --yes >/dev/null
zsh scripts/uninstall_smart.sh --dry-run --cli --yes --keep-binary --keep-config --keep-logs >/dev/null
zsh scripts/uninstall_smart.sh --dry-run --full --yes --keep-app --keep-binary --keep-config --keep-logs >/dev/null

bundle: $(EXECUTABLE) AppIcon.icns
@mkdir -p "$(BUNDLE)/Contents/MacOS"
@mkdir -p "$(BUNDLE)/Contents/Resources"
Expand Down
Loading