From 8a25dcb6e84fc0ce48bf8998906d81ac87fd8c42 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Sun, 7 Jun 2026 15:40:47 +0200 Subject: [PATCH 01/10] Add smart installer and safety watchdog --- README.md | 384 ++++++++++++++++-- ...o_enable_builtin_on_external_disconnect.sh | 167 ++++++++ scripts/install_smart.sh | 377 +++++++++++++++++ scripts/trust_current_external_displays.sh | 69 ++++ scripts/uninstall_smart.sh | 101 +++++ 5 files changed, 1061 insertions(+), 37 deletions(-) create mode 100755 scripts/auto_enable_builtin_on_external_disconnect.sh create mode 100755 scripts/install_smart.sh create mode 100755 scripts/trust_current_external_displays.sh create mode 100755 scripts/uninstall_smart.sh diff --git a/README.md b/README.md index ffd6bc5..42dd56f 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,375 @@ # 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. +A small macOS command-line utility to disable and re-enable displays by display ID. -[![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) +This fork adds a smart installer, configurable shell aliases, an optional safety watchdog, and a helper to trust additional external displays. -## Why +The safety watchdog is useful when using a MacBook with an external monitor: if the built-in display is disabled and the external display is disconnected, the watchdog can automatically re-enable the built-in display. -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. +> Note: this project uses private macOS display APIs. Future macOS updates may change or break this behavior. -Existing tools: +--- -| 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 | +## Features + +- Disable a display by ID +- Enable a display by ID +- List active and online displays +- Automatically detect the built-in display ID during setup +- Create convenient shell aliases such as `s-off` and `s-on` +- Optionally install a LaunchAgent watchdog +- Re-enable the built-in display when the external display disappears or becomes a generic fallback entry +- Trust additional external displays later with `trust-displays` +- Fully uninstall the smart setup and `/usr/local/bin/display_disable` + +--- ## Install -```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/ +Run: + +```bash +./scripts/install_smart.sh +``` + +The installer can: + +- install `display_disable` if it is missing +- detect the built-in display ID automatically +- create convenient shell aliases +- detect currently connected external display names +- save trusted external display names in a config file +- install the optional LaunchAgent safety watchdog + +Default aliases: + +```bash +s-off +s-on +trust-displays +``` + +Where: + +- `s-off` disables the built-in display +- `s-on` re-enables the built-in display +- `trust-displays` adds the currently connected external displays to the trusted display list + +After installation, reload your shell: + +```bash +source ~/.zshrc +``` + +--- + +## Manual usage + +List displays: + +```bash +display_disable list +``` + +Example output: + +```text +=== Active Displays === + +Display 0: + ID: 0x3 (3) + Built-in: NO + Main: YES + Resolution: 2560 x 1440 + Active: YES + +Display 1: + ID: 0x1 (1) + Built-in: YES + Main: NO + Resolution: 1512 x 982 + Active: YES + +=== Online Displays === +Online display count: 2 ``` -Or build from source — see below. +Disable the built-in display: -## Usage +```bash +display_disable disable 1 +``` + +Re-enable the built-in display: -```sh -# Disable the internal display -DisplayDisabler disable +```bash +display_disable enable 1 +``` -# Re-enable -DisplayDisabler enable +If you run `disable` while the display is already disabled, macOS may return: -# Toggle -DisplayDisabler toggle +```text +Error: Failed to commit display configuration (error 1001) ``` -## How it works +This usually means there was no display configuration change to commit. + +--- -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. +## Safety watchdog -Because this is a private API, the behaviour can change between macOS releases. Tested on macOS 13–14. +The optional watchdog is installed as: -## Build from source +```text +~/Scripts/DisplayDisabler-Watchdog +``` + +and runs through this LaunchAgent: -```sh -git clone https://github.com/oabdrabo/DisplayDisabler.git -cd DisplayDisabler -make +```text +~/Library/LaunchAgents/com.displaydisabler.watchdog.plist ``` -Requires Xcode Command Line Tools (`xcode-select --install`). +The LaunchAgent label is: + +```text +com.displaydisabler.watchdog +``` + +The watchdog is designed to prevent this situation: + +1. the built-in display is disabled +2. the external monitor is disconnected +3. macOS still reports a stale or generic external display entry +4. the user is left without the built-in display enabled + +If the built-in display is disabled and no trusted external display is detected, it waits for a configurable number of unsafe confirmations and then runs: + +```bash +display_disable enable +``` + +By default, the smart installer uses: + +- check interval: `10` seconds +- unsafe confirmations: `2` + +With the default configuration, the built-in display may be re-enabled after about 10-20 seconds. + +--- + +## Watchdog configuration + +The smart installer creates this file: + +```bash +~/.displaydisabler-watchdog.conf +``` + +Example: + +```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" +``` + +`TRUSTED_EXTERNAL_NAMES` is an extended regular expression of external display names that are considered safe while the built-in display is disabled. + +`SUSPICIOUS_DISPLAY_NAMES` contains generic or fallback names that may appear after a disconnect event. + +`MAX_LOG_SIZE_KB` rotates the log when it reaches the configured size. One backup is kept as `.1`. + +`DEBUG_LOGGING` controls whether full command output from `display_disable` and `system_profiler` is written to the log. + +--- + +## 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 `TRUSTED_EXTERNAL_NAMES`. + +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. + +If your monitor appears only as `Display` or `Unknown Display`, edit the config manually: + +```bash +nano ~/.displaydisabler-watchdog.conf +``` + +--- + +## 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`. By 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 and avoids writing full `system_profiler` output on every check. + +Set: + +```bash +DEBUG_LOGGING="1" +``` + +only when troubleshooting, because it writes much more data. + +Inspect the log: + +```bash +tail -f ~/Library/Logs/displaydisabler-watchdog.log +``` + +--- + +## Uninstall + +Run: + +```bash +./scripts/uninstall_smart.sh +``` + +The uninstaller removes: + +- the LaunchAgent +- the old LaunchAgent name, if present +- the watchdog script +- the old watchdog script name, if present +- the trust-displays helper +- the watchdog configuration file +- the watchdog state file +- aliases from `~/.zshrc` +- optionally the watchdog log file +- `/usr/local/bin/display_disable` + +--- + +## LaunchAgent management + +Check whether the watchdog is loaded: + +```bash +launchctl list | grep displaydisabler +``` + +Inspect the watchdog: + +```bash +launchctl print gui/$(id -u)/com.displaydisabler.watchdog +``` + +Restart the watchdog manually: + +```bash +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.displaydisabler.watchdog.plist 2>/dev/null +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.displaydisabler.watchdog.plist +launchctl enable gui/$(id -u)/com.displaydisabler.watchdog +launchctl kickstart -k gui/$(id -u)/com.displaydisabler.watchdog +``` + +--- + +## Recommended workflow + +1. Connect your external monitor. +2. Run `s-off`. +3. Use the external monitor normally. +4. If the external monitor is disconnected, the watchdog should re-enable the built-in display automatically. +5. Manually re-enable the built-in display anytime with `s-on`. +6. When using a new monitor, run `trust-displays`. + +--- + +## Files added by this fork + +```text +scripts/ +├── install_smart.sh +├── uninstall_smart.sh +├── auto_enable_builtin_on_external_disconnect.sh +└── trust_current_external_displays.sh +``` + +User-level files created by the smart installer: + +```text +~/.displaydisabler-watchdog.conf +~/Scripts/DisplayDisabler-Watchdog +~/Scripts/trust_current_external_displays.sh +~/Library/LaunchAgents/com.displaydisabler.watchdog.plist +~/Library/Logs/displaydisabler-watchdog.log +``` + +--- + +## Limitations + +The safety watchdog relies on display information reported by macOS, especially: + +```bash +display_disable list +``` + +and: + +```bash +system_profiler SPDisplaysDataType +``` + +This means: + +- external display names may vary depending on dock, cable, adapter, or macOS version +- some docks may expose generic names such as `Display` +- some displays may briefly appear as stale or fallback entries after disconnecting +- users may need to edit `~/.displaydisabler-watchdog.conf` manually +- the watchdog is a safety mechanism, not a guaranteed universal display-detection system + +The watchdog is intentionally conservative and waits for multiple unsafe checks before re-enabling the built-in display. + +--- -## License +## Disclaimer -MIT. See [LICENSE](LICENSE). +This project uses private macOS display APIs. Use it at your own risk. -## Maintenance +Behavior may vary depending on: -Supporting documentation lives in `docs/`, example inputs live in `examples/`, and lightweight validation notes live in `tests/smoke/`. +- macOS version +- Apple Silicon vs Intel Mac +- external monitor model +- dock or adapter +- cable type +- display firmware 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..cb6aeab --- /dev/null +++ b/scripts/auto_enable_builtin_on_external_disconnect.sh @@ -0,0 +1,167 @@ +#!/bin/zsh + +DISPLAY_DISABLE="/usr/local/bin/display_disable" +CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" +LOG_FILE="$HOME/Library/Logs/displaydisabler-watchdog.log" +STATE_FILE="$HOME/Library/Logs/displaydisabler-watchdog-suspicious-count" + +BUILTIN_ID="1" +TRUSTED_EXTERNAL_NAMES="" +SUSPICIOUS_DISPLAY_NAMES="Display|Unknown Display" +CHECK_CONFIRMATIONS="2" +ENABLE_LOGGING="0" +DEBUG_LOGGING="0" +MAX_LOG_SIZE_KB="1024" + +if [ -f "$CONFIG_FILE" ]; then + source "$CONFIG_FILE" +fi + +rotate_log_if_needed() { + if [ "$ENABLE_LOGGING" != "1" ]; then + return + fi + + if [ ! -f "$LOG_FILE" ]; then + return + fi + + LOG_SIZE_KB="$(du -k "$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 "$LOG_FILE" "$LOG_FILE.1" 2>/dev/null || true + touch "$LOG_FILE" 2>/dev/null || true + fi +} + +log() { + if [ "$ENABLE_LOGGING" = "1" ]; then + rotate_log_if_needed + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE" + fi +} + +debug_log() { + if [ "$ENABLE_LOGGING" = "1" ] && [ "$DEBUG_LOGGING" = "1" ]; then + rotate_log_if_needed + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE" + 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" + "$DISPLAY_DISABLE" enable "$BUILTIN_ID" >> "$LOG_FILE" 2>&1 + exit 0 +fi + +ACTIVE_SECTION="$(echo "$DD_OUTPUT" | awk ' + /=== Active Displays ===/ { flag=1; next } + /=== Online Displays ===/ { flag=0 } + flag +')" + +DD_BUILTIN_ACTIVE_COUNT="$(echo "$ACTIVE_SECTION" | grep -c "Built-in: YES")" + +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" | awk ' + /Displays:/ { in_displays=1; next } + + in_displays && /^[[:space:]]{8}[^[:space:]].*:$/ { + name=$0 + sub(/^[[:space:]]+/, "", name) + sub(/:$/, "", name) + print name + } +')" + +SP_EXTERNAL_NAMES="$(echo "$SP_DISPLAY_NAMES" | grep -v "^Color LCD$" || true)" +SP_EXTERNAL_COUNT="$(echo "$SP_EXTERNAL_NAMES" | sed '/^$/d' | wc -l | tr -d ' ')" + +TRUSTED_COUNT=0 +SUSPICIOUS_NAME_COUNT=0 + +if [ -n "$TRUSTED_EXTERNAL_NAMES" ]; then + TRUSTED_COUNT="$(echo "$SP_EXTERNAL_NAMES" | grep -E -c "^(${TRUSTED_EXTERNAL_NAMES})$" || true)" +fi + +if [ -n "$SUSPICIOUS_DISPLAY_NAMES" ]; then + SUSPICIOUS_NAME_COUNT="$(echo "$SP_EXTERNAL_NAMES" | grep -E -c "^(${SUSPICIOUS_DISPLAY_NAMES})$" || true)" +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 > "$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 > "$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 "$STATE_FILE" ]; then + CONFIRMATION_COUNT="$(cat "$STATE_FILE" 2>/dev/null)" + fi + + CONFIRMATION_COUNT=$((CONFIRMATION_COUNT + 1)) + echo "$CONFIRMATION_COUNT" > "$STATE_FILE" + + log "unsafe display state confirmation count=$CONFIRMATION_COUNT" + + if [ "$CONFIRMATION_COUNT" -ge "$CHECK_CONFIRMATIONS" ]; then + log "enabling built-in display $BUILTIN_ID" + "$DISPLAY_DISABLE" enable "$BUILTIN_ID" >> "$LOG_FILE" 2>&1 + echo 0 > "$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/install_smart.sh b/scripts/install_smart.sh new file mode 100755 index 0000000..b6fdc53 --- /dev/null +++ b/scripts/install_smart.sh @@ -0,0 +1,377 @@ +#!/bin/zsh + +set -e + +REPO="oabdrabo/DisplayDisabler" +BINARY_NAME="display_disable" +INSTALL_PATH="/usr/local/bin/$BINARY_NAME" + +ZSHRC="$HOME/.zshrc" +SCRIPTS_DIR="$HOME/Scripts" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" + +WATCHDOG_SOURCE="$(cd "$(dirname "$0")" && pwd)/auto_enable_builtin_on_external_disconnect.sh" +WATCHDOG_TARGET="$SCRIPTS_DIR/DisplayDisabler-Watchdog" + +TRUST_SCRIPT_SOURCE="$(cd "$(dirname "$0")" && pwd)/trust_current_external_displays.sh" +TRUST_SCRIPT_TARGET="$SCRIPTS_DIR/trust_current_external_displays.sh" + +CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" +PLIST_PATH="$LAUNCH_AGENTS_DIR/com.displaydisabler.watchdog.plist" + +echo +echo "DisplayDisabler Smart Installer" +echo "--------------------------------" +echo + +install_binary_if_missing() { + if [ -x "$INSTALL_PATH" ]; then + echo "Found existing binary: $INSTALL_PATH" + return + 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" +} + +show_detected_displays() { + echo + echo "Detected displays from display_disable:" + echo + "$INSTALL_PATH" list + echo + + echo "Detected displays from system_profiler:" + echo + /usr/sbin/system_profiler SPDisplaysDataType | awk ' + /Displays:/ { in_displays=1; print; next } + in_displays { print } + ' + echo +} + +detect_builtin_display_id() { + local output="$1" + + local detected_id + detected_id="$(echo "$output" | awk ' + /Display [0-9]+:/ { + id="" + } + + /ID:/ { + line=$0 + sub(/^.*\(/, "", line) + sub(/\).*$/, "", line) + id=line + } + + /Built-in: YES/ { + print id + exit + } + ')" + + echo "$detected_id" +} + +detect_system_profiler_display_names() { + /usr/sbin/system_profiler SPDisplaysDataType | awk ' + /Displays:/ { in_displays=1; next } + + in_displays && /^[[:space:]]{8}[^[:space:]].*:$/ { + name=$0 + sub(/^[[:space:]]+/, "", name) + sub(/:$/, "", name) + print name + } + ' +} + +escape_regex_name() { + echo "$1" | sed -E 's/[][(){}.^$+*?|\\]/\\&/g' +} + +build_trusted_external_names_regex() { + local display_names="$1" + + local trusted="" + local line + local escaped + + while IFS= read -r line; do + if [ -z "$line" ]; then + continue + fi + + # Color LCD is Apple's usual built-in display name. + if [ "$line" = "Color LCD" ]; then + continue + fi + + # Generic names are treated as suspicious, not trusted. + if [ "$line" = "Display" ] || [ "$line" = "Unknown Display" ]; then + continue + fi + + escaped="$(escape_regex_name "$line")" + + if [ -z "$trusted" ]; then + trusted="$escaped" + else + trusted="$trusted|$escaped" + fi + done <<< "$display_names" + + echo "$trusted" +} + +add_or_replace_alias() { + local alias_name="$1" + local alias_command="$2" + + touch "$ZSHRC" + cp "$ZSHRC" "$ZSHRC.displaydisabler.bak" + + sed -i.tmp "/^alias ${alias_name}=/d" "$ZSHRC" + rm -f "$ZSHRC.tmp" + + echo "alias ${alias_name}=\"${alias_command}\"" >> "$ZSHRC" +} + +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" + + cat > "$CONFIG_FILE" < "$PLIST_PATH" < + + + + Label + com.displaydisabler.watchdog + + ProgramArguments + + $WATCHDOG_TARGET + + + StartInterval + $interval + + RunAtLoad + + + +EOF_PLIST + + launchctl bootout "gui/$(id -u)" "$PLIST_PATH" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH" + launchctl enable "gui/$(id -u)/com.displaydisabler.watchdog" + launchctl kickstart -k "gui/$(id -u)/com.displaydisabler.watchdog" + + 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 [ -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 +} + +cleanup_old_watchdog_names + +install_binary_if_missing + +DD_OUTPUT="$($INSTALL_PATH list 2>/dev/null)" +SP_DISPLAY_NAMES="$(detect_system_profiler_display_names)" + +show_detected_displays + +BUILTIN_ID="$(detect_builtin_display_id "$DD_OUTPUT")" + +if [ -z "$BUILTIN_ID" ]; then + echo "Could not automatically detect the built-in display." + echo + read "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 + +TRUSTED_EXTERNAL_NAMES="$(build_trusted_external_names_regex "$SP_DISPLAY_NAMES")" + +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 + +read "OFF_ALIAS?Alias to disable built-in display [s-off]: " +OFF_ALIAS="${OFF_ALIAS:-s-off}" + +read "ON_ALIAS?Alias to enable built-in display [s-on]: " +ON_ALIAS="${ON_ALIAS:-s-on}" + +install_trust_script + +read "TRUST_ALIAS?Alias to trust currently connected external displays [trust-displays]: " +TRUST_ALIAS="${TRUST_ALIAS:-trust-displays}" + +add_or_replace_alias "$OFF_ALIAS" "$INSTALL_PATH disable $BUILTIN_ID" +add_or_replace_alias "$ON_ALIAS" "$INSTALL_PATH enable $BUILTIN_ID" +add_or_replace_alias "$TRUST_ALIAS" "$TRUST_SCRIPT_TARGET" + +echo +echo "Aliases added to $ZSHRC:" +echo " $OFF_ALIAS -> $INSTALL_PATH disable $BUILTIN_ID" +echo " $ON_ALIAS -> $INSTALL_PATH enable $BUILTIN_ID" +echo " $TRUST_ALIAS -> $TRUST_SCRIPT_TARGET" + +echo +read "INSTALL_WATCHDOG?Install safety watchdog to re-enable built-in display when external display disconnects? [Y/n]: " +INSTALL_WATCHDOG="${INSTALL_WATCHDOG:-Y}" + +if [[ "$INSTALL_WATCHDOG" =~ ^[Yy]$ ]]; then + read "CHECK_INTERVAL?Check interval in seconds [10]: " + CHECK_INTERVAL="${CHECK_INTERVAL:-10}" + + read "CHECK_CONFIRMATIONS?Unsafe checks before re-enabling built-in display [2]: " + CHECK_CONFIRMATIONS="${CHECK_CONFIRMATIONS:-2}" + + read "ENABLE_LOGGING?Enable lightweight watchdog logging? [y/N]: " + ENABLE_LOGGING_ANSWER="${ENABLE_LOGGING:-N}" + + if [[ "$ENABLE_LOGGING_ANSWER" =~ ^[Yy]$ ]]; then + ENABLE_LOGGING_VALUE="1" + + read "DEBUG_LOGGING?Enable verbose debug logging? [y/N]: " + DEBUG_LOGGING_ANSWER="${DEBUG_LOGGING:-N}" + + if [[ "$DEBUG_LOGGING_ANSWER" =~ ^[Yy]$ ]]; then + DEBUG_LOGGING_VALUE="1" + else + DEBUG_LOGGING_VALUE="0" + fi + + read "MAX_LOG_SIZE_KB?Max log size before rotation in KB [1024]: " + MAX_LOG_SIZE_KB="${MAX_LOG_SIZE_KB:-1024}" + 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_watchdog "$CHECK_INTERVAL" +else + echo "Watchdog not installed." +fi + +echo +echo "Done." +echo +echo "Reload your shell:" +echo " source ~/.zshrc" +echo +echo "Then use:" +echo " $OFF_ALIAS" +echo " $ON_ALIAS" +echo +echo "If the watchdog was installed, logs are available at:" +echo " ~/Library/Logs/displaydisabler-watchdog.log" +echo diff --git a/scripts/trust_current_external_displays.sh b/scripts/trust_current_external_displays.sh new file mode 100755 index 0000000..107f11e --- /dev/null +++ b/scripts/trust_current_external_displays.sh @@ -0,0 +1,69 @@ +#!/bin/zsh + +set -e + +CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "Config file not found: $CONFIG_FILE" + echo "Run ./scripts/install_smart.sh first." + exit 1 +fi + +escape_regex_name() { + echo "$1" | sed -E 's/[][(){}.^$+*?|\\]/\\&/g' +} + +CURRENT_EXTERNAL_NAMES="$(/usr/sbin/system_profiler SPDisplaysDataType | awk ' + /Displays:/ { in_displays=1; next } + + in_displays && /^[[:space:]]{8}[^[:space:]].*:$/ { + name=$0 + sub(/^[[:space:]]+/, "", name) + sub(/:$/, "", name) + print name + } +' | grep -v "^Color LCD$" | grep -v "^Display$" | grep -v "^Unknown Display$" || true)" + +if [ -z "$CURRENT_EXTERNAL_NAMES" ]; 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 " $CONFIG_FILE" + exit 1 +fi + +CURRENT_REGEX="" + +while IFS= read -r name; do + escaped="$(escape_regex_name "$name")" + + if [ -z "$CURRENT_REGEX" ]; then + CURRENT_REGEX="$escaped" + else + CURRENT_REGEX="$CURRENT_REGEX|$escaped" + fi +done <<< "$CURRENT_EXTERNAL_NAMES" + +EXISTING_REGEX="$(grep '^TRUSTED_EXTERNAL_NAMES=' "$CONFIG_FILE" | sed -E 's/^TRUSTED_EXTERNAL_NAMES="(.*)"$/\1/' || true)" + +if [ -z "$EXISTING_REGEX" ]; then + NEW_REGEX="$CURRENT_REGEX" +else + NEW_REGEX="$EXISTING_REGEX|$CURRENT_REGEX" +fi + +NEW_REGEX="$(echo "$NEW_REGEX" | tr '|' '\n' | awk 'NF && !seen[$0]++' | paste -sd '|' -)" + +cp "$CONFIG_FILE" "$CONFIG_FILE.bak" + +perl -pi -e "s|^TRUSTED_EXTERNAL_NAMES=.*|TRUSTED_EXTERNAL_NAMES=\"$NEW_REGEX\"|" "$CONFIG_FILE" + +echo "Trusted external display names updated:" +echo " $NEW_REGEX" +echo +echo "Backup created:" +echo " $CONFIG_FILE.bak" diff --git a/scripts/uninstall_smart.sh b/scripts/uninstall_smart.sh new file mode 100755 index 0000000..d8dac41 --- /dev/null +++ b/scripts/uninstall_smart.sh @@ -0,0 +1,101 @@ +#!/bin/zsh + +set -e + +ZSHRC="$HOME/.zshrc" +CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" +PLIST_PATH="$HOME/Library/LaunchAgents/com.displaydisabler.watchdog.plist" +WATCHDOG_SCRIPT="$HOME/Scripts/DisplayDisabler-Watchdog" +OLD_PLIST_PATH="$HOME/Library/LaunchAgents/com.displaydisabler.watchdog.plist" +OLD_WATCHDOG_SCRIPT="$HOME/Scripts/DisplayDisabler-Watchdog" +OLD_PLIST_PATH="$HOME/Library/LaunchAgents/com.displaydisabler.auto-enable-builtin.plist" +OLD_WATCHDOG_SCRIPT="$HOME/Scripts/auto_enable_builtin_on_external_disconnect.sh" +TRUST_SCRIPT="$HOME/Scripts/trust_current_external_displays.sh" +LOG_FILE="$HOME/Library/Logs/displaydisabler-watchdog.log" +STATE_FILE="$HOME/Library/Logs/displaydisabler-watchdog-suspicious-count" + +echo +echo "DisplayDisabler Smart Uninstaller" +echo "----------------------------------" +echo + +if [ -f "$PLIST_PATH" ]; then + launchctl bootout "gui/$(id -u)" "$PLIST_PATH" 2>/dev/null || true + rm -f "$PLIST_PATH" + echo "Removed LaunchAgent." +else + echo "No LaunchAgent found." +fi + +if [ -f "$OLD_PLIST_PATH" ]; then + launchctl bootout "gui/$(id -u)" "$OLD_PLIST_PATH" 2>/dev/null || true + rm -f "$OLD_PLIST_PATH" + echo "Removed old LaunchAgent." +fi + +if [ -f "$WATCHDOG_SCRIPT" ]; then + rm -f "$WATCHDOG_SCRIPT" + echo "Removed watchdog script." +else + echo "No watchdog script found." +fi + +if [ -f "$OLD_WATCHDOG_SCRIPT" ]; then + rm -f "$OLD_WATCHDOG_SCRIPT" + echo "Removed old watchdog script." +fi + +if [ -f "$TRUST_SCRIPT" ]; then + rm -f "$TRUST_SCRIPT" + echo "Removed trust-displays script." +else + echo "No trust-displays script found." +fi + +if [ -f "$CONFIG_FILE" ]; then + rm -f "$CONFIG_FILE" + echo "Removed watchdog config." +else + echo "No watchdog config found." +fi + +if [ -f "$STATE_FILE" ]; then + rm -f "$STATE_FILE" + echo "Removed watchdog state file." +fi + +if [ -f "$ZSHRC" ]; then + cp "$ZSHRC" "$ZSHRC.displaydisabler-uninstall.bak" + + sed -i.tmp '/display_disable disable/d' "$ZSHRC" + sed -i.tmp '/display_disable enable/d' "$ZSHRC" + sed -i.tmp '/trust_current_external_displays.sh/d' "$ZSHRC" + sed -i.tmp '/DisplayDisabler-Watchdog/d' "$ZSHRC" + rm -f "$ZSHRC.tmp" + + echo "Removed display_disable aliases from $ZSHRC." + echo "Backup created: $ZSHRC.displaydisabler-uninstall.bak" +fi + +echo +read "REMOVE_LOG?Remove watchdog log file? [y/N]: " +REMOVE_LOG="${REMOVE_LOG:-N}" + +if [[ "$REMOVE_LOG" =~ ^[Yy]$ ]]; then + rm -f "$LOG_FILE" + echo "Removed watchdog log file." +fi + +BINARY_PATH="/usr/local/bin/display_disable" + +if [ -f "$BINARY_PATH" ]; then + sudo rm "$BINARY_PATH" + echo "Removed display_disable binary:" + echo " $BINARY_PATH" +else + echo "No display_disable binary found." +fi + +echo +echo "Done." +echo From 22f5e32cce647d53e0d540f190ae212de2dfd44f Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Sun, 7 Jun 2026 15:46:17 +0200 Subject: [PATCH 02/10] Add smart installer and safety watchdog --- README.md | 252 +++++++++--------------------------------------------- 1 file changed, 40 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index 42dd56f..30a888b 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,19 @@ -# DisplayDisabler - -A small macOS command-line utility to disable and re-enable displays by display ID. - -This fork adds a smart installer, configurable shell aliases, an optional safety watchdog, and a helper to trust additional external displays. - -The safety watchdog is useful when using a MacBook with an external monitor: if the built-in display is disabled and the external display is disconnected, the watchdog can automatically re-enable the built-in display. - -> Note: this project uses private macOS display APIs. Future macOS updates may change or break this behavior. - --- -## Features +## Smart installer, aliases and safety watchdog -- Disable a display by ID -- Enable a display by ID -- List active and online displays -- Automatically detect the built-in display ID during setup -- Create convenient shell aliases such as `s-off` and `s-on` -- Optionally install a LaunchAgent watchdog -- Re-enable the built-in display when the external display disappears or becomes a generic fallback entry -- Trust additional external displays later with `trust-displays` -- Fully uninstall the smart setup and `/usr/local/bin/display_disable` +This fork adds an optional smart installer on top of the original `display_disable` binary. ---- +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` and `s-on` +- install an optional safety watchdog +- register trusted external displays +- fully uninstall the smart setup and the `display_disable` binary -## Install +### Smart install Run: @@ -32,14 +21,11 @@ Run: ./scripts/install_smart.sh ``` -The installer can: +The installer detects the built-in display ID using: -- install `display_disable` if it is missing -- detect the built-in display ID automatically -- create convenient shell aliases -- detect currently connected external display names -- save trusted external display names in a config file -- install the optional LaunchAgent safety watchdog +```bash +display_disable list +``` Default aliases: @@ -61,64 +47,11 @@ After installation, reload your shell: source ~/.zshrc ``` ---- - -## Manual usage - -List displays: - -```bash -display_disable list -``` - -Example output: - -```text -=== Active Displays === - -Display 0: - ID: 0x3 (3) - Built-in: NO - Main: YES - Resolution: 2560 x 1440 - Active: YES - -Display 1: - ID: 0x1 (1) - Built-in: YES - Main: NO - Resolution: 1512 x 982 - Active: YES - -=== Online Displays === -Online display count: 2 -``` - -Disable the built-in display: - -```bash -display_disable disable 1 -``` - -Re-enable the built-in display: - -```bash -display_disable enable 1 -``` - -If you run `disable` while the display is already disabled, macOS may return: +### Safety watchdog -```text -Error: Failed to commit display configuration (error 1001) -``` +The optional watchdog is designed to avoid being left without an active built-in display when the external display is disconnected. -This usually means there was no display configuration change to commit. - ---- - -## Safety watchdog - -The optional watchdog is installed as: +It is installed as: ```text ~/Scripts/DisplayDisabler-Watchdog @@ -130,37 +63,27 @@ and runs through this LaunchAgent: ~/Library/LaunchAgents/com.displaydisabler.watchdog.plist ``` -The LaunchAgent label is: +LaunchAgent label: ```text com.displaydisabler.watchdog ``` -The watchdog is designed to prevent this situation: - -1. the built-in display is disabled -2. the external monitor is disconnected -3. macOS still reports a stale or generic external display entry -4. the user is left without the built-in display enabled - -If the built-in display is disabled and no trusted external display is detected, it waits for a configurable number of unsafe confirmations and then runs: +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 ``` -By default, the smart installer uses: +Default behavior: - check interval: `10` seconds - unsafe confirmations: `2` +- logging disabled by default -With the default configuration, the built-in display may be re-enabled after about 10-20 seconds. - ---- - -## Watchdog configuration +### Configuration -The smart installer creates this file: +The installer creates: ```bash ~/.displaydisabler-watchdog.conf @@ -178,17 +101,11 @@ DEBUG_LOGGING="0" MAX_LOG_SIZE_KB="1024" ``` -`TRUSTED_EXTERNAL_NAMES` is an extended regular expression of external display names that are considered safe while the built-in display is disabled. +`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 names that may appear after a disconnect event. +`SUSPICIOUS_DISPLAY_NAMES` contains generic or fallback display names that may appear after a disconnect event. -`MAX_LOG_SIZE_KB` rotates the log when it reaches the configured size. One backup is kept as `.1`. - -`DEBUG_LOGGING` controls whether full command output from `display_disable` and `system_profiler` is written to the log. - ---- - -## Using multiple external monitors +### Using multiple external monitors If you use different monitors at home, at work, or through different docks, connect the new monitor and run: @@ -196,25 +113,21 @@ If you use different monitors at home, at work, or through different docks, conn trust-displays ``` -This adds the currently connected stable external display names to `TRUSTED_EXTERNAL_NAMES`. - -Example: +This adds the currently connected stable external display names to: ```bash -TRUSTED_EXTERNAL_NAMES="Q27G4|DELL U2720Q|Studio Display" +~/.displaydisabler-watchdog.conf ``` -Displays named `Display` or `Unknown Display` are not added automatically because those names are treated as suspicious fallback names. - -If your monitor appears only as `Display` or `Unknown Display`, edit the config manually: +Example: ```bash -nano ~/.displaydisabler-watchdog.conf +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 +### Logs and retention Logging is disabled by default. @@ -224,7 +137,9 @@ If lightweight logging is enabled, logs are written to: ~/Library/Logs/displaydisabler-watchdog.log ``` -The watchdog rotates the log when it reaches `MAX_LOG_SIZE_KB`. By default: +The watchdog rotates the log when it reaches `MAX_LOG_SIZE_KB`. + +Default: ```bash MAX_LOG_SIZE_KB="1024" @@ -236,7 +151,7 @@ One rotated backup is kept: ~/Library/Logs/displaydisabler-watchdog.log.1 ``` -`DEBUG_LOGGING="0"` keeps the log lightweight and avoids writing full `system_profiler` output on every check. +`DEBUG_LOGGING="0"` keeps the log lightweight. Set: @@ -244,17 +159,9 @@ Set: DEBUG_LOGGING="1" ``` -only when troubleshooting, because it writes much more data. - -Inspect the log: - -```bash -tail -f ~/Library/Logs/displaydisabler-watchdog.log -``` - ---- +only when troubleshooting, because it writes full command output from `display_disable` and `system_profiler`. -## Uninstall +### Uninstall Run: @@ -275,45 +182,7 @@ The uninstaller removes: - optionally the watchdog log file - `/usr/local/bin/display_disable` ---- - -## LaunchAgent management - -Check whether the watchdog is loaded: - -```bash -launchctl list | grep displaydisabler -``` - -Inspect the watchdog: - -```bash -launchctl print gui/$(id -u)/com.displaydisabler.watchdog -``` - -Restart the watchdog manually: - -```bash -launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.displaydisabler.watchdog.plist 2>/dev/null -launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.displaydisabler.watchdog.plist -launchctl enable gui/$(id -u)/com.displaydisabler.watchdog -launchctl kickstart -k gui/$(id -u)/com.displaydisabler.watchdog -``` - ---- - -## Recommended workflow - -1. Connect your external monitor. -2. Run `s-off`. -3. Use the external monitor normally. -4. If the external monitor is disconnected, the watchdog should re-enable the built-in display automatically. -5. Manually re-enable the built-in display anytime with `s-on`. -6. When using a new monitor, run `trust-displays`. - ---- - -## Files added by this fork +### Files added by this fork ```text scripts/ @@ -332,44 +201,3 @@ User-level files created by the smart installer: ~/Library/LaunchAgents/com.displaydisabler.watchdog.plist ~/Library/Logs/displaydisabler-watchdog.log ``` - ---- - -## Limitations - -The safety watchdog relies on display information reported by macOS, especially: - -```bash -display_disable list -``` - -and: - -```bash -system_profiler SPDisplaysDataType -``` - -This means: - -- external display names may vary depending on dock, cable, adapter, or macOS version -- some docks may expose generic names such as `Display` -- some displays may briefly appear as stale or fallback entries after disconnecting -- users may need to edit `~/.displaydisabler-watchdog.conf` manually -- the watchdog is a safety mechanism, not a guaranteed universal display-detection system - -The watchdog is intentionally conservative and waits for multiple unsafe checks before re-enabling the built-in display. - ---- - -## Disclaimer - -This project uses private macOS display APIs. Use it at your own risk. - -Behavior may vary depending on: - -- macOS version -- Apple Silicon vs Intel Mac -- external monitor model -- dock or adapter -- cable type -- display firmware From fa175a98fa3b9cce4ce0643a24a835f11826af79 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 09:26:38 +0200 Subject: [PATCH 03/10] feat: add app-first smart display safety --- .github/workflows/ci.yml | 11 + AppDelegate.m | 539 +++++++++++++++++- CHANGELOG.md | 5 + Makefile | 15 +- README.md | 109 +++- build_icon.m | 82 ++- docs/architecture.md | 7 + ...o_enable_builtin_on_external_disconnect.sh | 91 ++- scripts/displaydisabler_smart.sh | 332 +++++++++++ scripts/install_smart.sh | 497 ++++++++++------ scripts/lib/displaydisabler_smart_lib.sh | 170 ++++++ scripts/safe_disable_builtin.sh | 11 + scripts/trust_current_external_displays.sh | 66 +-- scripts/uninstall_smart.sh | 269 ++++++--- tests/smoke/README.md | 2 + tests/smoke/fixtures/display_disable_list.txt | 21 + .../fixtures/system_profiler_displays.txt | 16 + tests/smoke/test_smart_parsers.sh | 55 ++ 18 files changed, 1914 insertions(+), 384 deletions(-) create mode 100755 scripts/displaydisabler_smart.sh create mode 100644 scripts/lib/displaydisabler_smart_lib.sh create mode 100755 scripts/safe_disable_builtin.sh create mode 100644 tests/smoke/fixtures/display_disable_list.txt create mode 100644 tests/smoke/fixtures/system_profiler_displays.txt create mode 100755 tests/smoke/test_smart_parsers.sh 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..429e56b 100644 --- a/AppDelegate.m +++ b/AppDelegate.m @@ -16,6 +16,8 @@ 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"; // Notification identifier used for auto-manage events so consecutive // disable/re-enable banners replace each other instead of stacking. @@ -35,10 +37,15 @@ 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; + @interface AppDelegate () @property (nonatomic, strong) NSStatusItem *statusItem; @property (nonatomic, strong) DisplayManager *displayManager; @property (nonatomic) BOOL notificationAuthRequested; +@property (nonatomic) NSInteger smartRecoveryToken; @end @implementation AppDelegate @@ -75,11 +82,13 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { [strongSelf rebuildMenu]; [strongSelf performAutoDisableIfNeeded]; [strongSelf performAutoReenableIfNeeded]; + [strongSelf scheduleSmartRecoveryEvaluation]; }]; // 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]; } - (void)applicationWillTerminate:(NSNotification *)notification { @@ -94,6 +103,8 @@ - (void)registerDefaults { kShowNotifications: @YES, kConfirmDisable: @YES, kShowResolutions: @YES, + kSmartRecovery: @YES, + kTrustedDisplays: @[], }]; } @@ -192,6 +203,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 +282,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 +357,17 @@ - (void)addActiveDisplayControls:(DDDisplayInfo *)display toMenu:(NSMenu *)menu : @selector(installCrispHiDPI:)) displayID:display.displayID]; + if (!display.isBuiltIn) { + [self addActionToMenu:menu + title:([self isTrustedExternalDisplay:display] + ? @"Forget Trusted Display" + : @"Trust This Display") + action:([self isTrustedExternalDisplay:display] + ? @selector(forgetTrustedDisplay:) + : @selector(trustDisplay:)) + displayID:display.displayID]; + } + [self addActionToMenu:menu title:@"Disable" action:@selector(disableDisplay:) displayID:display.displayID]; } @@ -446,9 +486,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" @@ -603,6 +653,58 @@ - (NSMenu *)buildModesSubmenuForDisplay:(CGDirectDisplayID)displayID return submenu; } +// ── Trusted displays submenu ──────────────────────────────────────────────── + +- (NSMenu *)buildTrustedDisplaysSubmenu { + NSMenu *submenu = [[NSMenu alloc] init]; + submenu.autoenablesItems = NO; + + NSArray *records = [self trustedDisplayRecords]; + NSArray *external = [self externalDisplaysActiveOnly:NO]; + NSUInteger trustedConnected = 0; + for (DDDisplayInfo *d in external) { + if ([self isTrustedExternalDisplay:d]) 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 = ([self stableExternalDisplaysActiveOnly:NO].count > 0); + [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 +739,160 @@ - (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; +} + +- (BOOL)isSuspiciousExternalName:(NSString *)name { + return (name.length == 0 || + [name isEqualToString:@"Display"] || + [name isEqualToString:@"Unknown Display"]); +} + +- (NSArray *)externalDisplaysActiveOnly:(BOOL)activeOnly { + NSMutableArray *result = [NSMutableArray array]; + for (DDDisplayInfo *d in [self.displayManager allDisplays]) { + if (d.isBuiltIn) continue; + if (activeOnly && !d.isActive) continue; + [result addObject:d]; + } + return result; +} + +- (NSArray *)stableExternalDisplaysActiveOnly:(BOOL)activeOnly { + NSMutableArray *result = [NSMutableArray array]; + for (DDDisplayInfo *d in [self externalDisplaysActiveOnly:activeOnly]) { + if ([self isSuspiciousExternalName:d.name]) continue; + [result addObject:d]; + } + return result; +} + +- (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; +} + +- (void)setTrustedDisplayRecords:(NSArray *)records { + [[NSUserDefaults standardUserDefaults] setObject:records forKey:kTrustedDisplays]; +} + +- (BOOL)isTrustedExternalDisplay:(DDDisplayInfo *)display { + if (!display || display.isBuiltIn) return NO; + NSString *fingerprint = [self fingerprintForDisplay:display]; + for (NSDictionary *record in [self trustedDisplayRecords]) { + if ([record[@"fingerprint"] isEqualToString:fingerprint]) return YES; + } + return NO; +} + +- (NSArray *)trustedExternalDisplaysActiveOnly:(BOOL)activeOnly { + NSMutableArray *result = [NSMutableArray array]; + for (DDDisplayInfo *d in [self externalDisplaysActiveOnly:activeOnly]) { + if ([self isTrustedExternalDisplay:d]) [result addObject:d]; + } + return result; +} + +- (BOOL)hasTrustedActiveExternalDisplay { + return [self trustedExternalDisplaysActiveOnly:YES].count > 0; +} + +- (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 +919,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; @@ -686,6 +947,7 @@ - (void)disableDisplay:(NSMenuItem *)sender { if ([self.displayManager disableDisplay:did error:&error]) { [self postNotification:@"Display Disabled" body:[NSString stringWithFormat:@"%@ has been disabled.", name]]; + [self scheduleSmartRecoveryEvaluation]; } else { NSLog(@"DisplayDisabler: Failed to disable 0x%X: %@", did, error); [self postNotification:@"Disable Failed" @@ -700,6 +962,7 @@ - (void)enableDisplay:(NSMenuItem *)sender { if ([self.displayManager enableDisplay:did error:&error]) { [self postNotification:@"Display Enabled" body:[NSString stringWithFormat:@"%@ has been enabled.", name]]; + [self rebuildMenu]; } else { NSLog(@"DisplayDisabler: Failed to enable 0x%X: %@", did, error); [self postNotification:@"Enable Failed" @@ -764,6 +1027,243 @@ - (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 *external = [self externalDisplaysActiveOnly:NO]; + NSArray *trustedActive = [self trustedExternalDisplaysActiveOnly:YES]; + NSArray *records = [self trustedDisplayRecords]; + + 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 *stableExternal = [self stableExternalDisplaysActiveOnly:NO]; + NSArray *trustedActive = [self trustedExternalDisplaysActiveOnly:YES]; + NSArray *records = [self trustedDisplayRecords]; + 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]; @@ -874,6 +1374,9 @@ - (void)switchToggled:(NSSwitch *)sender { if ([key isEqualToString:kAutoManage] && [self pref:kAutoManage]) { [self performAutoDisableIfNeeded]; } + if ([key isEqualToString:kSmartRecovery] && [self pref:kSmartRecovery]) { + [self scheduleSmartRecoveryEvaluation]; + } } - (void)loginSwitchToggled:(NSSwitch *)sender { @@ -924,12 +1427,42 @@ - (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; + + DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; + if (!builtIn || builtIn.isActive) return; + if ([self hasTrustedActiveExternalDisplay]) return; + + NSError *error = nil; + if ([self.displayManager enableDisplay:builtIn.displayID error:&error]) { + [self postNotification:@"Built-in Display Recovered" + body:@"No trusted external monitor is active." + identifier:kAutoManageNotifID]; + [self rebuildMenu]; + } else { + NSLog(@"DisplayDisabler: Smart recovery failed: %@", error); + [self postNotification:@"Recovery Failed" + body:error.localizedDescription]; + } +} + - (void)performAutoDisableIfNeeded { if (![self pref:kAutoManage]) 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]) { @@ -947,7 +1480,7 @@ - (void)performAutoReenableIfNeeded { DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; if (!builtIn) return; if (builtIn.isActive) return; - if ([self.displayManager hasExternalDisplay]) return; + if ([self hasTrustedActiveExternalDisplay]) return; NSError *error = nil; if ([self.displayManager enableDisplay:builtIn.displayID error:&error]) { diff --git a/CHANGELOG.md b/CHANGELOG.md index ecca99c..2ae9cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,8 @@ - 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. diff --git a/Makefile b/Makefile index 3bbe8d0..a259101 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,23 @@ $(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 + zsh tests/smoke/test_smart_parsers.sh + zsh scripts/install_smart.sh --dry-run --no-download --yes --no-watchdog >/dev/null + zsh scripts/uninstall_smart.sh --dry-run --yes --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 30a888b..a3933a5 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,41 @@ --- +## Menu-bar app smart safety + +`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. + +The app now includes: + +- 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 +- System Status and Doctor menu actions for a lightweight, copyable runtime + report +- Launch at Login and trusted-display auto-manage from the app UI + +The app recovery path is event-driven, not polling-based. The short delay is +only a confirmation window after macOS reports a display topology change. + ## Smart installer, aliases and safety watchdog -This fork adds an optional smart installer on top of the original `display_disable` binary. +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` and `s-on` +- 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 ### Smart install @@ -21,6 +46,16 @@ Run: ./scripts/install_smart.sh ``` +Useful installer options: + +```bash +./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 @@ -33,13 +68,15 @@ Default aliases: s-off s-on trust-displays +dd-status ``` Where: -- `s-off` disables the built-in display +- `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: @@ -47,10 +84,43 @@ After installation, reload your shell: source ~/.zshrc ``` -### Safety watchdog +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 +``` + +`status` reports the binary path, config, detected built-in display, active +display count, trusted external display count and watchdog LaunchAgent state. + +`doctor` runs lightweight setup checks and exits non-zero only for critical +failures such as a missing `display_disable` binary or a failing +`display_disable list` command. + +### CLI safety watchdog The optional watchdog is designed to avoid being left without an active built-in display when the external display is disconnected. +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. + It is installed as: ```text @@ -169,16 +239,29 @@ Run: ./scripts/uninstall_smart.sh ``` +Useful uninstaller options: + +```bash +./scripts/uninstall_smart.sh --dry-run +./scripts/uninstall_smart.sh --yes +./scripts/uninstall_smart.sh --keep-binary +./scripts/uninstall_smart.sh --keep-config +./scripts/uninstall_smart.sh --keep-logs +``` + The uninstaller removes: - 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 `~/.zshrc` +- aliases from the marked block in `~/.zshrc` - optionally the watchdog log file - `/usr/local/bin/display_disable` @@ -189,15 +272,29 @@ scripts/ ├── install_smart.sh ├── uninstall_smart.sh ├── auto_enable_builtin_on_external_disconnect.sh -└── trust_current_external_displays.sh +├── displaydisabler_smart.sh +├── safe_disable_builtin.sh +├── trust_current_external_displays.sh +└── lib/displaydisabler_smart_lib.sh ``` User-level files created by the smart installer: ```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 index cb6aeab..9033be0 100755 --- a/scripts/auto_enable_builtin_on_external_disconnect.sh +++ b/scripts/auto_enable_builtin_on_external_disconnect.sh @@ -1,54 +1,59 @@ #!/bin/zsh -DISPLAY_DISABLE="/usr/local/bin/display_disable" -CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" -LOG_FILE="$HOME/Library/Logs/displaydisabler-watchdog.log" -STATE_FILE="$HOME/Library/Logs/displaydisabler-watchdog-suspicious-count" - -BUILTIN_ID="1" -TRUSTED_EXTERNAL_NAMES="" -SUSPICIOUS_DISPLAY_NAMES="Display|Unknown Display" -CHECK_CONFIRMATIONS="2" -ENABLE_LOGGING="0" -DEBUG_LOGGING="0" -MAX_LOG_SIZE_KB="1024" - -if [ -f "$CONFIG_FILE" ]; then - source "$CONFIG_FILE" +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 "$LOG_FILE" ]; then + if [ ! -f "$DD_LOG_FILE" ]; then return fi - LOG_SIZE_KB="$(du -k "$LOG_FILE" 2>/dev/null | awk '{print $1}')" + 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 "$LOG_FILE" "$LOG_FILE.1" 2>/dev/null || true - touch "$LOG_FILE" 2>/dev/null || true + 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" >> "$LOG_FILE" + 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" >> "$LOG_FILE" + 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 } @@ -65,17 +70,11 @@ debug_log "$DD_OUTPUT" if [ "$DD_STATUS" -ne 0 ]; then log "display_disable list failed, trying to enable built-in display $BUILTIN_ID" - "$DISPLAY_DISABLE" enable "$BUILTIN_ID" >> "$LOG_FILE" 2>&1 + enable_builtin_display exit 0 fi -ACTIVE_SECTION="$(echo "$DD_OUTPUT" | awk ' - /=== Active Displays ===/ { flag=1; next } - /=== Online Displays ===/ { flag=0 } - flag -')" - -DD_BUILTIN_ACTIVE_COUNT="$(echo "$ACTIVE_SECTION" | grep -c "Built-in: YES")" +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=$? @@ -83,29 +82,19 @@ SP_STATUS=$? log "system_profiler status=$SP_STATUS" debug_log "$SP_OUTPUT" -SP_DISPLAY_NAMES="$(echo "$SP_OUTPUT" | awk ' - /Displays:/ { in_displays=1; next } - - in_displays && /^[[:space:]]{8}[^[:space:]].*:$/ { - name=$0 - sub(/^[[:space:]]+/, "", name) - sub(/:$/, "", name) - print name - } -')" - -SP_EXTERNAL_NAMES="$(echo "$SP_DISPLAY_NAMES" | grep -v "^Color LCD$" || true)" -SP_EXTERNAL_COUNT="$(echo "$SP_EXTERNAL_NAMES" | sed '/^$/d' | wc -l | tr -d ' ')" +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="$(echo "$SP_EXTERNAL_NAMES" | grep -E -c "^(${TRUSTED_EXTERNAL_NAMES})$" || true)" + TRUSTED_COUNT="$(dd_match_count "$SP_EXTERNAL_NAMES" "$TRUSTED_EXTERNAL_NAMES")" fi if [ -n "$SUSPICIOUS_DISPLAY_NAMES" ]; then - SUSPICIOUS_NAME_COUNT="$(echo "$SP_EXTERNAL_NAMES" | grep -E -c "^(${SUSPICIOUS_DISPLAY_NAMES})$" || true)" + SUSPICIOUS_NAME_COUNT="$(dd_match_count "$SP_EXTERNAL_NAMES" "$SUSPICIOUS_DISPLAY_NAMES")" fi log "display_names=$(echo "$SP_DISPLAY_NAMES" | tr '\n' ',' )" @@ -113,7 +102,7 @@ log "external_count=$SP_EXTERNAL_COUNT trusted_count=$TRUSTED_COUNT suspicious_n # If the built-in display is already active, reset state and do nothing. if [ "$DD_BUILTIN_ACTIVE_COUNT" -gt 0 ]; then - echo 0 > "$STATE_FILE" + echo 0 > "$DD_STATE_FILE" log "built-in already active, nothing to do" exit 0 fi @@ -121,7 +110,7 @@ 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 > "$STATE_FILE" + echo 0 > "$DD_STATE_FILE" log "built-in inactive, trusted external display detected, nothing to do" exit 0 fi @@ -144,19 +133,19 @@ fi if [ "$SHOULD_ENABLE" = "1" ]; then CONFIRMATION_COUNT=0 - if [ -f "$STATE_FILE" ]; then - CONFIRMATION_COUNT="$(cat "$STATE_FILE" 2>/dev/null)" + if [ -f "$DD_STATE_FILE" ]; then + CONFIRMATION_COUNT="$(cat "$DD_STATE_FILE" 2>/dev/null)" fi CONFIRMATION_COUNT=$((CONFIRMATION_COUNT + 1)) - echo "$CONFIRMATION_COUNT" > "$STATE_FILE" + 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" - "$DISPLAY_DISABLE" enable "$BUILTIN_ID" >> "$LOG_FILE" 2>&1 - echo 0 > "$STATE_FILE" + enable_builtin_display + echo 0 > "$DD_STATE_FILE" exit 0 fi diff --git a/scripts/displaydisabler_smart.sh b/scripts/displaydisabler_smart.sh new file mode 100755 index 0000000..1acc36c --- /dev/null +++ b/scripts/displaydisabler_smart.sh @@ -0,0 +1,332 @@ +#!/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" +} + +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" + + 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 "----------------------------" + 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 "active displays: $active_count" + echo "built-in active count: $builtin_active_count" + 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 + + echo "DisplayDisabler smart doctor" + echo "----------------------------" + + if [ -x "$DISPLAY_DISABLE" ]; then + echo "ok: display_disable is executable" + else + echo "fail: display_disable is missing or not executable at $DISPLAY_DISABLE" + failures=$((failures + 1)) + fi + + if [ -f "$DD_CONFIG_FILE" ]; then + echo "ok: config exists" + else + echo "warn: config is missing at $DD_CONFIG_FILE" + fi + + if [ -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" + else + echo "fail: display_disable list failed with status $dd_status" + failures=$((failures + 1)) + 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 + echo "warn: watchdog plist is not installed" + 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 [ "$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 index b6fdc53..86ad53a 100755 --- a/scripts/install_smart.sh +++ b/scripts/install_smart.sh @@ -10,26 +10,154 @@ ZSHRC="$HOME/.zshrc" SCRIPTS_DIR="$HOME/Scripts" LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" -WATCHDOG_SOURCE="$(cd "$(dirname "$0")" && pwd)/auto_enable_builtin_on_external_disconnect.sh" +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_SOURCE="$(cd "$(dirname "$0")" && pwd)/trust_current_external_displays.sh" TRUST_SCRIPT_TARGET="$SCRIPTS_DIR/trust_current_external_displays.sh" CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" -PLIST_PATH="$LAUNCH_AGENTS_DIR/com.displaydisabler.watchdog.plist" +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" + +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 +run_or_echo() { + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] $*" + else + "$@" + fi +} + +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\"" +} + +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_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..." @@ -53,109 +181,45 @@ install_binary_if_missing() { sudo mv "$TMP_FILE" "$INSTALL_PATH" } -show_detected_displays() { - echo - echo "Detected displays from display_disable:" - echo - "$INSTALL_PATH" list - echo - - echo "Detected displays from system_profiler:" - echo - /usr/sbin/system_profiler SPDisplaysDataType | awk ' - /Displays:/ { in_displays=1; print; next } - in_displays { print } - ' - echo -} - -detect_builtin_display_id() { - local output="$1" - - local detected_id - detected_id="$(echo "$output" | awk ' - /Display [0-9]+:/ { - id="" - } - - /ID:/ { - line=$0 - sub(/^.*\(/, "", line) - sub(/\).*$/, "", line) - id=line - } - - /Built-in: YES/ { - print id - exit - } - ')" - - echo "$detected_id" -} - -detect_system_profiler_display_names() { - /usr/sbin/system_profiler SPDisplaysDataType | awk ' - /Displays:/ { in_displays=1; next } - - in_displays && /^[[:space:]]{8}[^[:space:]].*:$/ { - name=$0 - sub(/^[[:space:]]+/, "", name) - sub(/:$/, "", name) - print name - } - ' -} +collect_display_disable_output() { + if [ ! -x "$INSTALL_PATH" ]; then + DD_OUTPUT="" + return + fi -escape_regex_name() { - echo "$1" | sed -E 's/[][(){}.^$+*?|\\]/\\&/g' + DD_OUTPUT="$("$INSTALL_PATH" list 2>/dev/null || true)" } -build_trusted_external_names_regex() { - local display_names="$1" - - local trusted="" - local line - local escaped - - while IFS= read -r line; do - if [ -z "$line" ]; then - continue - fi +collect_system_profiler_names() { + local sp_output="" - # Color LCD is Apple's usual built-in display name. - if [ "$line" = "Color LCD" ]; then - continue - fi - - # Generic names are treated as suspicious, not trusted. - if [ "$line" = "Display" ] || [ "$line" = "Unknown Display" ]; then - continue - fi - - escaped="$(escape_regex_name "$line")" - - if [ -z "$trusted" ]; then - trusted="$escaped" - else - trusted="$trusted|$escaped" - fi - done <<< "$display_names" + if [ ! -x /usr/sbin/system_profiler ]; then + SP_DISPLAY_NAMES="" + return + fi - echo "$trusted" + sp_output="$(/usr/sbin/system_profiler SPDisplaysDataType 2>/dev/null || true)" + SP_DISPLAY_NAMES="$(echo "$sp_output" | dd_display_names_from_system_profiler_output)" } -add_or_replace_alias() { - local alias_name="$1" - local alias_command="$2" - - touch "$ZSHRC" - cp "$ZSHRC" "$ZSHRC.displaydisabler.bak" - - sed -i.tmp "/^alias ${alias_name}=/d" "$ZSHRC" - rm -f "$ZSHRC.tmp" +show_detected_displays() { + if [ -x "$INSTALL_PATH" ]; then + echo + echo "Detected displays from display_disable:" + echo + "$INSTALL_PATH" list || true + echo + fi - echo "alias ${alias_name}=\"${alias_command}\"" >> "$ZSHRC" + 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() { @@ -166,6 +230,12 @@ write_watchdog_config() { local debug_logging="$5" local max_log_size_kb="$6" + 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" @@ -221,7 +365,7 @@ install_watchdog() { Label - com.displaydisabler.watchdog + $WATCHDOG_LABEL ProgramArguments @@ -237,10 +381,14 @@ install_watchdog() { 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)/com.displaydisabler.watchdog" - launchctl kickstart -k "gui/$(id -u)/com.displaydisabler.watchdog" + launchctl enable "gui/$(id -u)/$WATCHDOG_LABEL" + launchctl kickstart -k "gui/$(id -u)/$WATCHDOG_LABEL" echo echo "Watchdog installed:" @@ -251,6 +399,11 @@ 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" @@ -266,20 +419,23 @@ cleanup_old_watchdog_names() { } cleanup_old_watchdog_names - install_binary_if_missing +collect_display_disable_output +collect_system_profiler_names -DD_OUTPUT="$($INSTALL_PATH list 2>/dev/null)" -SP_DISPLAY_NAMES="$(detect_system_profiler_display_names)" - -show_detected_displays +if [ "$ASSUME_YES" != "1" ]; then + show_detected_displays +fi -BUILTIN_ID="$(detect_builtin_display_id "$DD_OUTPUT")" +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 - read "BUILTIN_ID?Enter built-in display ID manually: " + prompt_default BUILTIN_ID "Enter built-in display ID manually" "" fi if [ -z "$BUILTIN_ID" ]; then @@ -287,10 +443,16 @@ if [ -z "$BUILTIN_ID" ]; then exit 1 fi -TRUSTED_EXTERNAL_NAMES="$(build_trusted_external_names_regex "$SP_DISPLAY_NAMES")" +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 @@ -300,70 +462,65 @@ else 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" -read "OFF_ALIAS?Alias to disable built-in display [s-off]: " -OFF_ALIAS="${OFF_ALIAS:-s-off}" - -read "ON_ALIAS?Alias to enable built-in display [s-on]: " -ON_ALIAS="${ON_ALIAS:-s-on}" - -install_trust_script - -read "TRUST_ALIAS?Alias to trust currently connected external displays [trust-displays]: " -TRUST_ALIAS="${TRUST_ALIAS:-trust-displays}" - -add_or_replace_alias "$OFF_ALIAS" "$INSTALL_PATH disable $BUILTIN_ID" -add_or_replace_alias "$ON_ALIAS" "$INSTALL_PATH enable $BUILTIN_ID" -add_or_replace_alias "$TRUST_ALIAS" "$TRUST_SCRIPT_TARGET" - -echo -echo "Aliases added to $ZSHRC:" -echo " $OFF_ALIAS -> $INSTALL_PATH disable $BUILTIN_ID" -echo " $ON_ALIAS -> $INSTALL_PATH enable $BUILTIN_ID" -echo " $TRUST_ALIAS -> $TRUST_SCRIPT_TARGET" - -echo -read "INSTALL_WATCHDOG?Install safety watchdog to re-enable built-in display when external display disconnects? [Y/n]: " -INSTALL_WATCHDOG="${INSTALL_WATCHDOG:-Y}" - -if [[ "$INSTALL_WATCHDOG" =~ ^[Yy]$ ]]; then - read "CHECK_INTERVAL?Check interval in seconds [10]: " - CHECK_INTERVAL="${CHECK_INTERVAL:-10}" - - read "CHECK_CONFIRMATIONS?Unsafe checks before re-enabling built-in display [2]: " - CHECK_CONFIRMATIONS="${CHECK_CONFIRMATIONS:-2}" - - read "ENABLE_LOGGING?Enable lightweight watchdog logging? [y/N]: " - ENABLE_LOGGING_ANSWER="${ENABLE_LOGGING:-N}" +prompt_default CHECK_CONFIRMATIONS "Unsafe checks before re-enabling built-in display" "$CHECK_CONFIRMATIONS" +validate_positive_int "$CHECK_CONFIRMATIONS" "Unsafe checks" - if [[ "$ENABLE_LOGGING_ANSWER" =~ ^[Yy]$ ]]; then - ENABLE_LOGGING_VALUE="1" - - read "DEBUG_LOGGING?Enable verbose debug logging? [y/N]: " - DEBUG_LOGGING_ANSWER="${DEBUG_LOGGING:-N}" - - if [[ "$DEBUG_LOGGING_ANSWER" =~ ^[Yy]$ ]]; then - DEBUG_LOGGING_VALUE="1" - else - DEBUG_LOGGING_VALUE="0" - fi +if [ "$ENABLE_LOGGING" = "1" ]; then + ENABLE_LOGGING_DEFAULT="Y" +else + ENABLE_LOGGING_DEFAULT="N" +fi - read "MAX_LOG_SIZE_KB?Max log size before rotation in KB [1024]: " - MAX_LOG_SIZE_KB="${MAX_LOG_SIZE_KB:-1024}" +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 - ENABLE_LOGGING_VALUE="0" DEBUG_LOGGING_VALUE="0" - MAX_LOG_SIZE_KB="1024" 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_helpers +write_alias_block "$OFF_ALIAS" "$ON_ALIAS" "$TRUST_ALIAS" "$STATUS_ALIAS" - write_watchdog_config "$BUILTIN_ID" "$TRUSTED_EXTERNAL_NAMES" "$CHECK_CONFIRMATIONS" "$ENABLE_LOGGING_VALUE" "$DEBUG_LOGGING_VALUE" "$MAX_LOG_SIZE_KB" - install_watchdog "$CHECK_INTERVAL" +if [ "$NO_WATCHDOG" = "1" ]; then + echo + echo "Watchdog skipped because --no-watchdog was set." else - echo "Watchdog not installed." + 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 "Done." +echo "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" @@ -371,7 +528,5 @@ echo echo "Then use:" echo " $OFF_ALIAS" echo " $ON_ALIAS" -echo -echo "If the watchdog was installed, logs are available at:" -echo " ~/Library/Logs/displaydisabler-watchdog.log" +echo " $STATUS_ALIAS" echo diff --git a/scripts/lib/displaydisabler_smart_lib.sh b/scripts/lib/displaydisabler_smart_lib.sh new file mode 100644 index 0000000..b63a7f7 --- /dev/null +++ b/scripts/lib/displaydisabler_smart_lib.sh @@ -0,0 +1,170 @@ +#!/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_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}" + + 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 index 107f11e..7ad2ac2 100755 --- a/scripts/trust_current_external_displays.sh +++ b/scripts/trust_current_external_displays.sh @@ -2,68 +2,10 @@ set -e -CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -if [ ! -f "$CONFIG_FILE" ]; then - echo "Config file not found: $CONFIG_FILE" - echo "Run ./scripts/install_smart.sh first." - exit 1 +if [ -x "$SCRIPT_DIR/displaydisabler-smart" ]; then + exec "$SCRIPT_DIR/displaydisabler-smart" trust "$@" fi -escape_regex_name() { - echo "$1" | sed -E 's/[][(){}.^$+*?|\\]/\\&/g' -} - -CURRENT_EXTERNAL_NAMES="$(/usr/sbin/system_profiler SPDisplaysDataType | awk ' - /Displays:/ { in_displays=1; next } - - in_displays && /^[[:space:]]{8}[^[:space:]].*:$/ { - name=$0 - sub(/^[[:space:]]+/, "", name) - sub(/:$/, "", name) - print name - } -' | grep -v "^Color LCD$" | grep -v "^Display$" | grep -v "^Unknown Display$" || true)" - -if [ -z "$CURRENT_EXTERNAL_NAMES" ]; 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 " $CONFIG_FILE" - exit 1 -fi - -CURRENT_REGEX="" - -while IFS= read -r name; do - escaped="$(escape_regex_name "$name")" - - if [ -z "$CURRENT_REGEX" ]; then - CURRENT_REGEX="$escaped" - else - CURRENT_REGEX="$CURRENT_REGEX|$escaped" - fi -done <<< "$CURRENT_EXTERNAL_NAMES" - -EXISTING_REGEX="$(grep '^TRUSTED_EXTERNAL_NAMES=' "$CONFIG_FILE" | sed -E 's/^TRUSTED_EXTERNAL_NAMES="(.*)"$/\1/' || true)" - -if [ -z "$EXISTING_REGEX" ]; then - NEW_REGEX="$CURRENT_REGEX" -else - NEW_REGEX="$EXISTING_REGEX|$CURRENT_REGEX" -fi - -NEW_REGEX="$(echo "$NEW_REGEX" | tr '|' '\n' | awk 'NF && !seen[$0]++' | paste -sd '|' -)" - -cp "$CONFIG_FILE" "$CONFIG_FILE.bak" - -perl -pi -e "s|^TRUSTED_EXTERNAL_NAMES=.*|TRUSTED_EXTERNAL_NAMES=\"$NEW_REGEX\"|" "$CONFIG_FILE" - -echo "Trusted external display names updated:" -echo " $NEW_REGEX" -echo -echo "Backup created:" -echo " $CONFIG_FILE.bak" +exec "$SCRIPT_DIR/displaydisabler_smart.sh" trust "$@" diff --git a/scripts/uninstall_smart.sh b/scripts/uninstall_smart.sh index d8dac41..9196ff0 100755 --- a/scripts/uninstall_smart.sh +++ b/scripts/uninstall_smart.sh @@ -3,97 +3,226 @@ set -e ZSHRC="$HOME/.zshrc" +SCRIPTS_DIR="$HOME/Scripts" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" + CONFIG_FILE="$HOME/.displaydisabler-watchdog.conf" -PLIST_PATH="$HOME/Library/LaunchAgents/com.displaydisabler.watchdog.plist" -WATCHDOG_SCRIPT="$HOME/Scripts/DisplayDisabler-Watchdog" -OLD_PLIST_PATH="$HOME/Library/LaunchAgents/com.displaydisabler.watchdog.plist" -OLD_WATCHDOG_SCRIPT="$HOME/Scripts/DisplayDisabler-Watchdog" -OLD_PLIST_PATH="$HOME/Library/LaunchAgents/com.displaydisabler.auto-enable-builtin.plist" -OLD_WATCHDOG_SCRIPT="$HOME/Scripts/auto_enable_builtin_on_external_disconnect.sh" -TRUST_SCRIPT="$HOME/Scripts/trust_current_external_displays.sh" +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="/usr/local/bin/display_disable" + +ALIAS_BEGIN="# >>> DisplayDisabler smart aliases >>>" +ALIAS_END="# <<< DisplayDisabler smart aliases <<<" + +DRY_RUN="0" +ASSUME_YES="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 "----------------------------------" -echo - -if [ -f "$PLIST_PATH" ]; then - launchctl bootout "gui/$(id -u)" "$PLIST_PATH" 2>/dev/null || true - rm -f "$PLIST_PATH" - echo "Removed LaunchAgent." -else - echo "No LaunchAgent found." -fi - -if [ -f "$OLD_PLIST_PATH" ]; then - launchctl bootout "gui/$(id -u)" "$OLD_PLIST_PATH" 2>/dev/null || true - rm -f "$OLD_PLIST_PATH" - echo "Removed old LaunchAgent." -fi - -if [ -f "$WATCHDOG_SCRIPT" ]; then - rm -f "$WATCHDOG_SCRIPT" - echo "Removed watchdog script." -else - echo "No watchdog script found." +if [ "$DRY_RUN" = "1" ]; then + echo "Mode: dry run" fi +echo -if [ -f "$OLD_WATCHDOG_SCRIPT" ]; then - rm -f "$OLD_WATCHDOG_SCRIPT" - echo "Removed old watchdog script." -fi +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\"" +} + +remove_file() { + local path="$1" + local label="$2" + + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] Would remove $label: $path" + return + fi + + if [ -e "$path" ]; then + rm -f "$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 -if [ -f "$TRUST_SCRIPT" ]; then - rm -f "$TRUST_SCRIPT" - echo "Removed trust-displays script." -else - echo "No trust-displays script found." -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" -if [ -f "$CONFIG_FILE" ]; then - rm -f "$CONFIG_FILE" - echo "Removed watchdog config." -else - echo "No watchdog config found." -fi + echo "Removed display_disable aliases from $ZSHRC." + echo "Backup created: $ZSHRC.displaydisabler-uninstall.bak" +} -if [ -f "$STATE_FILE" ]; then - rm -f "$STATE_FILE" - echo "Removed watchdog state file." -fi +bootout_plist "$PLIST_PATH" "LaunchAgent" +remove_file "$PLIST_PATH" "LaunchAgent" -if [ -f "$ZSHRC" ]; then - cp "$ZSHRC" "$ZSHRC.displaydisabler-uninstall.bak" +bootout_plist "$OLD_PLIST_PATH" "old LaunchAgent" +remove_file "$OLD_PLIST_PATH" "old LaunchAgent" - sed -i.tmp '/display_disable disable/d' "$ZSHRC" - sed -i.tmp '/display_disable enable/d' "$ZSHRC" - sed -i.tmp '/trust_current_external_displays.sh/d' "$ZSHRC" - sed -i.tmp '/DisplayDisabler-Watchdog/d' "$ZSHRC" - rm -f "$ZSHRC.tmp" +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" - echo "Removed display_disable aliases from $ZSHRC." - echo "Backup created: $ZSHRC.displaydisabler-uninstall.bak" +if [ "$KEEP_CONFIG" = "1" ]; then + echo "Keeping watchdog config: $CONFIG_FILE" +else + remove_file "$CONFIG_FILE" "watchdog config" fi -echo -read "REMOVE_LOG?Remove watchdog log file? [y/N]: " -REMOVE_LOG="${REMOVE_LOG:-N}" +remove_file "$STATE_FILE" "watchdog state file" +remove_aliases -if [[ "$REMOVE_LOG" =~ ^[Yy]$ ]]; then - rm -f "$LOG_FILE" - echo "Removed watchdog log file." +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 -BINARY_PATH="/usr/local/bin/display_disable" - -if [ -f "$BINARY_PATH" ]; then - sudo rm "$BINARY_PATH" - echo "Removed display_disable binary:" - echo " $BINARY_PATH" +if [ "$KEEP_BINARY" = "1" ]; then + echo "Keeping display_disable binary: $BINARY_PATH" else - echo "No display_disable binary found." + 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 + 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 echo diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 3dfb97c..fa7a47f 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -6,5 +6,7 @@ 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. - 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" From cfc7182c80c35ab5ef63f115ce1bcd4d76feff50 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 09:31:18 +0200 Subject: [PATCH 04/10] feat: add unified app cli installer --- CHANGELOG.md | 1 + Makefile | 4 +- README.md | 40 ++++++++++++-- scripts/install_smart.sh | 110 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae9cdb..c0c6ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,4 @@ - 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. diff --git a/Makefile b/Makefile index a259101..4ece349 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,9 @@ icon: AppIcon.icns test-smart: zsh -n scripts/*.sh scripts/lib/*.sh tests/smoke/test_smart_parsers.sh zsh tests/smoke/test_smart_parsers.sh - zsh scripts/install_smart.sh --dry-run --no-download --yes --no-watchdog >/dev/null + 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 --yes --keep-binary --keep-config --keep-logs >/dev/null bundle: $(EXECUTABLE) AppIcon.icns diff --git a/README.md b/README.md index a3933a5..315bd67 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,38 @@ The app now includes: The app recovery path is event-driven, not polling-based. The short delay is only a confirmation window after macOS reports a display topology change. -## Smart installer, aliases and safety watchdog +## 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. @@ -38,17 +69,18 @@ The smart installer can: - run lightweight status and doctor checks - fully uninstall the smart setup and the `display_disable` binary -### Smart install +### CLI install -Run: +Run the installer in CLI mode: ```bash -./scripts/install_smart.sh +./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 diff --git a/scripts/install_smart.sh b/scripts/install_smart.sh index 86ad53a..a51a18e 100755 --- a/scripts/install_smart.sh +++ b/scripts/install_smart.sh @@ -5,6 +5,9 @@ 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" @@ -35,12 +38,16 @@ NO_WATCHDOG="0" NO_DOWNLOAD="0" ASSUME_YES="0" REPAIR="0" +INSTALL_PROFILE="" usage() { cat <&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 @@ -516,7 +619,7 @@ else fi echo -echo "Aliases added to $ZSHRC:" +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" @@ -529,4 +632,9 @@ 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 From b7ffc597eed1c38d187aa9168eb2c9175f96e464 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 09:35:48 +0200 Subject: [PATCH 05/10] chore: remove stale display mode picker --- DisplayManager.m | 43 ++++--------------------------------------- 1 file changed, 4 insertions(+), 39 deletions(-) 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. From 9581cdec638bc0c2992fd94acb5f026d5b967361 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 09:39:47 +0200 Subject: [PATCH 06/10] feat: add uninstall profiles --- CHANGELOG.md | 1 + Makefile | 4 +- README.md | 5 + scripts/uninstall_smart.sh | 185 ++++++++++++++++++++++++++++--------- 4 files changed, 152 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c6ccf..92f31a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,4 @@ - 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/Makefile b/Makefile index 4ece349..3e18de6 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,9 @@ test-smart: 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 --yes --keep-binary --keep-config --keep-logs >/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" diff --git a/README.md b/README.md index 315bd67..51cd4f0 100644 --- a/README.md +++ b/README.md @@ -274,8 +274,12 @@ Run: 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 @@ -283,6 +287,7 @@ Useful uninstaller options: The uninstaller removes: +- the menu-bar app, when using `--app` or `--full` - the LaunchAgent - the old LaunchAgent name, if present - the watchdog script diff --git a/scripts/uninstall_smart.sh b/scripts/uninstall_smart.sh index 9196ff0..b72cb03 100755 --- a/scripts/uninstall_smart.sh +++ b/scripts/uninstall_smart.sh @@ -5,6 +5,9 @@ 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" @@ -27,6 +30,8 @@ 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" @@ -36,8 +41,12 @@ usage() { Usage: ./scripts/uninstall_smart.sh [options] Options: + --app Remove the menu-bar app only + --cli Remove CLI aliases/helpers only + --full Remove both menu-bar app and CLI helpers --dry-run Show planned removals without changing files --yes Use default answers for prompts + --keep-app Leave /Applications/DisplayDisabler.app installed --keep-binary Leave /usr/local/bin/display_disable installed --keep-config Leave ~/.displaydisabler-watchdog.conf installed --keep-logs Leave watchdog log files installed @@ -47,12 +56,24 @@ EOF_USAGE while [ "$#" -gt 0 ]; do case "$1" in + --app) + UNINSTALL_PROFILE="app" + ;; + --cli) + UNINSTALL_PROFILE="cli" + ;; + --full) + UNINSTALL_PROFILE="full" + ;; --dry-run) DRY_RUN="1" ;; --yes) ASSUME_YES="1" ;; + --keep-app) + KEEP_APP="1" + ;; --keep-binary) KEEP_BINARY="1" ;; @@ -100,17 +121,69 @@ prompt_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 path="$1" + 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: $path" + echo "[dry-run] Would remove $label: $target_path" return fi - if [ -e "$path" ]; then - rm -f "$path" + if [ -e "$target_path" ]; then + rm -rf "$target_path" echo "Removed $label." else echo "No $label found." @@ -172,56 +245,84 @@ remove_aliases() { echo "Backup created: $ZSHRC.displaydisabler-uninstall.bak" } -bootout_plist "$PLIST_PATH" "LaunchAgent" -remove_file "$PLIST_PATH" "LaunchAgent" +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 -bootout_plist "$OLD_PLIST_PATH" "old LaunchAgent" -remove_file "$OLD_PLIST_PATH" "old LaunchAgent" +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 -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" +echo "Uninstall type: $UNINSTALL_PROFILE" +echo -if [ "$KEEP_CONFIG" = "1" ]; then - echo "Keeping watchdog config: $CONFIG_FILE" -else - remove_file "$CONFIG_FILE" "watchdog config" +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 -remove_file "$STATE_FILE" "watchdog state file" -remove_aliases +if [ "$UNINSTALL_PROFILE" = "cli" ] || [ "$UNINSTALL_PROFILE" = "full" ]; then + bootout_plist "$PLIST_PATH" "LaunchAgent" + remove_file "$PLIST_PATH" "LaunchAgent" -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" + 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 - echo "Keeping watchdog log files." + remove_file "$CONFIG_FILE" "watchdog config" 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 - sudo rm "$BINARY_PATH" - echo "Removed display_disable binary:" - echo " $BINARY_PATH" + 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 "No display_disable binary found." + echo "Keeping watchdog log files." fi + fi + + if [ "$KEEP_BINARY" = "1" ]; then + echo "Keeping display_disable binary: $BINARY_PATH" else - echo "Keeping display_disable binary." + 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 + 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 From 3350fa78139fb8f36993cd0b4069d1cd69d31fec Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 09:59:52 +0200 Subject: [PATCH 07/10] test: verify smart uninstaller cleanup --- AppDelegate.m | 108 +++++++++++++++++++------ Makefile | 3 +- scripts/install_smart.sh | 8 -- scripts/uninstall_smart.sh | 4 +- tests/smoke/test_uninstall_smart.sh | 121 ++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 37 deletions(-) create mode 100755 tests/smoke/test_uninstall_smart.sh diff --git a/AppDelegate.m b/AppDelegate.m index 429e56b..8b09d8e 100644 --- a/AppDelegate.m +++ b/AppDelegate.m @@ -358,13 +358,10 @@ - (void)addActiveDisplayControls:(DDDisplayInfo *)display toMenu:(NSMenu *)menu displayID:display.displayID]; if (!display.isBuiltIn) { + BOOL trusted = [self isTrustedExternalDisplay:display]; [self addActionToMenu:menu - title:([self isTrustedExternalDisplay:display] - ? @"Forget Trusted Display" - : @"Trust This Display") - action:([self isTrustedExternalDisplay:display] - ? @selector(forgetTrustedDisplay:) - : @selector(trustDisplay:)) + title:(trusted ? @"Forget Trusted Display" : @"Trust This Display") + action:(trusted ? @selector(forgetTrustedDisplay:) : @selector(trustDisplay:)) displayID:display.displayID]; } @@ -660,10 +657,15 @@ - (NSMenu *)buildTrustedDisplaysSubmenu { 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 isTrustedExternalDisplay:d]) trustedConnected++; + if (![self isSuspiciousExternalName:d.name]) hasStableExternal = YES; + if ([self isTrustedExternalDisplay:d trustedFingerprints:trustedFingerprints]) { + trustedConnected++; + } } [self addLabelToMenu:submenu title: @@ -676,7 +678,7 @@ - (NSMenu *)buildTrustedDisplaysSubmenu { action:@selector(trustCurrentExternalDisplays:) keyEquivalent:@""]; trustCurrent.target = self; - trustCurrent.enabled = ([self stableExternalDisplaysActiveOnly:NO].count > 0); + trustCurrent.enabled = hasStableExternal; [submenu addItem:trustCurrent]; if (records.count > 0) { @@ -754,9 +756,10 @@ - (BOOL)isSuspiciousExternalName:(NSString *)name { [name isEqualToString:@"Unknown Display"]); } -- (NSArray *)externalDisplaysActiveOnly:(BOOL)activeOnly { +- (NSArray *)externalDisplaysFromDisplays:(NSArray *)displays + activeOnly:(BOOL)activeOnly { NSMutableArray *result = [NSMutableArray array]; - for (DDDisplayInfo *d in [self.displayManager allDisplays]) { + for (DDDisplayInfo *d in displays) { if (d.isBuiltIn) continue; if (activeOnly && !d.isActive) continue; [result addObject:d]; @@ -764,15 +767,26 @@ - (BOOL)isSuspiciousExternalName:(NSString *)name { return result; } -- (NSArray *)stableExternalDisplaysActiveOnly:(BOOL)activeOnly { +- (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 externalDisplaysActiveOnly:activeOnly]) { + 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); @@ -812,29 +826,62 @@ - (NSDictionary *)trustedRecordForDisplay:(DDDisplayInfo *)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 { +- (BOOL)isTrustedExternalDisplay:(DDDisplayInfo *)display + trustedFingerprints:(NSSet *)trustedFingerprints { if (!display || display.isBuiltIn) return NO; - NSString *fingerprint = [self fingerprintForDisplay:display]; - for (NSDictionary *record in [self trustedDisplayRecords]) { - if ([record[@"fingerprint"] isEqualToString:fingerprint]) return YES; - } - return NO; + return [trustedFingerprints containsObject:[self fingerprintForDisplay:display]]; } -- (NSArray *)trustedExternalDisplaysActiveOnly:(BOOL)activeOnly { +- (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 externalDisplaysActiveOnly:activeOnly]) { - if ([self isTrustedExternalDisplay:d]) [result addObject:d]; + 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 { - return [self trustedExternalDisplaysActiveOnly:YES].count > 0; + 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 { @@ -1158,9 +1205,13 @@ - (NSString *)launchAtLoginStatusText { - (NSString *)systemStatusText { NSArray *displays = [self.displayManager allDisplays]; DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; - NSArray *external = [self externalDisplaysActiveOnly:NO]; - NSArray *trustedActive = [self trustedExternalDisplaysActiveOnly:YES]; 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) { @@ -1199,9 +1250,14 @@ - (NSString *)doctorText { NSMutableArray *lines = [NSMutableArray array]; NSArray *displays = [self.displayManager allDisplays]; DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; - NSArray *stableExternal = [self stableExternalDisplaysActiveOnly:NO]; - NSArray *trustedActive = [self trustedExternalDisplaysActiveOnly:YES]; 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; diff --git a/Makefile b/Makefile index 3e18de6..5247ffb 100644 --- a/Makefile +++ b/Makefile @@ -42,8 +42,9 @@ AppIcon.icns: build_icon.m icon: AppIcon.icns test-smart: - zsh -n scripts/*.sh scripts/lib/*.sh tests/smoke/test_smart_parsers.sh + zsh -n scripts/*.sh scripts/lib/*.sh tests/smoke/test_smart_parsers.sh tests/smoke/test_uninstall_smart.sh zsh tests/smoke/test_smart_parsers.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 diff --git a/scripts/install_smart.sh b/scripts/install_smart.sh index a51a18e..b0f6463 100755 --- a/scripts/install_smart.sh +++ b/scripts/install_smart.sh @@ -114,14 +114,6 @@ elif [ "$REPAIR" = "1" ]; then fi echo -run_or_echo() { - if [ "$DRY_RUN" = "1" ]; then - echo "[dry-run] $*" - else - "$@" - fi -} - prompt_choice() { local __var="$1" local prompt="$2" diff --git a/scripts/uninstall_smart.sh b/scripts/uninstall_smart.sh index b72cb03..dd82d32 100755 --- a/scripts/uninstall_smart.sh +++ b/scripts/uninstall_smart.sh @@ -23,7 +23,7 @@ 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="/usr/local/bin/display_disable" +BINARY_PATH="${BINARY_PATH:-/usr/local/bin/display_disable}" ALIAS_BEGIN="# >>> DisplayDisabler smart aliases >>>" ALIAS_END="# <<< DisplayDisabler smart aliases <<<" @@ -314,7 +314,7 @@ if [ "$UNINSTALL_PROFILE" = "cli" ] || [ "$UNINSTALL_PROFILE" = "full" ]; then if [ "$DRY_RUN" = "1" ]; then echo "[dry-run] Would remove binary: $BINARY_PATH" elif [ -f "$BINARY_PATH" ]; then - sudo rm "$BINARY_PATH" + rm -f "$BINARY_PATH" 2>/dev/null || sudo rm "$BINARY_PATH" echo "Removed display_disable binary:" echo " $BINARY_PATH" else 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" From 892fd0712ef7e02365a3592f4003984c6003a0f2 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 10:33:58 +0200 Subject: [PATCH 08/10] fix: stabilize brightness controls --- AppDelegate.m | 6 +++--- Brightness.h | 23 +++++++++++------------ Brightness.m | 47 +++++++++++++++++++++++++---------------------- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/AppDelegate.m b/AppDelegate.m index 8b09d8e..06f9579 100644 --- a/AppDelegate.m +++ b/AppDelegate.m @@ -373,9 +373,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 @@ -1067,6 +1066,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" 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]; } From 202ef9e4449768ebb3ad6b3590047557b08928f8 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 11:09:42 +0200 Subject: [PATCH 09/10] fix: harden app recovery and smart cli diagnostics --- AppDelegate.m | 231 ++++++++++++++++++++--- Makefile | 3 +- README.md | 14 +- scripts/displaydisabler_smart.sh | 111 ++++++++++- scripts/install_smart.sh | 4 +- scripts/lib/displaydisabler_smart_lib.sh | 2 + tests/smoke/README.md | 1 + tests/smoke/test_smart_status_doctor.sh | 95 ++++++++++ 8 files changed, 424 insertions(+), 37 deletions(-) create mode 100755 tests/smoke/test_smart_status_doctor.sh diff --git a/AppDelegate.m b/AppDelegate.m index 06f9579..cc11244 100644 --- a/AppDelegate.m +++ b/AppDelegate.m @@ -18,6 +18,7 @@ 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. @@ -29,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 @@ -40,12 +43,64 @@ // 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 @@ -61,6 +116,7 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { UNUserNotificationCenter.currentNotificationCenter.delegate = self; [self setupStatusItem]; + [self rememberBuiltInDisplayIDIfAvailable]; [self rebuildMenu]; __weak __typeof(self) weakSelf = self; @@ -79,20 +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]; } @@ -105,6 +165,7 @@ - (void)registerDefaults { kShowResolutions: @YES, kSmartRecovery: @YES, kTrustedDisplays: @[], + kLastBuiltInDisplayID: @0, }]; } @@ -180,6 +241,7 @@ - (void)rebuildMenu { menu.autoenablesItems = NO; NSArray *displays = [self.displayManager allDisplays]; + displays = [self displaysIncludingKnownBuiltInFallback:displays]; NSUInteger activeCount = 0; BOOL anyDisabled = NO; @@ -526,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]; @@ -555,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 @@ -576,6 +639,10 @@ - (NSMenuItem *)switchRowWithTitle:(NSString *)title return item; } +- (void)applySwitchAppearance:(NSButton *)sw { + sw.needsDisplay = YES; +} + // ── Modes submenu ─────────────────────────────────────────────────────────── - (NSMenu *)buildModesSubmenuForDisplay:(CGDirectDisplayID)displayID @@ -749,6 +816,57 @@ - (DDDisplayInfo *)displayInfoForID:(CGDirectDisplayID)displayID { 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"] || @@ -991,9 +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" @@ -1004,11 +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" @@ -1423,9 +1554,11 @@ - (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]; @@ -1435,7 +1568,7 @@ - (void)switchToggled:(NSSwitch *)sender { } } -- (void)loginSwitchToggled:(NSSwitch *)sender { +- (void)loginSwitchToggled:(NSButton *)sender { SMAppService *service = [SMAppService mainAppService]; NSError *error = nil; @@ -1454,6 +1587,7 @@ - (void)loginSwitchToggled:(NSSwitch *)sender { sender.state = (service.status == SMAppServiceStatusEnabled) ? NSControlStateValueOn : NSControlStateValueOff; + [self applySwitchAppearance:sender]; } - (void)toggleCheckSetting:(NSMenuItem *)sender { @@ -1495,19 +1629,29 @@ - (void)scheduleSmartRecoveryEvaluation { - (void)evaluateSmartRecovery { if (![self pref:kSmartRecovery]) return; + [self recoverBuiltInDisplayIfUnsafeWithTitle:@"Built-in Display Recovered" + body:@"No trusted external monitor is active." + logFailureName:@"Smart recovery"]; +} - DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; - if (!builtIn || builtIn.isActive) return; +- (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:builtIn.displayID error:&error]) { - [self postNotification:@"Built-in Display Recovered" - body:@"No trusted external monitor is active." + if ([self.displayManager enableDisplay:builtInID error:&error]) { + [self postNotification:title + body:body identifier:kAutoManageNotifID]; [self rebuildMenu]; + [self updateSafetyWatchdog]; } else { - NSLog(@"DisplayDisabler: Smart recovery failed: %@", error); + NSLog(@"DisplayDisabler: %@ failed: %@", logFailureName, error); [self postNotification:@"Recovery Failed" body:error.localizedDescription]; } @@ -1515,6 +1659,7 @@ - (void)evaluateSmartRecovery { - (void)performAutoDisableIfNeeded { if (![self pref:kAutoManage]) return; + if (CFAbsoluteTimeGetCurrent() < self.suppressAutoDisableUntil) return; DDDisplayInfo *builtIn = [self.displayManager builtInDisplay]; if (!builtIn || !builtIn.isActive) return; @@ -1522,9 +1667,12 @@ - (void)performAutoDisableIfNeeded { 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); } @@ -1533,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 hasTrustedActiveExternalDisplay]) 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/Makefile b/Makefile index 5247ffb..37bc047 100644 --- a/Makefile +++ b/Makefile @@ -42,8 +42,9 @@ AppIcon.icns: build_icon.m icon: AppIcon.icns test-smart: - zsh -n scripts/*.sh scripts/lib/*.sh tests/smoke/test_smart_parsers.sh tests/smoke/test_uninstall_smart.sh + 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 diff --git a/README.md b/README.md index 51cd4f0..786ac29 100644 --- a/README.md +++ b/README.md @@ -136,12 +136,14 @@ Available commands: ~/Scripts/displaydisabler-smart trust ``` -`status` reports the binary path, config, detected built-in display, active -display count, trusted external display count and watchdog LaunchAgent state. - -`doctor` runs lightweight setup checks and exits non-zero only for critical -failures such as a missing `display_disable` binary or a failing -`display_disable list` command. +`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. ### CLI safety watchdog diff --git a/scripts/displaydisabler_smart.sh b/scripts/displaydisabler_smart.sh index 1acc36c..317da27 100755 --- a/scripts/displaydisabler_smart.sh +++ b/scripts/displaydisabler_smart.sh @@ -70,6 +70,35 @@ run_system_profiler_displays() { 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 @@ -84,6 +113,15 @@ status_command() { 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 @@ -112,6 +150,8 @@ status_command() { 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 @@ -123,8 +163,11 @@ status_command() { 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" @@ -146,24 +189,69 @@ 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" - else + 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 "warn: config is missing at $DD_CONFIG_FILE" + echo "info: CLI watchdog config is not installed at $DD_CONFIG_FILE" fi - if [ -n "$BUILTIN_ID" ]; then + 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" @@ -173,9 +261,11 @@ doctor_command() { run_display_disable_list dd_output dd_status if [ "$dd_status" -eq 0 ]; then echo "ok: display_disable list succeeded" - else + 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 @@ -190,7 +280,11 @@ doctor_command() { echo "warn: plutil is unavailable, plist not checked" fi else - echo "warn: watchdog plist is not installed" + 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 @@ -201,6 +295,13 @@ doctor_command() { 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 diff --git a/scripts/install_smart.sh b/scripts/install_smart.sh index b0f6463..a346252 100755 --- a/scripts/install_smart.sh +++ b/scripts/install_smart.sh @@ -290,6 +290,7 @@ write_watchdog_config() { 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" @@ -312,6 +313,7 @@ write_watchdog_config() { # MAX_LOG_SIZE_KB rotates the log when it reaches this size. One backup is kept as .1. BUILTIN_ID="$builtin_id" +DD_INSTALL_PROFILE="$install_profile" TRUSTED_EXTERNAL_NAMES="$trusted_external_names" SUSPICIOUS_DISPLAY_NAMES="Display|Unknown Display" CHECK_CONFIRMATIONS="$confirmations" @@ -593,7 +595,7 @@ else 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" +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" diff --git a/scripts/lib/displaydisabler_smart_lib.sh b/scripts/lib/displaydisabler_smart_lib.sh index b63a7f7..46b8d31 100644 --- a/scripts/lib/displaydisabler_smart_lib.sh +++ b/scripts/lib/displaydisabler_smart_lib.sh @@ -6,12 +6,14 @@ 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}" diff --git a/tests/smoke/README.md b/tests/smoke/README.md index fa7a47f..017c757 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -8,5 +8,6 @@ This directory tracks lightweight checks for `oabdrabo/DisplayDisabler`. - 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/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" From 03bbe212f039baf8cd5ec26d25dfb02787078901 Mon Sep 17 00:00:00 2001 From: Federico Cicognini Date: Mon, 8 Jun 2026 11:24:04 +0200 Subject: [PATCH 10/10] docs: align readme with app behavior --- README.md | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 786ac29..cfaa98c 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,28 @@ The app now includes: - 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 -The app recovery path is event-driven, not polling-based. The short delay is -only a confirmation window after macOS reports a display topology change. +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. + +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. + +The Settings switches use app-rendered blue/gray controls so their color stays +consistent after reopening the submenu. ## Unified installer @@ -145,6 +161,13 @@ 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. + ### CLI safety watchdog The optional watchdog is designed to avoid being left without an active built-in display when the external display is disconnected. @@ -317,7 +340,13 @@ scripts/ └── lib/displaydisabler_smart_lib.sh ``` -User-level files created by the smart installer: +User-level files created by `--app`: + +```text +/Applications/DisplayDisabler.app +``` + +Additional user-level files created by `--cli` or `--full`: ```text ~/.displaydisabler-watchdog.conf