Mirror the Mac battery into the guest as a real power_supply device - #220
Open
NimbleAINinja wants to merge 17 commits into
Open
NimbleAINinja wants to merge 17 commits into
NimbleAINinja wants to merge 17 commits into
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
…-battery tob_exit now removes the state attribute (draining in-flight writers) before tearing down the power supplies and platform device, closing a use-after-free/leak window a concurrent state_store could hit during module unload. state_store now rolls tob_state.present back to false when BAT0 registration fails, so a failed write does not leave state claiming a phantom battery until the next differing snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Package the in-repo try-omarchy-battery DKMS sources as a reproducible pacman package (try-omarchy-battery-dkms 1.0.0-1) and register it in the factory build so DKMS compiles try_omarchy_battery.ko against the pinned kernel and stages the archive in the local repository (archive count 6 -> 7). Also updates macos/run-qemu-gpu.sh's exact supplyChain key set so VM launch validation accepts the new tryOmarchyBattery pin, and fixes guest/tests/verify.py's existing archive-count/name assertion that the new seventh archive would otherwise break. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
…ript register-native-battery-module.sh canonicalized --root/--work with pwd -P but only checked the unsafe-root case statement before resolution, letting a symlinked --root that resolves to /usr, /etc, etc. slip through. Mirror register-patched-hyprland.sh: re-run the unsafe-root case after canonicalization, reject a --work inside the staged root, and reject newlines in either path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
…ttery wire contract Review round 1: the refresh path now dispatches onto stateQueue synchronously (via a new sendSync/sendOnQueue split) so a guest flooding refresh requests without draining its side is throttled by the blocking socket write instead of queuing unbounded work; the heartbeat still calls sendOnQueue directly to avoid a self-deadlock. An oversized guest line is now dropped by a new GuestLineReader instead of throwing and terminating the bridge, matching the "ignore everything but a well-formed refresh" wire contract. Also pins the encoded snapshot's key set in a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Existing guest disks keep their old rootfs across app updates, so they never receive the DKMS battery module from a factory image rebuild — only the virtio port arrives immediately via the host's QEMU launch command. This script lets a user install the eight staged battery files, build the module with the guest's already-present dkms/gcc/ kernel-headers toolchain, and enable the bridge service without a factory reset or any network fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
The prior check only proved three literal strings existed somewhere in the retrofit script; it would still pass if a destination path pointed at the wrong file, or if the service were enabled before the DKMS module was built. Since this script runs as root and modifies system paths, it can never run in CI, so this static check is the only automated guard it gets. Add assertions that all eight real destination paths appear in the script, that the DKMS install precedes the systemctl enable --now, and that set -euo pipefail is present so a mid-script failure cannot continue into a half-installed state. Also hardcode the three module-source destination paths under /usr/src/try-omarchy-battery-1.0.0/ instead of building them through variable interpolation, guarded by an assertion that the literal matches $version — consistent with the existing dkms install literal and needed so the new check can see the destination paths as text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Adds docs/host-battery.md covering the JSON snapshot protocol, the sysfs state-line grammar, why the UPower critical-battery drop-in needs both CriticalPowerAction=Ignore and AllowRiskyCriticalPowerAction=true, and the no-reset retrofit procedure for existing guests. Cross-links it from a new architecture.md paragraph and adds a README highlight line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
DKMS always passes KERNELRELEASE on its make command line, which selects the Makefile's kbuild branch — the branch that defines only obj-m and has no `modules` target. Both `dkms install` paths, factory image and retrofit, failed with "No rule to make target 'modules'". Drive kbuild directly from MAKE[0] instead, and drop the CLEAN line dkms 3.4.3 reports as deprecated and which carried the identical trap. Reproduced in an ARM64 container with the pinned dkms 3.4.3-2 and linux-aarch64-headers 7.2.6-1 running the real register script against a staged root: failing before, try_omarchy_battery.ko built after. Also correct the Makefile comment, which stated the opposite of what DKMS does, and mark the manual targets .PHONY. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
pacstrap tears down its devtmpfs before build.sh reaches the register scripts, and this is the first transaction whose hooks actually run programs in the staged root. With $root/dev empty, the hooks' >/dev/null created a 39-byte regular file at $root/dev/null — unowned by pacman, content dependent on hook output, shipped in the rootfs — and mkinitcpio aborted with "/proc must be mounted!". Mount proc, sysfs, devtmpfs and a run tmpfs around the single pacman -U, the way arch-chroot does for every other chroot invocation in the build, and unmount them before the verification queries. $root/dev is empty again afterwards and the initcpio hook completes. Also add the pacman -Qkk integrity check the three sibling register scripts run; this script hand-generates its .MTREE, so it is exactly where that check pays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Restart=always with RestartSec=1 and StartLimitIntervalSec=0 meant a guest that took the app update but not the module retrofit respawned Python once a second forever, writing a journal line each time, because the agent exits 1 immediately when the sysfs state attribute is missing. Condition the unit on that attribute alongside the virtio port. systemd re-evaluates conditions on every start attempt, so a later retrofit still brings the unit up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
The time estimates are plumbed end to end into sysfs as time_to_empty_avg and time_to_full_avg, but the pinned upower 1.91.4 reads neither property, and the capacity-only device gives it no energy, charge or power values to derive an estimate from. Percentage, charge state and AC presence do reach the bar; tools reading sysfs directly see the times. Say so in the README highlight, docs/host-battery.md and the spec's fidelity decision. Also update the "module absent" failure mode in both tables to the unit condition that now covers it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Nothing in the suite executed anything under guest/native-module/, and tob_parse plus the agent's format_state_line carry two independently written copies of the status-token table. Add a userspace harness that slices struct tob_state, the status table and tob_parse verbatim out of the shipped module source, compiles them against shims for the six kernel helpers the parser uses, and drives them from guest/tests/test_tob_parse.py: both line forms the agent emits, the disconnect line, every status token, an unknown key, a missing '=', each missing required key, out-of-range capacity, and -1 times. The extraction fails loudly rather than testing a stale copy. Pin MAKE[0]'s shape in verify.py so the DKMS build defect cannot recur, pin the unit's new module condition, add the "Is Present": false case the Swift snapshot predicate needs, and drop two pieces of dead test code: an unconditionally empty byte slice and a re-assertion of two strings an earlier check already covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Mirrors the Mac's battery into the Omarchy guest as a real
power_supplydevice, so the Quattro bar and every other UPower consumer show it with no
configuration. The guest behaves like the laptop it is running on.
Omarchy Quattro's bar is Quickshell, which reads UPower, which reads
/sys/class/power_supply. The VM exposes no power supply, so the widget issimply absent today.
How
A fifth virtio-serial port carries newline-delimited JSON, host to guest only.
The guest may send exactly one request line (
{"type":"refresh"}) on start,because a guest opening the port is not observable on the host's socket
chardev; nothing else it sends can influence Mac power state.
Each snapshot is complete rather than a delta, so a restarted agent is never
half-informed, and one
write()is one consistent state: consumers cannotobserve a new percentage beside a stale charging flag.
Design decisions worth reviewing
test_power. The pinnedlinux-aarch64ships notest_power.ko, andtest_powernames its devicestest_battery/test_ac, carries a USB supply and wakeup timer we do notwant, and its per-parameter writes allow torn state on every update. DKMS is
already proven in this image by
v4l2loopback.BAT0/ADP0, the conventional names, so tools that special-case them(
acpi, fastfetch, status scripts) work. Honesty lives in the propertiesinstead: manufacturer
Apple, modelMac Battery.CriticalPowerAction=IgnoreandAllowRiskyCriticalPowerAction=true. Both are required:upower 1.91.4classifies
Ignoreas risky and silently falls back toHybridSleep -> Hibernate -> PowerOff without the second key, which would
suspend the VM on a low Mac battery. The Mac's own power management stays the
only authority. Please do not "simplify" that file.
exposes nothing sensitive. On a Mac with no internal battery the guest gets
only mains, so the bar shows nothing — correct for a desktop.
MODE="0600", noGROUP), following the authenticationrule's posture rather than the clipboard's, since no user process needs it.
Existing guests
App updates retain persistent guest disks, so an existing VM does not receive
the module from an app update — though it does receive the port immediately,
since QEMU's command line comes from the host. No factory reset is needed:
the factory image already carries
dkms,gcc,make,kmodand matchingheaders, so
guest/scripts/install-battery-into-existing-guest.shbuilds themodule in place from files staged through the shared Mac folder. No network
fetch. Because it goes through DKMS, the pacman hook rebuilds it on a guest
kernel upgrade.
Verification
make testandguest/testpass. Beyond that, exercised on real hardware ontwo machines, both with guest kernel
7.2.2-2:BAT0/ADP0present; agent service active.DisplayDevice within 5 seconds, replug likewise.
icon-namebecamebattery-low-symbolicandwarning-levelwas correctlynone(UPower'sthreshold is 10%).
its one restart attempt, and the restart correctly refused to attach to a
non-QEMU pid — identical behaviour to the camera and authentication bridges.
linux-aarch64-headers 7.2.6-1in an ARM64 container, and the register script was run end to endagainst a staged root.
Tests added: guest agent unit tests, a host-testable harness driving the real
tob_parsesource (the cross-language seam, where two token tables mustagree), Swift tests for snapshot mapping and the exact wire contract, a
nr=5assertion in the launcher contract test, andverify.pychecks for theunit, the root-only rule, both UPower keys, and the DKMS build line.
Known limitation: no time remaining in the bar
The time estimates reach sysfs correctly and live — measured at
time_to_empty=3180, then3600at 22% — butupower 1.91.4does not readtime_to_empty_avg, and a capacity-only device gives it no energy or powervalues to derive from, so it reports
0 Wh/0 Wand prints no time field.The bar therefore shows level, charge state and AC presence only; anything
reading sysfs directly does see the estimates. The docs state this plainly.
The fix I would recommend is publishing real energy figures read from IOKit's
AppleSmartBattery(mAh x mV = uWh,mA x mV = uW), so UPower derives atrue time from true inputs, rather than synthesizing a fake capacity scale.
That is deliberately not in this PR.
Note for anyone building this
make guestcurrently fails onmainfor reasons unrelated to this branch:Arch Linux ARM has moved
hyprlandto0.56.2-3, which needslibaquamarine.so=14-64, while the ABI-pinnedaquamarine 0.14.0-2providesso=13-64. A coordinated hyprland/aquamarine pin bump is a prerequisite forbuilding an image containing this feature, and the feature itself is
unaffected — the failure occurs before its registration step runs.
🤖 Generated with Claude Code