Skip to content

Commit 767bfa3

Browse files
MoonBoi9001claude
andauthored
feat!: v3.0.0 -- safety watchdog fix and CLI cleanup (#46)
* fix: stop safety watchdog from re-enabling charging during sleep The safety watchdog's stale PID file check used wall clock time to detect hung daemons. During sleep the daemon's loop doesn't run, so the PID file appears stale after 5 minutes. Every Power Nap wake, the watchdog would re-enable charging, undoing the daemon's willSleep handler. This caused overnight charging from 65% to 96%. If the daemon process is alive, trust it. The willSleep handler has already set the correct SMC state for sleep. Bumps version to 2.1.3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add CodeRabbit configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat!: v3.0.0 -- safety watchdog fix and CLI cleanup Fix safety watchdog sleep conflict by using system uptime for stale PID detection and killing hung daemons instead of re-enabling charging. Remove unused commands (schedule, ssd, reinstall, ssd-log, daily-log, calibrate-log). Hide internal commands (charge, discharge, visudo). Update changelog with all missing versions and reverse to chronological order. Add CodeRabbit config with domain-specific review instructions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: consolidate ConfigStore instantiation, remove dead code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: align calibrate_next with actual schedule fire date Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: always recreate schedule plist on longevity activation Prevents permanently broken schedule when the plist is missing but calibrate_schedule config is still set. Logs a warning if plist creation fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ced9e8c commit 767bfa3

12 files changed

Lines changed: 240 additions & 544 deletions

File tree

.coderabbit.yaml

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
language: "en-US"
2+
3+
reviews:
4+
profile: "assertive"
5+
high_level_summary: true
6+
sequence_diagrams: true
7+
poem: false
8+
auto_review:
9+
enabled: true
10+
base_branches:
11+
- main
12+
drafts: false
13+
auto_incremental_review: true
14+
path_instructions:
15+
- path: "Sources/AppleJuice/Daemon/**"
16+
instructions: >
17+
apple-juice is a macOS battery charging manager that controls charging
18+
via SMC (System Management Controller) keys. The maintain daemon runs
19+
as a LaunchAgent with KeepAlive (SuccessfulExit: false) so launchd
20+
restarts it on crash.
21+
22+
Critical safety invariant: the Mac must never be left with charging
23+
disabled and no daemon running. Every code path that disables charging
24+
must have a corresponding recovery mechanism. Three overlapping layers
25+
provide this: (1) launchd KeepAlive auto-restart, (2) the safety
26+
watchdog running every 30 minutes, (3) startup recovery before any CLI
27+
command.
28+
29+
The daemon loop runs on a serial DispatchQueue (smcQueue) that
30+
serialises all SMC reads/writes across the main loop, signal handlers,
31+
and sleep/wake callbacks. Watch for thread safety -- any SMC access
32+
outside smcQueue is a race condition.
33+
34+
Sleep/wake handling is critical. willSleep disables discharging and
35+
either disables or enables charging depending on battery level, then
36+
schedules a pmset wake if charging is needed. didWake cancels the
37+
scheduled wake and resumes daemon control. The PID file is not updated
38+
during sleep, so staleness checks must use system uptime (monotonic
39+
clock that doesn't advance during sleep), not wall clock time.
40+
41+
SMC writes can fail silently during Power Nap partial-wake states.
42+
The daemon tracks consecutive control failures and exits after 10 for
43+
launchd restart. A 30s backoff applies when failures are occurring.
44+
45+
Signal-based IPC uses SIGUSR1 with a sig.pid file for commands like
46+
suspend/recover. The daemon reads the command from sig.pid, executes
47+
it under smcQueue, and sends SIGUSR1 back as ACK.
48+
49+
All PID files and state files must use atomic writes. The charge.state
50+
file tracks active charge/discharge operations with format
51+
"<PID> <operation> <maintain_status>" for orphan recovery.
52+
53+
- path: "Sources/AppleJuice/Commands/**"
54+
instructions: >
55+
CLI commands using swift-argument-parser. Key safety concerns:
56+
57+
Uninstall must verify charging is re-enabled via SMC readback BEFORE
58+
deleting binaries. If verification fails, it must abort and tell the
59+
user to reboot (SMC keys reset on boot).
60+
61+
Charge and discharge commands suspend the maintain daemon via SIGUSR1
62+
IPC before starting, write a charge.state file for orphan detection,
63+
and recover maintain on completion or failure. If the charge/discharge
64+
process dies unexpectedly, the safety watchdog detects the orphaned
65+
state file and re-enables charging.
66+
67+
The safety-check command (SafetyCheck.swift) is the periodic watchdog.
68+
It must never re-enable charging while the daemon is running -- that
69+
fights the daemon's own control loop. Stale PID detection uses a 120s
70+
threshold against ProcessInfo.processInfo.systemUptime (not wall clock
71+
time) because the PID file isn't updated during sleep.
72+
73+
Calibrate and balance are orchestrators that shell out to the charge
74+
and discharge subcommands via ProcessRunner. They don't interact with
75+
SMC directly. Sleep prevention (IOPMAssertion) and SIGTERM handling
76+
live in the charge/discharge commands, not in calibrate/balance.
77+
78+
- path: "Sources/AppleJuice/Utilities/**"
79+
instructions: >
80+
Utility code for SMC access, battery info, and process management.
81+
82+
SMC access has two paths: IOKitSMCClient for fast kernel-level reads
83+
(no sudo), and SMCBinaryClient for writes via a sudoers-allowlisted
84+
smc binary. All SMC writes require sudo and go through the smc binary
85+
at /usr/local/co.apple-juice/smc or the Homebrew bin directory.
86+
87+
SMCCapabilities.probe() detects which SMC keys exist on this machine.
88+
Different Apple Silicon models use different keys (CH0B vs CH0C for
89+
charge inhibit, CH0I vs CHIE for discharge control). Code must never
90+
hardcode key assumptions -- always use the probed capabilities.
91+
92+
BatteryInfo reads from IOKit (AppleSmartBattery service) and provides
93+
raw vs smoothed capacity, cell voltages, imbalance detection, and
94+
charge/discharge currents. accuratePercentage uses raw values from
95+
the BMS, not the smoothed macOS percentage.
96+
97+
ProcessHelper verifies PIDs by checking both process existence
98+
(kill(pid, 0)) and that the process is actually apple-juice (not a
99+
reused PID). All PID file reads must handle missing/malformed files.
100+
101+
tools:
102+
swiftlint:
103+
enabled: true
104+
105+
chat:
106+
auto_reply: true

CHANGELOG.md

Lines changed: 84 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,110 @@
11
# Changelog
22

3+
## v1.0.0
4+
5+
Initial release -- fork of BatteryOptimizer_for_Mac with full rebrand.
6+
7+
- `battery` command renamed to `apple-juice`, config directory moved from `~/.battery` to `~/.apple-juice`
8+
- Removed Electron GUI app (CLI only) and donation prompts
9+
- Root-owned executables in `/usr/local/co.apple-juice` with symlinks in `/usr/local/bin`
10+
- mktemp for visudo temp files, `-h` flag on chown calls to prevent symlink attacks
11+
12+
## v1.0.1
13+
14+
- Auto-detect installation directory for Homebrew compatibility
15+
- Process detection fix (use `args` instead of `comm`)
16+
- osascript quote escaping fix in setup dialog
17+
- Removed Chinese language support (English only)
18+
- Status now shows "Longevity mode active" when using longevity preset
19+
20+
## v1.0.2
21+
22+
- Create config file automatically if missing (fixes brew installs)
23+
324
## v2.0.0
425

526
Complete rewrite from bash to Swift. Apple Silicon only.
627

7-
### Added
828
- Native Swift binary via Swift Package Manager (no runtime dependencies)
929
- LaunchAgent with `KeepAlive` for automatic daemon restart on crash/SIGKILL/OOM
10-
- IOKit direct battery reads (replaces all `ioreg | grep | awk` chains)
30+
- IOKit direct battery reads (replaces `ioreg | grep | awk` chains)
1131
- IOKit sleep/wake notifications with CHWA management (replaces sleepwatcher)
1232
- `IOPMAssertionCreateWithName` for sleep prevention (replaces caffeinate)
1333
- Startup recovery check: re-enables charging if daemon is not running
1434
- SMC write verification with consecutive failure detection
1535
- Serial dispatch queue for thread-safe SMC access across main loop, sleep/wake, and signal handlers
16-
- Daily log rotation (365 entries, ~1 year)
17-
- Main log rotation (5MB threshold)
18-
- PID file write error handling with stderr fallback
19-
- `ProcessType: Interactive` and `ExitTimeOut: 30` in LaunchAgent plist
20-
- LaunchAgent migration for v1.x users upgrading (auto-regenerates plist with KeepAlive)
21-
- Config key migration from old formats
36+
- Daemon lifecycle managed by `launchctl bootstrap`/`bootout` instead of `nohup`
37+
- Config uses file-per-key storage with atomic writes (replaces single parsed file)
38+
- Daily log rotation (365 entries), main log rotation (5MB threshold)
2239
- `status --csv` flag for machine-readable output
23-
- `advanceCalibrateNext` for proper `week_period`/`month_period` enforcement
24-
- Webhook events for calibration Method 2
2540
- CI/CD: GitHub Actions for build/test and release binary generation
41+
- Dropped Intel Mac support, bash script, sleepwatcher dependency, and sleep/wake hook scripts
2642

27-
### Changed
28-
- Daemon lifecycle managed by `launchctl bootstrap`/`bootout` instead of `nohup`
29-
- `maintain stop` uses `launchctl bootout` for clean daemon shutdown
30-
- `uninstall` properly unloads LaunchAgent before removing plist
31-
- Signal ACK setup occurs before sending SIGUSR1 (fixes race condition)
32-
- Clean stop exits with code 0 (KeepAlive does not restart); crashes exit non-zero (restart)
33-
- Config uses file-per-key storage with atomic writes (replaces single parsed file)
34-
- All curl calls have `--max-time` timeouts with exponential backoff
43+
## v2.0.1
3544

36-
### Removed
37-
- Intel Mac support (Apple Silicon M1+ only)
38-
- Bash script (`apple-juice.sh`)
39-
- sleepwatcher dependency
40-
- `setup.sh` (installation via Homebrew)
41-
- Sleep/wake hook scripts (`.sleep`, `.wakeup`, `.reboot`, `.shutdown`)
42-
- `notification_permission.scpt`
43-
- `shutdown.sh` and `apple-juice_shutdown.plist`
45+
- Resolve binary path when invoked via PATH
4446

45-
## v1.0.2
47+
## v2.0.2
4648

47-
### Fixed
48-
- Create config file automatically if missing (fixes brew installs)
49+
- Register maintain-daemon as top-level subcommand
4950

50-
## v1.0.1
51+
## v2.0.3
5152

52-
### Fixed
53-
- Auto-detect installation directory for Homebrew compatibility
54-
- Process detection now works correctly (use `args` instead of `comm`)
55-
- osascript quote escaping in setup dialog
53+
- Daemon startup, cell voltage reading, and launchctl logging fixes
5654

57-
### Changed
58-
- Removed Chinese language support (English only)
59-
- Status now shows "Longevity mode active" when using longevity preset
60-
- Clean up orphaned language config on update
55+
## v2.0.4
6156

62-
## v1.0.0
57+
- Use stable symlink path in LaunchAgent plists (survives Homebrew upgrades)
58+
59+
## v2.0.5
60+
61+
- Redesigned status output with extended battery telemetry: capacity (mAh), adapter details, battery current draw, time estimates
62+
- Cell imbalance warnings at >20mV and >=50mV
63+
- Calibration skipped when cells are balanced (<50mV imbalance)
64+
65+
## v2.0.6
66+
67+
- Version line in status output with update check and brew upgrade command
68+
69+
## v2.0.7
70+
71+
- Safety recovery and watchdog now attempt to restart the daemon before falling back to re-enabling charging
72+
73+
## v2.0.8
74+
75+
- Status power description uses IOKit instead of lagging SMC keys
76+
- Daemon applies charging control immediately on startup
77+
- Status warns when longevity is configured but daemon isn't running
78+
79+
## v2.0.9
80+
81+
- `startDaemon()` ensures the LaunchAgent service is enabled before bootstrap, preventing `Input/output error` after `maintain stop` + `maintain longevity` cycle
82+
- Suppressed expected launchctl bootout errors from user-facing output
83+
84+
## v2.1.0
85+
86+
- Added `aj` as a shorthand alias for the `apple-juice` CLI
87+
88+
## v2.1.1
89+
90+
- Safety watchdog switched from `StartInterval` (affected by kqueue sleep bug) to `StartCalendarInterval`, which coalesces missed intervals and fires on wake
91+
- Always run launchctl enable/bootout/bootstrap for the safety daemon, even when the plist is unchanged on disk
92+
- SMC failure backoff increased to 30s with threshold raised to 10 consecutive failures, preventing premature daemon exits during Power Nap
93+
94+
## v2.1.2
95+
96+
- Added delay and retry between launchd `bootout` and `bootstrap` calls, preventing bootstrap race conditions during daemon startup
97+
- Removed immediate kickstart of the safety watchdog during daemon startup (was causing false "daemon not running" detection)
98+
- `maintain` now waits up to 5s for the old daemon process to exit before starting the new one
6399

64-
Initial release - fork of BatteryOptimizer_for_Mac with full rebrand.
100+
## v3.0.0
65101

66-
### Security
67-
- Root-owned executables in `/usr/local/co.apple-juice`
68-
- Symlinks in `/usr/local/bin` for PATH accessibility
69-
- mktemp for visudo temp files (isolated from user config)
70-
- `-h` flag on chown calls to prevent symlink attacks
102+
Safety watchdog fix and CLI cleanup. **Breaking**: six commands removed (see below). Scripts referencing `aj schedule`, `aj ssd`, `aj dailylog`, `aj calibratelog`, `aj ssdlog`, or `aj reinstall` will fail with an unknown-command error.
71103

72-
### Changes
73-
- Full rebrand: `battery` command is now `apple-juice`
74-
- Config directory: `~/.battery` is now `~/.apple-juice`
75-
- Removed Electron GUI app (CLI only)
76-
- Removed donation prompts
104+
- Safety watchdog stale PID detection now uses system uptime (monotonic clock) instead of wall clock time, preventing false triggers after sleep
105+
- When a hung daemon is detected, the watchdog kills it for launchd restart instead of re-enabling charging (which fought the daemon's own control loop)
106+
- Stale PID threshold tightened from 5 minutes to 2 minutes of awake time
107+
- Removed commands: `schedule`, `ssd`, `ssd-log`, `daily-log`, `calibrate-log`, `reinstall`
108+
- Hidden commands: `charge`, `discharge`, `visudo` (still functional, used internally)
109+
- Longevity mode schedule setup no longer depends on the `schedule` command
110+
- Added CodeRabbit configuration for automated PR reviews

Sources/AppleJuice/AppleJuice.swift

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ArgumentParser
22
import Foundation
33

4-
let appVersion = "2.1.2"
4+
let appVersion = "3.0.0"
55

66
@main
77
struct AppleJuice: ParsableCommand {
@@ -16,14 +16,8 @@ struct AppleJuice: ParsableCommand {
1616
Discharge.self,
1717
Calibrate.self,
1818
Balance.self,
19-
Schedule.self,
2019
Logs.self,
21-
DailyLog.self,
22-
CalibrateLog.self,
23-
SSD.self,
24-
SSDLog.self,
2520
Update.self,
26-
Reinstall.self,
2721
Uninstall.self,
2822
Visudo.self,
2923
Changelog.self,
@@ -58,7 +52,6 @@ extension Charge: StartupAware {}
5852
extension Discharge: StartupAware {}
5953
extension Calibrate: StartupAware {}
6054
extension Balance: StartupAware {}
61-
extension Schedule: StartupAware {}
6255
extension Update: StartupAware {}
6356
extension Uninstall: StartupAware {}
6457

@@ -107,8 +100,6 @@ enum Paths {
107100
static let calibratePidFile = (configFolder as NSString).appendingPathComponent("calibrate.pid")
108101
static let dailyLogFile = (configFolder as NSString).appendingPathComponent("daily.log")
109102
static let calibrateLogFile = (configFolder as NSString).appendingPathComponent("calibrate.log")
110-
static let ssdLogFile = (configFolder as NSString).appendingPathComponent("ssd.log")
111-
112103
static let daemonPath = (NSHomeDirectory() as NSString)
113104
.appendingPathComponent("Library/LaunchAgents/apple-juice.plist")
114105
static let schedulePath = (NSHomeDirectory() as NSString)

Sources/AppleJuice/Commands/Charge.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import Foundation
44
struct Charge: ParsableCommand {
55
static let configuration = CommandConfiguration(
66
commandName: "charge",
7-
abstract: "Charge battery to a target percentage"
7+
abstract: "Charge battery to a target percentage",
8+
shouldDisplay: false
89
)
910

1011
@Argument(help: "Target percentage (1-100) or 'stop'")
@@ -123,7 +124,8 @@ struct Charge: ParsableCommand {
123124
struct Discharge: ParsableCommand {
124125
static let configuration = CommandConfiguration(
125126
commandName: "discharge",
126-
abstract: "Discharge battery to a target percentage"
127+
abstract: "Discharge battery to a target percentage",
128+
shouldDisplay: false
127129
)
128130

129131
@Argument(help: "Target percentage (1-100) or 'stop'")

Sources/AppleJuice/Commands/Logs.swift

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,54 +17,3 @@ struct Logs: ParsableCommand {
1717
print(result.stdout, terminator: "")
1818
}
1919
}
20-
21-
struct DailyLog: ParsableCommand {
22-
static let configuration = CommandConfiguration(
23-
commandName: "dailylog",
24-
abstract: "Show the daily battery log"
25-
)
26-
27-
func run() throws {
28-
let path = Paths.dailyLogFile
29-
if FileManager.default.fileExists(atPath: path) {
30-
let contents = try String(contentsOfFile: path, encoding: .utf8)
31-
print(contents, terminator: "")
32-
} else {
33-
print("No daily log found at \(path)")
34-
}
35-
}
36-
}
37-
38-
struct CalibrateLog: ParsableCommand {
39-
static let configuration = CommandConfiguration(
40-
commandName: "calibratelog",
41-
abstract: "Show the calibration log"
42-
)
43-
44-
func run() throws {
45-
let path = Paths.calibrateLogFile
46-
if FileManager.default.fileExists(atPath: path) {
47-
let contents = try String(contentsOfFile: path, encoding: .utf8)
48-
print(contents, terminator: "")
49-
} else {
50-
print("No calibration log found at \(path)")
51-
}
52-
}
53-
}
54-
55-
struct SSDLog: ParsableCommand {
56-
static let configuration = CommandConfiguration(
57-
commandName: "ssdlog",
58-
abstract: "Show the SSD health log"
59-
)
60-
61-
func run() throws {
62-
let path = Paths.ssdLogFile
63-
if FileManager.default.fileExists(atPath: path) {
64-
let contents = try String(contentsOfFile: path, encoding: .utf8)
65-
print(contents, terminator: "")
66-
} else {
67-
print("No SSD log found at \(path)")
68-
}
69-
}
70-
}

0 commit comments

Comments
 (0)