diff --git a/Makefile b/Makefile index bb35775e5..56151984d 100644 --- a/Makefile +++ b/Makefile @@ -936,7 +936,7 @@ fish-test-dev: ## Run fish tests inside the Nix dev shell (mirrors CI). .PHONY: shell-check shell-check: ## Run ShellCheck on shell scripts. @echo "🔍 Running ShellCheck..." - @find . -name '*.sh' -not -path './node_modules/*' -not -path './.git/*' -not -path './result/*' -not -path './.venv/*' -not -path './.claude/*' -not -path './dotagents/.claude/*' -not -path './dotagents/.codex/*' -print0 | xargs -0 shellcheck + @find . -name '*.sh' -not -path './node_modules/*' -not -path './.git/*' -not -path './.worktrees/*' -not -path './result/*' -not -path './.venv/*' -not -path './.claude/*' -not -path './dotagents/.claude/*' -not -path './dotagents/.codex/*' -print0 | xargs -0 shellcheck .PHONY: shell-check-dev shell-check-dev: ## Run ShellCheck inside the Nix dev shell (mirrors CI). diff --git a/nix-darwin/default.nix b/nix-darwin/default.nix index 43c4934f4..61c4df138 100644 --- a/nix-darwin/default.nix +++ b/nix-darwin/default.nix @@ -14,6 +14,7 @@ let networking = import ./config/networking.nix; nix = import ./config/nix.nix; security = import ./config/security.nix { inherit username; }; + serviceModules = import ./services { inherit lib isRunner pkgs; }; system = import ./config/system.nix { inherit isRunner pkgs username; }; time = import ./config/time.nix; in @@ -27,6 +28,7 @@ in security system time - ]; + ] + ++ serviceModules; } diff --git a/nix-darwin/services/default.nix b/nix-darwin/services/default.nix new file mode 100644 index 000000000..37ff3ef7a --- /dev/null +++ b/nix-darwin/services/default.nix @@ -0,0 +1,11 @@ +{ + lib, + isRunner, + pkgs, +}: +let + pmsetBatteryPolicyModule = import ./pmset-battery-policy { inherit lib isRunner pkgs; }; +in +[ + pmsetBatteryPolicyModule +] diff --git a/nix-darwin/services/pmset-battery-policy/default.nix b/nix-darwin/services/pmset-battery-policy/default.nix new file mode 100644 index 000000000..e124d82c2 --- /dev/null +++ b/nix-darwin/services/pmset-battery-policy/default.nix @@ -0,0 +1,28 @@ +{ + lib, + isRunner, + pkgs, +}: +let + highBatteryThreshold = 30; + highBatterySleepMinutes = 300; + lowBatterySleepMinutes = 30; + pmsetBatteryPolicyScript = builtins.readFile ( + pkgs.replaceVars ./power-policy.sh { + awkBin = "/usr/bin/awk"; + inherit highBatteryThreshold highBatterySleepMinutes lowBatterySleepMinutes; + pmsetBin = "/usr/bin/pmset"; + } + ); +in +lib.mkIf (!isRunner) { + system.activationScripts.pmsetBatteryPolicy.text = lib.mkAfter pmsetBatteryPolicyScript; + + launchd.daemons."com.shunkakinoki.pmset-battery-policy" = { + script = pmsetBatteryPolicyScript; + serviceConfig = { + RunAtLoad = true; + StartInterval = 86400; + }; + }; +} diff --git a/nix-darwin/services/pmset-battery-policy/power-policy.sh b/nix-darwin/services/pmset-battery-policy/power-policy.sh new file mode 100644 index 000000000..d27bbe053 --- /dev/null +++ b/nix-darwin/services/pmset-battery-policy/power-policy.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -eu + +pmset_bin="@pmsetBin@" +awk_bin="@awkBin@" +high_battery_threshold=@highBatteryThreshold@ +high_battery_sleep_minutes=@highBatterySleepMinutes@ +low_battery_sleep_minutes=@lowBatterySleepMinutes@ + +# Idle sleep timers do not override the hardware lid-close sleep path. +# shellcheck disable=SC2016 +battery_percentage_awk='NR == 2 { if (match($0, /[0-9]+%/)) print substr($0, RSTART, RLENGTH - 1); exit }' +batteryPercentage="$($pmset_bin -g batt | $awk_bin "$battery_percentage_awk")" +batterySleepMinutes=$low_battery_sleep_minutes +batteryPowerNap=0 + +$pmset_bin -c sleep 0 +$pmset_bin -c powernap 1 + +if [ -n "$batteryPercentage" ] && [ "$batteryPercentage" -ge "$high_battery_threshold" ]; then + batterySleepMinutes=$high_battery_sleep_minutes + batteryPowerNap=1 +fi + +$pmset_bin -b sleep "$batterySleepMinutes" +$pmset_bin -b powernap "$batteryPowerNap" diff --git a/spec/coverage_spec.sh b/spec/coverage_spec.sh index c1f0de3de..8128aa0e9 100644 --- a/spec/coverage_spec.sh +++ b/spec/coverage_spec.sh @@ -168,6 +168,10 @@ End It 'has spec file for home-manager/services/docker/docker-setup.sh' The path "spec/docker_setup_wrapper_spec.sh" should be exist End +It 'has spec file for nix-darwin/services/pmset-battery-policy/power-policy.sh' +The path "spec/pmset_battery_policy_spec.sh" should be exist +End + End Describe 'fish shortcut declarations' @@ -244,6 +248,7 @@ named-hosts/kyber/rekey-galactica.sh named-hosts/kyber/setup.sh named-hosts/matic/falcon-init.sh named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh +nix-darwin/services/pmset-battery-policy/power-policy.sh scripts/check-nix-inline-scripts.sh scripts/fishtape-wrapper.sh scripts/llm-update.sh diff --git a/spec/pmset_battery_policy_spec.sh b/spec/pmset_battery_policy_spec.sh new file mode 100644 index 000000000..6051d1da9 --- /dev/null +++ b/spec/pmset_battery_policy_spec.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'nix-darwin/services/pmset-battery-policy/power-policy.sh' +SCRIPT="$PWD/nix-darwin/services/pmset-battery-policy/power-policy.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after replacing placeholders' +When run bash -c "sed -e 's|@pmsetBin@|/usr/bin/pmset|g' -e 's|@awkBin@|/usr/bin/awk|g' -e 's|@highBatteryThreshold@|30|g' -e 's|@highBatterySleepMinutes@|300|g' -e 's|@lowBatterySleepMinutes@|30|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +setup_policy_script() { + TEST_DIR=$(mktemp -d) + PMSET_LOG="$TEST_DIR/pmset.log" + PMSET_BIN="$TEST_DIR/pmset" + PREPROCESSED_SCRIPT="$TEST_DIR/power-policy.sh" + : >"$PMSET_LOG" + + cat >"$PMSET_BIN" <<'EOF' +#!/usr/bin/env bash +set -eu +printf '%s\n' "$*" >>"$PMSET_LOG" +if [ "$1" = "-g" ] && [ "$2" = "batt" ]; then + printf "Now drawing from 'Battery Power'\n -InternalBattery-0 (id=1)\t%s%%; discharging; 4:00 remaining present: true\n" "$BATTERY_PERCENTAGE" +fi +EOF + chmod +x "$PMSET_BIN" + + sed \ + -e "s|@pmsetBin@|$PMSET_BIN|g" \ + -e 's|@awkBin@|/usr/bin/awk|g' \ + -e 's|@highBatteryThreshold@|30|g' \ + -e 's|@highBatterySleepMinutes@|300|g' \ + -e 's|@lowBatterySleepMinutes@|30|g' \ + "$SCRIPT" >"$PREPROCESSED_SCRIPT" + chmod +x "$PREPROCESSED_SCRIPT" +} + +cleanup_policy_script() { + rm -rf "$TEST_DIR" +} + +Describe 'percentage-based policy' +Before 'setup_policy_script' +After 'cleanup_policy_script' + +It 'extends battery sleep at or above the threshold' +When run bash -c ': >"'"$PMSET_LOG"'"; BATTERY_PERCENTAGE=35 PMSET_LOG="'"$PMSET_LOG"'" "'"$PREPROCESSED_SCRIPT"'" >/dev/null && cat "'"$PMSET_LOG"'"' +The status should be success +The output should include '-c sleep 0' +The output should include '-c powernap 1' +The output should include '-b sleep 300' +The output should include '-b powernap 1' +End + +It 'keeps the conservative battery sleep below the threshold' +When run bash -c ': >"'"$PMSET_LOG"'"; BATTERY_PERCENTAGE=12 PMSET_LOG="'"$PMSET_LOG"'" "'"$PREPROCESSED_SCRIPT"'" >/dev/null && cat "'"$PMSET_LOG"'"' +The status should be success +The output should include '-b sleep 30' +The output should include '-b powernap 0' +End +End + +End