Merge pull request #446 from base47/root-owned-executables

Fix security issues, simplify app setup/updates, fix calibrate and some minor issues
This commit is contained in:
Mentor Palokaj
2026-02-25 11:55:36 +01:00
committed by GitHub
6 changed files with 661 additions and 323 deletions

View File

@@ -4,11 +4,28 @@ 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/co.palokaj.battery'
const battery = `${ binfolder }/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, '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
@@ -18,21 +35,37 @@ 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.code ??= (error.signal ? 'SIGNAL' : 'UNKNOWN')
error.cmd ??= command
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 error = new Error( `${ command } timed out` )
error.code = 'ETIMEDOUT'
error.cmd = command
error.output = { stdout: '', stderr: '' }
throw error;
})
);
}
return Promise.race( workers )
}
// Execute with sudo
const exec_sudo_async = command => new Promise( ( resolve, reject ) => {
@@ -42,21 +75,30 @@ 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.code ??= (error.signal ? 'SIGNAL' : 'UNKNOWN')
error.cmd ??= command
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'
@@ -74,23 +116,32 @@ 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()
}
}
}
/* ///////////////////////////////
// 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` )
// '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' : '' }`,
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 )
@@ -112,7 +163,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
}
@@ -126,91 +177,73 @@ 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( `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)
// 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.
// Note: We assume that ownership and permissions of /usr/local folders are SIP protected by macOS.
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( `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 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` ) )
// 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` ) )
// 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( `sudo -n ${ battery } update_silent` )
log( `Update details: `, result )
} catch ( e ) {
log( `Battery update failed: `, e )
await alert( `Couldnt 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` )
if( online ) exec_async( `nohup curl "https://unidentifiedanalytics.web.app/touch/?namespace=battery" > /dev/null 2>&1` ).catch(() => {})
} 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()
}
@@ -222,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( `${ path_fix } 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 <process cmd pattern>' is still used by 'battery uninstall'.
if ( e.code !== 'SIGNAL' ) throw e;
})
await alert( `Battery is now uninstalled!` )
return true
} catch ( e ) {
@@ -233,13 +270,12 @@ 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 }` )
@@ -247,7 +283,6 @@ const is_limiter_enabled = async () => {
}
module.exports = {
enable_battery_limiter,
disable_battery_limiter,

View File

@@ -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

View File

@@ -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

View File

@@ -6,13 +6,14 @@
## ###############
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
## ###############
binfolder=/usr/local/bin
visudo_folder=/private/etc/sudoers.d
visudo_file=${visudo_folder}/battery
configfolder=$HOME/.battery
@@ -22,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"
@@ -29,6 +31,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 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
# security critical variables.
binfolder="/usr/local/co.palokaj.battery"
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
## ###############
@@ -57,11 +81,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
@@ -82,19 +106,17 @@ 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
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
@@ -107,29 +129,37 @@ Usage:
"
# Visudo instructions
# File location: /etc/sudoers.d/battery
# 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
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
# 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-chargingrelated 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
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.
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
battery_binary=$0
action=$1
setting=$2
subsetting=$3
@@ -139,7 +169,7 @@ subsetting=$3
## ###############
function log() {
echo -e "$(date +%D-%T) - $1"
echo -e "$(date +%D-%T) [$$]: $*"
}
function valid_percentage() {
@@ -187,7 +217,7 @@ function valid_voltage() {
function smc_read_hex() {
key=$1
line=$(echo $(sudo smc -k $key -r))
line=$(echo $($smc_binary -k $key -r))
if [[ $line =~ "no data" ]]; then
echo
else
@@ -198,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
@@ -208,11 +238,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_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"
@@ -225,27 +255,20 @@ 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"
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
}
@@ -260,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() {
@@ -415,43 +438,161 @@ 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 -h "${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 -h "$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 $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"
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"
}
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"
}
## ###############
## 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
# 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
# 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
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 tempfolder
rm -rf "$tempfolder"
# exit because no changes are needed
exit 0
@@ -461,24 +602,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 +623,94 @@ 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
# 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
# Determine the name of unprivileged user
username="$(determine_unprivileged_user "")"
assert_unprivileged_user "$username"
# Use opportunity 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
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 ! 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
)
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
else
version_before="$($battery_binary version)"
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
@@ -529,12 +722,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 "$binfolder/smc" "$binfolder/battery" $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
@@ -570,9 +775,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
@@ -590,18 +795,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
@@ -610,11 +814,18 @@ if [[ "$action" == "charge" ]]; then
caffeinate -is sleep 60
fi
battery_percentage=$(get_battery_percentage)
done
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
@@ -627,12 +838,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%)"
@@ -644,6 +860,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
@@ -699,7 +922,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"
@@ -800,6 +1023,10 @@ fi
# Asynchronous battery level maintenance
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)"
@@ -901,62 +1128,43 @@ if [[ "$action" == "maintain" ]]; then
fi
# Battery calibration
if [[ "$action" == "calibrate_synchronous" ]]; then
log "Starting calibration"
if [[ "$action" == "calibrate" ]]; then
# Stop the maintaining
battery maintain stop
$battery_binary maintain stop &>/dev/null
# Discharge battery to 15%
battery 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
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 discharge 80
# Recover old maintain status
battery 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 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
@@ -991,6 +1199,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 +1215,7 @@ if [[ "$action" == "create_daemon" ]]; then
<string>com.battery.app</string>
<key>ProgramArguments</key>
<array>
<string>$binfolder/battery</string>
<string>$battery_binary</string>
<string>$call_action</string>
<string>recover</string>
</array>

