diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da3cb2..81d1871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AppDelegate.m b/AppDelegate.m index 9ec2e8c..cc11244 100644 --- a/AppDelegate.m +++ b/AppDelegate.m @@ -16,6 +16,9 @@ static NSString * const kShowNotifications = @"ShowNotifications"; static NSString * const kConfirmDisable = @"ConfirmBeforeDisable"; static NSString * const kShowResolutions = @"ShowResolutions"; +static NSString * const kSmartRecovery = @"SmartRecoveryEnabled"; +static NSString * const kTrustedDisplays = @"TrustedExternalDisplays"; +static NSString * const kLastBuiltInDisplayID = @"LastBuiltInDisplayID"; // Notification identifier used for auto-manage events so consecutive // disable/re-enable banners replace each other instead of stacking. @@ -27,6 +30,8 @@ static const CGFloat kSwitchRowHeight = 28; static const CGFloat kSwitchRowPad = 18; static const CGFloat kSwitchLabelGap = 8; +static const CGFloat kSwitchWidth = 36; +static const CGFloat kSwitchHeight = 20; // ── Modes-submenu layout ──────────────────────────────────────────────────── // Column widths (in chars) tuned for a monospaced font. Covers 8K + 5-digit @@ -35,10 +40,67 @@ static const NSUInteger kModeColLogical = 17; static const NSUInteger kModeColType = 10; +// Display topology changes arrive in bursts; this delay lets macOS settle +// before the event-driven recovery check decides whether to re-enable built-in. +static const NSTimeInterval kSmartRecoveryDelay = 1.25; +static const NSTimeInterval kSafetyWatchdogInterval = 2.0; + +@interface DDSwitchButton : NSButton +@end + +@implementation DDSwitchButton + +- (instancetype)init { + self = [super initWithFrame:NSMakeRect(0, 0, kSwitchWidth, kSwitchHeight)]; + if (self) { + self.buttonType = NSButtonTypeToggle; + self.bordered = NO; + self.title = @""; + self.imagePosition = NSNoImage; + self.focusRingType = NSFocusRingTypeNone; + } + return self; +} + +- (void)setState:(NSControlStateValue)state { + [super setState:state]; + self.needsDisplay = YES; +} + +- (void)mouseDown:(NSEvent *)event { + (void)event; + self.state = (self.state == NSControlStateValueOn) + ? NSControlStateValueOff + : NSControlStateValueOn; + [NSApp sendAction:self.action to:self.target from:self]; +} + +- (void)drawRect:(NSRect)dirtyRect { + (void)dirtyRect; + BOOL on = (self.state == NSControlStateValueOn); + NSRect bounds = NSInsetRect(self.bounds, 1, 1); + NSColor *trackColor = on ? NSColor.controlAccentColor : NSColor.tertiaryLabelColor; + [trackColor setFill]; + [[NSBezierPath bezierPathWithRoundedRect:bounds + xRadius:NSHeight(bounds) / 2 + yRadius:NSHeight(bounds) / 2] fill]; + + CGFloat knobSize = NSHeight(bounds) - 4; + CGFloat knobX = on ? NSMaxX(bounds) - knobSize - 2 : NSMinX(bounds) + 2; + NSRect knob = NSMakeRect(knobX, NSMinY(bounds) + 2, knobSize, knobSize); + [NSColor.controlBackgroundColor setFill]; + [[NSBezierPath bezierPathWithOvalInRect:knob] fill]; +} + +@end + @interface AppDelegate () @property (nonatomic, strong) NSStatusItem *statusItem; @property (nonatomic, strong) DisplayManager *displayManager; @property (nonatomic) BOOL notificationAuthRequested; +@property (nonatomic) NSInteger smartRecoveryToken; +@property (nonatomic) dispatch_source_t safetyWatchdog; +@property (nonatomic) NSTimeInterval suppressAutoDisableUntil; @end @implementation AppDelegate @@ -54,6 +116,7 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { UNUserNotificationCenter.currentNotificationCenter.delegate = self; [self setupStatusItem]; + [self rememberBuiltInDisplayIDIfAvailable]; [self rebuildMenu]; __weak __typeof(self) weakSelf = self; @@ -72,18 +135,24 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { [strongSelf.displayManager pruneStaleVirtualDisplays]; [strongSelf.displayManager realignForcedDisplay]; [[Brightness shared] invalidateServiceCache]; + [strongSelf rememberBuiltInDisplayIDIfAvailable]; [strongSelf rebuildMenu]; [strongSelf performAutoDisableIfNeeded]; [strongSelf performAutoReenableIfNeeded]; + [strongSelf scheduleSmartRecoveryEvaluation]; + [strongSelf updateSafetyWatchdog]; }]; // Reconfiguration callbacks don't fire on registration, so run once // now to cover the common "launched while already plugged in" case. [self performAutoDisableIfNeeded]; + [self scheduleSmartRecoveryEvaluation]; + [self updateSafetyWatchdog]; } - (void)applicationWillTerminate:(NSNotification *)notification { (void)notification; + [self stopSafetyWatchdog]; [self.displayManager cleanUpAllVirtualDisplays]; [self.displayManager stopMonitoring]; } @@ -94,6 +163,9 @@ - (void)registerDefaults { kShowNotifications: @YES, kConfirmDisable: @YES, kShowResolutions: @YES, + kSmartRecovery: @YES, + kTrustedDisplays: @[], + kLastBuiltInDisplayID: @0, }]; } @@ -169,6 +241,7 @@ - (void)rebuildMenu { menu.autoenablesItems = NO; NSArray *displays = [self.displayManager allDisplays]; + displays = [self displaysIncludingKnownBuiltInFallback:displays]; NSUInteger activeCount = 0; BOOL anyDisabled = NO; @@ -192,6 +265,22 @@ - (void)rebuildMenu { [menu addItem:[NSMenuItem separatorItem]]; } + NSMenuItem *statusItem = [[NSMenuItem alloc] + initWithTitle:@"System Status..." + action:@selector(showSystemStatus:) + keyEquivalent:@""]; + statusItem.target = self; + [menu addItem:statusItem]; + + NSMenuItem *doctorItem = [[NSMenuItem alloc] + initWithTitle:@"Run Doctor..." + action:@selector(runDoctor:) + keyEquivalent:@""]; + doctorItem.target = self; + [menu addItem:doctorItem]; + + [menu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *settingsItem = [[NSMenuItem alloc] initWithTitle:@"Settings" action:nil keyEquivalent:@""]; // Populate lazily in -menuNeedsUpdate:. Building the custom switch-row @@ -255,6 +344,8 @@ - (void)addDisplayHeader:(DDDisplayInfo *)display if (forced) [tags addObject:@"HiDPI forced"]; else if (!display.isActive) [tags addObject:@"disabled"]; if (display.isBuiltIn) [tags addObject:@"built-in"]; + else if ([self isTrustedExternalDisplay:display]) [tags addObject:@"trusted"]; + else [tags addObject:@"untrusted"]; if (display.isMain) [tags addObject:@"main"]; if (tags.count > 0) { @@ -328,6 +419,14 @@ - (void)addActiveDisplayControls:(DDDisplayInfo *)display toMenu:(NSMenu *)menu : @selector(installCrispHiDPI:)) displayID:display.displayID]; + if (!display.isBuiltIn) { + BOOL trusted = [self isTrustedExternalDisplay:display]; + [self addActionToMenu:menu + title:(trusted ? @"Forget Trusted Display" : @"Trust This Display") + action:(trusted ? @selector(forgetTrustedDisplay:) : @selector(trustDisplay:)) + displayID:display.displayID]; + } + [self addActionToMenu:menu title:@"Disable" action:@selector(disableDisplay:) displayID:display.displayID]; } @@ -336,9 +435,8 @@ - (NSMenu *)buildBrightnessSubmenuForDisplay:(CGDirectDisplayID)displayID { NSMenu *submenu = [[NSMenu alloc] init]; submenu.autoenablesItems = NO; - // Current-level header, when the display reports a readable brightness - // (built-in via DisplayServicesGetBrightness). DDC reads over IOAVService - // are fragile so we don't expose them here. + // Current-level header, when DisplayServices reports readable brightness. + // DDC reads over IOAVService are fragile so we don't expose them here. int cur = [[Brightness shared] brightnessPercentForDisplay:displayID]; if (cur >= 0) { [self addLabelToMenu:submenu @@ -446,9 +544,19 @@ - (void)menuNeedsUpdate:(NSMenu *)menu { [menu removeAllItems]; [menu addItem:[self switchRowWithTitle: - @"Turn off laptop screen when external monitor is connected" + @"Turn off laptop screen when trusted external monitor is connected" state:[self pref:kAutoManage] identifier:kAutoManage action:@selector(switchToggled:)]]; + [menu addItem:[self switchRowWithTitle: + @"Recover laptop screen when trusted monitor disconnects" + state:[self pref:kSmartRecovery] identifier:kSmartRecovery + action:@selector(switchToggled:)]]; + [menu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem *trustedItem = [[NSMenuItem alloc] + initWithTitle:@"Trusted Displays" action:nil keyEquivalent:@""]; + trustedItem.submenu = [self buildTrustedDisplaysSubmenu]; + [menu addItem:trustedItem]; [menu addItem:[NSMenuItem separatorItem]]; [menu addItem:[self checkItemWithTitle:@"Show notifications" @@ -480,13 +588,13 @@ - (NSMenuItem *)switchRowWithTitle:(NSString *)title identifier:(nullable NSString *)identifier action:(SEL)action { NSMenuItem *item = [[NSMenuItem alloc] init]; + item.enabled = YES; - NSSwitch *sw = [[NSSwitch alloc] init]; - sw.controlSize = NSControlSizeMini; + DDSwitchButton *sw = [[DDSwitchButton alloc] init]; sw.translatesAutoresizingMaskIntoConstraints = YES; - [sw sizeToFit]; - CGFloat switchWidth = sw.frame.size.width; - CGFloat switchHeight = sw.frame.size.height; + sw.enabled = YES; + CGFloat switchWidth = kSwitchWidth; + CGFloat switchHeight = kSwitchHeight; CGFloat labelWidth = kSwitchRowWidth - (kSwitchRowPad * 2) - switchWidth - kSwitchLabelGap; NSFont *font = [NSFont menuFontOfSize:13]; @@ -509,6 +617,7 @@ - (NSMenuItem *)switchRowWithTitle:(NSString *)title sw.action = action; sw.identifier = identifier; sw.accessibilityLabel = title; + [self applySwitchAppearance:sw]; [row addSubview:sw]; // `labelWithString:` returns a label with auto-layout enabled, which @@ -530,6 +639,10 @@ - (NSMenuItem *)switchRowWithTitle:(NSString *)title return item; } +- (void)applySwitchAppearance:(NSButton *)sw { + sw.needsDisplay = YES; +} + // ── Modes submenu ─────────────────────────────────────────────────────────── - (NSMenu *)buildModesSubmenuForDisplay:(CGDirectDisplayID)displayID @@ -603,6 +716,63 @@ - (NSMenu *)buildModesSubmenuForDisplay:(CGDirectDisplayID)displayID return submenu; } +// ── Trusted displays submenu ──────────────────────────────────────────────── + +- (NSMenu *)buildTrustedDisplaysSubmenu { + NSMenu *submenu = [[NSMenu alloc] init]; + submenu.autoenablesItems = NO; + + NSArray *records = [self trustedDisplayRecords]; + NSSet *trustedFingerprints = [self trustedDisplayFingerprintsFromRecords:records]; + NSArray *external = [self externalDisplaysActiveOnly:NO]; + NSUInteger trustedConnected = 0; + BOOL hasStableExternal = NO; + for (DDDisplayInfo *d in external) { + if (![self isSuspiciousExternalName:d.name]) hasStableExternal = YES; + if ([self isTrustedExternalDisplay:d trustedFingerprints:trustedFingerprints]) { + trustedConnected++; + } + } + + [self addLabelToMenu:submenu title: + [NSString stringWithFormat:@"%lu trusted, %lu connected", + (unsigned long)records.count, (unsigned long)trustedConnected]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem *trustCurrent = [[NSMenuItem alloc] + initWithTitle:@"Trust Current External Displays" + action:@selector(trustCurrentExternalDisplays:) + keyEquivalent:@""]; + trustCurrent.target = self; + trustCurrent.enabled = hasStableExternal; + [submenu addItem:trustCurrent]; + + if (records.count > 0) { + [submenu addItem:[NSMenuItem separatorItem]]; + for (NSDictionary *record in records) { + NSString *name = record[@"name"] ?: @"External Display"; + NSString *fingerprint = record[@"fingerprint"] ?: @""; + NSMenuItem *forget = [[NSMenuItem alloc] + initWithTitle:[NSString stringWithFormat:@"Forget %@", name] + action:@selector(forgetTrustedDisplayRecord:) + keyEquivalent:@""]; + forget.target = self; + forget.representedObject = fingerprint; + [submenu addItem:forget]; + } + + [submenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *forgetAll = [[NSMenuItem alloc] + initWithTitle:@"Forget All Trusted Displays" + action:@selector(forgetAllTrustedDisplays:) + keyEquivalent:@""]; + forgetAll.target = self; + [submenu addItem:forgetAll]; + } + + return submenu; +} + // ── Menu helpers ──────────────────────────────────────────────────────────── - (void)addLabelToMenu:(NSMenu *)menu title:(NSString *)title { @@ -637,6 +807,256 @@ - (void)flipPref:(NSString *)key { [defaults setBool:![defaults boolForKey:key] forKey:key]; } +// ── Smart safety helpers ──────────────────────────────────────────────────── + +- (DDDisplayInfo *)displayInfoForID:(CGDirectDisplayID)displayID { + for (DDDisplayInfo *d in [self.displayManager allDisplays]) { + if (d.displayID == displayID) return d; + } + return nil; +} + +- (NSArray *)displaysIncludingKnownBuiltInFallback:(NSArray *)displays { + BOOL hasBuiltIn = NO; + for (DDDisplayInfo *d in displays) { + if (d.isBuiltIn) { + hasBuiltIn = YES; + break; + } + } + if (hasBuiltIn) return displays; + + CGDirectDisplayID builtInID = [self knownBuiltInDisplayID]; + if (builtInID == 0) return displays; + + DDDisplayInfo *fallback = [[DDDisplayInfo alloc] init]; + fallback.displayID = builtInID; + fallback.name = [self.displayManager nameForDisplayID:builtInID]; + fallback.isBuiltIn = YES; + fallback.isActive = NO; + fallback.isMain = NO; + + NSMutableArray *withFallback = [displays mutableCopy]; + [withFallback insertObject:fallback atIndex:0]; + return withFallback; +} + +- (void)rememberBuiltInDisplayIDIfAvailable { + DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; + if (!builtIn) return; + [[NSUserDefaults standardUserDefaults] setObject:@(builtIn.displayID) + forKey:kLastBuiltInDisplayID]; +} + +- (CGDirectDisplayID)knownBuiltInDisplayID { + DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; + if (builtIn) { + [[NSUserDefaults standardUserDefaults] setObject:@(builtIn.displayID) + forKey:kLastBuiltInDisplayID]; + return builtIn.displayID; + } + + NSNumber *cached = [[NSUserDefaults standardUserDefaults] objectForKey:kLastBuiltInDisplayID]; + return cached.unsignedIntValue; +} + +- (BOOL)isDisplayActive:(CGDirectDisplayID)displayID { + for (DDDisplayInfo *d in [self.displayManager allDisplays]) { + if (d.displayID == displayID) return d.isActive; + } + return NO; +} + +- (BOOL)isSuspiciousExternalName:(NSString *)name { + return (name.length == 0 || + [name isEqualToString:@"Display"] || + [name isEqualToString:@"Unknown Display"]); +} + +- (NSArray *)externalDisplaysFromDisplays:(NSArray *)displays + activeOnly:(BOOL)activeOnly { + NSMutableArray *result = [NSMutableArray array]; + for (DDDisplayInfo *d in displays) { + if (d.isBuiltIn) continue; + if (activeOnly && !d.isActive) continue; + [result addObject:d]; + } + return result; +} + +- (NSArray *)externalDisplaysActiveOnly:(BOOL)activeOnly { + return [self externalDisplaysFromDisplays:[self.displayManager allDisplays] + activeOnly:activeOnly]; +} + +- (NSArray *)stableExternalDisplaysFromDisplays:(NSArray *)displays + activeOnly:(BOOL)activeOnly { + NSMutableArray *result = [NSMutableArray array]; + for (DDDisplayInfo *d in [self externalDisplaysFromDisplays:displays activeOnly:activeOnly]) { + if ([self isSuspiciousExternalName:d.name]) continue; + [result addObject:d]; + } + return result; +} + +- (NSArray *)stableExternalDisplaysActiveOnly:(BOOL)activeOnly { + return [self stableExternalDisplaysFromDisplays:[self.displayManager allDisplays] + activeOnly:activeOnly]; +} + +- (NSString *)fingerprintForDisplay:(DDDisplayInfo *)display { + uint32_t vendor = CGDisplayVendorNumber(display.displayID); + uint32_t model = CGDisplayModelNumber(display.displayID); + uint32_t serial = CGDisplaySerialNumber(display.displayID); + + if (vendor == 0 && model == 0 && serial == 0) { + return [NSString stringWithFormat:@"name:%@", display.name ?: @"External Display"]; + } + return [NSString stringWithFormat:@"hw:%u:%u:%u", vendor, model, serial]; +} + +- (NSDictionary *)trustedRecordForDisplay:(DDDisplayInfo *)display { + return @{ + @"fingerprint": [self fingerprintForDisplay:display], + @"name": display.name ?: @"External Display", + }; +} + +- (NSArray *)trustedDisplayRecords { + NSArray *raw = [[NSUserDefaults standardUserDefaults] arrayForKey:kTrustedDisplays]; + NSMutableArray *records = [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + + for (id item in raw) { + if (![item isKindOfClass:NSDictionary.class]) continue; + NSString *fingerprint = item[@"fingerprint"]; + NSString *name = item[@"name"]; + if (![fingerprint isKindOfClass:NSString.class] || fingerprint.length == 0) continue; + if ([seen containsObject:fingerprint]) continue; + [seen addObject:fingerprint]; + [records addObject:@{ + @"fingerprint": fingerprint, + @"name": ([name isKindOfClass:NSString.class] && name.length > 0) + ? name : @"External Display", + }]; + } + return records; +} + +- (NSSet *)trustedDisplayFingerprintsFromRecords:(NSArray *)records { + NSMutableSet *fingerprints = [NSMutableSet set]; + for (NSDictionary *record in records) { + NSString *fingerprint = record[@"fingerprint"]; + if ([fingerprint isKindOfClass:NSString.class] && fingerprint.length > 0) { + [fingerprints addObject:fingerprint]; + } + } + return fingerprints; +} + +- (void)setTrustedDisplayRecords:(NSArray *)records { + [[NSUserDefaults standardUserDefaults] setObject:records forKey:kTrustedDisplays]; +} + +- (BOOL)isTrustedExternalDisplay:(DDDisplayInfo *)display + trustedFingerprints:(NSSet *)trustedFingerprints { + if (!display || display.isBuiltIn) return NO; + return [trustedFingerprints containsObject:[self fingerprintForDisplay:display]]; +} + +- (BOOL)isTrustedExternalDisplay:(DDDisplayInfo *)display { + NSArray *records = [self trustedDisplayRecords]; + return [self isTrustedExternalDisplay:display + trustedFingerprints:[self trustedDisplayFingerprintsFromRecords:records]]; +} + +- (NSArray *)trustedExternalDisplaysFromDisplays:(NSArray *)displays + activeOnly:(BOOL)activeOnly + trustedFingerprints:(NSSet *)trustedFingerprints { + NSMutableArray *result = [NSMutableArray array]; + for (DDDisplayInfo *d in [self externalDisplaysFromDisplays:displays activeOnly:activeOnly]) { + if ([self isTrustedExternalDisplay:d trustedFingerprints:trustedFingerprints]) { + [result addObject:d]; + } + } + return result; +} + +- (NSArray *)trustedExternalDisplaysActiveOnly:(BOOL)activeOnly { + NSArray *records = [self trustedDisplayRecords]; + return [self trustedExternalDisplaysFromDisplays:[self.displayManager allDisplays] + activeOnly:activeOnly + trustedFingerprints:[self trustedDisplayFingerprintsFromRecords:records]]; +} + +- (BOOL)hasTrustedActiveExternalDisplay { + NSArray *records = [self trustedDisplayRecords]; + if (records.count == 0) return NO; + + NSSet *trustedFingerprints = [self trustedDisplayFingerprintsFromRecords:records]; + for (DDDisplayInfo *d in [self.displayManager allDisplays]) { + if (d.isBuiltIn || !d.isActive) continue; + if ([self isTrustedExternalDisplay:d trustedFingerprints:trustedFingerprints]) return YES; + } + return NO; +} + +- (NSUInteger)trustExternalDisplays:(NSArray *)displays { + NSMutableArray *records = + [[self trustedDisplayRecords] mutableCopy] ?: [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + for (NSDictionary *record in records) { + NSString *fingerprint = record[@"fingerprint"]; + if (fingerprint) [seen addObject:fingerprint]; + } + + NSUInteger added = 0; + for (DDDisplayInfo *display in displays) { + if (display.isBuiltIn || [self isSuspiciousExternalName:display.name]) continue; + NSDictionary *record = [self trustedRecordForDisplay:display]; + NSString *fingerprint = record[@"fingerprint"]; + if ([seen containsObject:fingerprint]) continue; + [seen addObject:fingerprint]; + [records addObject:record]; + added++; + } + + if (added > 0) [self setTrustedDisplayRecords:records]; + return added; +} + +- (BOOL)prepareToDisableBuiltInDisplay:(DDDisplayInfo *)builtIn { + if ([self hasTrustedActiveExternalDisplay]) return YES; + + NSArray *stableExternal = [self stableExternalDisplaysActiveOnly:YES]; + if (stableExternal.count == 0) { + [self postNotification:@"Cannot Disable" + body:@"No stable active external display is available."]; + return NO; + } + + [NSApp activate]; + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = [NSString stringWithFormat: + @"Trust external display before disabling \"%@\"?", builtIn.name]; + alert.informativeText = + @"The app only keeps the built-in display off while a trusted external " + @"display remains active. This avoids being left without a usable screen."; + alert.alertStyle = NSAlertStyleWarning; + [alert addButtonWithTitle:@"Trust & Disable"]; + [alert addButtonWithTitle:@"Cancel"]; + if ([alert runModal] != NSAlertFirstButtonReturn) return NO; + + NSUInteger added = [self trustExternalDisplays:stableExternal]; + if (added == 0 && ![self hasTrustedActiveExternalDisplay]) { + [self postNotification:@"Cannot Disable" + body:@"No trusted external display could be registered."]; + return NO; + } + [self rebuildMenu]; + return YES; +} + // ── Display actions ───────────────────────────────────────────────────────── - (void)switchMode:(NSMenuItem *)sender { @@ -663,6 +1083,11 @@ - (void)switchMode:(NSMenuItem *)sender { - (void)disableDisplay:(NSMenuItem *)sender { CGDirectDisplayID did = [sender.representedObject unsignedIntValue]; NSString *name = [self.displayManager nameForDisplayID:did]; + DDDisplayInfo *target = [self displayInfoForID:did]; + + if (target.isBuiltIn && ![self prepareToDisableBuiltInDisplay:target]) { + return; + } // Refuse to disable the last active display — prevents an unrecoverable black screen. NSUInteger activeCount = 0; @@ -684,8 +1109,14 @@ - (void)disableDisplay:(NSMenuItem *)sender { NSError *error = nil; if ([self.displayManager disableDisplay:did error:&error]) { + if (target.isBuiltIn) { + [[NSUserDefaults standardUserDefaults] setObject:@(did) + forKey:kLastBuiltInDisplayID]; + } [self postNotification:@"Display Disabled" body:[NSString stringWithFormat:@"%@ has been disabled.", name]]; + [self scheduleSmartRecoveryEvaluation]; + [self updateSafetyWatchdog]; } else { NSLog(@"DisplayDisabler: Failed to disable 0x%X: %@", did, error); [self postNotification:@"Disable Failed" @@ -696,10 +1127,19 @@ - (void)disableDisplay:(NSMenuItem *)sender { - (void)enableDisplay:(NSMenuItem *)sender { CGDirectDisplayID did = [sender.representedObject unsignedIntValue]; NSString *name = [self.displayManager nameForDisplayID:did]; + BOOL enablingBuiltIn = (did == [self knownBuiltInDisplayID]); + + if (enablingBuiltIn) { + [[NSUserDefaults standardUserDefaults] setBool:NO forKey:kAutoManage]; + self.suppressAutoDisableUntil = CFAbsoluteTimeGetCurrent() + 8.0; + } + NSError *error = nil; if ([self.displayManager enableDisplay:did error:&error]) { [self postNotification:@"Display Enabled" body:[NSString stringWithFormat:@"%@ has been enabled.", name]]; + [self rebuildMenu]; + [self updateSafetyWatchdog]; } else { NSLog(@"DisplayDisabler: Failed to enable 0x%X: %@", did, error); [self postNotification:@"Enable Failed" @@ -757,6 +1197,7 @@ - (void)setBrightness:(NSMenuItem *)sender { if ([[Brightness shared] setBrightnessPercent:pct forDisplay:did error:&error]) { [self postNotification:@"Brightness" body:[NSString stringWithFormat:@"%@ set to %u%%.", name, pct]]; + [self rebuildMenu]; } else { NSLog(@"DisplayDisabler: Failed to set brightness on 0x%X: %@", did, error); [self postNotification:@"Brightness Failed" @@ -764,6 +1205,252 @@ - (void)setBrightness:(NSMenuItem *)sender { } } +// ── Smart safety actions ──────────────────────────────────────────────────── + +- (void)trustDisplay:(NSMenuItem *)sender { + CGDirectDisplayID did = [sender.representedObject unsignedIntValue]; + DDDisplayInfo *display = [self displayInfoForID:did]; + if (!display || display.isBuiltIn) return; + + NSUInteger added = [self trustExternalDisplays:@[display]]; + if (added > 0) { + [self postNotification:@"Display Trusted" + body:[NSString stringWithFormat:@"%@ will keep built-in recovery armed.", + display.name]]; + } + [self rebuildMenu]; +} + +- (void)trustCurrentExternalDisplays:(id)sender { + (void)sender; + NSArray *stableExternal = [self stableExternalDisplaysActiveOnly:NO]; + NSUInteger added = [self trustExternalDisplays:stableExternal]; + + if (added > 0) { + [self postNotification:@"Trusted Displays Updated" + body:[NSString stringWithFormat:@"%lu display(s) added.", + (unsigned long)added]]; + } else { + [self postNotification:@"No Displays Added" + body:@"No new stable external display was detected."]; + } + [self rebuildMenu]; +} + +- (void)forgetTrustedDisplay:(NSMenuItem *)sender { + CGDirectDisplayID did = [sender.representedObject unsignedIntValue]; + DDDisplayInfo *display = [self displayInfoForID:did]; + if (!display) return; + [self removeTrustedDisplayFingerprint:[self fingerprintForDisplay:display]]; + [self postNotification:@"Trusted Display Removed" + body:[NSString stringWithFormat:@"%@ is no longer trusted.", + display.name]]; + [self rebuildMenu]; + [self scheduleSmartRecoveryEvaluation]; +} + +- (void)forgetTrustedDisplayRecord:(NSMenuItem *)sender { + NSString *fingerprint = sender.representedObject; + if (![fingerprint isKindOfClass:NSString.class]) return; + [self removeTrustedDisplayFingerprint:fingerprint]; + [self postNotification:@"Trusted Display Removed" + body:@"The display was removed from the trusted list."]; + [self rebuildMenu]; + [self scheduleSmartRecoveryEvaluation]; +} + +- (void)forgetAllTrustedDisplays:(id)sender { + (void)sender; + if (![self confirmDestructive:@"Forget all trusted displays?" + info:@"The app will re-enable the built-in display whenever no trusted external monitor is active." + actionName:@"Forget All"]) { + return; + } + [self setTrustedDisplayRecords:@[]]; + [self postNotification:@"Trusted Displays Cleared" + body:@"No external displays are trusted now."]; + [self rebuildMenu]; + [self scheduleSmartRecoveryEvaluation]; +} + +- (void)removeTrustedDisplayFingerprint:(NSString *)fingerprint { + if (fingerprint.length == 0) return; + NSMutableArray *records = [NSMutableArray array]; + for (NSDictionary *record in [self trustedDisplayRecords]) { + if ([record[@"fingerprint"] isEqualToString:fingerprint]) continue; + [records addObject:record]; + } + [self setTrustedDisplayRecords:records]; +} + +- (void)showSystemStatus:(id)sender { + (void)sender; + [NSApp activate]; + + NSString *status = [self systemStatusText]; + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"DisplayDisabler Status"; + alert.informativeText = status; + alert.alertStyle = NSAlertStyleInformational; + [alert addButtonWithTitle:@"OK"]; + [alert addButtonWithTitle:@"Copy"]; + NSModalResponse response = [alert runModal]; + if (response == NSAlertSecondButtonReturn) { + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + [pb clearContents]; + [pb setString:status forType:NSPasteboardTypeString]; + } +} + +- (void)runDoctor:(id)sender { + (void)sender; + [NSApp activate]; + + NSString *doctor = [self doctorText]; + BOOL hasFailure = [doctor containsString:@"FAIL:"]; + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = hasFailure + ? @"DisplayDisabler Doctor Found Issues" + : @"DisplayDisabler Doctor"; + alert.informativeText = doctor; + alert.alertStyle = hasFailure ? NSAlertStyleWarning : NSAlertStyleInformational; + [alert addButtonWithTitle:@"OK"]; + [alert runModal]; +} + +- (NSString *)launchAtLoginStatusText { + switch (SMAppService.mainAppService.status) { + case SMAppServiceStatusEnabled: + return @"enabled"; + case SMAppServiceStatusRequiresApproval: + return @"requires approval"; + case SMAppServiceStatusNotRegistered: + return @"not registered"; + case SMAppServiceStatusNotFound: + return @"not found"; + default: + return @"unknown"; + } +} + +- (NSString *)systemStatusText { + NSArray *displays = [self.displayManager allDisplays]; + DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; + NSArray *records = [self trustedDisplayRecords]; + NSSet *trustedFingerprints = [self trustedDisplayFingerprintsFromRecords:records]; + NSArray *external = [self externalDisplaysFromDisplays:displays activeOnly:NO]; + NSArray *trustedActive = + [self trustedExternalDisplaysFromDisplays:displays + activeOnly:YES + trustedFingerprints:trustedFingerprints]; + + NSUInteger activeCount = 0; + for (DDDisplayInfo *d in displays) { + if (d.isActive) activeCount++; + } + + BOOL cliExecutable = + [[NSFileManager defaultManager] isExecutableFileAtPath:@"/usr/local/bin/display_disable"]; + + NSMutableString *status = [NSMutableString string]; + [status appendFormat:@"Displays: %lu connected, %lu active\n", + (unsigned long)displays.count, (unsigned long)activeCount]; + [status appendFormat:@"Built-in: %@%@\n", + builtIn ? builtIn.name : @"not detected", + builtIn ? (builtIn.isActive ? @" (active)" : @" (inactive)") : @""]; + [status appendFormat:@"External: %lu connected, %lu trusted active\n", + (unsigned long)external.count, (unsigned long)trustedActive.count]; + [status appendFormat:@"Smart recovery: %@\n", [self pref:kSmartRecovery] ? @"on" : @"off"]; + [status appendFormat:@"Auto-manage: %@\n", [self pref:kAutoManage] ? @"on" : @"off"]; + [status appendFormat:@"Launch at Login: %@\n", [self launchAtLoginStatusText]]; + [status appendFormat:@"CLI fallback: %@\n", cliExecutable ? @"available" : @"missing"]; + + if (records.count > 0) { + [status appendString:@"\nTrusted displays:\n"]; + for (NSDictionary *record in records) { + [status appendFormat:@"- %@\n", record[@"name"] ?: @"External Display"]; + } + } else { + [status appendString:@"\nTrusted displays: none\n"]; + } + + return status; +} + +- (NSString *)doctorText { + NSMutableArray *lines = [NSMutableArray array]; + NSArray *displays = [self.displayManager allDisplays]; + DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; + NSArray *records = [self trustedDisplayRecords]; + NSSet *trustedFingerprints = [self trustedDisplayFingerprintsFromRecords:records]; + NSArray *stableExternal = + [self stableExternalDisplaysFromDisplays:displays activeOnly:NO]; + NSArray *trustedActive = + [self trustedExternalDisplaysFromDisplays:displays + activeOnly:YES + trustedFingerprints:trustedFingerprints]; + BOOL hasFailure = NO; + BOOL hasWarning = NO; + + if (displays.count == 0) { + [lines addObject:@"FAIL: No online displays were reported by CoreGraphics."]; + hasFailure = YES; + } else { + [lines addObject:@"OK: CoreGraphics reports online displays."]; + } + + if (!builtIn) { + [lines addObject:@"WARN: Built-in display was not detected."]; + hasWarning = YES; + } else if (!builtIn.isActive && trustedActive.count == 0) { + [lines addObject:@"WARN: Built-in display is inactive and no trusted external display is active."]; + hasWarning = YES; + } else { + [lines addObject:@"OK: Built-in display state is recoverable."]; + } + + if (records.count == 0) { + [lines addObject:@"WARN: No trusted external displays are configured."]; + hasWarning = YES; + } else { + [lines addObject:@"OK: Trusted external displays are configured."]; + } + + if (stableExternal.count == 0) { + [lines addObject:@"INFO: No stable external display is currently connected."]; + } else { + [lines addObject:@"OK: Stable external display names are available."]; + } + + if ([self pref:kAutoManage] && records.count == 0) { + [lines addObject:@"WARN: Auto-manage is on, but no trusted display can trigger it."]; + hasWarning = YES; + } + + if ([self pref:kSmartRecovery]) { + [lines addObject:@"OK: Event-driven smart recovery is enabled."]; + } else { + [lines addObject:@"WARN: Event-driven smart recovery is disabled."]; + hasWarning = YES; + } + + BOOL cliExecutable = + [[NSFileManager defaultManager] isExecutableFileAtPath:@"/usr/local/bin/display_disable"]; + [lines addObject:(cliExecutable + ? @"OK: CLI fallback is available." + : @"INFO: CLI fallback is not installed at /usr/local/bin/display_disable.")]; + + if (!hasFailure && !hasWarning) { + [lines insertObject:@"Doctor: OK" atIndex:0]; + } else if (!hasFailure) { + [lines insertObject:@"Doctor: warnings found" atIndex:0]; + } else { + [lines insertObject:@"Doctor: failures found" atIndex:0]; + } + + return [lines componentsJoinedByString:@"\n"]; +} + - (void)installCrispHiDPI:(NSMenuItem *)sender { CGDirectDisplayID did = [sender.representedObject unsignedIntValue]; NSString *name = [self.displayManager nameForDisplayID:did]; @@ -867,16 +1554,21 @@ - (void)offerRebootWithMessage:(NSString *)message { // ── Settings actions ──────────────────────────────────────────────────────── -- (void)switchToggled:(NSSwitch *)sender { +- (void)switchToggled:(NSButton *)sender { NSString *key = sender.identifier; [self flipPref:key]; + sender.state = [self pref:key] ? NSControlStateValueOn : NSControlStateValueOff; + [self applySwitchAppearance:sender]; if ([key isEqualToString:kAutoManage] && [self pref:kAutoManage]) { [self performAutoDisableIfNeeded]; } + if ([key isEqualToString:kSmartRecovery] && [self pref:kSmartRecovery]) { + [self scheduleSmartRecoveryEvaluation]; + } } -- (void)loginSwitchToggled:(NSSwitch *)sender { +- (void)loginSwitchToggled:(NSButton *)sender { SMAppService *service = [SMAppService mainAppService]; NSError *error = nil; @@ -895,6 +1587,7 @@ - (void)loginSwitchToggled:(NSSwitch *)sender { sender.state = (service.status == SMAppServiceStatusEnabled) ? NSControlStateValueOn : NSControlStateValueOff; + [self applySwitchAppearance:sender]; } - (void)toggleCheckSetting:(NSMenuItem *)sender { @@ -924,18 +1617,62 @@ - (BOOL)confirmDestructive:(NSString *)message // ── Auto-manage logic ─────────────────────────────────────────────────────── +- (void)scheduleSmartRecoveryEvaluation { + NSInteger token = ++self.smartRecoveryToken; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(kSmartRecoveryDelay * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + if (token != self.smartRecoveryToken) return; + [self evaluateSmartRecovery]; + }); +} + +- (void)evaluateSmartRecovery { + if (![self pref:kSmartRecovery]) return; + [self recoverBuiltInDisplayIfUnsafeWithTitle:@"Built-in Display Recovered" + body:@"No trusted external monitor is active." + logFailureName:@"Smart recovery"]; +} + +- (void)recoverBuiltInDisplayIfUnsafeWithTitle:(NSString *)title + body:(NSString *)body + logFailureName:(NSString *)logFailureName { + if ([self hasTrustedActiveExternalDisplay]) return; + + CGDirectDisplayID builtInID = [self knownBuiltInDisplayID]; + if (builtInID == 0) return; + if ([self isDisplayActive:builtInID]) return; + + NSError *error = nil; + if ([self.displayManager enableDisplay:builtInID error:&error]) { + [self postNotification:title + body:body + identifier:kAutoManageNotifID]; + [self rebuildMenu]; + [self updateSafetyWatchdog]; + } else { + NSLog(@"DisplayDisabler: %@ failed: %@", logFailureName, error); + [self postNotification:@"Recovery Failed" + body:error.localizedDescription]; + } +} + - (void)performAutoDisableIfNeeded { if (![self pref:kAutoManage]) return; + if (CFAbsoluteTimeGetCurrent() < self.suppressAutoDisableUntil) return; DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; if (!builtIn || !builtIn.isActive) return; - if (![self.displayManager hasExternalDisplay]) return; + if (![self hasTrustedActiveExternalDisplay]) return; NSError *error = nil; if ([self.displayManager disableDisplay:builtIn.displayID error:&error]) { + [[NSUserDefaults standardUserDefaults] setObject:@(builtIn.displayID) + forKey:kLastBuiltInDisplayID]; [self postNotification:@"Built-in Display Disabled" body:@"External monitor detected." identifier:kAutoManageNotifID]; + [self updateSafetyWatchdog]; } else { NSLog(@"DisplayDisabler: Auto-disable failed: %@", error); } @@ -944,19 +1681,54 @@ - (void)performAutoDisableIfNeeded { - (void)performAutoReenableIfNeeded { if (![self pref:kAutoManage]) return; - DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; - if (!builtIn) return; - if (builtIn.isActive) return; - if ([self.displayManager hasExternalDisplay]) return; + [self recoverBuiltInDisplayIfUnsafeWithTitle:@"Built-in Display Re-enabled" + body:@"No external monitor detected." + logFailureName:@"Auto-reenable"]; +} - NSError *error = nil; - if ([self.displayManager enableDisplay:builtIn.displayID error:&error]) { - [self postNotification:@"Built-in Display Re-enabled" - body:@"No external monitor detected." - identifier:kAutoManageNotifID]; - } else { - NSLog(@"DisplayDisabler: Auto-reenable failed: %@", error); +- (BOOL)builtInDisplayNeedsSafetyWatchdog { + CGDirectDisplayID builtInID = [self knownBuiltInDisplayID]; + return (builtInID != 0 && ![self isDisplayActive:builtInID]); +} + +- (void)updateSafetyWatchdog { + if (![self builtInDisplayNeedsSafetyWatchdog]) { + [self stopSafetyWatchdog]; + return; } + + if (self.safetyWatchdog) return; + + dispatch_source_t timer = + dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); + dispatch_source_set_timer(timer, + dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(kSafetyWatchdogInterval * NSEC_PER_SEC)), + (uint64_t)(kSafetyWatchdogInterval * NSEC_PER_SEC), + (uint64_t)(0.25 * NSEC_PER_SEC)); + + __weak __typeof(self) weakSelf = self; + dispatch_source_set_event_handler(timer, ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + if (![strongSelf builtInDisplayNeedsSafetyWatchdog]) { + [strongSelf stopSafetyWatchdog]; + return; + } + + [strongSelf evaluateSmartRecovery]; + [strongSelf performAutoReenableIfNeeded]; + }); + + self.safetyWatchdog = timer; + dispatch_resume(timer); +} + +- (void)stopSafetyWatchdog { + if (!self.safetyWatchdog) return; + dispatch_source_cancel(self.safetyWatchdog); + self.safetyWatchdog = nil; } @end diff --git a/Brightness.h b/Brightness.h index 96a2c03..ab6ea89 100644 --- a/Brightness.h +++ b/Brightness.h @@ -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 @@ -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 diff --git a/Brightness.m b/Brightness.m index fb13370..703ccf5 100644 --- a/Brightness.m +++ b/Brightness.m @@ -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. */ @@ -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; @@ -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"); }); @@ -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]); @@ -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; @@ -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]; } diff --git a/CHANGELOG.md b/CHANGELOG.md index ecca99c..92f31a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/DisplayManager.m b/DisplayManager.m index b9c4f8d..233137f 100644 --- a/DisplayManager.m +++ b/DisplayManager.m @@ -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 *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. diff --git a/Makefile b/Makefile index 3bbe8d0..37bc047 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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" diff --git a/README.md b/README.md index ffd6bc5..cfaa98c 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,368 @@ -# DisplayDisabler +--- -Disable a MacBook's built-in display using private Apple CoreGraphics APIs. A 51 KB open-source alternative to BetterDisplay (30+ MB commercial app) for users who only need the disable-internal-display feature on headless / clamshell-mode MacBook setups. +## Menu-bar app smart safety -[![Latest release](https://img.shields.io/github/v/release/oabdrabo/DisplayDisabler?label=release)](https://github.com/oabdrabo/DisplayDisabler/releases) -[![License](https://img.shields.io/github/license/oabdrabo/DisplayDisabler)](LICENSE) +`DisplayDisabler.app` is the primary lightweight experience. It runs as a +macOS menu-bar app, not as a Dock app, and manages displays from the status +item menu. -## Why +The app now includes: -Closing a MacBook in clamshell mode and connecting an external display works, but the *moment the lid opens* the internal display reactivates. For headless / docked / external-monitor-only setups, you want the internal display permanently disabled until you explicitly re-enable it. +- safe built-in display disable: the built-in display is kept off only when a + trusted active external display is available +- trusted external displays managed from the Settings menu, without exposing + regex configuration to normal users +- event-driven smart recovery: display change callbacks trigger a short + debounce check, then the app re-enables the built-in display if no trusted + external monitor remains active +- an internal safety watchdog that runs only while the built-in display is + inactive, so disconnect recovery can still happen when CoreGraphics stops + reporting the disabled built-in panel as an online display +- a fallback disabled built-in row in the menu when the app knows the last + built-in display ID but macOS is no longer listing that panel +- manual brightness presets for displays supported by macOS DisplayServices or + DDC/CI; the app sets brightness on demand and does not keep enforcing it +- System Status and Doctor menu actions for a lightweight, copyable runtime + report +- Launch at Login and trusted-display auto-manage from the app UI -Existing tools: +The app recovery path starts from display-change events. If the built-in display +is inactive, the app also arms a lightweight in-process watchdog that checks +roughly every two seconds and stops again once the built-in display is active. -| Tool | Size | Notes | -|---|---|---| -| **BetterDisplay** | 30+ MB | Full-featured display management; overkill if you only need one feature | -| **DisplayDisabler** | 51 KB | Single-purpose, single-binary, no UI background process | +If you manually choose `Enable` on the built-in display, the app turns +Auto-manage off and briefly suppresses auto-disable. This prevents the built-in +panel from being re-disabled immediately while a trusted external monitor is +still connected. -## Install +The Settings switches use app-rendered blue/gray controls so their color stays +consistent after reopening the submenu. -```sh -# Download the latest binary -curl -L -o DisplayDisabler https://github.com/oabdrabo/DisplayDisabler/releases/latest/download/DisplayDisabler -chmod +x DisplayDisabler -sudo mv DisplayDisabler /usr/local/bin/ +## Unified installer + +Use the installer as the single entry point: + +```bash +./scripts/install_smart.sh +``` + +It asks which profile to install: + +- `app`: menu-bar app only, recommended +- `cli`: shell aliases/helpers only +- `full`: menu-bar app plus CLI fallback + +Non-interactive profile flags are also available: + +```bash +./scripts/install_smart.sh --app +./scripts/install_smart.sh --cli +./scripts/install_smart.sh --full +``` + +After an app or full install, open: + +```bash +open /Applications/DisplayDisabler.app +``` + +Then use `Settings -> Trusted Displays` from the menu-bar app to trust your +current external monitor. + +## CLI aliases and safety watchdog + +This fork also keeps an optional smart installer on top of the original +`display_disable` binary for CLI users. + +The smart installer can: + +- install `display_disable` if it is missing +- detect the built-in display ID automatically +- create shell aliases such as `s-off`, `s-on` and `dd-status` +- route `s-off` through a safety wrapper before disabling the built-in display +- install an optional safety watchdog +- register trusted external displays +- run lightweight status and doctor checks +- fully uninstall the smart setup and the `display_disable` binary + +### CLI install + +Run the installer in CLI mode: + +```bash +./scripts/install_smart.sh --cli +``` + +Useful installer options: + +```bash +./scripts/install_smart.sh --full +./scripts/install_smart.sh --dry-run +./scripts/install_smart.sh --repair +./scripts/install_smart.sh --no-watchdog +./scripts/install_smart.sh --no-download +./scripts/install_smart.sh --yes +``` + +The installer detects the built-in display ID using: + +```bash +display_disable list +``` + +Default aliases: + +```bash +s-off +s-on +trust-displays +dd-status +``` + +Where: + +- `s-off` safely disables the built-in display only when another active display is present +- `s-on` re-enables the built-in display +- `trust-displays` adds the currently connected external displays to the trusted display list +- `dd-status` prints the smart setup and current display state + +After installation, reload your shell: + +```bash +source ~/.zshrc +``` + +The aliases are written inside a marked block in `~/.zshrc`, so rerunning the +installer updates that block instead of appending duplicate aliases. + +### Smart status and doctor + +The installer adds: + +```bash +~/Scripts/displaydisabler-smart +``` + +Available commands: + +```bash +~/Scripts/displaydisabler-smart status +~/Scripts/displaydisabler-smart doctor +~/Scripts/displaydisabler-smart safe-disable +~/Scripts/displaydisabler-smart trust ``` -Or build from source — see below. +`status` reports the detected install profile, menu-bar app path, binary path, +config, detected built-in display, app safety defaults, active display count, +trusted external display count and watchdog LaunchAgent state. + +`doctor` runs lightweight setup checks and is profile-aware. In app-only +installs, missing CLI pieces are reported as `info`; in CLI/full installs, +missing `display_disable`, config or failing `display_disable list` checks are +reported as failures and return a non-zero exit code. + +The menu-bar app also has its own `System Status...` and `Run Doctor...` +actions. Those report the app runtime state from CoreGraphics and app defaults, +including Auto-manage, Smart Recovery, Launch at Login, trusted displays and +CLI fallback availability. The shell `displaydisabler-smart` command is the +diagnostic surface for installer profile, CLI fallback and LaunchAgent watchdog +state. -## Usage +### CLI safety watchdog -```sh -# Disable the internal display -DisplayDisabler disable +The optional watchdog is designed to avoid being left without an active built-in display when the external display is disconnected. -# Re-enable -DisplayDisabler enable +The LaunchAgent watchdog remains a lightweight CLI fallback. The menu-bar app +keeps its own launch-at-login, trusted-display auto-manage and event-driven +recovery flow, while the smart shell path shares the same parser/helper library +across `safe-disable`, `status`, `doctor`, `trust` and the LaunchAgent +watchdog. -# Toggle -DisplayDisabler toggle +It is installed as: + +```text +~/Scripts/DisplayDisabler-Watchdog ``` -## How it works +and runs through this LaunchAgent: -Uses the private `CGSConfigureDisplayEnabled` Core Graphics function (part of `SkyLight.framework`) to flip the enabled state of the built-in display ID. The internal display retains its hardware identification but stops being part of the active display set. +```text +~/Library/LaunchAgents/com.displaydisabler.watchdog.plist +``` -Because this is a private API, the behaviour can change between macOS releases. Tested on macOS 13–14. +LaunchAgent label: -## Build from source +```text +com.displaydisabler.watchdog +``` -```sh -git clone https://github.com/oabdrabo/DisplayDisabler.git -cd DisplayDisabler -make +If the built-in display is disabled and no trusted external display is detected, the watchdog waits for a configurable number of unsafe confirmations and then runs: + +```bash +display_disable enable ``` -Requires Xcode Command Line Tools (`xcode-select --install`). +Default behavior: + +- check interval: `10` seconds +- unsafe confirmations: `2` +- logging disabled by default + +### Configuration -## License +The installer creates: -MIT. See [LICENSE](LICENSE). +```bash +~/.displaydisabler-watchdog.conf +``` + +Example: -## Maintenance +```bash +BUILTIN_ID="1" +TRUSTED_EXTERNAL_NAMES="DELL U2720Q|LG HDR 4K|Q27G4" +SUSPICIOUS_DISPLAY_NAMES="Display|Unknown Display" +CHECK_CONFIRMATIONS="2" +ENABLE_LOGGING="0" +DEBUG_LOGGING="0" +MAX_LOG_SIZE_KB="1024" +``` -Supporting documentation lives in `docs/`, example inputs live in `examples/`, and lightweight validation notes live in `tests/smoke/`. +`TRUSTED_EXTERNAL_NAMES` contains external display names that are considered safe while the built-in display is disabled. + +`SUSPICIOUS_DISPLAY_NAMES` contains generic or fallback display names that may appear after a disconnect event. + +### Using multiple external monitors + +If you use different monitors at home, at work, or through different docks, connect the new monitor and run: + +```bash +trust-displays +``` + +This adds the currently connected stable external display names to: + +```bash +~/.displaydisabler-watchdog.conf +``` + +Example: + +```bash +TRUSTED_EXTERNAL_NAMES="Q27G4|DELL U2720Q|Studio Display" +``` + +Displays named `Display` or `Unknown Display` are not added automatically because those names are treated as suspicious fallback names. + +### Logs and retention + +Logging is disabled by default. + +If lightweight logging is enabled, logs are written to: + +```bash +~/Library/Logs/displaydisabler-watchdog.log +``` + +The watchdog rotates the log when it reaches `MAX_LOG_SIZE_KB`. + +Default: + +```bash +MAX_LOG_SIZE_KB="1024" +``` + +One rotated backup is kept: + +```bash +~/Library/Logs/displaydisabler-watchdog.log.1 +``` + +`DEBUG_LOGGING="0"` keeps the log lightweight. + +Set: + +```bash +DEBUG_LOGGING="1" +``` + +only when troubleshooting, because it writes full command output from `display_disable` and `system_profiler`. + +### Uninstall + +Run: + +```bash +./scripts/uninstall_smart.sh +``` + +Useful uninstaller options: + +```bash +./scripts/uninstall_smart.sh --app +./scripts/uninstall_smart.sh --cli +./scripts/uninstall_smart.sh --full +./scripts/uninstall_smart.sh --dry-run +./scripts/uninstall_smart.sh --yes +./scripts/uninstall_smart.sh --keep-app +./scripts/uninstall_smart.sh --keep-binary +./scripts/uninstall_smart.sh --keep-config +./scripts/uninstall_smart.sh --keep-logs +``` + +The uninstaller removes: + +- the menu-bar app, when using `--app` or `--full` +- the LaunchAgent +- the old LaunchAgent name, if present +- the watchdog script +- the old watchdog script name, if present +- the smart status/doctor command +- the safe-disable wrapper +- the trust-displays helper +- the shared smart helper library +- the watchdog configuration file +- the watchdog state file +- aliases from the marked block in `~/.zshrc` +- optionally the watchdog log file +- `/usr/local/bin/display_disable` + +### Files added by this fork + +```text +scripts/ +├── install_smart.sh +├── uninstall_smart.sh +├── auto_enable_builtin_on_external_disconnect.sh +├── displaydisabler_smart.sh +├── safe_disable_builtin.sh +├── trust_current_external_displays.sh +└── lib/displaydisabler_smart_lib.sh +``` + +User-level files created by `--app`: + +```text +/Applications/DisplayDisabler.app +``` + +Additional user-level files created by `--cli` or `--full`: + +```text +~/.displaydisabler-watchdog.conf +~/Scripts/displaydisabler-smart +~/Scripts/displaydisabler_smart_lib.sh +~/Scripts/safe_disable_builtin.sh +~/Scripts/DisplayDisabler-Watchdog +~/Scripts/trust_current_external_displays.sh +~/Library/LaunchAgents/com.displaydisabler.watchdog.plist +~/Library/Logs/displaydisabler-watchdog.log +``` + +### Lightweight validation + +Run the shell/parser smoke checks with: + +```bash +make test-smart +``` diff --git a/build_icon.m b/build_icon.m index 29fd21f..696b897 100644 --- a/build_icon.m +++ b/build_icon.m @@ -1,11 +1,11 @@ /* - * build_icon.m — generate AppIcon.iconset from the "display" SF Symbol. - * Not shipped. Invoked by `make icon` → iconutil → AppIcon.icns. + * build_icon.m — generate AppIcon.iconset and AppIcon.icns from the "display" + * SF Symbol. Not shipped. Invoked by `make icon`. */ #import -static void renderAtSize(CGFloat size, NSString *path) { +static NSData *renderAtSize(CGFloat size, NSString *path) { NSImage *out = [[NSImage alloc] initWithSize:NSMakeSize(size, size)]; [out lockFocus]; @@ -39,11 +39,52 @@ static void renderAtSize(CGFloat size, NSString *path) { fprintf(stderr, "failed to write %s\n", path.UTF8String); exit(1); } + return png; +} + +static void appendBE32(NSMutableData *data, uint32_t value) { + uint8_t bytes[] = { + (uint8_t)((value >> 24) & 0xff), + (uint8_t)((value >> 16) & 0xff), + (uint8_t)((value >> 8) & 0xff), + (uint8_t)(value & 0xff), + }; + [data appendBytes:bytes length:sizeof bytes]; +} + +static void appendFourCC(NSMutableData *data, const char *fourCC) { + [data appendBytes:fourCC length:4]; +} + +static void writeICNS(NSArray *chunks, NSString *path) { + uint32_t totalLength = 8; + for (NSDictionary *chunk in chunks) { + NSData *png = chunk[@"png"]; + totalLength += 8 + (uint32_t)png.length; + } + + NSMutableData *icns = [NSMutableData dataWithCapacity:totalLength]; + appendFourCC(icns, "icns"); + appendBE32(icns, totalLength); + + for (NSDictionary *chunk in chunks) { + NSString *type = chunk[@"type"]; + NSData *png = chunk[@"png"]; + appendFourCC(icns, type.UTF8String); + appendBE32(icns, 8 + (uint32_t)png.length); + [icns appendData:png]; + } + + if (![icns writeToFile:path atomically:YES]) { + fprintf(stderr, "failed to write %s\n", path.UTF8String); + exit(1); + } } int main(int argc, const char *argv[]) { @autoreleasepool { NSString *dir = (argc > 1) ? @(argv[1]) : @"AppIcon.iconset"; + NSString *icnsPath = (argc > 2) ? @(argv[2]) : @"AppIcon.icns"; NSError *err = nil; if (![[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES @@ -52,23 +93,32 @@ int main(int argc, const char *argv[]) { return 1; } - // Sizes iconutil expects for a complete .icns. - struct { CGFloat px; const char *name; } sizes[] = { - { 16, "icon_16x16.png" }, - { 32, "icon_16x16@2x.png" }, - { 32, "icon_32x32.png" }, - { 64, "icon_32x32@2x.png" }, - { 128, "icon_128x128.png" }, - { 256, "icon_128x128@2x.png"}, - { 256, "icon_256x256.png" }, - { 512, "icon_256x256@2x.png"}, - { 512, "icon_512x512.png" }, - {1024, "icon_512x512@2x.png"}, + // File names match iconutil's conventional iconset layout. The ICNS + // writer stores one PNG chunk per unique pixel size, avoiding a + // toolchain dependency on iconutil while keeping the iconset inspectable. + struct { CGFloat px; const char *name; const char *icnsType; } sizes[] = { + { 16, "icon_16x16.png", "icp4" }, + { 32, "icon_16x16@2x.png", NULL }, + { 32, "icon_32x32.png", "icp5" }, + { 64, "icon_32x32@2x.png", "icp6" }, + { 128, "icon_128x128.png", "ic07" }, + { 256, "icon_128x128@2x.png", NULL }, + { 256, "icon_256x256.png", "ic08" }, + { 512, "icon_256x256@2x.png", NULL }, + { 512, "icon_512x512.png", "ic09" }, + {1024, "icon_512x512@2x.png", "ic10" }, }; + + NSMutableArray *chunks = [NSMutableArray array]; for (size_t i = 0; i < sizeof sizes / sizeof *sizes; i++) { NSString *path = [dir stringByAppendingPathComponent:@(sizes[i].name)]; - renderAtSize(sizes[i].px, path); + NSData *png = renderAtSize(sizes[i].px, path); + if (sizes[i].icnsType) { + [chunks addObject:@{ @"type": @(sizes[i].icnsType), @"png": png }]; + } } + + writeICNS(chunks, icnsPath); } return 0; } diff --git a/docs/architecture.md b/docs/architecture.md index 22f7f88..70346cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,13 @@ - Local output, generated artifacts, and credentials stay out of committed history. - Documentation should describe workflows that are expected to be repeated. +## App-First Smart Safety + +- The menu-bar app is the primary safety surface for everyday use. +- Trusted external displays are stored in user defaults and managed through the app menu. +- Built-in display recovery is event-driven through display reconfiguration callbacks, with a short debounce before acting. +- The LaunchAgent watchdog remains a CLI fallback for users who install the smart shell helpers. + ## Change Review - Identify the entry point before modifying behavior. diff --git a/scripts/auto_enable_builtin_on_external_disconnect.sh b/scripts/auto_enable_builtin_on_external_disconnect.sh new file mode 100755 index 0000000..9033be0 --- /dev/null +++ b/scripts/auto_enable_builtin_on_external_disconnect.sh @@ -0,0 +1,156 @@ +#!/bin/zsh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -f "$SCRIPT_DIR/lib/displaydisabler_smart_lib.sh" ]; then + source "$SCRIPT_DIR/lib/displaydisabler_smart_lib.sh" +elif [ -f "$SCRIPT_DIR/displaydisabler_smart_lib.sh" ]; then + source "$SCRIPT_DIR/displaydisabler_smart_lib.sh" +else + exit 0 +fi + +dd_source_config + +rotate_log_if_needed() { + if [ "$ENABLE_LOGGING" != "1" ]; then + return + fi + + if [ ! -f "$DD_LOG_FILE" ]; then + return + fi + + LOG_SIZE_KB="$(du -k "$DD_LOG_FILE" 2>/dev/null | awk '{print $1}')" + + if [ -z "$LOG_SIZE_KB" ]; then + return + fi + + if [ "$LOG_SIZE_KB" -ge "$MAX_LOG_SIZE_KB" ]; then + mv "$DD_LOG_FILE" "$DD_LOG_FILE.1" 2>/dev/null || true + touch "$DD_LOG_FILE" 2>/dev/null || true + fi +} + +log() { + if [ "$ENABLE_LOGGING" = "1" ]; then + mkdir -p "$(dirname "$DD_LOG_FILE")" 2>/dev/null || true + rotate_log_if_needed + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$DD_LOG_FILE" + fi +} + +debug_log() { + if [ "$ENABLE_LOGGING" = "1" ] && [ "$DEBUG_LOGGING" = "1" ]; then + mkdir -p "$(dirname "$DD_LOG_FILE")" 2>/dev/null || true + rotate_log_if_needed + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$DD_LOG_FILE" + fi +} + +enable_builtin_display() { + if [ "$ENABLE_LOGGING" = "1" ]; then + "$DISPLAY_DISABLE" enable "$BUILTIN_ID" >> "$DD_LOG_FILE" 2>&1 + else + "$DISPLAY_DISABLE" enable "$BUILTIN_ID" >/dev/null 2>&1 + fi +} + +if [ ! -x "$DISPLAY_DISABLE" ]; then + log "display_disable not found or not executable" + exit 0 +fi + +DD_OUTPUT="$($DISPLAY_DISABLE list 2>&1)" +DD_STATUS=$? + +log "watchdog tick, display_disable status=$DD_STATUS" +debug_log "$DD_OUTPUT" + +if [ "$DD_STATUS" -ne 0 ]; then + log "display_disable list failed, trying to enable built-in display $BUILTIN_ID" + enable_builtin_display + exit 0 +fi + +DD_BUILTIN_ACTIVE_COUNT="$(echo "$DD_OUTPUT" | dd_builtin_active_count_from_display_disable_output)" + +SP_OUTPUT="$(/usr/sbin/system_profiler SPDisplaysDataType 2>&1)" +SP_STATUS=$? + +log "system_profiler status=$SP_STATUS" +debug_log "$SP_OUTPUT" + +SP_DISPLAY_NAMES="$(echo "$SP_OUTPUT" | dd_display_names_from_system_profiler_output)" +SP_EXTERNAL_NAMES="$(echo "$SP_DISPLAY_NAMES" | dd_external_display_names_from_names)" +SP_EXTERNAL_COUNT="$(echo "$SP_EXTERNAL_NAMES" | dd_nonempty_line_count)" + +TRUSTED_COUNT=0 +SUSPICIOUS_NAME_COUNT=0 + +if [ -n "$TRUSTED_EXTERNAL_NAMES" ]; then + TRUSTED_COUNT="$(dd_match_count "$SP_EXTERNAL_NAMES" "$TRUSTED_EXTERNAL_NAMES")" +fi + +if [ -n "$SUSPICIOUS_DISPLAY_NAMES" ]; then + SUSPICIOUS_NAME_COUNT="$(dd_match_count "$SP_EXTERNAL_NAMES" "$SUSPICIOUS_DISPLAY_NAMES")" +fi + +log "display_names=$(echo "$SP_DISPLAY_NAMES" | tr '\n' ',' )" +log "external_count=$SP_EXTERNAL_COUNT trusted_count=$TRUSTED_COUNT suspicious_name_count=$SUSPICIOUS_NAME_COUNT builtin_active=$DD_BUILTIN_ACTIVE_COUNT" + +# If the built-in display is already active, reset state and do nothing. +if [ "$DD_BUILTIN_ACTIVE_COUNT" -gt 0 ]; then + echo 0 > "$DD_STATE_FILE" + log "built-in already active, nothing to do" + exit 0 +fi + +# If the built-in display is inactive but a trusted external display is present, +# keep the built-in display disabled. +if [ "$TRUSTED_COUNT" -gt 0 ]; then + echo 0 > "$DD_STATE_FILE" + log "built-in inactive, trusted external display detected, nothing to do" + exit 0 +fi + +SHOULD_ENABLE="0" + +# If no external displays are reported, it is unsafe to keep the built-in display disabled. +if [ "$SP_EXTERNAL_COUNT" -eq 0 ]; then + SHOULD_ENABLE="1" + log "built-in inactive and no external display names detected" +fi + +# If only suspicious/untrusted external display names are reported, it may be a +# stale/fallback display entry after a disconnect event. +if [ "$SUSPICIOUS_NAME_COUNT" -gt 0 ] && [ "$TRUSTED_COUNT" -eq 0 ]; then + SHOULD_ENABLE="1" + log "built-in inactive and suspicious/untrusted external display detected" +fi + +if [ "$SHOULD_ENABLE" = "1" ]; then + CONFIRMATION_COUNT=0 + + if [ -f "$DD_STATE_FILE" ]; then + CONFIRMATION_COUNT="$(cat "$DD_STATE_FILE" 2>/dev/null)" + fi + + CONFIRMATION_COUNT=$((CONFIRMATION_COUNT + 1)) + echo "$CONFIRMATION_COUNT" > "$DD_STATE_FILE" + + log "unsafe display state confirmation count=$CONFIRMATION_COUNT" + + if [ "$CONFIRMATION_COUNT" -ge "$CHECK_CONFIRMATIONS" ]; then + log "enabling built-in display $BUILTIN_ID" + enable_builtin_display + echo 0 > "$DD_STATE_FILE" + exit 0 + fi + + log "waiting for more confirmations before enabling built-in" + exit 0 +fi + +log "built-in inactive but external display state is not recognized as unsafe, nothing to do" diff --git a/scripts/displaydisabler_smart.sh b/scripts/displaydisabler_smart.sh new file mode 100755 index 0000000..317da27 --- /dev/null +++ b/scripts/displaydisabler_smart.sh @@ -0,0 +1,433 @@ +#!/bin/zsh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -f "$SCRIPT_DIR/lib/displaydisabler_smart_lib.sh" ]; then + source "$SCRIPT_DIR/lib/displaydisabler_smart_lib.sh" +elif [ -f "$SCRIPT_DIR/displaydisabler_smart_lib.sh" ]; then + source "$SCRIPT_DIR/displaydisabler_smart_lib.sh" +else + echo "Missing displaydisabler smart library." >&2 + exit 1 +fi + +dd_source_config + +usage() { + cat < + +Commands: + status Print current smart setup and display state + doctor Run lightweight setup checks; exits non-zero on failures + safe-disable [id] Disable the built-in display only when another display is active + trust Add currently connected stable external displays to the trusted list + help Show this help +EOF_USAGE +} + +run_display_disable_list() { + local __out_var="$1" + local __status_var="$2" + local output + local cmd_status + + if [ ! -x "$DISPLAY_DISABLE" ]; then + eval "$__out_var=''" + eval "$__status_var=127" + return + fi + + set +e + output="$("$DISPLAY_DISABLE" list 2>&1)" + cmd_status=$? + set -e + + eval "$__out_var=\"\$output\"" + eval "$__status_var=$cmd_status" +} + +run_system_profiler_displays() { + local __out_var="$1" + local __status_var="$2" + local output + local cmd_status + + if [ ! -x /usr/sbin/system_profiler ]; then + eval "$__out_var=''" + eval "$__status_var=127" + return + fi + + set +e + output="$(/usr/sbin/system_profiler SPDisplaysDataType 2>&1)" + cmd_status=$? + set -e + + eval "$__out_var=\"\$output\"" + eval "$__status_var=$cmd_status" +} + +defaults_read_value() { + local key="$1" + defaults read com.local.DisplayDisabler "$key" 2>/dev/null || true +} + +installed_profile() { + if [ -n "$DD_INSTALL_PROFILE" ]; then + echo "$DD_INSTALL_PROFILE" + return + fi + + if [ -d "$DD_APP_PATH" ] && [ ! -x "$DISPLAY_DISABLE" ] && [ ! -f "$DD_CONFIG_FILE" ]; then + echo "app" + return + fi + + if [ -d "$DD_APP_PATH" ] && { [ -x "$DISPLAY_DISABLE" ] || [ -f "$DD_CONFIG_FILE" ]; }; then + echo "full" + return + fi + + if [ -x "$DISPLAY_DISABLE" ] || [ -f "$DD_CONFIG_FILE" ] || [ -f "$DD_PLIST_PATH" ]; then + echo "cli" + return + fi + + echo "none" +} + +status_command() { + local dd_output="" + local dd_status=0 + local sp_output="" + local sp_status=0 + local detected_builtin_id="" + local active_count=0 + local builtin_active_count=0 + local display_names="" + local external_names="" + local external_count=0 + local trusted_count=0 + local suspicious_count=0 + local launchd_state="unknown" + local profile="" + local auto_manage="" + local smart_recovery="" + local last_builtin_id="" + + profile="$(installed_profile)" + auto_manage="$(defaults_read_value AutoManageBuiltIn)" + smart_recovery="$(defaults_read_value SmartRecoveryEnabled)" + last_builtin_id="$(defaults_read_value LastBuiltInDisplayID)" + + run_display_disable_list dd_output dd_status + run_system_profiler_displays sp_output sp_status + + if [ "$dd_status" -eq 0 ]; then + detected_builtin_id="$(echo "$dd_output" | dd_builtin_display_id_from_display_disable_output)" + active_count="$(echo "$dd_output" | dd_active_display_count_from_display_disable_output)" + builtin_active_count="$(echo "$dd_output" | dd_builtin_active_count_from_display_disable_output)" + fi + + if [ "$sp_status" -eq 0 ]; then + display_names="$(echo "$sp_output" | dd_display_names_from_system_profiler_output)" + external_names="$(echo "$display_names" | dd_external_display_names_from_names)" + external_count="$(echo "$external_names" | dd_nonempty_line_count)" + trusted_count="$(dd_match_count "$external_names" "$TRUSTED_EXTERNAL_NAMES")" + suspicious_count="$(dd_match_count "$external_names" "$SUSPICIOUS_DISPLAY_NAMES")" + fi + + if command -v launchctl >/dev/null 2>&1; then + if launchctl print "gui/$(id -u)/$DD_WATCHDOG_LABEL" >/dev/null 2>&1; then + launchd_state="loaded" + else + launchd_state="not loaded" + fi + fi + + echo "DisplayDisabler smart status" + echo "----------------------------" + echo "install profile: $profile" + echo "menu-bar app: $([ -d "$DD_APP_PATH" ] && echo "present" || echo "missing") ($DD_APP_PATH)" + if [ -x "$DISPLAY_DISABLE" ]; then + echo "binary: ok ($DISPLAY_DISABLE)" + else + echo "binary: missing ($DISPLAY_DISABLE)" + fi + echo "display_disable list: status=$dd_status" + echo "config: $([ -f "$DD_CONFIG_FILE" ] && echo "ok" || echo "missing") ($DD_CONFIG_FILE)" + echo "built-in id: ${BUILTIN_ID:-unset}" + if [ -n "$detected_builtin_id" ]; then + echo "detected built-in id: $detected_builtin_id" + fi + echo "app last built-in id: ${last_builtin_id:-unset}" + echo "active displays: $active_count" + echo "built-in active count: $builtin_active_count" + echo "app auto-manage: ${auto_manage:-unset}" + echo "app smart recovery: ${smart_recovery:-unset}" + echo "trusted external regex: ${TRUSTED_EXTERNAL_NAMES:-unset}" + echo "suspicious external regex: ${SUSPICIOUS_DISPLAY_NAMES:-unset}" + echo "system_profiler: status=$sp_status" + echo "external display count: $external_count" + echo "trusted external count: $trusted_count" + echo "suspicious external count: $suspicious_count" + echo "watchdog plist: $([ -f "$DD_PLIST_PATH" ] && echo "present" || echo "missing") ($DD_PLIST_PATH)" + echo "watchdog launchd: $launchd_state" + echo "logging: ENABLE_LOGGING=$ENABLE_LOGGING DEBUG_LOGGING=$DEBUG_LOGGING MAX_LOG_SIZE_KB=$MAX_LOG_SIZE_KB" + + if [ -n "$external_names" ]; then + echo + echo "external displays:" + echo "$external_names" | sed 's/^/ - /' + fi +} + +doctor_command() { + local failures=0 + local dd_output="" + local dd_status=0 + local profile="" + local app_present=0 + local cli_required=0 + local app_required=0 + local auto_manage="" + local smart_recovery="" + local last_builtin_id="" + + profile="$(installed_profile)" + auto_manage="$(defaults_read_value AutoManageBuiltIn)" + smart_recovery="$(defaults_read_value SmartRecoveryEnabled)" + last_builtin_id="$(defaults_read_value LastBuiltInDisplayID)" + [ -d "$DD_APP_PATH" ] && app_present=1 + + case "$profile" in + app) + app_required=1 + ;; + cli) + cli_required=1 + ;; + full) + app_required=1 + cli_required=1 + ;; + *) + ;; + esac + + echo "DisplayDisabler smart doctor" + echo "----------------------------" + echo "profile: $profile" + + if [ "$app_present" -eq 1 ]; then + echo "ok: menu-bar app is installed at $DD_APP_PATH" + elif [ "$app_required" -eq 1 ]; then + echo "fail: menu-bar app is missing at $DD_APP_PATH" + failures=$((failures + 1)) + else + echo "info: menu-bar app is not installed at $DD_APP_PATH" + fi + + if [ -x "$DISPLAY_DISABLE" ]; then + echo "ok: display_disable is executable" + elif [ "$cli_required" -eq 1 ]; then + echo "fail: display_disable is missing or not executable at $DISPLAY_DISABLE" + failures=$((failures + 1)) + else + echo "info: display_disable CLI fallback is not installed at $DISPLAY_DISABLE" + fi + + if [ -f "$DD_CONFIG_FILE" ]; then + echo "ok: config exists" + elif [ "$cli_required" -eq 1 ]; then + echo "fail: config is missing at $DD_CONFIG_FILE" + failures=$((failures + 1)) + else + echo "info: CLI watchdog config is not installed at $DD_CONFIG_FILE" + fi + + if [ "$cli_required" -eq 0 ]; then + echo "info: CLI built-in id is not required for profile '$profile'" + elif [ -n "$BUILTIN_ID" ]; then + echo "ok: built-in id is set to $BUILTIN_ID" + else + echo "fail: built-in id is not set" + failures=$((failures + 1)) + fi + + run_display_disable_list dd_output dd_status + if [ "$dd_status" -eq 0 ]; then + echo "ok: display_disable list succeeded" + elif [ "$cli_required" -eq 1 ]; then + echo "fail: display_disable list failed with status $dd_status" + failures=$((failures + 1)) + else + echo "info: display_disable list unavailable; skipped for profile '$profile'" + fi + + if [ -f "$DD_PLIST_PATH" ]; then + if command -v plutil >/dev/null 2>&1; then + if plutil -lint "$DD_PLIST_PATH" >/dev/null 2>&1; then + echo "ok: watchdog plist is valid" + else + echo "fail: watchdog plist is not valid" + failures=$((failures + 1)) + fi + else + echo "warn: plutil is unavailable, plist not checked" + fi + else + if [ "$cli_required" -eq 1 ]; then + echo "warn: watchdog plist is not installed" + else + echo "info: LaunchAgent watchdog is not installed for profile '$profile'" + fi + fi + + if command -v launchctl >/dev/null 2>&1 && [ -f "$DD_PLIST_PATH" ]; then + if launchctl print "gui/$(id -u)/$DD_WATCHDOG_LABEL" >/dev/null 2>&1; then + echo "ok: watchdog LaunchAgent is loaded" + else + echo "warn: watchdog LaunchAgent is not loaded" + fi + fi + + if [ "$app_present" -eq 1 ]; then + echo "app defaults: AutoManageBuiltIn=${auto_manage:-unset} SmartRecoveryEnabled=${smart_recovery:-unset} LastBuiltInDisplayID=${last_builtin_id:-unset}" + if [ "${auto_manage:-0}" = "1" ] && [ -z "$last_builtin_id" ]; then + echo "warn: auto-manage is enabled but the app has not recorded a built-in display id yet" + fi + fi + + if [ "$failures" -eq 0 ]; then + echo "doctor: ok" + else + echo "doctor: $failures failure(s)" + fi + + return "$failures" +} + +safe_disable_command() { + local target_id="${1:-$BUILTIN_ID}" + local dd_output="" + local dd_status=0 + local active_count=0 + local builtin_active_count=0 + + if [ -z "$target_id" ]; then + echo "No built-in display id configured." >&2 + exit 1 + fi + + if [ ! -x "$DISPLAY_DISABLE" ]; then + echo "display_disable is missing or not executable at $DISPLAY_DISABLE" >&2 + exit 1 + fi + + run_display_disable_list dd_output dd_status + if [ "$dd_status" -ne 0 ]; then + echo "display_disable list failed; refusing to disable a display." >&2 + echo "$dd_output" >&2 + exit 1 + fi + + active_count="$(echo "$dd_output" | dd_active_display_count_from_display_disable_output)" + builtin_active_count="$(echo "$dd_output" | dd_builtin_active_count_from_display_disable_output)" + + if [ "$builtin_active_count" -eq 0 ]; then + echo "Built-in display already appears inactive; nothing to do." + exit 0 + fi + + if [ "$active_count" -le 1 ]; then + echo "Refusing to disable the built-in display because it appears to be the only active display." >&2 + exit 2 + fi + + echo "Disabling built-in display $target_id with safety check passed ($active_count active displays)." + "$DISPLAY_DISABLE" disable "$target_id" +} + +trust_command() { + local sp_output="" + local sp_status=0 + local display_names="" + local current_regex="" + local existing_regex="" + local new_regex="" + + if [ ! -f "$DD_CONFIG_FILE" ]; then + echo "Config file not found: $DD_CONFIG_FILE" >&2 + echo "Run ./scripts/install_smart.sh first." >&2 + exit 1 + fi + + run_system_profiler_displays sp_output sp_status + if [ "$sp_status" -ne 0 ]; then + echo "system_profiler failed with status $sp_status" >&2 + echo "$sp_output" >&2 + exit 1 + fi + + display_names="$(echo "$sp_output" | dd_display_names_from_system_profiler_output)" + current_regex="$(echo "$display_names" | dd_trusted_external_names_regex_from_names)" + + if [ -z "$current_regex" ]; then + echo "No stable external display names detected." + echo + echo "Displays named 'Display' or 'Unknown Display' are not added automatically" + echo "because they are treated as suspicious fallback names." + echo + echo "You can edit the config manually if needed:" + echo " $DD_CONFIG_FILE" + exit 1 + fi + + existing_regex="$(grep '^TRUSTED_EXTERNAL_NAMES=' "$DD_CONFIG_FILE" | sed -E 's/^TRUSTED_EXTERNAL_NAMES="(.*)"$/\1/' || true)" + if [ -z "$existing_regex" ]; then + new_regex="$current_regex" + else + new_regex="$(printf '%s|%s\n' "$existing_regex" "$current_regex" | dd_join_regex_unique)" + fi + + cp "$DD_CONFIG_FILE" "$DD_CONFIG_FILE.bak" + dd_write_trusted_regex_to_config "$DD_CONFIG_FILE" "$new_regex" + + echo "Trusted external display names updated:" + echo " $new_regex" + echo + echo "Backup created:" + echo " $DD_CONFIG_FILE.bak" +} + +COMMAND="${1:-status}" +if [ "$#" -gt 0 ]; then + shift +fi + +case "$COMMAND" in + status) + status_command "$@" + ;; + doctor) + doctor_command "$@" + ;; + safe-disable) + safe_disable_command "$@" + ;; + trust) + trust_command "$@" + ;; + help|-h|--help) + usage + ;; + *) + echo "Unknown command: $COMMAND" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/scripts/install_smart.sh b/scripts/install_smart.sh new file mode 100755 index 0000000..a346252 --- /dev/null +++ b/scripts/install_smart.sh @@ -0,0 +1,634 @@ +#!/bin/zsh + +set -e + +REPO="oabdrabo/DisplayDisabler" +BINARY_NAME="display_disable" +INSTALL_PATH="/usr/local/bin/$BINARY_NAME" +APP_NAME="DisplayDisabler" +APP_BUNDLE="$APP_NAME.app" +APP_INSTALL_PATH="/Applications/$APP_BUNDLE" + +ZSHRC="$HOME/.zshrc" +SCRIPTS_DIR="$HOME/Scripts" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LIB_SOURCE="$SCRIPT_DIR/lib/displaydisabler_smart_lib.sh" +SMART_SOURCE="$SCRIPT_DIR/displaydisabler_smart.sh" +SAFE_SOURCE="$SCRIPT_DIR/safe_disable_builtin.sh" +WATCHDOG_SOURCE="$SCRIPT_DIR/auto_enable_builtin_on_external_disconnect.sh" +TRUST_SCRIPT_SOURCE="$SCRIPT_DIR/trust_current_external_displays.sh" + +LIB_TARGET="$SCRIPTS_DIR/displaydisabler_smart_lib.sh" +SMART_TARGET="$SCRIPTS_DIR/displaydisabler-smart" +SAFE_TARGET="$SCRIPTS_DIR/safe_disable_builtin.sh" +WATCHDOG_TARGET="$SCRIPTS_DIR/DisplayDisabler-Watchdog" +TRUST_SCRIPT_TARGET="$SCRIPTS_DIR/trust_current_external_displays.sh" + +CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" +WATCHDOG_LABEL="com.displaydisabler.watchdog" +PLIST_PATH="$LAUNCH_AGENTS_DIR/$WATCHDOG_LABEL.plist" + +ALIAS_BEGIN="# >>> DisplayDisabler smart aliases >>>" +ALIAS_END="# <<< DisplayDisabler smart aliases <<<" + +DRY_RUN="0" +NO_WATCHDOG="0" +NO_DOWNLOAD="0" +ASSUME_YES="0" +REPAIR="0" +INSTALL_PROFILE="" + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +source "$LIB_SOURCE" +DISPLAY_DISABLE="$INSTALL_PATH" +DD_CONFIG_FILE="$CONFIG_FILE" +DD_PLIST_PATH="$PLIST_PATH" +DD_WATCHDOG_LABEL="$WATCHDOG_LABEL" +dd_source_config + +echo +echo "DisplayDisabler Smart Installer" +echo "--------------------------------" +if [ "$DRY_RUN" = "1" ]; then + echo "Mode: dry run" +elif [ "$REPAIR" = "1" ]; then + echo "Mode: repair" +fi +echo + +prompt_choice() { + local __var="$1" + local prompt="$2" + local default="$3" + local answer + + if [ "$ASSUME_YES" = "1" ]; then + echo "$prompt [$default]: $default" + eval "$__var=\"\$default\"" + return + fi + + read "answer?$prompt [$default]: " + answer="${answer:-$default}" + eval "$__var=\"\$answer\"" +} + +prompt_default() { + local __var="$1" + local prompt="$2" + local default="$3" + local answer + + if [ "$ASSUME_YES" = "1" ]; then + echo "$prompt [$default]: $default" + eval "$__var=\"\$default\"" + return + fi + + read "answer?$prompt [$default]: " + answer="${answer:-$default}" + eval "$__var=\"\$answer\"" +} + +normalize_install_profile() { + local profile="$1" + case "$profile" in + app|a|menu|menubar|menu-bar) + echo "app" + ;; + cli|c|shell) + echo "cli" + ;; + full|f|both|all) + echo "full" + ;; + *) + echo "" + ;; + esac +} + +validate_alias_name() { + local alias_name="$1" + if [[ ! "$alias_name" =~ '^[A-Za-z0-9_][A-Za-z0-9_-]*$' ]]; then + echo "Invalid alias name: $alias_name" >&2 + exit 1 + fi +} + +validate_positive_int() { + local value="$1" + local label="$2" + if [[ ! "$value" =~ '^[0-9]+$' ]] || [ "$value" -lt 1 ]; then + echo "$label must be a positive integer." >&2 + exit 1 + fi +} + +install_menu_bar_app() { + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would build $APP_BUNDLE with make clean all" + echo "[dry-run] Would install $APP_BUNDLE to $APP_INSTALL_PATH" + return + fi + + echo + echo "Building menu-bar app..." + make clean all + + echo + echo "Installing menu-bar app to $APP_INSTALL_PATH" + rm -rf "$APP_INSTALL_PATH" + cp -R "$APP_BUNDLE" "$APP_INSTALL_PATH" + echo "Installed $APP_INSTALL_PATH" +} + +install_binary_if_missing() { + if [ -x "$INSTALL_PATH" ]; then + echo "Found existing binary: $INSTALL_PATH" + return + fi + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would download latest release asset from $REPO" + echo "[dry-run] Would install it to $INSTALL_PATH" + return + fi + + if [ "$NO_DOWNLOAD" = "1" ]; then + echo "$BINARY_NAME not found at $INSTALL_PATH and --no-download was set." >&2 + echo "Install display_disable manually, then rerun this installer." >&2 + exit 1 + fi + + echo "$BINARY_NAME not found at $INSTALL_PATH." + echo "Downloading latest release asset from $REPO..." + + DOWNLOAD_URL="$(curl -s "https://api.github.com/repos/$REPO/releases/latest" \ + | grep browser_download_url \ + | grep "$BINARY_NAME" \ + | sed -E 's/.*"([^"]+)".*/\1/' \ + | head -n 1)" + + if [ -z "$DOWNLOAD_URL" ]; then + echo "Could not find release asset named $BINARY_NAME." + echo "Please install display_disable manually, then rerun this installer." + exit 1 + fi + + TMP_FILE="$(mktemp)" + curl -L -o "$TMP_FILE" "$DOWNLOAD_URL" + chmod +x "$TMP_FILE" + + echo "Installing to $INSTALL_PATH" + sudo mv "$TMP_FILE" "$INSTALL_PATH" +} + +collect_display_disable_output() { + if [ ! -x "$INSTALL_PATH" ]; then + DD_OUTPUT="" + return + fi + + DD_OUTPUT="$("$INSTALL_PATH" list 2>/dev/null || true)" +} + +collect_system_profiler_names() { + local sp_output="" + + if [ ! -x /usr/sbin/system_profiler ]; then + SP_DISPLAY_NAMES="" + return + fi + + sp_output="$(/usr/sbin/system_profiler SPDisplaysDataType 2>/dev/null || true)" + SP_DISPLAY_NAMES="$(echo "$sp_output" | dd_display_names_from_system_profiler_output)" +} + +show_detected_displays() { + if [ -x "$INSTALL_PATH" ]; then + echo + echo "Detected displays from display_disable:" + echo + "$INSTALL_PATH" list || true + echo + fi + + if [ -x /usr/sbin/system_profiler ]; then + echo "Detected displays from system_profiler:" + echo + /usr/sbin/system_profiler SPDisplaysDataType | awk ' + /Displays:/ { in_displays=1; print; next } + in_displays { print } + ' + echo + fi +} + +write_watchdog_config() { + local builtin_id="$1" + local trusted_external_names="$2" + local confirmations="$3" + local enable_logging="$4" + local debug_logging="$5" + local max_log_size_kb="$6" + local install_profile="$7" + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would write watchdog config: $CONFIG_FILE" + echo "[dry-run] BUILTIN_ID=$builtin_id TRUSTED_EXTERNAL_NAMES=$trusted_external_names" + return + fi + + cat > "$CONFIG_FILE" < "$tmp_file" + + { + echo "$ALIAS_BEGIN" + echo "alias ${off_alias}=\"$SAFE_TARGET\"" + echo "alias ${on_alias}=\"$INSTALL_PATH enable $BUILTIN_ID\"" + echo "alias ${trust_alias}=\"$SMART_TARGET trust\"" + echo "alias ${status_alias}=\"$SMART_TARGET status\"" + echo "$ALIAS_END" + } >> "$tmp_file" + + mv "$tmp_file" "$ZSHRC" +} + +install_watchdog() { + local interval="$1" + + validate_positive_int "$interval" "Check interval" + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would install watchdog script: $WATCHDOG_TARGET" + echo "[dry-run] Would write LaunchAgent: $PLIST_PATH" + return + fi + + mkdir -p "$SCRIPTS_DIR" + mkdir -p "$LAUNCH_AGENTS_DIR" + + cp "$WATCHDOG_SOURCE" "$WATCHDOG_TARGET" + chmod +x "$WATCHDOG_TARGET" + + cat > "$PLIST_PATH" < + + + + Label + $WATCHDOG_LABEL + + ProgramArguments + + $WATCHDOG_TARGET + + + StartInterval + $interval + + RunAtLoad + + + +EOF_PLIST + + if command -v plutil >/dev/null 2>&1; then + plutil -lint "$PLIST_PATH" >/dev/null + fi + + launchctl bootout "gui/$(id -u)" "$PLIST_PATH" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH" + launchctl enable "gui/$(id -u)/$WATCHDOG_LABEL" + launchctl kickstart -k "gui/$(id -u)/$WATCHDOG_LABEL" + + echo + echo "Watchdog installed:" + echo " $PLIST_PATH" +} + +cleanup_old_watchdog_names() { + local old_plist="$LAUNCH_AGENTS_DIR/com.displaydisabler.auto-enable-builtin.plist" + local old_script="$SCRIPTS_DIR/auto_enable_builtin_on_external_disconnect.sh" + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would remove old watchdog names if present" + return + fi + + if [ -f "$old_plist" ]; then + launchctl bootout "gui/$(id -u)" "$old_plist" 2>/dev/null || true + rm -f "$old_plist" + echo "Removed old LaunchAgent name:" + echo " $old_plist" + fi + + if [ -f "$old_script" ]; then + rm -f "$old_script" + echo "Removed old watchdog script name:" + echo " $old_script" + fi +} + +if [ -z "$INSTALL_PROFILE" ]; then + echo "Choose installation type:" + echo " app - menu-bar app only (recommended)" + echo " cli - shell aliases/helpers only" + echo " full - app plus CLI fallback" + echo + prompt_choice INSTALL_PROFILE "Install type: app, cli or full" "app" +fi + +INSTALL_PROFILE="$(normalize_install_profile "$INSTALL_PROFILE")" +if [ -z "$INSTALL_PROFILE" ]; then + echo "Invalid install type. Use app, cli, or full." >&2 + exit 1 +fi + +echo "Install type: $INSTALL_PROFILE" +echo + +if [ "$INSTALL_PROFILE" = "app" ] || [ "$INSTALL_PROFILE" = "full" ]; then + install_menu_bar_app +fi + +if [ "$INSTALL_PROFILE" = "app" ]; then + echo + echo "Done." + echo + echo "Open the menu-bar app:" + echo " open $APP_INSTALL_PATH" + echo + echo "Then use Settings -> Trusted Displays to trust your current monitor." + echo + exit 0 +fi + +cleanup_old_watchdog_names +install_binary_if_missing +collect_display_disable_output +collect_system_profiler_names + +if [ "$ASSUME_YES" != "1" ]; then + show_detected_displays +fi + +DETECTED_BUILTIN_ID="$(echo "$DD_OUTPUT" | dd_builtin_display_id_from_display_disable_output)" +if [ -n "$DETECTED_BUILTIN_ID" ]; then + BUILTIN_ID="$DETECTED_BUILTIN_ID" +fi + +if [ -z "$BUILTIN_ID" ]; then + echo "Could not automatically detect the built-in display." + echo + prompt_default BUILTIN_ID "Enter built-in display ID manually" "" +fi + +if [ -z "$BUILTIN_ID" ]; then + echo "No built-in display ID provided. Aborting." + exit 1 +fi + +DETECTED_TRUSTED_EXTERNAL_NAMES="$(echo "$SP_DISPLAY_NAMES" | dd_trusted_external_names_regex_from_names)" +if [ -n "$DETECTED_TRUSTED_EXTERNAL_NAMES" ]; then + if [ -n "$TRUSTED_EXTERNAL_NAMES" ]; then + TRUSTED_EXTERNAL_NAMES="$(printf '%s|%s\n' "$TRUSTED_EXTERNAL_NAMES" "$DETECTED_TRUSTED_EXTERNAL_NAMES" | dd_join_regex_unique)" + else + TRUSTED_EXTERNAL_NAMES="$DETECTED_TRUSTED_EXTERNAL_NAMES" + fi +fi + +echo "Built-in display ID: $BUILTIN_ID" +if [ -n "$TRUSTED_EXTERNAL_NAMES" ]; then + echo "Trusted external display names regex: $TRUSTED_EXTERNAL_NAMES" +else + echo "No trusted external display names detected." + echo "If your external monitor is currently connected but appears only as 'Display'," + echo "you may need to edit $CONFIG_FILE manually after installation." +fi + +echo +prompt_default OFF_ALIAS "Alias to safely disable built-in display" "s-off" +prompt_default ON_ALIAS "Alias to enable built-in display" "s-on" +prompt_default TRUST_ALIAS "Alias to trust currently connected external displays" "trust-displays" +prompt_default STATUS_ALIAS "Alias to show smart setup status" "dd-status" + +prompt_default CHECK_CONFIRMATIONS "Unsafe checks before re-enabling built-in display" "$CHECK_CONFIRMATIONS" +validate_positive_int "$CHECK_CONFIRMATIONS" "Unsafe checks" + +if [ "$ENABLE_LOGGING" = "1" ]; then + ENABLE_LOGGING_DEFAULT="Y" +else + ENABLE_LOGGING_DEFAULT="N" +fi + +prompt_default ENABLE_LOGGING_ANSWER "Enable lightweight watchdog logging? y/N" "$ENABLE_LOGGING_DEFAULT" +if [[ "$ENABLE_LOGGING_ANSWER" =~ '^[Yy]$' ]]; then + ENABLE_LOGGING_VALUE="1" + if [ "$DEBUG_LOGGING" = "1" ]; then + DEBUG_LOGGING_DEFAULT="Y" + else + DEBUG_LOGGING_DEFAULT="N" + fi + prompt_default DEBUG_LOGGING_ANSWER "Enable verbose debug logging? y/N" "$DEBUG_LOGGING_DEFAULT" + if [[ "$DEBUG_LOGGING_ANSWER" =~ '^[Yy]$' ]]; then + DEBUG_LOGGING_VALUE="1" + else + DEBUG_LOGGING_VALUE="0" + fi + prompt_default MAX_LOG_SIZE_KB "Max log size before rotation in KB" "$MAX_LOG_SIZE_KB" + validate_positive_int "$MAX_LOG_SIZE_KB" "Max log size" +else + ENABLE_LOGGING_VALUE="0" + DEBUG_LOGGING_VALUE="0" + MAX_LOG_SIZE_KB="1024" +fi + +write_watchdog_config "$BUILTIN_ID" "$TRUSTED_EXTERNAL_NAMES" "$CHECK_CONFIRMATIONS" "$ENABLE_LOGGING_VALUE" "$DEBUG_LOGGING_VALUE" "$MAX_LOG_SIZE_KB" "$INSTALL_PROFILE" +install_helpers +write_alias_block "$OFF_ALIAS" "$ON_ALIAS" "$TRUST_ALIAS" "$STATUS_ALIAS" + +if [ "$NO_WATCHDOG" = "1" ]; then + echo + echo "Watchdog skipped because --no-watchdog was set." +else + prompt_default INSTALL_WATCHDOG "Install safety watchdog to re-enable built-in display when external display disconnects? Y/n" "Y" + if [[ "$INSTALL_WATCHDOG" =~ '^[Yy]$' ]]; then + prompt_default CHECK_INTERVAL "Check interval in seconds" "10" + install_watchdog "$CHECK_INTERVAL" + else + echo "Watchdog not installed." + fi +fi + +echo +echo "CLI aliases added to $ZSHRC:" +echo " $OFF_ALIAS -> $SAFE_TARGET" +echo " $ON_ALIAS -> $INSTALL_PATH enable $BUILTIN_ID" +echo " $TRUST_ALIAS -> $SMART_TARGET trust" +echo " $STATUS_ALIAS -> $SMART_TARGET status" +echo +echo "Reload your shell:" +echo " source ~/.zshrc" +echo +echo "Then use:" +echo " $OFF_ALIAS" +echo " $ON_ALIAS" +echo " $STATUS_ALIAS" +if [ "$INSTALL_PROFILE" = "full" ]; then + echo + echo "Open the menu-bar app:" + echo " open $APP_INSTALL_PATH" +fi +echo diff --git a/scripts/lib/displaydisabler_smart_lib.sh b/scripts/lib/displaydisabler_smart_lib.sh new file mode 100644 index 0000000..46b8d31 --- /dev/null +++ b/scripts/lib/displaydisabler_smart_lib.sh @@ -0,0 +1,172 @@ +#!/bin/zsh + +# Shared helpers for the lightweight smart installer/watchdog scripts. +# Keep this file side-effect free: callers decide when to read hardware, +# write files, or invoke display_disable. + +dd_init_defaults() { + DISPLAY_DISABLE="${DISPLAY_DISABLE:-/usr/local/bin/display_disable}" + DD_APP_PATH="${DD_APP_PATH:-/Applications/DisplayDisabler.app}" + DD_CONFIG_FILE="${DD_CONFIG_FILE:-$HOME/.displaydisabler-watchdog.conf}" + DD_LOG_FILE="${DD_LOG_FILE:-$HOME/Library/Logs/displaydisabler-watchdog.log}" + DD_STATE_FILE="${DD_STATE_FILE:-$HOME/Library/Logs/displaydisabler-watchdog-suspicious-count}" + DD_WATCHDOG_LABEL="${DD_WATCHDOG_LABEL:-com.displaydisabler.watchdog}" + DD_PLIST_PATH="${DD_PLIST_PATH:-$HOME/Library/LaunchAgents/$DD_WATCHDOG_LABEL.plist}" + + DD_INSTALL_PROFILE="${DD_INSTALL_PROFILE:-}" + BUILTIN_ID="${BUILTIN_ID:-1}" + TRUSTED_EXTERNAL_NAMES="${TRUSTED_EXTERNAL_NAMES:-}" + SUSPICIOUS_DISPLAY_NAMES="${SUSPICIOUS_DISPLAY_NAMES:-Display|Unknown Display}" + CHECK_CONFIRMATIONS="${CHECK_CONFIRMATIONS:-2}" + ENABLE_LOGGING="${ENABLE_LOGGING:-0}" + DEBUG_LOGGING="${DEBUG_LOGGING:-0}" + MAX_LOG_SIZE_KB="${MAX_LOG_SIZE_KB:-1024}" +} + +dd_source_config() { + dd_init_defaults + if [ -f "$DD_CONFIG_FILE" ]; then + source "$DD_CONFIG_FILE" + fi + dd_init_defaults +} + +dd_escape_regex_name() { + echo "$1" | sed -E 's/[][(){}.^$+*?|\\]/\\&/g' +} + +dd_nonempty_line_count() { + sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ' +} + +dd_display_names_from_system_profiler_output() { + awk ' + /Displays:/ { in_displays=1; next } + + in_displays && /^[[:space:]]{8}[^[:space:]].*:$/ { + name=$0 + sub(/^[[:space:]]+/, "", name) + sub(/:$/, "", name) + print name + } + ' +} + +dd_external_display_names_from_names() { + grep -v "^Color LCD$" | sed '/^[[:space:]]*$/d' || true +} + +dd_trusted_external_names_regex_from_names() { + local trusted="" + local line + local escaped + + while IFS= read -r line; do + if [ -z "$line" ]; then + continue + fi + + if [ "$line" = "Color LCD" ]; then + continue + fi + + if [ "$line" = "Display" ] || [ "$line" = "Unknown Display" ]; then + continue + fi + + escaped="$(dd_escape_regex_name "$line")" + + if [ -z "$trusted" ]; then + trusted="$escaped" + else + trusted="$trusted|$escaped" + fi + done + + echo "$trusted" +} + +dd_match_count() { + local names="$1" + local regex="$2" + + if [ -z "$regex" ]; then + echo 0 + return + fi + + echo "$names" | grep -E -c "^(${regex})$" || true +} + +dd_active_section_from_display_disable_output() { + awk ' + /=== Active Displays ===/ { flag=1; next } + /^=== / && flag { flag=0 } + flag + ' +} + +dd_builtin_display_id_from_display_disable_output() { + awk ' + /Display [0-9]+:/ { + id="" + } + + /ID:/ { + line=$0 + if (line ~ /\([^)]+\)/) { + sub(/^.*\(/, "", line) + sub(/\).*$/, "", line) + id=line + } else { + sub(/^.*ID:[[:space:]]*/, "", line) + sub(/[[:space:]].*$/, "", line) + id=line + } + } + + /Built-in: YES/ { + print id + exit + } + ' +} + +dd_active_display_count_from_display_disable_output() { + dd_active_section_from_display_disable_output | awk ' + /Display [0-9]+:/ { count++ } + END { print count + 0 } + ' +} + +dd_builtin_active_count_from_display_disable_output() { + dd_active_section_from_display_disable_output | grep -c "Built-in: YES" || true +} + +dd_join_regex_unique() { + tr '|' '\n' | awk 'NF && !seen[$0]++' | paste -sd '|' - +} + +dd_write_trusted_regex_to_config() { + local config_file="$1" + local new_regex="$2" + local tmp_file + + tmp_file="$(mktemp)" + awk -v new_regex="$new_regex" ' + BEGIN { written=0 } + /^TRUSTED_EXTERNAL_NAMES=/ { + print "TRUSTED_EXTERNAL_NAMES=\"" new_regex "\"" + written=1 + next + } + { print } + END { + if (!written) { + print "TRUSTED_EXTERNAL_NAMES=\"" new_regex "\"" + } + } + ' "$config_file" > "$tmp_file" + + mv "$tmp_file" "$config_file" +} diff --git a/scripts/safe_disable_builtin.sh b/scripts/safe_disable_builtin.sh new file mode 100755 index 0000000..c6b8099 --- /dev/null +++ b/scripts/safe_disable_builtin.sh @@ -0,0 +1,11 @@ +#!/bin/zsh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -x "$SCRIPT_DIR/displaydisabler-smart" ]; then + exec "$SCRIPT_DIR/displaydisabler-smart" safe-disable "$@" +fi + +exec "$SCRIPT_DIR/displaydisabler_smart.sh" safe-disable "$@" diff --git a/scripts/trust_current_external_displays.sh b/scripts/trust_current_external_displays.sh new file mode 100755 index 0000000..7ad2ac2 --- /dev/null +++ b/scripts/trust_current_external_displays.sh @@ -0,0 +1,11 @@ +#!/bin/zsh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -x "$SCRIPT_DIR/displaydisabler-smart" ]; then + exec "$SCRIPT_DIR/displaydisabler-smart" trust "$@" +fi + +exec "$SCRIPT_DIR/displaydisabler_smart.sh" trust "$@" diff --git a/scripts/uninstall_smart.sh b/scripts/uninstall_smart.sh new file mode 100755 index 0000000..dd82d32 --- /dev/null +++ b/scripts/uninstall_smart.sh @@ -0,0 +1,331 @@ +#!/bin/zsh + +set -e + +ZSHRC="$HOME/.zshrc" +SCRIPTS_DIR="$HOME/Scripts" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +APP_NAME="DisplayDisabler" +APP_BUNDLE="$APP_NAME.app" +APP_INSTALL_PATH="${APP_INSTALL_PATH:-/Applications/$APP_BUNDLE}" + +CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" +WATCHDOG_LABEL="com.displaydisabler.watchdog" +PLIST_PATH="$LAUNCH_AGENTS_DIR/$WATCHDOG_LABEL.plist" +OLD_PLIST_PATH="$LAUNCH_AGENTS_DIR/com.displaydisabler.auto-enable-builtin.plist" + +WATCHDOG_SCRIPT="$SCRIPTS_DIR/DisplayDisabler-Watchdog" +OLD_WATCHDOG_SCRIPT="$SCRIPTS_DIR/auto_enable_builtin_on_external_disconnect.sh" +SMART_SCRIPT="$SCRIPTS_DIR/displaydisabler-smart" +SAFE_SCRIPT="$SCRIPTS_DIR/safe_disable_builtin.sh" +TRUST_SCRIPT="$SCRIPTS_DIR/trust_current_external_displays.sh" +LIB_SCRIPT="$SCRIPTS_DIR/displaydisabler_smart_lib.sh" + +LOG_FILE="$HOME/Library/Logs/displaydisabler-watchdog.log" +STATE_FILE="$HOME/Library/Logs/displaydisabler-watchdog-suspicious-count" +BINARY_PATH="${BINARY_PATH:-/usr/local/bin/display_disable}" + +ALIAS_BEGIN="# >>> DisplayDisabler smart aliases >>>" +ALIAS_END="# <<< DisplayDisabler smart aliases <<<" + +DRY_RUN="0" +ASSUME_YES="0" +UNINSTALL_PROFILE="" +KEEP_APP="0" +KEEP_BINARY="0" +KEEP_CONFIG="0" +KEEP_LOGS="0" + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +echo +echo "DisplayDisabler Smart Uninstaller" +echo "----------------------------------" +if [ "$DRY_RUN" = "1" ]; then + echo "Mode: dry run" +fi +echo + +prompt_default() { + local __var="$1" + local prompt="$2" + local default="$3" + local answer + + if [ "$ASSUME_YES" = "1" ]; then + echo "$prompt [$default]: $default" + eval "$__var=\"\$default\"" + return + fi + + read "answer?$prompt [$default]: " + answer="${answer:-$default}" + eval "$__var=\"\$answer\"" +} + +prompt_choice() { + local __var="$1" + local prompt="$2" + local default="$3" + local answer + + if [ "$ASSUME_YES" = "1" ]; then + echo "$prompt [$default]: $default" + eval "$__var=\"\$default\"" + return + fi + + read "answer?$prompt [$default]: " + answer="${answer:-$default}" + eval "$__var=\"\$answer\"" +} + +normalize_uninstall_profile() { + local profile="$1" + case "$profile" in + app|a|menu|menubar|menu-bar) + echo "app" + ;; + cli|c|shell) + echo "cli" + ;; + full|f|both|all) + echo "full" + ;; + *) + echo "" + ;; + esac +} + +remove_file() { + local target_path="$1" + local label="$2" + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would remove $label: $target_path" + return + fi + + if [ -e "$target_path" ]; then + rm -f "$target_path" + echo "Removed $label." + else + echo "No $label found." + fi +} + +remove_path() { + local target_path="$1" + local label="$2" + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would remove $label: $target_path" + return + fi + + if [ -e "$target_path" ]; then + rm -rf "$target_path" + echo "Removed $label." + else + echo "No $label found." + fi +} + +bootout_plist() { + local plist_path="$1" + local label="$2" + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would unload $label if loaded: $plist_path" + return + fi + + if [ -f "$plist_path" ]; then + launchctl bootout "gui/$(id -u)" "$plist_path" 2>/dev/null || true + fi +} + +remove_aliases() { + local tmp_file + local tmp_file_2 + + if [ ! -f "$ZSHRC" ]; then + echo "No $ZSHRC found." + return + fi + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would remove DisplayDisabler alias block and legacy aliases from $ZSHRC" + return + fi + + cp "$ZSHRC" "$ZSHRC.displaydisabler-uninstall.bak" + tmp_file="$(mktemp)" + tmp_file_2="$(mktemp)" + + awk -v begin="$ALIAS_BEGIN" -v end="$ALIAS_END" ' + $0 == begin { skip=1; next } + $0 == end { skip=0; next } + !skip { print } + ' "$ZSHRC" > "$tmp_file" + + awk ' + /display_disable disable/ { next } + /display_disable enable/ { next } + /trust_current_external_displays.sh/ { next } + /DisplayDisabler-Watchdog/ { next } + /displaydisabler-smart/ { next } + /safe_disable_builtin.sh/ { next } + { print } + ' "$tmp_file" > "$tmp_file_2" + + mv "$tmp_file_2" "$ZSHRC" + rm -f "$tmp_file" + + echo "Removed display_disable aliases from $ZSHRC." + echo "Backup created: $ZSHRC.displaydisabler-uninstall.bak" +} + +if [ -z "$UNINSTALL_PROFILE" ]; then + echo "Choose uninstall type:" + echo " app - menu-bar app only" + echo " cli - shell aliases/helpers only" + echo " full - app plus CLI fallback" + echo + prompt_choice UNINSTALL_PROFILE "Uninstall type: app, cli or full" "full" +fi + +UNINSTALL_PROFILE="$(normalize_uninstall_profile "$UNINSTALL_PROFILE")" +if [ -z "$UNINSTALL_PROFILE" ]; then + echo "Invalid uninstall type. Use app, cli, or full." >&2 + exit 1 +fi + +echo "Uninstall type: $UNINSTALL_PROFILE" +echo + +if [ "$UNINSTALL_PROFILE" = "app" ] || [ "$UNINSTALL_PROFILE" = "full" ]; then + if [ "$KEEP_APP" = "1" ]; then + echo "Keeping menu-bar app: $APP_INSTALL_PATH" + else + remove_path "$APP_INSTALL_PATH" "menu-bar app" + fi +fi + +if [ "$UNINSTALL_PROFILE" = "cli" ] || [ "$UNINSTALL_PROFILE" = "full" ]; then + bootout_plist "$PLIST_PATH" "LaunchAgent" + remove_file "$PLIST_PATH" "LaunchAgent" + + bootout_plist "$OLD_PLIST_PATH" "old LaunchAgent" + remove_file "$OLD_PLIST_PATH" "old LaunchAgent" + + remove_file "$WATCHDOG_SCRIPT" "watchdog script" + remove_file "$OLD_WATCHDOG_SCRIPT" "old watchdog script" + remove_file "$SMART_SCRIPT" "smart command" + remove_file "$SAFE_SCRIPT" "safe-disable wrapper" + remove_file "$TRUST_SCRIPT" "trust-displays script" + remove_file "$LIB_SCRIPT" "smart helper library" + + if [ "$KEEP_CONFIG" = "1" ]; then + echo "Keeping watchdog config: $CONFIG_FILE" + else + remove_file "$CONFIG_FILE" "watchdog config" + fi + + remove_file "$STATE_FILE" "watchdog state file" + remove_aliases + + if [ "$KEEP_LOGS" = "1" ]; then + echo "Keeping watchdog logs." + else + prompt_default REMOVE_LOG "Remove watchdog log files? y/N" "N" + if [[ "$REMOVE_LOG" =~ '^[Yy]$' ]]; then + remove_file "$LOG_FILE" "watchdog log file" + remove_file "$LOG_FILE.1" "rotated watchdog log file" + else + echo "Keeping watchdog log files." + fi + fi + + if [ "$KEEP_BINARY" = "1" ]; then + echo "Keeping display_disable binary: $BINARY_PATH" + else + prompt_default REMOVE_BINARY "Remove display_disable binary from /usr/local/bin? Y/n" "Y" + if [[ "$REMOVE_BINARY" =~ '^[Yy]$' ]]; then + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would remove binary: $BINARY_PATH" + elif [ -f "$BINARY_PATH" ]; then + rm -f "$BINARY_PATH" 2>/dev/null || sudo rm "$BINARY_PATH" + echo "Removed display_disable binary:" + echo " $BINARY_PATH" + else + echo "No display_disable binary found." + fi + else + echo "Keeping display_disable binary." + fi + fi +fi + +echo +echo "Done." +echo diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 3dfb97c..017c757 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -6,5 +6,8 @@ This directory tracks lightweight checks for `oabdrabo/DisplayDisabler`. - Documentation files exist under `docs/`. - Example configuration is valid JSON. +- Smart shell scripts pass `zsh -n`. +- Smart parser fixtures cover `display_disable list` and `system_profiler` output. +- Smart status/doctor checks cover app-only and CLI-required profiles. - CI can inspect the repository on `main`. - Release notes explain maintenance-facing changes. diff --git a/tests/smoke/fixtures/display_disable_list.txt b/tests/smoke/fixtures/display_disable_list.txt new file mode 100644 index 0000000..203c959 --- /dev/null +++ b/tests/smoke/fixtures/display_disable_list.txt @@ -0,0 +1,21 @@ +=== Active Displays === +Display 1: + ID: 0x1 (1) + Name: Color LCD + Built-in: YES + +Display 2: + ID: 0x2 (2) + Name: DELL U2720Q + Built-in: NO + +=== Online Displays === +Display 1: + ID: 0x1 (1) + Name: Color LCD + Built-in: YES + +Display 2: + ID: 0x2 (2) + Name: DELL U2720Q + Built-in: NO diff --git a/tests/smoke/fixtures/system_profiler_displays.txt b/tests/smoke/fixtures/system_profiler_displays.txt new file mode 100644 index 0000000..f04e61e --- /dev/null +++ b/tests/smoke/fixtures/system_profiler_displays.txt @@ -0,0 +1,16 @@ +Graphics/Displays: + + Apple M3: + + Chipset Model: Apple M3 + Type: GPU + Bus: Built-In + Displays: + Color LCD: + Display Type: Built-In Retina LCD + Resolution: 2560 x 1664 Retina + DELL U2720Q: + Resolution: 3840 x 2160 + UI Looks like: 1920 x 1080 @ 60.00Hz + Display: + Resolution: 3840 x 2160 diff --git a/tests/smoke/test_smart_parsers.sh b/tests/smoke/test_smart_parsers.sh new file mode 100755 index 0000000..bdc5e3f --- /dev/null +++ b/tests/smoke/test_smart_parsers.sh @@ -0,0 +1,55 @@ +#!/bin/zsh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +source "$REPO_ROOT/scripts/lib/displaydisabler_smart_lib.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +assert_eq() { + local label="$1" + local actual="$2" + local expected="$3" + + if [ "$actual" != "$expected" ]; then + echo "FAIL: $label" >&2 + echo "expected: [$expected]" >&2 + echo "actual: [$actual]" >&2 + exit 1 + fi +} + +DD_FIXTURE="$(cat "$SCRIPT_DIR/fixtures/display_disable_list.txt")" +SP_FIXTURE="$(cat "$SCRIPT_DIR/fixtures/system_profiler_displays.txt")" + +assert_eq "built-in id" \ + "$(echo "$DD_FIXTURE" | dd_builtin_display_id_from_display_disable_output)" \ + "1" + +assert_eq "active display count" \ + "$(echo "$DD_FIXTURE" | dd_active_display_count_from_display_disable_output)" \ + "2" + +assert_eq "built-in active count" \ + "$(echo "$DD_FIXTURE" | dd_builtin_active_count_from_display_disable_output)" \ + "1" + +DISPLAY_NAMES="$(echo "$SP_FIXTURE" | dd_display_names_from_system_profiler_output)" +assert_eq "display names" "$DISPLAY_NAMES" $'Color LCD\nDELL U2720Q\nDisplay' + +EXTERNAL_NAMES="$(echo "$DISPLAY_NAMES" | dd_external_display_names_from_names)" +assert_eq "external names" "$EXTERNAL_NAMES" $'DELL U2720Q\nDisplay' + +TRUSTED_REGEX="$(echo "$DISPLAY_NAMES" | dd_trusted_external_names_regex_from_names)" +assert_eq "trusted regex" "$TRUSTED_REGEX" "DELL U2720Q" + +assert_eq "trusted count" "$(dd_match_count "$EXTERNAL_NAMES" "$TRUSTED_REGEX")" "1" +assert_eq "suspicious count" "$(dd_match_count "$EXTERNAL_NAMES" "Display|Unknown Display")" "1" + +echo "smart parser smoke tests passed" diff --git a/tests/smoke/test_smart_status_doctor.sh b/tests/smoke/test_smart_status_doctor.sh new file mode 100755 index 0000000..cda2e32 --- /dev/null +++ b/tests/smoke/test_smart_status_doctor.sh @@ -0,0 +1,95 @@ +#!/bin/zsh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +assert_contains() { + local text="$1" + local needle="$2" + local label="$3" + + echo "$text" | grep -Fq "$needle" || fail "$label should contain: $needle" +} + +assert_not_contains() { + local text="$1" + local needle="$2" + local label="$3" + + if echo "$text" | grep -Fq "$needle"; then + fail "$label should not contain: $needle" + fi +} + +TMP_HOME="$(mktemp -d "${TMPDIR:-/tmp}/displaydisabler-status-doctor.XXXXXX")" + +cleanup() { + rm -rf "$TMP_HOME" +} + +trap cleanup EXIT + +APP_PATH="$TMP_HOME/Applications/DisplayDisabler.app" +BIN_PATH="$TMP_HOME/bin/display_disable" +CONFIG_PATH="$TMP_HOME/.displaydisabler-watchdog.conf" +PLIST_PATH="$TMP_HOME/Library/LaunchAgents/com.displaydisabler.watchdog.plist" + +mkdir -p "$APP_PATH" "$TMP_HOME/bin" "$TMP_HOME/Library/LaunchAgents" + +APP_ONLY_STATUS="$(HOME="$TMP_HOME" \ + DD_APP_PATH="$APP_PATH" \ + DISPLAY_DISABLE="$BIN_PATH" \ + DD_CONFIG_FILE="$CONFIG_PATH" \ + DD_PLIST_PATH="$PLIST_PATH" \ + zsh "$REPO_ROOT/scripts/displaydisabler_smart.sh" status)" + +assert_contains "$APP_ONLY_STATUS" "install profile: app" "app-only status" +assert_contains "$APP_ONLY_STATUS" "menu-bar app: present" "app-only status" +assert_contains "$APP_ONLY_STATUS" "binary: missing" "app-only status" + +APP_ONLY_DOCTOR="$(HOME="$TMP_HOME" \ + DD_APP_PATH="$APP_PATH" \ + DISPLAY_DISABLE="$BIN_PATH" \ + DD_CONFIG_FILE="$CONFIG_PATH" \ + DD_PLIST_PATH="$PLIST_PATH" \ + zsh "$REPO_ROOT/scripts/displaydisabler_smart.sh" doctor)" + +assert_contains "$APP_ONLY_DOCTOR" "profile: app" "app-only doctor" +assert_contains "$APP_ONLY_DOCTOR" "info: display_disable CLI fallback is not installed" "app-only doctor" +assert_contains "$APP_ONLY_DOCTOR" "doctor: ok" "app-only doctor" +assert_not_contains "$APP_ONLY_DOCTOR" "fail:" "app-only doctor" + +cat > "$CONFIG_PATH" <<'EOF_CONFIG' +DD_INSTALL_PROFILE="cli" +BUILTIN_ID="1" +TRUSTED_EXTERNAL_NAMES="DELL" +SUSPICIOUS_DISPLAY_NAMES="Display|Unknown Display" +CHECK_CONFIRMATIONS="2" +ENABLE_LOGGING="0" +DEBUG_LOGGING="0" +MAX_LOG_SIZE_KB="1024" +EOF_CONFIG + +set +e +CLI_DOCTOR="$(HOME="$TMP_HOME" \ + DD_APP_PATH="$APP_PATH.missing" \ + DISPLAY_DISABLE="$BIN_PATH" \ + DD_CONFIG_FILE="$CONFIG_PATH" \ + DD_PLIST_PATH="$PLIST_PATH" \ + zsh "$REPO_ROOT/scripts/displaydisabler_smart.sh" doctor)" +CLI_STATUS=$? +set -e + +[ "$CLI_STATUS" -ne 0 ] || fail "cli doctor should fail when display_disable is missing" +assert_contains "$CLI_DOCTOR" "profile: cli" "cli doctor" +assert_contains "$CLI_DOCTOR" "fail: display_disable is missing" "cli doctor" +assert_contains "$CLI_DOCTOR" "doctor: 2 failure(s)" "cli doctor" + +echo "smart status/doctor smoke tests passed" diff --git a/tests/smoke/test_uninstall_smart.sh b/tests/smoke/test_uninstall_smart.sh new file mode 100755 index 0000000..a1240c7 --- /dev/null +++ b/tests/smoke/test_uninstall_smart.sh @@ -0,0 +1,121 @@ +#!/bin/zsh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +assert_exists() { + local target_path="$1" + local label="$2" + + [ -e "$target_path" ] || fail "$label should exist: $target_path" +} + +assert_not_exists() { + local target_path="$1" + local label="$2" + + [ ! -e "$target_path" ] || fail "$label should be removed: $target_path" +} + +assert_contains() { + local target_path="$1" + local needle="$2" + local label="$3" + + grep -Fq "$needle" "$target_path" || fail "$label should contain: $needle" +} + +assert_not_contains() { + local target_path="$1" + local needle="$2" + local label="$3" + + if grep -Fq "$needle" "$target_path"; then + fail "$label should not contain: $needle" + fi +} + +TMP_HOME="$(mktemp -d "${TMPDIR:-/tmp}/displaydisabler-uninstall.XXXXXX")" + +cleanup() { + rm -rf "$TMP_HOME" +} + +trap cleanup EXIT + +APP_PATH="$TMP_HOME/Applications/DisplayDisabler.app" +BIN_PATH="$TMP_HOME/bin/display_disable" +ZSHRC="$TMP_HOME/.zshrc" + +mkdir -p "$APP_PATH/Contents/MacOS" +mkdir -p "$TMP_HOME/Scripts" "$TMP_HOME/Library/LaunchAgents" "$TMP_HOME/Library/Logs" "$TMP_HOME/bin" + +touch "$APP_PATH/Contents/MacOS/DisplayDisabler" +touch "$TMP_HOME/Library/LaunchAgents/com.displaydisabler.watchdog.plist" +touch "$TMP_HOME/Library/LaunchAgents/com.displaydisabler.auto-enable-builtin.plist" +touch "$TMP_HOME/Scripts/DisplayDisabler-Watchdog" +touch "$TMP_HOME/Scripts/auto_enable_builtin_on_external_disconnect.sh" +touch "$TMP_HOME/Scripts/displaydisabler-smart" +touch "$TMP_HOME/Scripts/safe_disable_builtin.sh" +touch "$TMP_HOME/Scripts/trust_current_external_displays.sh" +touch "$TMP_HOME/Scripts/displaydisabler_smart_lib.sh" +touch "$TMP_HOME/.displaydisabler-watchdog.conf" +touch "$TMP_HOME/Library/Logs/displaydisabler-watchdog.log" +touch "$TMP_HOME/Library/Logs/displaydisabler-watchdog.log.1" +touch "$TMP_HOME/Library/Logs/displaydisabler-watchdog-suspicious-count" +touch "$BIN_PATH" + +cat > "$ZSHRC" <<'EOF_ZSHRC' +export KEEP_ME=1 +# >>> DisplayDisabler smart aliases >>> +alias ddo="$HOME/Scripts/safe_disable_builtin.sh" +alias dds="$HOME/Scripts/displaydisabler-smart status" +# <<< DisplayDisabler smart aliases <<< +alias legacy_off="display_disable disable 1" +alias legacy_on="display_disable enable 1" +alias legacy_trust="$HOME/Scripts/trust_current_external_displays.sh" +alias legacy_watchdog="$HOME/Scripts/DisplayDisabler-Watchdog" +alias legacy_smart="$HOME/Scripts/displaydisabler-smart" +alias legacy_safe="$HOME/Scripts/safe_disable_builtin.sh" +export AFTER=1 +EOF_ZSHRC + +printf 'y\ny\n' | HOME="$TMP_HOME" \ + APP_INSTALL_PATH="$APP_PATH" \ + BINARY_PATH="$BIN_PATH" \ + zsh "$REPO_ROOT/scripts/uninstall_smart.sh" --full >/dev/null + +assert_not_exists "$APP_PATH" "menu-bar app" +assert_not_exists "$TMP_HOME/Library/LaunchAgents/com.displaydisabler.watchdog.plist" "LaunchAgent" +assert_not_exists "$TMP_HOME/Library/LaunchAgents/com.displaydisabler.auto-enable-builtin.plist" "old LaunchAgent" +assert_not_exists "$TMP_HOME/Scripts/DisplayDisabler-Watchdog" "watchdog script" +assert_not_exists "$TMP_HOME/Scripts/auto_enable_builtin_on_external_disconnect.sh" "old watchdog script" +assert_not_exists "$TMP_HOME/Scripts/displaydisabler-smart" "smart command" +assert_not_exists "$TMP_HOME/Scripts/safe_disable_builtin.sh" "safe-disable wrapper" +assert_not_exists "$TMP_HOME/Scripts/trust_current_external_displays.sh" "trust helper" +assert_not_exists "$TMP_HOME/Scripts/displaydisabler_smart_lib.sh" "helper library" +assert_not_exists "$TMP_HOME/.displaydisabler-watchdog.conf" "watchdog config" +assert_not_exists "$TMP_HOME/Library/Logs/displaydisabler-watchdog.log" "watchdog log" +assert_not_exists "$TMP_HOME/Library/Logs/displaydisabler-watchdog.log.1" "rotated watchdog log" +assert_not_exists "$TMP_HOME/Library/Logs/displaydisabler-watchdog-suspicious-count" "watchdog state" +assert_not_exists "$BIN_PATH" "display_disable binary" + +assert_exists "$ZSHRC.displaydisabler-uninstall.bak" "zshrc backup" +assert_contains "$ZSHRC" "export KEEP_ME=1" ".zshrc" +assert_contains "$ZSHRC" "export AFTER=1" ".zshrc" +assert_not_contains "$ZSHRC" "DisplayDisabler smart aliases" ".zshrc" +assert_not_contains "$ZSHRC" "display_disable disable" ".zshrc" +assert_not_contains "$ZSHRC" "display_disable enable" ".zshrc" +assert_not_contains "$ZSHRC" "trust_current_external_displays.sh" ".zshrc" +assert_not_contains "$ZSHRC" "DisplayDisabler-Watchdog" ".zshrc" +assert_not_contains "$ZSHRC" "displaydisabler-smart" ".zshrc" +assert_not_contains "$ZSHRC" "safe_disable_builtin.sh" ".zshrc" + +echo "smart uninstaller smoke tests passed"