From 39d78e0134e398504c6f1c9277458f9c4a28b6db Mon Sep 17 00:00:00 2001 From: base47 Date: Sun, 23 Nov 2025 13:44:59 +0100 Subject: [PATCH 01/18] Do not use sudo to read SMC keys --- battery.sh | 50 +++++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/battery.sh b/battery.sh index 9849b01..aa50ceb 100755 --- a/battery.sh +++ b/battery.sh @@ -107,25 +107,29 @@ Usage: " # Visudo instructions +# File location: /etc/sudoers.d/battery +# Purpose: Allows this script to execute 'sudo smc -w' commands without a user password. visudoconfig=" # Visudo settings for the battery utility installed from https://github.com/actuallymentor/battery # intended to be placed in $visudo_file on a mac -Cmnd_Alias BATTERYOFF = $binfolder/smc -k CH0B -w 02, $binfolder/smc -k CH0C -w 02, $binfolder/smc -k CH0B -r, $binfolder/smc -k CH0C -r -Cmnd_Alias BATTERYON = $binfolder/smc -k CH0B -w 00, $binfolder/smc -k CH0C -w 00 -Cmnd_Alias DISCHARGEOFF = $binfolder/smc -k CH0I -w 00, $binfolder/smc -k CH0I -r -Cmnd_Alias DISCHARGEON = $binfolder/smc -k CH0I -w 01 -Cmnd_Alias LEDCONTROL = $binfolder/smc -k ACLC -w 04, $binfolder/smc -k ACLC -w 03, $binfolder/smc -k ACLC -w 02, $binfolder/smc -k ACLC -w 01, $binfolder/smc -k ACLC -w 00, $binfolder/smc -k ACLC -r -Cmnd_Alias CHTE = $binfolder/smc -k CHTE -r, $binfolder/smc -k CHTE -w 00000000, $binfolder/smc -k CHTE -w 01000000 -Cmnd_Alias CHIE = $binfolder/smc -k CHIE -r, $binfolder/smc -k CHIE -w 08, $binfolder/smc -k CHIE -w 00 -Cmnd_Alias CH0J = $binfolder/smc -k CH0J -r, $binfolder/smc -k CH0J -w 01, $binfolder/smc -k CH0J -w 00 -ALL ALL = NOPASSWD: BATTERYOFF -ALL ALL = NOPASSWD: BATTERYON -ALL ALL = NOPASSWD: DISCHARGEOFF -ALL ALL = NOPASSWD: DISCHARGEON -ALL ALL = NOPASSWD: LEDCONTROL -ALL ALL = NOPASSWD: CHTE -ALL ALL = NOPASSWD: CHIE -ALL ALL = NOPASSWD: CH0J + +# Passwordless SMC writing commands used by battery.sh +Cmnd_Alias CHARGING_OFF = $binfolder/smc -k CH0B -w 02, $binfolder/smc -k CH0C -w 02, $binfolder/smc -k CHTE -w 01000000 +Cmnd_Alias CHARGING_ON = $binfolder/smc -k CH0B -w 00, $binfolder/smc -k CH0C -w 00, $binfolder/smc -k CHTE -w 00000000 +Cmnd_Alias FORCE_DISCHARGE_OFF = $binfolder/smc -k CH0I -w 00, $binfolder/smc -k CHIE -w 00, $binfolder/smc -k CH0J -w 00 +Cmnd_Alias FORCE_DISCHARGE_ON = $binfolder/smc -k CH0I -w 01, $binfolder/smc -k CHIE -w 08, $binfolder/smc -k CH0J -w 01 +Cmnd_Alias LED_CONTROL = $binfolder/smc -k ACLC -w 04, $binfolder/smc -k ACLC -w 03, $binfolder/smc -k ACLC -w 02, $binfolder/smc -k ACLC -w 01, $binfolder/smc -k ACLC -w 00 +ALL ALL = NOPASSWD: CHARGING_OFF +ALL ALL = NOPASSWD: CHARGING_ON +ALL ALL = NOPASSWD: FORCE_DISCHARGE_OFF +ALL ALL = NOPASSWD: FORCE_DISCHARGE_ON +ALL ALL = NOPASSWD: LED_CONTROL + +# Temporarily keep passwordless SMC reading commands so the old menubar GUI versions don't ask for password on each launch +# trying to execute 'battery visudo'. There is no harm in removing this, so do it as soon as you believe users are no +# longer using old versions. +Cmnd_Alias TMP_OLD_GUI_COMPATIBILITY = $binfolder/smc -k CH0B -r, $binfolder/smc -k CH0C -r, $binfolder/smc -k CH0I -r, $binfolder/smc -k ACLC -r, $binfolder/smc -k CHTE -r, $binfolder/smc -k CHIE -r, $binfolder/smc -k CH0J -r +ALL ALL = NOPASSWD: TMP_OLD_GUI_COMPATIBILITY " # Get parameters @@ -187,7 +191,7 @@ function valid_voltage() { function smc_read_hex() { key=$1 - line=$(echo $(sudo smc -k $key -r)) + line=$(echo $(smc -k $key -r)) if [[ $line =~ "no data" ]]; then echo else @@ -208,11 +212,11 @@ function smc_write_hex() { ## ######################### ## Detect supported SMC keys ## ######################### -[[ $(sudo smc -k CHTE -r) =~ "no data" ]] && smc_supports_tahoe=false || smc_supports_tahoe=true; -[[ $(sudo smc -k CH0B -r) =~ "no data" ]] && smc_supports_legacy=false || smc_supports_legacy=true; -[[ $(sudo smc -k CHIE -r) =~ "no data" ]] && smc_supports_adapter_chie=false || smc_supports_adapter_chie=true; -[[ $(sudo smc -k CH0I -r) =~ "no data" ]] && smc_supports_adapter_ch0i=false || smc_supports_adapter_ch0i=true; -[[ $(sudo smc -k CH0J -r) =~ "no data" || $(sudo smc -k CH0J -r) =~ "Error" ]] && smc_supports_adapter_ch0j=false || smc_supports_adapter_ch0j=true; +[[ $(smc -k CHTE -r) =~ "no data" ]] && smc_supports_tahoe=false || smc_supports_tahoe=true; +[[ $(smc -k CH0B -r) =~ "no data" ]] && smc_supports_legacy=false || smc_supports_legacy=true; +[[ $(smc -k CHIE -r) =~ "no data" ]] && smc_supports_adapter_chie=false || smc_supports_adapter_chie=true; +[[ $(smc -k CH0I -r) =~ "no data" ]] && smc_supports_adapter_ch0i=false || smc_supports_adapter_ch0i=true; +[[ $(smc -k CH0J -r) =~ "no data" || $(smc -k CH0J -r) =~ "Error" ]] && smc_supports_adapter_ch0j=false || smc_supports_adapter_ch0j=true; function log_smc_capabilities() { log "SMC capabilities: tahoe=$smc_supports_tahoe legacy=$smc_supports_legacy CHIE=$smc_supports_adapter_chie CH0I=$smc_supports_adapter_ch0i CH0J=$smc_supports_adapter_ch0j" @@ -425,7 +429,7 @@ if [ -z "$action" ] || [[ "$action" == "help" ]]; then exit 0 fi -# Visudo message +# Update '/etc/sudoers.d/battery' config if needed if [[ "$action" == "visudo" ]]; then # User to set folder ownership to is $setting if it is defined and $USER otherwise From 2bdc8ae953b8945c27ed1859f341a43cb30347b2 Mon Sep 17 00:00:00 2001 From: base47 Date: Sun, 11 Jan 2026 20:35:05 +0100 Subject: [PATCH 02/18] Remove visudo related checks from change_magsafe_led_color() --- battery.sh | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/battery.sh b/battery.sh index aa50ceb..342e44d 100755 --- a/battery.sh +++ b/battery.sh @@ -229,16 +229,9 @@ function log_smc_capabilities() { # Change magsafe color # see community sleuthing: https://github.com/actuallymentor/battery/issues/71 function change_magsafe_led_color() { - log "MagSafe LED function invoked" - color=$1 + local color=$1 - # Check whether user can run color changes without password (required for backwards compatibility) - if sudo -n smc -k ACLC -r &>/dev/null; then - log "πŸ’‘ Setting magsafe color to $color" - else - log "🚨 Your version of battery is using an old visudo file, please run 'battery visudo' to fix this, until you do battery cannot change magsafe led colors" - return - fi + log "πŸ’‘ Setting magsafe color to $color" if [[ "$color" == "green" ]]; then log "setting LED to green" From b9f5a661949ef465de2c8b3c6ba117ee8ddd7aed Mon Sep 17 00:00:00 2001 From: base47 Date: Sun, 11 Jan 2026 20:53:48 +0100 Subject: [PATCH 03/18] Add missing visudo.tmp file removal --- battery.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/battery.sh b/battery.sh index 342e44d..c9f7117 100755 --- a/battery.sh +++ b/battery.sh @@ -450,6 +450,9 @@ if [[ "$action" == "visudo" ]]; then sudo chmod 440 $visudo_file fi + # Delete tempfile + rm $visudo_tmpfile + # exit because no changes are needed exit 0 From 33fb672ae1a19a91118891a7f3237bf1bfad9c39 Mon Sep 17 00:00:00 2001 From: base47 Date: Mon, 9 Feb 2026 21:22:22 +0100 Subject: [PATCH 04/18] Make all background battery executables root-owned + cleanup (#443) --- app/modules/battery.js | 103 ++++++-------- battery.sh | 311 ++++++++++++++++++++++++++++++----------- setup.sh | 90 ++++++++---- update.sh | 51 ++++--- 4 files changed, 357 insertions(+), 198 deletions(-) diff --git a/app/modules/battery.js b/app/modules/battery.js index 742eb05..e2b5532 100644 --- a/app/modules/battery.js +++ b/app/modules/battery.js @@ -112,7 +112,7 @@ const disable_battery_limiter = async () => { } -const low_err_return_false = ( ...errdata ) => { +const log_err_return_false = ( ...errdata ) => { log( 'Error in shell call: ', ...errdata ) return false } @@ -132,85 +132,62 @@ const initialize_battery = async () => { ] ) log( `Internet online: ${ online }` ) - // Check if battery is installed and visudo entries are complete. New visudo entries are added when we do new `sudo` stuff in battery.sh - // note to self: only added a few of the new entries, there is no need to be exhaustive except to make sure all new sudo stuff is covered - const smc_commands = [ - // Old list - '-k CH0C -r', - '-k CH0I -r', - '-k ACLC -r', - // Apple introduced the following two in 2025. - // It seems to apply to all Apple Silicon MacBooks, regardless of macOS version. - '-k CHTE -r', - '-k CHIE -r' - ] + // Check if battery background executables are installed and owned by root. const [ - battery_installed, - smc_installed, - charging_in_visudo, - discharging_in_visudo, - magsafe_led_in_visudo, - chte_charging_in_visudo, - chie_discharging_in_visudo, + bin_dir_root_owned, // This is important. Other software can potentially change the owner allowing for battery executable replacement. + battery_installed, // Make sure battery script exists and is root-owned. + smc_installed, // Make sure smc binary exists and is root-owned. + silent_update_enabled // Make sure visudo config is installed and allows passwordless update ] = await Promise.all( [ - exec_async( `${ path_fix } which battery` ).catch( low_err_return_false ), - exec_async( `${ path_fix } which smc` ).catch( low_err_return_false ), - ...smc_commands.map( cmd => exec_async( `${ path_fix } sudo -n /usr/local/bin/smc ${ cmd }` ).catch( low_err_return_false ) ) + exec_async( `${ path_fix } test "$(stat -f '%u' /usr/local/bin)" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `${ path_fix } test "$(stat -f '%u' /usr/local/bin/battery)" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `${ path_fix } test "$(stat -f '%u' /usr/local/bin/smc)" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `${ path_fix } sudo -n /usr/local/bin/battery update_silent is_enabled` ).then( () => true ).catch( log_err_return_false ) ] ) - - const visudo_complete = ![ charging_in_visudo, discharging_in_visudo, magsafe_led_in_visudo, chte_charging_in_visudo, chie_discharging_in_visudo ].some( entry => entry === false ) - const is_installed = battery_installed && smc_installed - log( 'Is installed? ', is_installed ) - log( 'Visudo complete? ', { - charging_in_visudo, - discharging_in_visudo, - magsafe_led_in_visudo, - chte_charging_in_visudo, - chie_discharging_in_visudo, - visudo_complete - } ) + const is_installed = bin_dir_root_owned && battery_installed && smc_installed && silent_update_enabled + log( 'Is installed? ', is_installed, 'details: ', bin_dir_root_owned, battery_installed, smc_installed, silent_update_enabled ) // Kill running instances of battery const processes = await exec_async( `ps aux | grep "/usr/local/bin/battery " | wc -l | grep -Eo "\\d*"` ) log( `Found ${ `${ processes }`.replace( /\n/, '' ) } battery related processed to kill` ) if( is_installed ) await exec_async( `${ battery } maintain stop` ) - await exec_async( `pkill -f "/usr/local/bin/battery.*"` ).catch( e => log( `Error killing existing battery progesses, usually means no running processes` ) ) + await exec_async( `pkill -f "/usr/local/bin/battery.*"` ).catch( e => log( `Error killing existing battery processes, usually means no running processes` ) ) - // If installed, update - if( is_installed && visudo_complete ) { - if( !online ) return log( `Skipping battery update because we are offline` ) - if( skipupdate ) return log( `Skipping update due to environment variable` ) - log( `Updating battery...` ) - const result = await exec_async( `${ battery } update silent` ).catch( e => e ) - log( `Update result: `, result ) - } - - // If not installed, run install script + // Reinstall or try updating if( !is_installed ) { log( `Installing battery for ${ USER }...` ) if( !online ) return alert( `Battery needs an internet connection to download the latest version, please connect to the internet and open the app again.` ) - if( !is_installed ) await alert( `Welcome to the Battery limiting tool. The app needs to install/update some components, so it will ask for your password. This should only be needed once.` ) - const result = await exec_sudo_async( `curl -s https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh | bash -s -- $USER` ) - log( `Install result success `, result ) - await alert( `Battery background components installed successfully. You can find the battery limiter icon in the top right of your menu bar.` ) + await alert( `Welcome to the Battery limiting tool. The app needs to install/update some components, so it will ask for your password. This should only be needed once.` ) + try { + const result = await exec_sudo_async( `curl -s https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh | bash -s -- $USER` ) + log( `Install result success `, result ) + await alert( `Battery background components installed/updated successfully. You can find the battery limiter icon in the top right of your menu bar.` ) + } catch ( e ) { + log( `Battery setup failed: `, e ) + await alert( `Failed to install battery background components.\n\n${e.message}`) + app.quit() + app.exit() + } + } else { + // Try updating to the latest version + if( !online ) return log( `Skipping battery update because we are offline` ) + if( skipupdate ) return log( `Skipping update due to environment variable` ) + log( `Updating battery...` ) + try { + const result = await exec_async( `${ path_fix } sudo -n /usr/local/bin/battery update_silent` ) + log( `Update details: `, result ) + } catch ( e ) { + log( `Battery update failed: `, e ) + await alert( `Couldn’t complete the update.\n\n${e.message}`) + } } - // If visudo entries are incomplete, update - if( !visudo_complete ) { - await alert( `Battery needs to apply a backwards incompatible update, to do this it will ask for your password. This should not happen frequently.` ) - await exec_sudo_async( `${ path_fix } battery visudo` ) - } - - // Recover old battery setting on boot (as we killed all old processes above) - await exec_async( `${ battery } maintain recover` ) - // Basic user tracking on app open, run it in the background so it does not cause any delay for the user if( online ) exec_async( `nohup curl "https://unidentifiedanalytics.web.app/touch/?namespace=battery" > /dev/null 2>&1` ) - } catch ( e ) { - log( `Update/install error: `, e ) - await alert( `Error installing battery limiter: ${ e.message }` ) + log( `Error Initializing battery: `, e ) + await alert( `Battery limiter initialization error: ${ e.message }` ) app.quit() app.exit() } @@ -233,7 +210,6 @@ const uninstall_battery = async () => { } - const is_limiter_enabled = async () => { try { @@ -247,7 +223,6 @@ const is_limiter_enabled = async () => { } - module.exports = { enable_battery_limiter, disable_battery_limiter, diff --git a/battery.sh b/battery.sh index c9f7117..2238387 100755 --- a/battery.sh +++ b/battery.sh @@ -12,7 +12,6 @@ PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin ## ############### ## Variables ## ############### -binfolder=/usr/local/bin visudo_folder=/private/etc/sudoers.d visudo_file=${visudo_folder}/battery configfolder=$HOME/.battery @@ -29,6 +28,28 @@ voltage_max="12.6" voltage_hyst_min="0.1" voltage_hyst_max="2" +# SECURITY NOTES: +# - ALWAYS hardcode and use the absolute path to the battery executables to avoid PATH-based spoofing. +# Think of the scenario where 'battery update_silent' running as root invokes 'battery visudo' as a +# PATH spoofing opportunity example. +# - Ensure /usr/local/bin folder, this script, and the smc binary are root-owned and not writable by +# the user or others. +# - Ensure that you are not sourcing any user-writable scripts within this script to avoid overrides of +# critical variables. +binfolder=/usr/local/bin +battery_binary="$binfolder/battery" +smc_binary="$binfolder/smc" + +# GitHub URLs for setup and updates. +# Temporarily set to your username and branch to test update functionality with your fork. +# Security note: Do NOT allow github_user or github_branch to be injected via environment +# variables or any other means. Keep them hardcoded. +github_user="actuallymentor" +github_branch="main" +github_url_setup_sh="https://raw.githubusercontent.com/${github_user}/battery/${github_branch}/setup.sh" +github_url_update_sh="https://raw.githubusercontent.com/${github_user}/battery/${github_branch}/update.sh" +github_url_battery_sh="https://raw.githubusercontent.com/${github_user}/battery/${github_branch}/battery.sh" + ## ############### ## Housekeeping ## ############### @@ -108,17 +129,23 @@ Usage: # Visudo instructions # File location: /etc/sudoers.d/battery -# Purpose: Allows this script to execute 'sudo smc -w' commands without a user password. +# Purpose: +# - Allows this script to execute 'sudo smc -w' commands without requiring a user password. +# - Allows passwordless updates. visudoconfig=" # Visudo settings for the battery utility installed from https://github.com/actuallymentor/battery # intended to be placed in $visudo_file on a mac -# Passwordless SMC writing commands used by battery.sh -Cmnd_Alias CHARGING_OFF = $binfolder/smc -k CH0B -w 02, $binfolder/smc -k CH0C -w 02, $binfolder/smc -k CHTE -w 01000000 -Cmnd_Alias CHARGING_ON = $binfolder/smc -k CH0B -w 00, $binfolder/smc -k CH0C -w 00, $binfolder/smc -k CHTE -w 00000000 -Cmnd_Alias FORCE_DISCHARGE_OFF = $binfolder/smc -k CH0I -w 00, $binfolder/smc -k CHIE -w 00, $binfolder/smc -k CH0J -w 00 -Cmnd_Alias FORCE_DISCHARGE_ON = $binfolder/smc -k CH0I -w 01, $binfolder/smc -k CHIE -w 08, $binfolder/smc -k CH0J -w 01 -Cmnd_Alias LED_CONTROL = $binfolder/smc -k ACLC -w 04, $binfolder/smc -k ACLC -w 03, $binfolder/smc -k ACLC -w 02, $binfolder/smc -k ACLC -w 01, $binfolder/smc -k ACLC -w 00 +# Allow passwordless update (All battery app executables are owned by root to prevent privilege escalation attacks) +ALL ALL = NOPASSWD: $battery_binary update_silent +ALL ALL = NOPASSWD: $battery_binary update_silent is_enabled + +# Allow passwordless battery-charging–related SMC write commands +Cmnd_Alias CHARGING_OFF = $smc_binary -k CH0B -w 02, $smc_binary -k CH0C -w 02, $smc_binary -k CHTE -w 01000000 +Cmnd_Alias CHARGING_ON = $smc_binary -k CH0B -w 00, $smc_binary -k CH0C -w 00, $smc_binary -k CHTE -w 00000000 +Cmnd_Alias FORCE_DISCHARGE_OFF = $smc_binary -k CH0I -w 00, $smc_binary -k CHIE -w 00, $smc_binary -k CH0J -w 00 +Cmnd_Alias FORCE_DISCHARGE_ON = $smc_binary -k CH0I -w 01, $smc_binary -k CHIE -w 08, $smc_binary -k CH0J -w 01 +Cmnd_Alias LED_CONTROL = $smc_binary -k ACLC -w 04, $smc_binary -k ACLC -w 03, $smc_binary -k ACLC -w 02, $smc_binary -k ACLC -w 01, $smc_binary -k ACLC -w 00 ALL ALL = NOPASSWD: CHARGING_OFF ALL ALL = NOPASSWD: CHARGING_ON ALL ALL = NOPASSWD: FORCE_DISCHARGE_OFF @@ -128,12 +155,11 @@ ALL ALL = NOPASSWD: LED_CONTROL # Temporarily keep passwordless SMC reading commands so the old menubar GUI versions don't ask for password on each launch # trying to execute 'battery visudo'. There is no harm in removing this, so do it as soon as you believe users are no # longer using old versions. -Cmnd_Alias TMP_OLD_GUI_COMPATIBILITY = $binfolder/smc -k CH0B -r, $binfolder/smc -k CH0C -r, $binfolder/smc -k CH0I -r, $binfolder/smc -k ACLC -r, $binfolder/smc -k CHTE -r, $binfolder/smc -k CHIE -r, $binfolder/smc -k CH0J -r +Cmnd_Alias TMP_OLD_GUI_COMPATIBILITY = $smc_binary -k CH0B -r, $smc_binary -k CH0C -r, $smc_binary -k CH0I -r, $smc_binary -k ACLC -r, $smc_binary -k CHTE -r, $smc_binary -k CHIE -r, $smc_binary -k CH0J -r ALL ALL = NOPASSWD: TMP_OLD_GUI_COMPATIBILITY " # Get parameters -battery_binary=$0 action=$1 setting=$2 subsetting=$3 @@ -191,7 +217,7 @@ function valid_voltage() { function smc_read_hex() { key=$1 - line=$(echo $(smc -k $key -r)) + line=$(echo $($smc_binary -k $key -r)) if [[ $line =~ "no data" ]]; then echo else @@ -202,7 +228,7 @@ function smc_read_hex() { function smc_write_hex() { local key=$1 local hex_value=$2 - if ! sudo smc -k "$key" -w "$hex_value" >/dev/null 2>&1; then + if ! sudo $smc_binary -k "$key" -w "$hex_value" >/dev/null 2>&1; then log "⚠️ Failed to write $hex_value to $key" return 1 fi @@ -212,11 +238,11 @@ function smc_write_hex() { ## ######################### ## Detect supported SMC keys ## ######################### -[[ $(smc -k CHTE -r) =~ "no data" ]] && smc_supports_tahoe=false || smc_supports_tahoe=true; -[[ $(smc -k CH0B -r) =~ "no data" ]] && smc_supports_legacy=false || smc_supports_legacy=true; -[[ $(smc -k CHIE -r) =~ "no data" ]] && smc_supports_adapter_chie=false || smc_supports_adapter_chie=true; -[[ $(smc -k CH0I -r) =~ "no data" ]] && smc_supports_adapter_ch0i=false || smc_supports_adapter_ch0i=true; -[[ $(smc -k CH0J -r) =~ "no data" || $(smc -k CH0J -r) =~ "Error" ]] && smc_supports_adapter_ch0j=false || smc_supports_adapter_ch0j=true; +[[ $($smc_binary -k CHTE -r) =~ "no data" ]] && smc_supports_tahoe=false || smc_supports_tahoe=true; +[[ $($smc_binary -k CH0B -r) =~ "no data" ]] && smc_supports_legacy=false || smc_supports_legacy=true; +[[ $($smc_binary -k CHIE -r) =~ "no data" ]] && smc_supports_adapter_chie=false || smc_supports_adapter_chie=true; +[[ $($smc_binary -k CH0I -r) =~ "no data" ]] && smc_supports_adapter_ch0i=false || smc_supports_adapter_ch0i=true; +[[ $($smc_binary -k CH0J -r) =~ "no data" || $($smc_binary -k CH0J -r) =~ "Error" ]] && smc_supports_adapter_ch0j=false || smc_supports_adapter_ch0j=true; function log_smc_capabilities() { log "SMC capabilities: tahoe=$smc_supports_tahoe legacy=$smc_supports_legacy CHIE=$smc_supports_adapter_chie CH0I=$smc_supports_adapter_ch0i CH0J=$smc_supports_adapter_ch0j" @@ -235,14 +261,14 @@ function change_magsafe_led_color() { if [[ "$color" == "green" ]]; then log "setting LED to green" - sudo smc -k ACLC -w 03 + sudo $smc_binary -k ACLC -w 03 elif [[ "$color" == "orange" ]]; then log "setting LED to orange" - sudo smc -k ACLC -w 04 + sudo $smc_binary -k ACLC -w 04 else # Default action: reset. Value 00 is a guess and needs confirmation log "resetting LED" - sudo smc -k ACLC -w 00 + sudo $smc_binary -k ACLC -w 00 fi } @@ -257,7 +283,7 @@ function enable_discharging() { else smc_write_hex CH0I 01 fi - sudo smc -k ACLC -w 01 + sudo $smc_binary -k ACLC -w 01 } function disable_discharging() { @@ -412,10 +438,118 @@ function get_voltage() { echo "$voltage" } +## ################## +## Miscellany helpers +## ################## + +function determine_unprivileged_user() { + local username="$1" + if [[ "$username" == "root" ]]; then + log "⚠️ 'battery $action $setting $subsetting': argument user is root, trying to recover" >&2 + username="" + fi + if [[ -z "$username" && -n "$SUDO_USER" && "$SUDO_USER" != "root" ]]; then + username="$SUDO_USER" + fi + if [[ -z "$username" && -n "$USER" && "$USER" != "root" ]]; then + username="$USER" + fi + if [[ -z "$username" && "$HOME" == /Users/* ]]; then + username="$(basename "$HOME")"; + fi + if [[ -z "$username" ]]; then + log "⚠️ 'battery $action $setting $subsetting': unable to determine unprivileged user; falling back to 'logname'" >&2 + username="$(logname 2>/dev/null || true)" + fi + echo "$username" +} + +function assert_unprivileged_user() { + local username="$1" + if [[ -z "$username" || "$username" == "root" ]]; then + log "❌ 'battery $action $setting $subsetting': failed to determine unprivileged user" + exit 11 + fi +} + +function assert_not_running_as_root() { + if [[ $EUID -eq 0 ]]; then + echo " ❌ The following command should not be executed with root privileges:" + echo " battery $action $setting $subsetting" + echo " Please, try running without 'sudo'" + exit 1 + fi +} + +function assert_running_as_root() { + if [[ $EUID -ne 0 ]]; then + log "❌ battery $action $setting $subsetting: must be executed with root privileges" + exit 1 + fi +} + +function ensure_owner() { + local owner="$1" group="$2" path="$3" + [[ -e $path ]] || { return 1; } + local cur_owner=$(stat -f '%Su' "$path") + local cur_group=$(stat -f '%Sg' "$path") + if [[ $cur_owner != "$owner" || $cur_group != "$group" ]]; then + sudo chown "${owner}:${group}" "$path" + fi +} + +function ensure_owner_mode() { + local owner="$1" group="$2" mode="$3" path="$4" + ensure_owner "$owner" "$group" "$path" || return + local cur_mode=$(stat -f '%Lp' "$path") + if [[ $cur_mode != "${mode#0}" ]]; then + sudo chmod "$mode" "$path" + fi +} + +# Use the following function to apply any setup related fixes which require root permissions. +# This function is executed by 'update_silent' action with EUID==0. +function fixup_installation_owner_mode() { + local username=$1 + + ensure_owner_mode root wheel 755 "$visudo_folder" + ensure_owner_mode root wheel 440 "$visudo_file" + + ensure_owner_mode root wheel 755 "$binfolder" + ensure_owner_mode root wheel 755 "$battery_binary" + ensure_owner_mode root wheel 755 "$smc_binary" + + ensure_owner_mode $username staff 755 "$(dirname "$daemon_path")" + ensure_owner_mode $username staff 644 "$daemon_path" + + ensure_owner_mode $username staff 755 "$configfolder" + ensure_owner_mode $username staff 644 "$pidfile" + ensure_owner_mode $username staff 644 "$logfile" + ensure_owner_mode $username staff 644 "$maintain_percentage_tracker_file" + ensure_owner_mode $username staff 644 "$maintain_voltage_tracker_file" + ensure_owner_mode $username staff 644 "$calibrate_pidfile" + + # Do some cleanup after previous versions + sudo rm -f "$configfolder/visudo.tmp" +} + +function is_latest_version_installed() { + curl -sS "$github_url_battery_sh" 2>/dev/null | grep -q "$BATTERY_CLI_VERSION" +} + ## ############### ## Actions ## ############### +# If the config folder or log file were just created by the code above while +# running as root, set the correct ownership and permissions. +if [[ $EUID -eq 0 ]]; then + username="$(determine_unprivileged_user "$SUDO_USER")" + if [[ -n "$username" && "$username" != "root" ]]; then + fixup_installation_owner_mode "$username" + fi +fi + # Help message if [ -z "$action" ] || [[ "$action" == "help" ]]; then echo -e "$helpmessage" @@ -425,33 +559,31 @@ fi # Update '/etc/sudoers.d/battery' config if needed if [[ "$action" == "visudo" ]]; then - # User to set folder ownership to is $setting if it is defined and $USER otherwise - if [[ -z "$setting" ]]; then - setting=$USER - fi - - # Set visudo tempfile ownership to current user - log "Setting visudo file permissions to $setting" - sudo chown -R $setting $configfolder + # Allocate temp folder + tempfolder="$(mktemp -d)" + function cleanup() { rm -rf "$tempfolder"; } + trap cleanup EXIT # Write the visudo file to a tempfile - visudo_tmpfile="$configfolder/visudo.tmp" - sudo rm $visudo_tmpfile 2>/dev/null + visudo_tmpfile="$tempfolder/visudo.tmp" echo -e "$visudoconfig" >$visudo_tmpfile + # If the visudo folder does not exist, make it + if ! test -d "$visudo_folder"; then + sudo mkdir -p "$visudo_folder" + fi + ensure_owner_mode root wheel 755 "$visudo_folder" + # If the visudo file is the same (no error, exit code 0), set the permissions just if sudo cmp $visudo_file $visudo_tmpfile &>/dev/null; then - echo "The existing battery visudo file is what it should be for version $BATTERY_CLI_VERSION" + echo "β˜‘οΈ The existing battery visudo file is what it should be for version $BATTERY_CLI_VERSION" # Check if file permissions are correct, if not, set them - current_visudo_file_permissions=$(stat -f "%Lp" $visudo_file) - if [[ "$current_visudo_file_permissions" != "440" ]]; then - sudo chmod 440 $visudo_file - fi + ensure_owner_mode root wheel 440 "$visudo_file" - # Delete tempfile - rm $visudo_tmpfile + # Delete tempfolder + rm -rf "$tempfolder" # exit because no changes are needed exit 0 @@ -461,24 +593,19 @@ if [[ "$action" == "visudo" ]]; then # Validate that the visudo tempfile is valid if sudo visudo -c -f $visudo_tmpfile &>/dev/null; then - # If the visudo folder does not exist, make it - if ! test -d "$visudo_folder"; then - sudo mkdir -p "$visudo_folder" - fi - # Copy the visudo file from tempfile to live location sudo cp $visudo_tmpfile $visudo_file - # Delete tempfile - rm $visudo_tmpfile - # Set correct permissions on visudo file - sudo chmod 440 $visudo_file + ensure_owner_mode root wheel 440 "$visudo_file" - echo "Visudo file updated successfully" + # Delete tempfolder + rm -rf "$tempfolder" + + echo "βœ… Visudo file updated successfully" else - echo "Error validating visudo file, this should never happen:" + echo "❌ Error validating visudo file, this should never happen:" sudo visudo -c -f $visudo_tmpfile fi @@ -487,37 +614,61 @@ fi # Reinstall helper if [[ "$action" == "reinstall" ]]; then - echo "This will run curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh | bash" + echo "This will run curl -sS ${github_url_setup_sh} | bash" if [[ ! "$setting" == "silent" ]]; then echo "Press any key to continue" read fi - curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh | bash + curl -sS "$github_url_setup_sh" | bash exit 0 fi -# Update helper +# Update helper for GUI app +if [[ "$action" == "update_silent" ]]; then + + assert_running_as_root + + # Exit with success when the GUI app just checks if passwordless updates are enabled + if [[ "$setting" == "is_enabled" ]]; then + exit 0 + fi + + # Try updating + if ! is_latest_version_installed; then + curl -sS "$github_url_update_sh" | bash + echo "βœ… battery background script was updated to the latest version." + else + echo "β˜‘οΈ No updates found" + fi + + # Try updating the visudo configuration on each update attempt to ensure that a + # possibly broken installation is fixed. + # Note: this will overwrite the visudo configuration file only if it is outdated. + $battery_binary visudo + + # Determine the name of unprivileged user + username="$(determine_unprivileged_user "")" + assert_unprivileged_user "$username" + + # Use oportunity to fixup installation + fixup_installation_owner_mode "$username" + + exit 0 +fi + +# Update helper for Terminal users if [[ "$action" == "update" ]]; then - # Check if we have the most recent version - if curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/battery.sh | grep -q "$BATTERY_CLI_VERSION"; then - echo "No need to update, offline version number $BATTERY_CLI_VERSION matches remote version number" - else - echo "This will run curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/update.sh | bash" - if [[ ! "$setting" == "silent" ]]; then - echo "Press any key to continue" - read - fi - curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/update.sh | bash - if [[ ! "$setting" == "silent" ]]; then - # The setting "silent" is always passed when `battery update` is invoked from the UI app, - # which decides whether to invoke `battery visudo` as well. But for Terminal-only users - # there is no UI app. So it's either we invoke `battery visudo` here, or assume that users - # remember to do it themselves, which did not work in the past. - echo "Runnig $battery_binary visudo" - $battery_binary visudo - fi + # Temporarily support the 'silent' setting for backward compatibility with older UI versions. + if [[ "$setting" == "silent" ]]; then + sudo -n $battery_binary update_silent + exit 0 fi + + # If the visudo configuration has been removed, prompt for a password + # before running the "update_silent" action. Do not use "sudo -n" below. + sudo $battery_binary update_silent + exit 0 fi @@ -532,7 +683,7 @@ if [[ "$action" == "uninstall" ]]; then enable_charging disable_discharging $battery_binary remove_daemon - sudo rm -v "$binfolder/smc" "$binfolder/battery" $visudo_file + sudo rm -v "$smc_binary" "$battery_binary" $visudo_file sudo rm -v -r "$configfolder" pkill -f "/usr/local/bin/battery.*" exit 0 @@ -800,6 +951,8 @@ fi # Asynchronous battery level maintenance if [[ "$action" == "maintain" ]]; then + assert_not_running_as_root + # Kill old process silently if test -f "$pidfile"; then log "Killing old maintain process at $(cat $pidfile)" @@ -905,15 +1058,15 @@ if [[ "$action" == "calibrate_synchronous" ]]; then log "Starting calibration" # Stop the maintaining - battery maintain stop + $battery_binary maintain stop # Discharge battery to 15% - battery discharge 15 + $battery_binary discharge 15 while true; do log "checking if at 100%" # Check if battery level has reached 100% - if battery status | head -n 1 | grep -q "Battery at 100%"; then + if $battery_binary status | head -n 1 | grep -q "Battery at 100%"; then break else sleep 300 @@ -926,10 +1079,10 @@ if [[ "$action" == "calibrate_synchronous" ]]; then sleep 3600 # Discharge battery to 80% - battery discharge 80 + $battery_binary discharge 80 # Recover old maintain status - battery maintain recover + $battery_binary maintain recover exit 0 fi @@ -952,7 +1105,7 @@ if [[ "$action" == "calibrate" ]]; then # Start calibration script log "Starting calibration script" - nohup battery calibrate_synchronous >>$logfile & + nohup $battery_binary calibrate_synchronous >>$logfile & # Store pid of calibration process and setting echo $! >$calibrate_pidfile @@ -991,6 +1144,8 @@ fi # launchd daemon creator, inspiration: https://www.launchd.info/ if [[ "$action" == "create_daemon" ]]; then + assert_not_running_as_root + call_action="maintain_synchronous" if test -f "$maintain_voltage_tracker_file"; then call_action="maintain_voltage_synchronous" @@ -1005,7 +1160,7 @@ if [[ "$action" == "create_daemon" ]]; then com.battery.app ProgramArguments - $binfolder/battery + $battery_binary $call_action recover diff --git a/setup.sh b/setup.sh index adca09e..af527fe 100755 --- a/setup.sh +++ b/setup.sh @@ -1,30 +1,63 @@ #!/bin/bash +# +# ‼️ SECURITY NOTES FOR MAINTAINERS: +# +# This app uses a visudo configuration that allows a background script running as +# an unprivileged user to execute battery management commands without requiring a +# user password. This requires careful installation and design to avoid potential +# privilege-escalation vulnerabilities. +# +# Rule of thumb: +# - Unprivileged users must not be able to modify, replace, or inject any code +# that can be executed with root privileges. +# +# For this reason: +# - All battery-related binaries and scripts that are executed via sudo, +# including those that prompt for a user password, must be owned by root. +# - They must not be writable by group or others. +# - Their parent directories must also be owned by root and not be writable by +# unprivileged users, to prevent the replacement of executables. +# + # User welcome message echo -e "\n####################################################################" echo '# πŸ‘‹ Welcome, this is the setup script for the battery CLI tool.' -echo -e "# Note: this script will ask for your password once or multiple times." +echo -e "# Note: this script may ask for your password." echo -e "####################################################################\n\n" -# Set environment variables -tempfolder=~/.battery-tmp -binfolder=/usr/local/bin -mkdir -p $tempfolder +# Determine unprivileged user name +if [[ -n "$1" ]]; then + calling_user="$1" +else + if [[ -n "$SUDO_USER" ]]; then + calling_user=$SUDO_USER + else + calling_user=$USER + fi +fi +if [[ "$calling_user" == "root" ]]; then + echo "❌ Failed to determine unprivileged username" + exit 1 +fi -# Set script value -calling_user=${1:-"$USER"} +# Set variables +tempfolder=/Users/$calling_user/.battery-tmp +binfolder=/usr/local/bin configfolder=/Users/$calling_user/.battery pidfile=$configfolder/battery.pid logfile=$configfolder/battery.log +launch_agent_plist=/Users/$calling_user/Library/LaunchAgents/battery.plist # Ask for sudo once, in most systems this will cache the permissions for a bit sudo echo "πŸ”‹ Starting battery installation" -echo -e "[ 1 ] Superuser permissions acquired." +echo "[ 1 ] Superuser permissions acquired." # Note: github names zips by -.replace( '/', '-' ) update_branch="main" in_zip_folder_name="battery-$update_branch" batteryfolder="$tempfolder/battery" + echo "[ 2 ] Downloading latest version of battery CLI" rm -rf $batteryfolder mkdir -p $batteryfolder @@ -33,44 +66,41 @@ unzip -qq $batteryfolder/repo.zip -d $batteryfolder cp -r $batteryfolder/$in_zip_folder_name/* $batteryfolder rm $batteryfolder/repo.zip -# Move built file to bin folder -echo "[ 3 ] Move smc to executable folder" -sudo mkdir -p $binfolder -sudo cp $batteryfolder/dist/smc $binfolder -sudo chown $calling_user $binfolder/smc -sudo chmod 755 $binfolder/smc -sudo chmod +x $binfolder/smc +echo "[ 3 ] Make sure $binfolder exists and owned by root" +sudo install -d -m 755 -o root -g wheel "$binfolder" -echo "[ 4 ] Writing script to $binfolder/battery for user $calling_user" -sudo cp $batteryfolder/battery.sh $binfolder/battery +echo "[ 4 ] Install prebuilt smc binary into $binfolder" +sudo install -m 755 -o root -g wheel "$batteryfolder/dist/smc" "$binfolder/smc" -echo "[ 5 ] Setting correct file permissions for $calling_user" -# Set permissions for battery executables -sudo chown -R $calling_user $binfolder/battery -sudo chmod 755 $binfolder/battery -sudo chmod +x $binfolder/battery +echo "[ 5 ] Install battery script into $binfolder" +sudo install -m 755 -o root -g wheel "$batteryfolder/battery.sh" "$binfolder/battery" -# Set permissions for logfiles +echo "[ 6 ] Set ownership and permissions for $configfolder" mkdir -p $configfolder sudo chown -R $calling_user $configfolder +sudo chmod 755 $configfolder touch $logfile sudo chown $calling_user $logfile -sudo chmod 755 $logfile +sudo chmod 644 $logfile touch $pidfile sudo chown $calling_user $pidfile -sudo chmod 755 $pidfile +sudo chmod 644 $pidfile -sudo chown $calling_user $binfolder/battery +# Fix permissions for 'create_daemon' action +echo "[ 7 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")" +sudo chown $calling_user "$(dirname "$launch_agent_plist")" +sudo chmod 755 "$(dirname "$launch_agent_plist")" +sudo chown -f $calling_user "$launch_agent_plist" -echo "[ 6 ] Setting up visudo declarations" -sudo $batteryfolder/battery.sh visudo $USER +echo "[ 8 ] Setup visudo configuration" +sudo $binfolder/battery visudo $calling_user sudo chown -R $calling_user $configfolder # Remove tempfiles -cd ../.. -echo "[ 7 ] Removing temp folder $tempfolder" +echo "[ 9 ] Remove temp folder $tempfolder" rm -rf $tempfolder echo -e "\nπŸŽ‰ Battery tool installed. Type \"battery help\" for instructions.\n" +exit 0 diff --git a/update.sh b/update.sh index fb13c58..52cd3f5 100644 --- a/update.sh +++ b/update.sh @@ -1,32 +1,31 @@ #!/bin/bash -# Force-set path to include sbin -PATH="$PATH:/usr/sbin" - -# Set environment variables -tempfolder=~/.battery-tmp -binfolder=/usr/local/bin -batteryfolder="$tempfolder/battery" -mkdir -p $batteryfolder - echo -e "πŸ”‹ Starting battery update\n" -# Write battery function as executable +echo -n "[ 1 ] Allocating temp folder: " +tempfolder="$(mktemp -d)" +echo "$tempfolder" +function cleanup() { + echo "[ 4 ] Removed temporary folder" + rm -rf "$tempfolder"; +} +trap cleanup EXIT -echo "[ 1 ] Downloading latest battery version" -rm -rf $batteryfolder -mkdir -p $batteryfolder -curl -sS -o $batteryfolder/battery.sh https://raw.githubusercontent.com/actuallymentor/battery/main/battery.sh +binfolder=/usr/local/bin +updatefolder="$tempfolder/battery" +mkdir -p $updatefolder -echo "[ 2 ] Writing script to $binfolder/battery" -cp $batteryfolder/battery.sh $binfolder/battery -chown $USER $binfolder/battery -chmod 755 $binfolder/battery -chmod u+x $binfolder/battery - -# Remove tempfiles -cd -rm -rf $tempfolder -echo "[ 3 ] Removed temporary folder" - -echo -e "\nπŸŽ‰ Battery tool updated.\n" +echo "[ 2 ] Downloading latest battery version" +mkdir -p $updatefolder +if curl -sS -o $updatefolder/battery.sh https://raw.githubusercontent.com/actuallymentor/battery/main/battery.sh; then + echo "[ 3 ] Writing script to $binfolder/battery" + set -eu + sudo install -d -m 755 -o root -g wheel "$binfolder" + sudo install -m 755 -o root -g wheel "$updatefolder/battery.sh" "$binfolder/battery" + sudo chown root:wheel "$binfolder/smc" + echo -e "\nπŸŽ‰ Battery tool updated.\n" +else + err=$? + echo -e "\n❌ Failed to download the update.\n" + exit $err +fi From 65a07fcbb1d629e0330c07ae11817f2bff1507c6 Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 12 Feb 2026 00:47:05 +0100 Subject: [PATCH 05/18] battery.js shell-execution helpers: unified output contract, explicit timeouts only, tolerate stderr warnings --- app/modules/battery.js | 95 +++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/app/modules/battery.js b/app/modules/battery.js index e2b5532..e18863e 100644 --- a/app/modules/battery.js +++ b/app/modules/battery.js @@ -6,6 +6,23 @@ const { get_force_discharge_setting } = require( './settings' ) const { USER } = process.env const path_fix = 'PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' const battery = `${ path_fix } battery` + +/* /////////////////////////////// +// Shell-execution helpers +// /////////////////////////////// +// +// exec_async_no_timeout(), exec_async() and exec_sudo_async() are async helper functions +// which run bash-shell commands. +// +// They provide a unified output contract: +// Fulfilled result: { stdout: string, stderr: string } +// Rejected result: 'Error' object having the following extra properties: +// - 'cmd': shell command string +// - 'code': shell exit code or 'ETIMEDOUT' +// - 'output': { stdout: string, stderr: string } +// +// /////////////////////////////*/ + const shell_options = { shell: '/bin/bash', env: { ...process.env, PATH: `${ process.env.PATH }:/usr/local/bin` } @@ -18,21 +35,34 @@ const exec_async_no_timeout = command => new Promise( ( resolve, reject ) => { exec( command, shell_options, ( error, stdout, stderr ) => { - if( error ) return reject( error, stderr, stdout ) - if( stderr ) return reject( stderr ) - if( stdout ) return resolve( stdout ) - if( !stdout ) return resolve( '' ) + const output = { stdout: stdout ?? '', stderr: stderr ?? '' } + if (error) { + error.output = output + return reject( error ) + } else { + return resolve( output ) + } } ) } ) -const exec_async = ( command, timeout_in_ms=2000, throw_on_timeout=false ) => Promise.race( [ - exec_async_no_timeout( command ), - wait( timeout_in_ms ).then( () => { - if( throw_on_timeout ) throw new Error( `${ command } timed out` ) - } ) -] ) +const exec_async = ( command, timeout_in_ms=0 ) => { + + const workers = [ exec_async_no_timeout( command ) ] + if ( timeout_in_ms > 0 ) { + workers.push( + wait(timeout_in_ms).then( () => { + const err = new Error( `${ command } timed out` ) + err.code = 'ETIMEDOUT' + err.output = { stdout: '', stderr: '' } + throw err; + }) + ); + } + + return Promise.race( workers ) +} // Execute with sudo const exec_sudo_async = command => new Promise( ( resolve, reject ) => { @@ -42,21 +72,28 @@ const exec_sudo_async = command => new Promise( ( resolve, reject ) => { exec( `osascript -e "do shell script \\"${ command }\\" with administrator privileges"`, shell_options, ( error, stdout, stderr ) => { - if( error ) return reject( error, stderr, stdout ) - if( stderr ) return reject( stderr ) - if( stdout ) return resolve( stdout ) - if( !stdout ) return resolve( '' ) + const output = { stdout: stdout ?? '', stderr: stderr ?? '' } + if (error) { + error.output = output + return reject(error) + } else { + return resolve(output) + } } ) } ) +/* /////////////////////////////// +// Battery cli functions +// /////////////////////////////*/ + // Battery status checker const get_battery_status = async () => { try { - const message = await exec_async( `${ battery } status_csv` ) - let [ percentage='??', remaining='', charging='', discharging='', maintain_percentage='' ] = message?.split( ',' ) || [] + const result = await exec_async( `${ battery } status_csv` ) + let [ percentage='??', remaining='', charging='', discharging='', maintain_percentage='' ] = result.stdout.split( ',' ) || [] maintain_percentage = maintain_percentage.trim() maintain_percentage = maintain_percentage.length ? maintain_percentage : undefined charging = charging == 'enabled' @@ -79,18 +116,20 @@ const get_battery_status = async () => { } -/* /////////////////////////////// -// Battery cli functions -// /////////////////////////////*/ const enable_battery_limiter = async () => { - try { - // Start battery maintainer const status = await get_battery_status() const allow_force_discharge = get_force_discharge_setting() - await exec_async( `${ battery } maintain ${ status?.maintain_percentage || 80 }${ allow_force_discharge ? ' --force-discharge' : '' }` ) - log( `enable_battery_limiter exec complete` ) + // 'batery maintain' creates a child process, so when the command exits exec_async does not return. + // That's why here we use a timeout and wait for some time. + await exec_async( + `${ battery } maintain ${ status?.maintain_percentage || 80 }${ allow_force_discharge ? ' --force-discharge' : '' }`, + 1000 // timeout in milliseconds + ).catch( e => { + if ( e.code !== 'ETIMEDOUT' ) throw e; + }) + log( `enable_battery_limiter exec completed` ) return status?.percentage } catch ( e ) { log( 'Error enabling battery: ', e ) @@ -149,7 +188,7 @@ const initialize_battery = async () => { // Kill running instances of battery const processes = await exec_async( `ps aux | grep "/usr/local/bin/battery " | wc -l | grep -Eo "\\d*"` ) - log( `Found ${ `${ processes }`.replace( /\n/, '' ) } battery related processed to kill` ) + log( `Found ${ `${ processes.stdout }`.replace( /\n/, '' ) } battery related processed to kill` ) if( is_installed ) await exec_async( `${ battery } maintain stop` ) await exec_async( `pkill -f "/usr/local/bin/battery.*"` ).catch( e => log( `Error killing existing battery processes, usually means no running processes` ) ) @@ -183,7 +222,7 @@ const initialize_battery = async () => { } // Basic user tracking on app open, run it in the background so it does not cause any delay for the user - if( online ) exec_async( `nohup curl "https://unidentifiedanalytics.web.app/touch/?namespace=battery" > /dev/null 2>&1` ) + if( online ) exec_async( `nohup curl "https://unidentifiedanalytics.web.app/touch/?namespace=battery" > /dev/null 2>&1` ).catch(() => {}) } catch ( e ) { log( `Error Initializing battery: `, e ) @@ -213,9 +252,9 @@ const uninstall_battery = async () => { const is_limiter_enabled = async () => { try { - const message = await exec_async( `${ battery } status` ) - log( `Limiter status message: `, message ) - return message?.includes( 'being maintained at' ) + const result = await exec_async( `${ battery } status` ) + log( `Limiter status message: `, result ) + return result.stdout.includes( 'being maintained at' ) } catch ( e ) { log( `Error getting battery status: `, e ) alert( `Battery limiter error: ${ e.message }` ) From 502d9579492f95734c1ecf641fd6c46037ad1d80 Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 12 Feb 2026 01:48:54 +0100 Subject: [PATCH 06/18] gui.log: preserve rich object output --- app/modules/helpers.js | 5 ++++- app/modules/interface.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/modules/helpers.js b/app/modules/helpers.js index 9da04c8..878307a 100644 --- a/app/modules/helpers.js +++ b/app/modules/helpers.js @@ -1,5 +1,7 @@ const { promises: fs } = require( 'fs' ) const { HOME } = process.env +const util = require("util"); + let has_alerted_user_no_home = false const { dialog } = require( 'electron' ) @@ -21,7 +23,8 @@ const log = async ( ...messages ) => { try { if( HOME ) { await fs.mkdir( `${ HOME }/.battery/`, { recursive: true } ) - await fs.appendFile( `${ HOME }/.battery/gui.log`, `${ messages.join( '\n' ) }\n`, 'utf8' ) + const line = util.format(...messages) + "\n"; + await fs.appendFile( `${ HOME }/.battery/gui.log`, line, 'utf8' ) } else if( !has_alerted_user_no_home ) { alert( `No HOME variable set, this should never happen` ) has_alerted_user_no_home = true diff --git a/app/modules/interface.js b/app/modules/interface.js index be7283c..99d6107 100644 --- a/app/modules/interface.js +++ b/app/modules/interface.js @@ -177,7 +177,7 @@ const refresh_logo = async ( percent=80, force ) => { // /////////////////////////////*/ async function set_initial_interface() { - log( "Starting tray app" ) + log('\n===\n=== Starting tray app\n===\n') tray = new Tray( get_logo_template( 100, true ) ) // Set "loading" context From aef458db8ff4677db66c84af64c2c38bb4e3b392 Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 12 Feb 2026 02:03:26 +0100 Subject: [PATCH 07/18] Fix never-ending 'battery charge' action (#439) --- battery.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/battery.sh b/battery.sh index 2238387..5d223ba 100755 --- a/battery.sh +++ b/battery.sh @@ -761,6 +761,8 @@ if [[ "$action" == "charge" ]]; then caffeinate -is sleep 60 fi + battery_percentage=$(get_battery_percentage) + done disable_charging From 8fb2e4d18d35d48a400eef50c5d8d9b2ca5f8331 Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 12 Feb 2026 02:17:13 +0100 Subject: [PATCH 08/18] battery.js: Fix online connectivity check --- app/modules/battery.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/modules/battery.js b/app/modules/battery.js index e18863e..9b3ab07 100644 --- a/app/modules/battery.js +++ b/app/modules/battery.js @@ -165,11 +165,12 @@ const initialize_battery = async () => { if( development ) log( `Dev mode on, skip updates: ${ skipupdate }` ) // Check for network - const online = await Promise.race( [ - exec_async( `${ path_fix } curl -I https://icanhazip.com &> /dev/null` ).then( () => true ).catch( () => false ), - exec_async( `${ path_fix } curl -I https://github.com &> /dev/null` ).then( () => true ).catch( () => false ) - ] ) - log( `Internet online: ${ online }` ) + const online_check_timeout_millisec = 3000 + const online = await Promise.any( [ + exec_async( `${ path_fix } curl -I https://icanhazip.com > /dev/null 2>&1`, online_check_timeout_millisec ), + exec_async( `${ path_fix } curl -I https://github.com > /dev/null 2>&1`, online_check_timeout_millisec ) + ] ).then( () => true ).catch( () => false ) + log( `Internet online: `, online) // Check if battery background executables are installed and owned by root. const [ From 30a30da9b70ce1e3305fdda2bf253f32833bc4d2 Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 12 Feb 2026 02:28:55 +0100 Subject: [PATCH 09/18] Add --help and --version actions (#433) --- battery.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/battery.sh b/battery.sh index 5d223ba..4e6780c 100755 --- a/battery.sh +++ b/battery.sh @@ -550,8 +550,14 @@ if [[ $EUID -eq 0 ]]; then fi fi +# Version message +if [[ "$action" == "version" ]] || [[ "$action" == "--version" ]]; then + echo "$BATTERY_CLI_VERSION" + exit 0 +fi + # Help message -if [ -z "$action" ] || [[ "$action" == "help" ]]; then +if [ -z "$action" ] || [[ "$action" == "help" ]] || [[ "$action" == "--help" ]]; then echo -e "$helpmessage" exit 0 fi From 88f501a3b5a81dcf5e980883df7c42d3e074e686 Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 12 Feb 2026 02:34:42 +0100 Subject: [PATCH 10/18] Fix confusing on/off meaning in 'adapter on/off' action --- battery.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/battery.sh b/battery.sh index 4e6780c..2a72f1d 100755 --- a/battery.sh +++ b/battery.sh @@ -727,9 +727,9 @@ if [[ "$action" == "adapter" ]]; then # Set charging to on and off if [[ "$setting" == "on" ]]; then - enable_discharging - elif [[ "$setting" == "off" ]]; then disable_discharging + elif [[ "$setting" == "off" ]]; then + enable_discharging else log "Error: $setting is not \"on\" or \"off\"." exit 1 From 6fb7abb11c74ff96a425aa40b61d23b9a9d93e48 Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 12 Feb 2026 03:46:46 +0100 Subject: [PATCH 11/18] Add PID to battery.sh log messages --- battery.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/battery.sh b/battery.sh index 2a72f1d..17c7548 100755 --- a/battery.sh +++ b/battery.sh @@ -169,7 +169,7 @@ subsetting=$3 ## ############### function log() { - echo -e "$(date +%D-%T) - $1" + echo -e "$(date +%D-%T) [$$]: $*" } function valid_percentage() { From ba94751a92f5ae7ae115e37e4f9f63c00e8db653 Mon Sep 17 00:00:00 2001 From: base47 Date: Sat, 14 Feb 2026 00:20:26 +0100 Subject: [PATCH 12/18] battery.sh: Check network reachability before version check --- battery.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/battery.sh b/battery.sh index 17c7548..2514d10 100755 --- a/battery.sh +++ b/battery.sh @@ -534,6 +534,9 @@ function fixup_installation_owner_mode() { } function is_latest_version_installed() { + # Check if content is reachable first with HEAD request + curl -sSI "$github_url_battery_sh" &>/dev/null || return 0 + # Start downloading and check version curl -sS "$github_url_battery_sh" 2>/dev/null | grep -q "$BATTERY_CLI_VERSION" } From 2e17e17971e30f0ef569a67e82d0351039c68d11 Mon Sep 17 00:00:00 2001 From: base47 Date: Sat, 14 Feb 2026 03:54:16 +0100 Subject: [PATCH 13/18] Consistent behavior for charge/discharge actions. Fix calibrate action (#301) --- battery.sh | 113 ++++++++++++++++++++++++++--------------------------- 1 file changed, 56 insertions(+), 57 deletions(-) diff --git a/battery.sh b/battery.sh index 2514d10..b0113d4 100755 --- a/battery.sh +++ b/battery.sh @@ -78,11 +78,11 @@ Usage: battery logs LINES[integer, optional] output logs of the battery CLI and GUI - eg: battery logs 100 + eg: battery logs 100 battery maintain PERCENTAGE[1-100,stop] or RANGE[lower-upper] reboot-persistent battery level maintenance: turn off charging above, and on below a certain value - it has the option of a --force-discharge flag that discharges even when plugged in (this does NOT work well with clamshell mode) + it has the option of a --force-discharge flag that discharges even when plugged in (this does NOT work well with clamshell mode) eg: battery maintain 80 # maintain at 80% eg: battery maintain 70-80 # maintain between 70-80% eg: battery maintain stop @@ -103,13 +103,15 @@ Usage: battery calibrate calibrate the battery by discharging it to 15%, then recharging it to 100%, and keeping it there for 1 hour + battery maintenance is restored upon completion + menubar battery app execution and/or battery maintain command will interrupt calibration battery charge LEVEL[1-100] - charge the battery to a certain percentage, and disable charging when that percentage is reached + charge the battery to a certain percentage; battery maintenance is restored upon completion eg: battery charge 90 battery discharge LEVEL[1-100] - block power input from the adapter until battery falls to this level + block adapter power until the battery reaches the specified level; battery maintenance is restored upon completion eg: battery discharge 90 battery visudo @@ -750,18 +752,17 @@ if [[ "$action" == "charge" ]]; then exit 1 fi - # Disable running daemon - $battery_binary maintain stop - - # Disable charge blocker if enabled - $battery_binary adapter on + # Stop battery maintenance if invoked by user from Terminal + if [[ "$BATTERY_HELPER_MODE" != "1" ]]; then + $battery_binary maintain stop + fi # Start charging battery_percentage=$(get_battery_percentage) log "Charging to $setting% from $battery_percentage%" enable_charging # also disables discharging - # Loop until battery percent is exceeded + # Loop until battery charging level is reached while [[ "$battery_percentage" -lt "$setting" ]]; do if [[ "$battery_percentage" -ge "$((setting - 3))" ]]; then @@ -777,6 +778,11 @@ if [[ "$action" == "charge" ]]; then disable_charging log "Charging completed at $battery_percentage%" + # Try restoring maintenance if invoked by user from Terminal + if [[ "$BATTERY_HELPER_MODE" != "1" ]]; then + $battery_binary maintain recover + fi + exit 0 fi @@ -789,12 +795,17 @@ if [[ "$action" == "discharge" ]]; then exit 1 fi - # Start charging + # Stop battery maintenance if invoked by user from Terminal + if [[ "$BATTERY_HELPER_MODE" != "1" ]]; then + $battery_binary maintain stop + fi + + # Start discharging battery_percentage=$(get_battery_percentage) log "Discharging to $setting% from $battery_percentage%" enable_discharging - # Loop until battery percent is exceeded + # Loop until battery charging level is reached while [[ "$battery_percentage" -gt "$setting" ]]; do log "Battery at $battery_percentage% (target $setting%)" @@ -806,6 +817,13 @@ if [[ "$action" == "discharge" ]]; then disable_discharging log "Discharging completed at $battery_percentage%" + # Try restoring maintenance if invoked by user from Terminal + if [[ "$BATTERY_HELPER_MODE" != "1" ]]; then + $battery_binary maintain recover + fi + + exit 0 + fi # Maintain at level @@ -861,7 +879,7 @@ if [[ "$action" == "maintain_synchronous" ]]; then # Before we start maintaining the battery level, first discharge to the target level discharge_target="$lower_bound" log "Triggering discharge to $discharge_target before enabling charging limiter" - $battery_binary discharge "$discharge_target" + BATTERY_HELPER_MODE=1 $battery_binary discharge "$discharge_target" log "Discharge pre battery-maintenance complete, continuing to battery maintenance loop" else log "Not triggering discharge as it is not requested" @@ -1065,62 +1083,43 @@ if [[ "$action" == "maintain" ]]; then fi # Battery calibration -if [[ "$action" == "calibrate_synchronous" ]]; then - log "Starting calibration" +if [[ "$action" == "calibrate" ]]; then # Stop the maintaining - $battery_binary maintain stop + $battery_binary maintain stop &>/dev/null - # Discharge battery to 15% - $battery_binary discharge 15 - - while true; do - log "checking if at 100%" - # Check if battery level has reached 100% - if $battery_binary status | head -n 1 | grep -q "Battery at 100%"; then - break - else - sleep 300 - continue - fi - done - - # Wait before discharging to target level - log "reached 100%, maintaining for 1 hour" - sleep 3600 - - # Discharge battery to 80% - $battery_binary discharge 80 - - # Recover old maintain status - $battery_binary maintain recover - exit 0 -fi - -# Asynchronous battery level maintenance -if [[ "$action" == "calibrate" ]]; then # Kill old process silently if test -f "$calibrate_pidfile"; then pid=$(cat "$calibrate_pidfile" 2>/dev/null) kill $pid &>/dev/null fi + echo $$ >$calibrate_pidfile - if [[ "$setting" == "stop" ]]; then - log "Killing running calibration daemon" - pid=$(cat "$calibrate_pidfile" 2>/dev/null) - kill $pid &>/dev/null - rm $calibrate_pidfile 2>/dev/null + echo -e "Starting battery calibration\n" - exit 0 - fi + echo "[ 1 ] Discharging battery to 15%" + BATTERY_HELPER_MODE=1 $battery_binary discharge 15 &>/dev/null - # Start calibration script - log "Starting calibration script" - nohup $battery_binary calibrate_synchronous >>$logfile & + echo "[ 2 ] Charging to 100%" + BATTERY_HELPER_MODE=1 $battery_binary charge 100 &>/dev/null + + echo "[ 3 ] Reached 100%, waiting for 1 hour" + enable_charging &>/dev/null + sleep 3600 + + echo "[ 4 ] Discharging battery to 80%" + BATTERY_HELPER_MODE=1 $battery_binary discharge 80 &>/dev/null + + # Remove pidfile + rm -f $calibrate_pidfile + + # Recover old maintain status + echo "[ 5 ] Restarting battery maintenance" + $battery_binary maintain recover &>/dev/null + + echo -e "\nβœ… Done\n" + exit 0 - # Store pid of calibration process and setting - echo $! >$calibrate_pidfile - pid=$(cat "$calibrate_pidfile" 2>/dev/null) fi # Status logger From acb0eac3498f82bb26f4ae05764fca1491cd3f99 Mon Sep 17 00:00:00 2001 From: base47 Date: Sun, 15 Feb 2026 20:42:16 +0100 Subject: [PATCH 14/18] Address the PR comments from base47 --- battery.sh | 4 ++-- setup.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/battery.sh b/battery.sh index b0113d4..dafa84a 100755 --- a/battery.sh +++ b/battery.sh @@ -670,9 +670,9 @@ fi # Update helper for Terminal users if [[ "$action" == "update" ]]; then - # Temporarily support the 'silent' setting for backward compatibility with older UI versions. + # The older GUI versions 1_3_2 and below can not run silent passwordless update and + # will complain with alert. Just exit with success and let them update themselves. if [[ "$setting" == "silent" ]]; then - sudo -n $battery_binary update_silent exit 0 fi diff --git a/setup.sh b/setup.sh index af527fe..afad9f2 100755 --- a/setup.sh +++ b/setup.sh @@ -95,7 +95,7 @@ sudo chmod 755 "$(dirname "$launch_agent_plist")" sudo chown -f $calling_user "$launch_agent_plist" echo "[ 8 ] Setup visudo configuration" -sudo $binfolder/battery visudo $calling_user +sudo $binfolder/battery visudo sudo chown -R $calling_user $configfolder # Remove tempfiles From 15fb4552069cbd37b35478f507ec94c5b63a292a Mon Sep 17 00:00:00 2001 From: base47 Date: Thu, 19 Feb 2026 12:58:11 +0100 Subject: [PATCH 15/18] Address most of the PR comments from Copilot --- app/modules/battery.js | 41 +++++++++++++++++++++++------------------ battery.sh | 6 ++++-- setup.sh | 29 ++++++++++++++++------------- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/app/modules/battery.js b/app/modules/battery.js index 9b3ab07..2cd7d9b 100644 --- a/app/modules/battery.js +++ b/app/modules/battery.js @@ -4,8 +4,8 @@ const { exec } = require( 'node:child_process' ) const { log, alert, wait, confirm } = require( './helpers' ) const { get_force_discharge_setting } = require( './settings' ) const { USER } = process.env -const path_fix = 'PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' -const battery = `${ path_fix } battery` +const binfolder = '/usr/local/bin' +const battery = `${ binfolder }/battery` /* /////////////////////////////// // Shell-execution helpers @@ -18,14 +18,14 @@ const battery = `${ path_fix } battery` // Fulfilled result: { stdout: string, stderr: string } // Rejected result: 'Error' object having the following extra properties: // - 'cmd': shell command string -// - 'code': shell exit code or 'ETIMEDOUT' +// - 'code': shell exit code, 'ETIMEDOUT', 'SIGNAL' or 'UNKNOWN' // - 'output': { stdout: string, stderr: string } // // /////////////////////////////*/ const shell_options = { shell: '/bin/bash', - env: { ...process.env, PATH: `${ process.env.PATH }:/usr/local/bin` } + env: { ...process.env, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' } } // Execute without sudo @@ -37,6 +37,8 @@ const exec_async_no_timeout = command => new Promise( ( resolve, reject ) => { const output = { stdout: stdout ?? '', stderr: stderr ?? '' } if (error) { + error.code ??= (error.signal ? 'SIGNAL' : 'UNKNOWN') + error.cmd ??= command error.output = output return reject( error ) } else { @@ -53,10 +55,11 @@ const exec_async = ( command, timeout_in_ms=0 ) => { if ( timeout_in_ms > 0 ) { workers.push( wait(timeout_in_ms).then( () => { - const err = new Error( `${ command } timed out` ) - err.code = 'ETIMEDOUT' - err.output = { stdout: '', stderr: '' } - throw err; + const error = new Error( `${ command } timed out` ) + error.code = 'ETIMEDOUT' + error.cmd = command + error.output = { stdout: '', stderr: '' } + throw error; }) ); } @@ -74,6 +77,8 @@ const exec_sudo_async = command => new Promise( ( resolve, reject ) => { const output = { stdout: stdout ?? '', stderr: stderr ?? '' } if (error) { + error.code ??= (error.signal ? 'SIGNAL' : 'UNKNOWN') + error.cmd ??= command error.output = output return reject(error) } else { @@ -167,8 +172,8 @@ const initialize_battery = async () => { // Check for network const online_check_timeout_millisec = 3000 const online = await Promise.any( [ - exec_async( `${ path_fix } curl -I https://icanhazip.com > /dev/null 2>&1`, online_check_timeout_millisec ), - exec_async( `${ path_fix } curl -I https://github.com > /dev/null 2>&1`, online_check_timeout_millisec ) + exec_async( `curl -I https://icanhazip.com > /dev/null 2>&1`, online_check_timeout_millisec ), + exec_async( `curl -I https://github.com > /dev/null 2>&1`, online_check_timeout_millisec ) ] ).then( () => true ).catch( () => false ) log( `Internet online: `, online) @@ -179,19 +184,19 @@ const initialize_battery = async () => { smc_installed, // Make sure smc binary exists and is root-owned. silent_update_enabled // Make sure visudo config is installed and allows passwordless update ] = await Promise.all( [ - exec_async( `${ path_fix } test "$(stat -f '%u' /usr/local/bin)" -eq 0` ).then( () => true ).catch( log_err_return_false ), - exec_async( `${ path_fix } test "$(stat -f '%u' /usr/local/bin/battery)" -eq 0` ).then( () => true ).catch( log_err_return_false ), - exec_async( `${ path_fix } test "$(stat -f '%u' /usr/local/bin/smc)" -eq 0` ).then( () => true ).catch( log_err_return_false ), - exec_async( `${ path_fix } sudo -n /usr/local/bin/battery update_silent is_enabled` ).then( () => true ).catch( log_err_return_false ) + exec_async( `test "$(stat -f '%u' /usr/local/bin)" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `test "$(stat -f '%u' ${ battery })" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `test "$(stat -f '%u' /usr/local/bin/smc)" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `sudo -n ${ battery } update_silent is_enabled` ).then( () => true ).catch( log_err_return_false ) ] ) const is_installed = bin_dir_root_owned && battery_installed && smc_installed && silent_update_enabled log( 'Is installed? ', is_installed, 'details: ', bin_dir_root_owned, battery_installed, smc_installed, silent_update_enabled ) // Kill running instances of battery - const processes = await exec_async( `ps aux | grep "/usr/local/bin/battery " | wc -l | grep -Eo "\\d*"` ) + const processes = await exec_async( `ps aux | grep "${ battery } " | wc -l | grep -Eo "\\d*"` ) log( `Found ${ `${ processes.stdout }`.replace( /\n/, '' ) } battery related processed to kill` ) if( is_installed ) await exec_async( `${ battery } maintain stop` ) - await exec_async( `pkill -f "/usr/local/bin/battery.*"` ).catch( e => log( `Error killing existing battery processes, usually means no running processes` ) ) + await exec_async( `pkill -f "${ battery }.*"` ).catch( e => log( `Error killing existing battery processes, usually means no running processes` ) ) // Reinstall or try updating if( !is_installed ) { @@ -214,7 +219,7 @@ const initialize_battery = async () => { if( skipupdate ) return log( `Skipping update due to environment variable` ) log( `Updating battery...` ) try { - const result = await exec_async( `${ path_fix } sudo -n /usr/local/bin/battery update_silent` ) + const result = await exec_async( `sudo -n ${ battery } update_silent` ) log( `Update details: `, result ) } catch ( e ) { log( `Battery update failed: `, e ) @@ -239,7 +244,7 @@ const uninstall_battery = async () => { try { const confirmed = await confirm( `Are you sure you want to uninstall Battery?` ) if( !confirmed ) return false - await exec_sudo_async( `${ path_fix } sudo battery uninstall silent` ) + await exec_sudo_async( `sudo ${ battery } uninstall silent` ) await alert( `Battery is now uninstalled!` ) return true } catch ( e ) { diff --git a/battery.sh b/battery.sh index dafa84a..ed1b404 100755 --- a/battery.sh +++ b/battery.sh @@ -6,8 +6,10 @@ ## ############### BATTERY_CLI_VERSION="v1.3.2" -# Path fixes for unexpected environments -PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +# If a script may run as root: +# - Reset PATH to safe defaults at the very beginning of the script. +# - Never include user-owned directories in PATH. +PATH=/usr/bin:/bin:/usr/sbin:/sbin ## ############### ## Variables diff --git a/setup.sh b/setup.sh index afad9f2..faad8eb 100755 --- a/setup.sh +++ b/setup.sh @@ -13,7 +13,7 @@ # that can be executed with root privileges. # # For this reason: -# - All battery-related binaries and scripts that are executed via sudo, +# - All battery-related binaries and scripts that can be executed via sudo, # including those that prompt for a user password, must be owned by root. # - They must not be writable by group or others. # - Their parent directories must also be owned by root and not be writable by @@ -42,7 +42,6 @@ if [[ "$calling_user" == "root" ]]; then fi # Set variables -tempfolder=/Users/$calling_user/.battery-tmp binfolder=/usr/local/bin configfolder=/Users/$calling_user/.battery pidfile=$configfolder/battery.pid @@ -51,14 +50,19 @@ launch_agent_plist=/Users/$calling_user/Library/LaunchAgents/battery.plist # Ask for sudo once, in most systems this will cache the permissions for a bit sudo echo "πŸ”‹ Starting battery installation" -echo "[ 1 ] Superuser permissions acquired." +echo "[ 1 ] Superuser permissions acquired." # Note: github names zips by -.replace( '/', '-' ) update_branch="main" in_zip_folder_name="battery-$update_branch" -batteryfolder="$tempfolder/battery" -echo "[ 2 ] Downloading latest version of battery CLI" +echo "[ 2 ] Allocate temp folder" +tempfolder="$(mktemp -d)" +function cleanup() { rm -rf "$tempfolder"; } +trap cleanup EXIT + +echo "[ 3 ] Downloading latest version of battery CLI" +batteryfolder="$tempfolder/battery" rm -rf $batteryfolder mkdir -p $batteryfolder curl -sSL -o $batteryfolder/repo.zip "https://github.com/actuallymentor/battery/archive/refs/heads/$update_branch.zip" @@ -66,16 +70,16 @@ unzip -qq $batteryfolder/repo.zip -d $batteryfolder cp -r $batteryfolder/$in_zip_folder_name/* $batteryfolder rm $batteryfolder/repo.zip -echo "[ 3 ] Make sure $binfolder exists and owned by root" +echo "[ 4 ] Make sure $binfolder exists and owned by root" sudo install -d -m 755 -o root -g wheel "$binfolder" -echo "[ 4 ] Install prebuilt smc binary into $binfolder" +echo "[ 5 ] Install prebuilt smc binary into $binfolder" sudo install -m 755 -o root -g wheel "$batteryfolder/dist/smc" "$binfolder/smc" -echo "[ 5 ] Install battery script into $binfolder" +echo "[ 6 ] Install battery script into $binfolder" sudo install -m 755 -o root -g wheel "$batteryfolder/battery.sh" "$binfolder/battery" -echo "[ 6 ] Set ownership and permissions for $configfolder" +echo "[ 7 ] Set ownership and permissions for $configfolder" mkdir -p $configfolder sudo chown -R $calling_user $configfolder sudo chmod 755 $configfolder @@ -89,17 +93,16 @@ sudo chown $calling_user $pidfile sudo chmod 644 $pidfile # Fix permissions for 'create_daemon' action -echo "[ 7 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")" +echo "[ 8 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")" sudo chown $calling_user "$(dirname "$launch_agent_plist")" sudo chmod 755 "$(dirname "$launch_agent_plist")" sudo chown -f $calling_user "$launch_agent_plist" -echo "[ 8 ] Setup visudo configuration" +echo "[ 9 ] Setup visudo configuration" sudo $binfolder/battery visudo sudo chown -R $calling_user $configfolder -# Remove tempfiles -echo "[ 9 ] Remove temp folder $tempfolder" +echo "[ 10 ] Remove temp folder $tempfolder" rm -rf $tempfolder echo -e "\nπŸŽ‰ Battery tool installed. Type \"battery help\" for instructions.\n" From 18f75882a4082251d74a23bcc23adb0667355b3e Mon Sep 17 00:00:00 2001 From: base47 Date: Sat, 21 Feb 2026 00:38:04 +0100 Subject: [PATCH 16/18] Relocate battery background executables to /usr/local/co.palokaj.battery --- app/modules/battery.js | 35 ++++++++++++------ battery.sh | 82 ++++++++++++++++++++++++++++++++---------- setup.sh | 42 ++++++++++++++++------ update.sh | 65 +++++++++++++++++++++++++-------- 4 files changed, 170 insertions(+), 54 deletions(-) diff --git a/app/modules/battery.js b/app/modules/battery.js index 2cd7d9b..94fba93 100644 --- a/app/modules/battery.js +++ b/app/modules/battery.js @@ -4,7 +4,7 @@ const { exec } = require( 'node:child_process' ) const { log, alert, wait, confirm } = require( './helpers' ) const { get_force_discharge_setting } = require( './settings' ) const { USER } = process.env -const binfolder = '/usr/local/bin' +const binfolder = '/usr/local/co.palokaj.battery' const battery = `${ binfolder }/battery` /* /////////////////////////////// @@ -116,7 +116,14 @@ const get_battery_status = async () => { } catch ( e ) { log( `Error getting battery status: `, e ) - alert( `Battery limiter error: ${ e.message }` ) + await alert( `Battery limiter error: ${ e.message }` ) + const ERR_COMMAND_NOT_FOUND = 127 + if ( e.code === ERR_COMMAND_NOT_FOUND ) { + // No battery script found. Constant alerts will be preventing a user from quitting, so do it now. + // Happens if battery is uninstalled from Terminal while the app is running. + app.quit() + app.exit() + } } } @@ -178,25 +185,29 @@ const initialize_battery = async () => { log( `Internet online: `, online) // Check if battery background executables are installed and owned by root. + // Note: We assume that ownership and permissions of /usr/local folders are SIP protected by macOS. const [ bin_dir_root_owned, // This is important. Other software can potentially change the owner allowing for battery executable replacement. battery_installed, // Make sure battery script exists and is root-owned. smc_installed, // Make sure smc binary exists and is root-owned. silent_update_enabled // Make sure visudo config is installed and allows passwordless update ] = await Promise.all( [ - exec_async( `test "$(stat -f '%u' /usr/local/bin)" -eq 0` ).then( () => true ).catch( log_err_return_false ), - exec_async( `test "$(stat -f '%u' ${ battery })" -eq 0` ).then( () => true ).catch( log_err_return_false ), - exec_async( `test "$(stat -f '%u' /usr/local/bin/smc)" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `test ! -L ${ binfolder } && test "$(stat -f '%u' ${ binfolder })" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `test ! -L ${ battery } && test "$(stat -f '%u' ${ battery })" -eq 0` ).then( () => true ).catch( log_err_return_false ), + exec_async( `test ! -L ${ binfolder }/smc && test "$(stat -f '%u' ${ binfolder }/smc)" -eq 0` ).then( () => true ).catch( log_err_return_false ), exec_async( `sudo -n ${ battery } update_silent is_enabled` ).then( () => true ).catch( log_err_return_false ) ] ) const is_installed = bin_dir_root_owned && battery_installed && smc_installed && silent_update_enabled log( 'Is installed? ', is_installed, 'details: ', bin_dir_root_owned, battery_installed, smc_installed, silent_update_enabled ) // Kill running instances of battery - const processes = await exec_async( `ps aux | grep "${ battery } " | wc -l | grep -Eo "\\d*"` ) - log( `Found ${ `${ processes.stdout }`.replace( /\n/, '' ) } battery related processed to kill` ) - if( is_installed ) await exec_async( `${ battery } maintain stop` ) - await exec_async( `pkill -f "${ battery }.*"` ).catch( e => log( `Error killing existing battery processes, usually means no running processes` ) ) + // Why are we doing this: The maintenance battery process which launches on macOS startup does not update a + // pidfile (bug). Consider removing the following lines when process management improves. + if( is_installed ) await exec_async( `${ battery } maintain stop` ).catch( log_err_return_false ) + const battery_process_pattern = `/usr/local/bin/battery.*|${battery.replace(/\./g, '\\.')}.*` + const processes = await exec_async( `ps aux | grep -E "${battery_process_pattern}" | grep -v grep | wc -l | grep -Eo "\\d*"` ).catch( e => e.output ) + log( `Found '${ `${ processes.stdout }`.replace( /\n/, '' ) }' dangling battery processes to kill` ) + await exec_async( `pkill -f "${battery_process_pattern}"` ).catch( e => log( `Error killing existing battery processes, usually means no running processes` ) ) // Reinstall or try updating if( !is_installed ) { @@ -244,7 +255,11 @@ const uninstall_battery = async () => { try { const confirmed = await confirm( `Are you sure you want to uninstall Battery?` ) if( !confirmed ) return false - await exec_sudo_async( `sudo ${ battery } uninstall silent` ) + await exec_sudo_async( `sudo ${ battery } uninstall silent` ).catch( e => { + // Being killed is an expected successful completion for 'battery uninstall' and the osascript running it. + // If you need to change this, check whether 'pkill -f ' is still used by 'battery uninstall'. + if ( e.code !== 'SIGNAL' ) throw e; + }) await alert( `Battery is now uninstalled!` ) return true } catch ( e ) { diff --git a/battery.sh b/battery.sh index ed1b404..9e5fd44 100755 --- a/battery.sh +++ b/battery.sh @@ -23,6 +23,7 @@ maintain_percentage_tracker_file=$configfolder/maintain.percentage maintain_voltage_tracker_file=$configfolder/maintain.voltage daemon_path=$HOME/Library/LaunchAgents/battery.plist calibrate_pidfile=$configfolder/calibrate.pid +path_configfile=/etc/paths.d/50-battery # Voltage limits voltage_min="10.5" @@ -34,11 +35,11 @@ voltage_hyst_max="2" # - ALWAYS hardcode and use the absolute path to the battery executables to avoid PATH-based spoofing. # Think of the scenario where 'battery update_silent' running as root invokes 'battery visudo' as a # PATH spoofing opportunity example. -# - Ensure /usr/local/bin folder, this script, and the smc binary are root-owned and not writable by +# - Ensure this script, smc binary and their parent folders are root-owned and not writable by # the user or others. # - Ensure that you are not sourcing any user-writable scripts within this script to avoid overrides of -# critical variables. -binfolder=/usr/local/bin +# security critical variables. +binfolder="/usr/local/co.palokaj.battery" battery_binary="$binfolder/battery" smc_binary="$binfolder/smc" @@ -116,10 +117,6 @@ Usage: block adapter power until the battery reaches the specified level; battery maintenance is restored upon completion eg: battery discharge 90 - battery visudo - ensure you don't need to call battery with sudo - This is already used in the setup script, so you should't need it. - battery update update the battery utility to the latest version @@ -159,8 +156,7 @@ ALL ALL = NOPASSWD: LED_CONTROL # Temporarily keep passwordless SMC reading commands so the old menubar GUI versions don't ask for password on each launch # trying to execute 'battery visudo'. There is no harm in removing this, so do it as soon as you believe users are no # longer using old versions. -Cmnd_Alias TMP_OLD_GUI_COMPATIBILITY = $smc_binary -k CH0B -r, $smc_binary -k CH0C -r, $smc_binary -k CH0I -r, $smc_binary -k ACLC -r, $smc_binary -k CHTE -r, $smc_binary -k CHIE -r, $smc_binary -k CH0J -r -ALL ALL = NOPASSWD: TMP_OLD_GUI_COMPATIBILITY +ALL ALL = NOPASSWD: $smc_binary -k CH0C -r, $smc_binary -k CH0I -r, $smc_binary -k ACLC -r, $smc_binary -k CHIE -r, $smc_binary -k CHTE -r " # Get parameters @@ -654,8 +650,8 @@ if [[ "$action" == "update_silent" ]]; then echo "β˜‘οΈ No updates found" fi - # Try updating the visudo configuration on each update attempt to ensure that a - # possibly broken installation is fixed. + # Update the visudo configuration on each update ensuring that the latest version + # is always installed. # Note: this will overwrite the visudo configuration file only if it is outdated. $battery_binary visudo @@ -663,7 +659,7 @@ if [[ "$action" == "update_silent" ]]; then username="$(determine_unprivileged_user "")" assert_unprivileged_user "$username" - # Use oportunity to fixup installation + # Use opportunity to fixup installation fixup_installation_owner_mode "$username" exit 0 @@ -672,15 +668,49 @@ fi # Update helper for Terminal users if [[ "$action" == "update" ]]; then + assert_not_running_as_root + # The older GUI versions 1_3_2 and below can not run silent passwordless update and # will complain with alert. Just exit with success and let them update themselves. + # Remove this condition in future versions when you believe the old UI is not used anymore. if [[ "$setting" == "silent" ]]; then exit 0 fi - # If the visudo configuration has been removed, prompt for a password - # before running the "update_silent" action. Do not use "sudo -n" below. - sudo $battery_binary update_silent + if ! curl -fsI "$github_url_battery_sh" &>/dev/null; then + echo "❌ Can't check for updates: no internet connection (or GitHub unreachable)." + exit 1 + fi + + # The code below repeats integrity checks from GUI app, specifically from + # app/modules/battery.js: 'initialize_battery'. Try keeping it consistent. + + function check_installation_integrity() ( + function not_link_and_root_owned() { + [[ ! -L "$1" ]] && [[ $(stat -f '%u' "$1") -eq 0 ]] + } + + not_link_and_root_owned "$binfolder" && \ + not_link_and_root_owned "$battery_binary" && \ + not_link_and_root_owned "$smc_binary" && \ + sudo -n "$battery_binary" update_silent is_enabled >/dev/null 2>&1 + ) + + version_before="$($battery_binary version)" + + if ! check_installation_integrity; then + echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n" + $battery_binary reinstall silent + version_before="0" # Force restart maintenance process + else + sudo $battery_binary update_silent + fi + + # Restart background maintenance process if update was installed + if [[ -x $battery_binary ]] && [[ "$($battery_binary version)" != "$version_before" ]]; then + printf "\n%s\n" "πŸ› οΈ Restarting 'battery maintain' ..." + $battery_binary maintain recover + fi exit 0 fi @@ -693,12 +723,24 @@ if [[ "$action" == "uninstall" ]]; then echo "Press any key to continue" read fi + + $battery_binary maintain stop + $battery_binary remove_daemon + enable_charging disable_discharging - $battery_binary remove_daemon - sudo rm -v "$smc_binary" "$battery_binary" $visudo_file - sudo rm -v -r "$configfolder" - pkill -f "/usr/local/bin/battery.*" + + sudo rm -fv /usr/local/bin/battery + sudo rm -fv /usr/local/bin/smc + + sudo rm -fv "$visudo_file" + sudo rm -frv "$binfolder" + sudo rm -frv "$configfolder" + sudo rm -fv "$path_configfile" + + # Ensure no dangling battery processes are left running + pkill -f "/usr/local/bin/battery.*|/usr/local/co\.palokaj\.battery/battery.*" + exit 0 fi @@ -984,6 +1026,8 @@ if [[ "$action" == "maintain" ]]; then assert_not_running_as_root + disable_discharging + # Kill old process silently if test -f "$pidfile"; then log "Killing old maintain process at $(cat $pidfile)" diff --git a/setup.sh b/setup.sh index faad8eb..fceb311 100755 --- a/setup.sh +++ b/setup.sh @@ -20,6 +20,9 @@ # unprivileged users, to prevent the replacement of executables. # +# Reset PATH to minimal safe defaults +PATH=/usr/bin:/bin:/usr/sbin:/sbin + # User welcome message echo -e "\n####################################################################" echo '# πŸ‘‹ Welcome, this is the setup script for the battery CLI tool.' @@ -42,19 +45,20 @@ if [[ "$calling_user" == "root" ]]; then fi # Set variables -binfolder=/usr/local/bin +binfolder=/usr/local/co.palokaj.battery configfolder=/Users/$calling_user/.battery pidfile=$configfolder/battery.pid logfile=$configfolder/battery.log launch_agent_plist=/Users/$calling_user/Library/LaunchAgents/battery.plist +path_configfile=/etc/paths.d/50-battery # Ask for sudo once, in most systems this will cache the permissions for a bit sudo echo "πŸ”‹ Starting battery installation" echo "[ 1 ] Superuser permissions acquired." -# Note: github names zips by -.replace( '/', '-' ) -update_branch="main" -in_zip_folder_name="battery-$update_branch" +# Cleanup after versions 1_3_2 and below +sudo rm -f /usr/local/bin/battery +sudo rm -f /usr/local/bin/smc echo "[ 2 ] Allocate temp folder" tempfolder="$(mktemp -d)" @@ -62,6 +66,9 @@ function cleanup() { rm -rf "$tempfolder"; } trap cleanup EXIT echo "[ 3 ] Downloading latest version of battery CLI" +# Note: github names zips by -.replace( '/', '-' ) +update_branch="main" +in_zip_folder_name="battery-$update_branch" batteryfolder="$tempfolder/battery" rm -rf $batteryfolder mkdir -p $batteryfolder @@ -79,7 +86,22 @@ sudo install -m 755 -o root -g wheel "$batteryfolder/dist/smc" "$binfolder/smc" echo "[ 6 ] Install battery script into $binfolder" sudo install -m 755 -o root -g wheel "$batteryfolder/battery.sh" "$binfolder/battery" -echo "[ 7 ] Set ownership and permissions for $configfolder" +echo "[ 7 ] Make sure the PATH environment variable includes '$binfolder'" +if ! grep -qF "$binfolder" $path_configfile 2>/dev/null; then + printf '%s\n' "$binfolder" | sudo tee "$path_configfile" >/dev/null +fi +sudo chown root:wheel $path_configfile +sudo chmod 644 $path_configfile +# Create a symlink for rare shells that do not initialize PATH from /etc/paths.d (including the current one) +sudo mkdir -p /usr/local/bin +sudo ln -sf "$binfolder/battery" /usr/local/bin/battery +sudo chown root:wheel /usr/local/bin/battery +# Create a link to smc as well to silence older GUI apps running with updated background executables +# (consider removing in the next releases) +sudo ln -sf "$binfolder/smc" /usr/local/bin/smc +sudo chown root:wheel /usr/local/bin/smc + +echo "[ 8 ] Set ownership and permissions for $configfolder" mkdir -p $configfolder sudo chown -R $calling_user $configfolder sudo chmod 755 $configfolder @@ -93,17 +115,17 @@ sudo chown $calling_user $pidfile sudo chmod 644 $pidfile # Fix permissions for 'create_daemon' action -echo "[ 8 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")" +echo "[ 9 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")" sudo chown $calling_user "$(dirname "$launch_agent_plist")" sudo chmod 755 "$(dirname "$launch_agent_plist")" -sudo chown -f $calling_user "$launch_agent_plist" +sudo chown -f $calling_user "$launch_agent_plist" 2>/dev/null -echo "[ 9 ] Setup visudo configuration" +echo "[ 10 ] Setup visudo configuration" sudo $binfolder/battery visudo -sudo chown -R $calling_user $configfolder -echo "[ 10 ] Remove temp folder $tempfolder" +echo "[ 11 ] Remove temp folder $tempfolder" rm -rf $tempfolder echo -e "\nπŸŽ‰ Battery tool installed. Type \"battery help\" for instructions.\n" + exit 0 diff --git a/update.sh b/update.sh index 52cd3f5..48e7b2b 100644 --- a/update.sh +++ b/update.sh @@ -2,30 +2,65 @@ echo -e "πŸ”‹ Starting battery update\n" +# This script is running as root: +# - Reset PATH to safe defaults at the very beginning of the script. +# - Never include user-owned directories in PATH. +PATH=/usr/bin:/bin:/usr/sbin:/sbin + +# Define the installation directory for the battery background executables +binfolder="/usr/local/co.palokaj.battery" + +function is_launched_by_gui_app() { + # Determine the process group ID (PGID) of the current process + local this_process_pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + # Return 0 if any process in the same process group has battery.app or Electron.app in its command string + ps -x -g $this_process_pgid -o command= -ww 2>/dev/null | grep -qE '(battery\.app|Electron\.app)' >&/dev/null +} + +# If running as an unprivileged user and launched by GUI app +if [[ $EUID -ne 0 ]] && is_launched_by_gui_app; then + # This execution path is taken when GUI app version 1_3_2 or earlier launches update using + # the battery script of the same version. This is expected when a new version of battery.sh + # is released but the GUI app has not been updated yet. + # Exit successfully with a silent warning to avoid disrupting the existing installation. + printf "%s\n%s\n" \ + "The update to the next version requires root privileges." \ + "Run the updated menu bar GUI app or issue the 'battery update' command in Terminal." + exit 0 +fi + +# Trigger reinstall for Terminal users to update from version 1_3_2 or earlier. +# Consider removing the following if..fi block in future versions when you believe +# that users are no longer using versions 1_3_2 or earlier. New versions of battery.sh are using +# more comprehensive checks in 'battery update' in order to trigger 'battery reinstall' when needed. +if [[ $EUID -ne 0 && ! -x "$binfolder/battery" ]]; then + echo -e "πŸ’‘ This battery update requires a full reinstall...\n" + curl -sS "https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh" | bash + $binfolder/battery maintain recover + exit 0 +fi + echo -n "[ 1 ] Allocating temp folder: " tempfolder="$(mktemp -d)" echo "$tempfolder" -function cleanup() { - echo "[ 4 ] Removed temporary folder" - rm -rf "$tempfolder"; -} +function cleanup() { rm -rf "$tempfolder"; } trap cleanup EXIT -binfolder=/usr/local/bin updatefolder="$tempfolder/battery" mkdir -p $updatefolder -echo "[ 2 ] Downloading latest battery version" -mkdir -p $updatefolder -if curl -sS -o $updatefolder/battery.sh https://raw.githubusercontent.com/actuallymentor/battery/main/battery.sh; then - echo "[ 3 ] Writing script to $binfolder/battery" - set -eu - sudo install -d -m 755 -o root -g wheel "$binfolder" - sudo install -m 755 -o root -g wheel "$updatefolder/battery.sh" "$binfolder/battery" - sudo chown root:wheel "$binfolder/smc" - echo -e "\nπŸŽ‰ Battery tool updated.\n" -else +echo "[ 2 ] Downloading the latest battery version" +if ! curl -sS -o $updatefolder/battery.sh https://raw.githubusercontent.com/actuallymentor/battery/main/battery.sh; then err=$? echo -e "\n❌ Failed to download the update.\n" exit $err fi + +echo "[ 3 ] Writing script to $binfolder/battery" +sudo install -d -m 755 -o root -g wheel "$binfolder" +sudo install -m 755 -o root -g wheel "$updatefolder/battery.sh" "$binfolder/battery" + +echo "[ 4 ] Remove temporary folder" +rm -rf "$tempfolder"; + +echo -e "\nπŸŽ‰ Battery tool updated.\n" From 80d00f682627f41b32436d56d962edab2736bc28 Mon Sep 17 00:00:00 2001 From: base47 Date: Sun, 22 Feb 2026 20:40:16 +0100 Subject: [PATCH 17/18] setup.sh: start with an empty binfolder and ensure there is no symlink or file at the path --- setup.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.sh b/setup.sh index fceb311..ebb30bf 100755 --- a/setup.sh +++ b/setup.sh @@ -77,7 +77,8 @@ unzip -qq $batteryfolder/repo.zip -d $batteryfolder cp -r $batteryfolder/$in_zip_folder_name/* $batteryfolder rm $batteryfolder/repo.zip -echo "[ 4 ] Make sure $binfolder exists and owned by root" +echo "[ 4 ] Make sure $binfolder is recreated and owned by root" +sudo rm -rf "$binfolder" # start with an empty $binfolder and ensure there is no symlink or file at the path sudo install -d -m 755 -o root -g wheel "$binfolder" echo "[ 5 ] Install prebuilt smc binary into $binfolder" From fdced04a677a2f3d7c38d33daae77e92eae6aefe Mon Sep 17 00:00:00 2001 From: base47 Date: Wed, 25 Feb 2026 02:00:33 +0100 Subject: [PATCH 18/18] Address the second set of comments from Copilot --- app/modules/battery.js | 2 +- app/modules/helpers.js | 2 +- battery.sh | 23 +++++++++++------------ setup.sh | 26 +++++++++++++------------- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/app/modules/battery.js b/app/modules/battery.js index 94fba93..5a8763e 100644 --- a/app/modules/battery.js +++ b/app/modules/battery.js @@ -133,7 +133,7 @@ const enable_battery_limiter = async () => { try { const status = await get_battery_status() const allow_force_discharge = get_force_discharge_setting() - // 'batery maintain' creates a child process, so when the command exits exec_async does not return. + // 'battery maintain' creates a child process, so when the command exits exec_async does not return. // That's why here we use a timeout and wait for some time. await exec_async( `${ battery } maintain ${ status?.maintain_percentage || 80 }${ allow_force_discharge ? ' --force-discharge' : '' }`, diff --git a/app/modules/helpers.js b/app/modules/helpers.js index 878307a..cae7ad1 100644 --- a/app/modules/helpers.js +++ b/app/modules/helpers.js @@ -1,6 +1,6 @@ const { promises: fs } = require( 'fs' ) const { HOME } = process.env -const util = require("util"); +const util = require( 'util' ) let has_alerted_user_no_home = false diff --git a/battery.sh b/battery.sh index 9e5fd44..2bbc109 100755 --- a/battery.sh +++ b/battery.sh @@ -494,7 +494,7 @@ function ensure_owner() { local cur_owner=$(stat -f '%Su' "$path") local cur_group=$(stat -f '%Sg' "$path") if [[ $cur_owner != "$owner" || $cur_group != "$group" ]]; then - sudo chown "${owner}:${group}" "$path" + sudo chown -h "${owner}:${group}" "$path" fi } @@ -503,7 +503,7 @@ function ensure_owner_mode() { ensure_owner "$owner" "$group" "$path" || return local cur_mode=$(stat -f '%Lp' "$path") if [[ $cur_mode != "${mode#0}" ]]; then - sudo chmod "$mode" "$path" + sudo chmod -h "$mode" "$path" fi } @@ -512,13 +512,6 @@ function ensure_owner_mode() { function fixup_installation_owner_mode() { local username=$1 - ensure_owner_mode root wheel 755 "$visudo_folder" - ensure_owner_mode root wheel 440 "$visudo_file" - - ensure_owner_mode root wheel 755 "$binfolder" - ensure_owner_mode root wheel 755 "$battery_binary" - ensure_owner_mode root wheel 755 "$smc_binary" - ensure_owner_mode $username staff 755 "$(dirname "$daemon_path")" ensure_owner_mode $username staff 644 "$daemon_path" @@ -529,6 +522,13 @@ function fixup_installation_owner_mode() { ensure_owner_mode $username staff 644 "$maintain_voltage_tracker_file" ensure_owner_mode $username staff 644 "$calibrate_pidfile" + ensure_owner_mode root wheel 755 "$visudo_folder" + ensure_owner_mode root wheel 440 "$visudo_file" + + ensure_owner_mode root wheel 755 "$binfolder" + ensure_owner_mode root wheel 755 "$battery_binary" + ensure_owner_mode root wheel 755 "$smc_binary" + # Do some cleanup after previous versions sudo rm -f "$configfolder/visudo.tmp" } @@ -696,13 +696,12 @@ if [[ "$action" == "update" ]]; then sudo -n "$battery_binary" update_silent is_enabled >/dev/null 2>&1 ) - version_before="$($battery_binary version)" - if ! check_installation_integrity; then + version_before="0" # Force restart maintenance process echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n" $battery_binary reinstall silent - version_before="0" # Force restart maintenance process else + version_before="$($battery_binary version)" sudo $battery_binary update_silent fi diff --git a/setup.sh b/setup.sh index ebb30bf..4927597 100755 --- a/setup.sh +++ b/setup.sh @@ -91,35 +91,35 @@ echo "[ 7 ] Make sure the PATH environment variable includes '$binfolder'" if ! grep -qF "$binfolder" $path_configfile 2>/dev/null; then printf '%s\n' "$binfolder" | sudo tee "$path_configfile" >/dev/null fi -sudo chown root:wheel $path_configfile -sudo chmod 644 $path_configfile +sudo chown -h root:wheel $path_configfile +sudo chmod -h 644 $path_configfile # Create a symlink for rare shells that do not initialize PATH from /etc/paths.d (including the current one) sudo mkdir -p /usr/local/bin sudo ln -sf "$binfolder/battery" /usr/local/bin/battery -sudo chown root:wheel /usr/local/bin/battery +sudo chown -h root:wheel /usr/local/bin/battery # Create a link to smc as well to silence older GUI apps running with updated background executables # (consider removing in the next releases) sudo ln -sf "$binfolder/smc" /usr/local/bin/smc -sudo chown root:wheel /usr/local/bin/smc +sudo chown -h root:wheel /usr/local/bin/smc echo "[ 8 ] Set ownership and permissions for $configfolder" mkdir -p $configfolder -sudo chown -R $calling_user $configfolder -sudo chmod 755 $configfolder +sudo chown -hRP $calling_user $configfolder +sudo chmod -h 755 $configfolder touch $logfile -sudo chown $calling_user $logfile -sudo chmod 644 $logfile +sudo chown -h $calling_user $logfile +sudo chmod -h 644 $logfile touch $pidfile -sudo chown $calling_user $pidfile -sudo chmod 644 $pidfile +sudo chown -h $calling_user $pidfile +sudo chmod -h 644 $pidfile # Fix permissions for 'create_daemon' action echo "[ 9 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")" -sudo chown $calling_user "$(dirname "$launch_agent_plist")" -sudo chmod 755 "$(dirname "$launch_agent_plist")" -sudo chown -f $calling_user "$launch_agent_plist" 2>/dev/null +sudo chown -h $calling_user "$(dirname "$launch_agent_plist")" +sudo chmod -h 755 "$(dirname "$launch_agent_plist")" +sudo chown -hf $calling_user "$launch_agent_plist" 2>/dev/null echo "[ 10 ] Setup visudo configuration" sudo $binfolder/battery visudo