128
setup.sh
View File

@@ -1,31 +1,75 @@
#!/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 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
# 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.'
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
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 -e "[ 1 ] Superuser permissions acquired."
echo "[ 1 ] Superuser permissions acquired."
# 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)"
function cleanup() { rm -rf "$tempfolder"; }
trap cleanup EXIT
echo "[ 3 ] Downloading latest version of battery CLI"
# Note: github names zips by <reponame>-<branchname>.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
curl -sSL -o $batteryfolder/repo.zip "https://github.com/actuallymentor/battery/archive/refs/heads/$update_branch.zip"
@@ -33,44 +77,56 @@ 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 "[ 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 "[ 4 ] Writing script to $binfolder/battery for user $calling_user"
sudo cp $batteryfolder/battery.sh $binfolder/battery
echo "[ 5 ] 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 "[ 6 ] Install battery script into $binfolder"
sudo install -m 755 -o root -g wheel "$batteryfolder/battery.sh" "$binfolder/battery"
# Set permissions for logfiles
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 -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 -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 -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 chown -hRP $calling_user $configfolder
sudo chmod -h 755 $configfolder
touch $logfile
sudo chown $calling_user $logfile
sudo chmod 755 $logfile
sudo chown -h $calling_user $logfile
sudo chmod -h 644 $logfile
touch $pidfile
sudo chown $calling_user $pidfile
sudo chmod 755 $pidfile
sudo chown -h $calling_user $pidfile
sudo chmod -h 644 $pidfile
sudo chown $calling_user $binfolder/battery
# Fix permissions for 'create_daemon' action
echo "[ 9 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")"
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 "[ 6 ] Setting up visudo declarations"
sudo $batteryfolder/battery.sh visudo $USER
sudo chown -R $calling_user $configfolder
echo "[ 10 ] Setup visudo configuration"
sudo $binfolder/battery visudo
# Remove tempfiles
cd ../..
echo "[ 7 ] Removing 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

View File

@@ -1,32 +1,66 @@
#!/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
# 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
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
# Define the installation directory for the battery background executables
binfolder="/usr/local/co.palokaj.battery"
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
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
}
# Remove tempfiles
cd
rm -rf $tempfolder
echo "[ 3 ] Removed temporary folder"
# 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() { rm -rf "$tempfolder"; }
trap cleanup EXIT
updatefolder="$tempfolder/battery"
mkdir -p $updatefolder
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"