Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b77a7eb
Add the guest battery agent that feeds host snapshots into sysfs
NimbleAINinja Sep 15, 2026
4f7ef76
Add the try-omarchy-battery DKMS module exposing BAT0 and ADP0
NimbleAINinja Sep 15, 2026
6d23126
Fix teardown ordering and failed-registration rollback in try-omarchy…
NimbleAINinja Sep 15, 2026
8f41688
Build and install the battery DKMS module in the factory image
NimbleAINinja Sep 15, 2026
9c428e5
Recheck root safety after canonicalization in the battery register sc…
NimbleAINinja Sep 15, 2026
ee139af
Enable the battery agent, root-only port, and warn-only UPower policy
NimbleAINinja Sep 15, 2026
9e50bf9
Add the host battery bridge fed by IOKit power notifications
NimbleAINinja Sep 15, 2026
ef1efd8
Fix guest-input backpressure, oversized-line handling, and pin the ba…
NimbleAINinja Sep 15, 2026
aed713e
Wire the battery bridge into the launcher and build contracts
NimbleAINinja Sep 15, 2026
4fc54c1
Add the battery retrofit script for existing persistent guests
NimbleAINinja Sep 15, 2026
8bb5d28
Strengthen the retrofit verify.py check beyond a pure substring test
NimbleAINinja Sep 15, 2026
e130693
Document the host battery mirror and its retrofit path
NimbleAINinja Sep 15, 2026
0064dd7
Make the battery module's DKMS build line actually build it
NimbleAINinja Sep 15, 2026
511039f
Give the battery DKMS transaction the API filesystems its hooks need
NimbleAINinja Sep 15, 2026
9212bb1
Stop the battery agent restart-looping on an un-retrofitted guest
NimbleAINinja Sep 15, 2026
27dd1ba
Correct the battery docs on time estimates and the absent module
NimbleAINinja Sep 15, 2026
b02468e
Close the battery test gaps the whole-branch review found
NimbleAINinja Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Omarchy's trademark rights.
- Resizable native window with automatic guest resolution and HiDPI scale updates
- Mac audio input/output selection inside Omarchy, with live routing and system-default fallback
- FaceTime HD and other Mac cameras exposed to Omarchy as an on-demand 720p webcam
- The Mac's battery charge and charging state mirrored into the Omarchy bar
- Two-way clipboard sharing for text and PNG images between macOS and Omarchy
- One optional shared Mac folder, available inside Omarchy under the same name (`~/Work` stays `~/Work`)
- Loopback-only TCP and UDP port forwarding from the Mac into Omarchy
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ is reading the camera. Camera permission, capture failure, or device removal is
non-fatal to the VM; the launcher can restart the optional bridge without
restarting Omarchy.

A further virtio-serial port (`dev.tryomarchy.battery`) mirrors the Mac's
battery into the guest. A Swift bridge watches IOKit power sources and sends
complete JSON snapshots — percentage, charge state, AC presence, and time
estimates — on every change and every 30 seconds. A root guest agent writes
each snapshot as one line into a small DKMS `power_supply` module, which
presents `BAT0` and `ADP0` under `/sys/class/power_supply`, so UPower and the
Omarchy bar treat the VM as the laptop it runs on. The guest can only request
a refresh; nothing it sends can change Mac power state. A UPower drop-in keeps
the guest from acting on a critical battery — warnings appear, the Mac decides.
On a Mac with no internal battery the guest sees only mains power and the bar
shows nothing. See [host battery](host-battery.md) for the protocol, the sysfs
contract, and how to retrofit an existing guest without a factory reset.

A root-only authentication port
(`dev.tryomarchy.authentication`) lets the guest's `sudo` PAM policy request a
fixed-purpose macOS Touch ID prompt. Enrollment creates a non-exportable P-256
Expand Down
148 changes: 148 additions & 0 deletions docs/host-battery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Host battery

Try Omarchy mirrors the Mac's battery into the guest as a real
`/sys/class/power_supply` device: `BAT0` and `ADP0`. Omarchy Quattro's bar is
Quickshell, and Quickshell's `UPower` bindings read that sysfs tree, so the bar
shows the Mac's battery charge and charging state with no configuration. The
time estimates reach sysfs but not the bar; see "Time estimates" below. The
device is honest about where it comes from — manufacturer `Apple`, model
`Mac Battery` — but named `BAT0`/`ADP0` because those are the names status
tools special-case.

State flows one way, host to guest. The guest may only ask for a fresh
snapshot; nothing it sends can change Mac power state. On a Mac with no
internal battery, the guest keeps `ADP0` and sees no `BAT0`, so the bar shows
nothing.

## Protocol

A fifth virtio-serial port, `dev.tryomarchy.battery`, carries
newline-delimited JSON. There is one message type — a complete snapshot every
time, never a delta — so a restarted or late-joining agent is never
half-informed:

```json
{"type":"state","present":true,"percentage":57,"state":"discharging",
"acConnected":false,"timeToEmptySeconds":8100,"timeToFullSeconds":null}
```

`state` is one of `charging`, `discharging`, `full`, `not-charging`,
`unknown`. `percentage` is an integer 0-100, and is `null` when `present` is
`false`. The time fields are integer seconds or `null` when the host has no
estimate. A Mac with no internal battery sends `"present":false` with
`"acConnected":true`.

`macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift` builds these
snapshots from `IOPSCopyPowerSourcesInfo` and `IOPSGetPowerSourceDescription`,
and sends one on every coalesced IOKit change and every 30 seconds regardless,
as a safety net against a missed notification. A guest opening the virtio port
is not observable on the host's socket chardev, so
`omarchy-native-battery-bridge`, the guest agent, sends one request line on
start, `{"type":"refresh"}`, and the host answers with a fresh snapshot. The
host ignores any other guest input.

## Sysfs contract

The DKMS module `try-omarchy-battery` (`guest/native-module/try-omarchy-battery/`)
exposes one writable attribute,
`/sys/devices/platform/try-omarchy-battery/state`, mode 0600 root-only. The
guest agent writes it as one whole snapshot per write:

```text
present=1 status=discharging capacity=57 ac=0 time_to_empty=8100 time_to_full=-1
present=0 ac=1
```

The first form is used whenever `present` is true, and carries `status`
(the same token set the protocol's `state` field uses, passed through
unchanged), `capacity` (0-100), `ac`, and both time fields. The second, short
form is used when the host reports no internal battery: only `present=0` and
`ac` are written, and every battery-only key is omitted. `-1` in a time field
means no estimate.

One write is one consistent snapshot and triggers at most one
`power_supply_changed()` per supply that actually moved — consumers can never
observe a new percentage next to a stale charging flag. A malformed line, or
one missing a required key for the state it declares, is rejected whole and
the module keeps the previous state. `BAT0` is registered on the first
`present=1` write and unregistered on the next `present=0`, so a desktop Mac
never creates it and the bar has nothing to render.

## Time estimates

The module publishes the host's estimates as `time_to_empty_avg` and
`time_to_full_avg` under `/sys/class/power_supply/BAT0/`. The pinned
`upower 1.91.4` does not read those two properties, and the device carries no
energy, charge or power values for UPower to derive an estimate from, so the
time remaining does not appear in the Omarchy bar. Percentage, charge state
and AC presence do. Tools that read sysfs directly, such as `acpi` and
fastfetch, show the estimates.

## Critical battery policy

`/etc/UPower/UPower.conf.d/90-try-omarchy.conf` sets two keys:

```ini
[UPower]
AllowRiskyCriticalPowerAction=true
CriticalPowerAction=Ignore
```

Both are required. Setting only `CriticalPowerAction=Ignore` is not enough:
the pinned `upower 1.91.4` classifies `Ignore` itself as a risky action, and
without `AllowRiskyCriticalPowerAction=true` it silently refuses to honor the
setting and falls back through HybridSleep, then Hibernate, then PowerOff —
the guest would suspend or shut itself down on a low reading with no warning
that the configured policy had been overridden. With both keys set, Omarchy
still shows its low- and critical-battery warnings, but the VM never acts on
them. The Mac's own power handling is the only authority over what actually
happens to the battery.

## Retrofitting an existing guest

App updates keep an existing guest's persistent disk, so an already-running VM
does not get the new kernel module from an app update alone — it does get the
virtio port immediately, because QEMU's command line comes from the host at
launch. No factory reset is needed: the factory image already carries `dkms`,
`gcc`, `make`, `kmod`, and headers matching the pinned kernel, so the guest can
build the module itself.

`guest/scripts/install-battery-into-existing-guest.sh` runs **inside** the
guest, against files staged through the shared Mac folder rather than fetched
over the network. Stage these repo paths into the shared folder, preserving
the layout:

```text
native-module/try-omarchy-battery/{try-omarchy-battery.c,Makefile,dkms.conf}
native-overlay/usr/local/bin/omarchy-native-battery-bridge
native-overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service
native-overlay/etc/udev/rules.d/95-omarchy-native-battery.rules
native-overlay/etc/modules-load.d/95-try-omarchy-battery.conf
native-overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf
```

Then, in the guest:

```sh
sudo ~/<folder>/battery-retrofit/install-battery-into-existing-guest.sh
```

The script installs those eight files, runs `dkms install
try-omarchy-battery/1.0.0`, loads the module, reloads udev, and enables
`omarchy-native-battery-bridge.service`. Because the module is installed
through DKMS, the pacman DKMS hook rebuilds it whenever a later `pacman -Syu`
bumps the guest kernel, so the retrofit survives guest kernel updates — a
factory reset is never required.

## Failure modes

All are non-fatal to the VM, matching the camera bridge's posture:

| Condition | Behavior |
| --- | --- |
| Mac has no internal battery | `present:false`; guest keeps `ADP0` only; bar shows nothing |
| Host bridge dies | Agent writes `status=unknown`, exits; systemd restarts it; launcher restarts the bridge |
| Module absent (un-retrofitted guest) | The unit's `ConditionPathExists` on the sysfs attribute fails; the agent never starts, and a later retrofit brings it up |
| Malformed JSON line or state line | Rejected; previous state retained |
| Host sleep and wake | Fresh snapshot on the next notification or the 30-second tick |
| Critically low Mac battery | Omarchy warns; the VM does not suspend or power off |
5 changes: 5 additions & 0 deletions guest/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,11 @@ python3 "$guest_dir/scripts/apply-omarchy-backports.py" --root "$root" --spec "$
--work "$work" \
--spec "$spec" \
--pacman-config "$pacman_config"
"$guest_dir/scripts/register-native-battery-module.sh" \
--root "$root" \
--work "$work" \
--spec "$spec" \
--pacman-config "$pacman_config"
"$guest_dir/scripts/register-local-repository.sh" --root "$root" --spec "$spec"
arch-chroot "$root" /usr/local/lib/try-omarchy/finalize-rootfs
arch-chroot "$root" pacman -Q | LC_ALL=C sort >"$root/usr/share/try-omarchy/packages.lock.txt"
Expand Down
20 changes: 20 additions & 0 deletions guest/native-module/try-omarchy-battery/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Standard two-phase kbuild Makefile. Kbuild sets KERNELRELEASE and takes the
# first branch; DKMS also passes KERNELRELEASE on its make command line, so it
# drives kbuild directly (see MAKE[0] in dkms.conf) rather than through the
# targets below. Those targets serve manual builds, where KVER selects the
# kernel so the build never depends on the builder's running kernel.
ifneq ($(KERNELRELEASE),)
obj-m := try_omarchy_battery.o
try_omarchy_battery-y := try-omarchy-battery.o
else
KVER ?= $(shell uname -r)
KDIR ?= /usr/lib/modules/$(KVER)/build

.PHONY: modules clean

modules:
$(MAKE) -C $(KDIR) M=$(CURDIR) modules

clean:
$(MAKE) -C $(KDIR) M=$(CURDIR) clean
endif
9 changes: 9 additions & 0 deletions guest/native-module/try-omarchy-battery/dkms.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
PACKAGE_NAME="try-omarchy-battery"
PACKAGE_VERSION="1.0.0"
BUILT_MODULE_NAME[0]="try_omarchy_battery"
DEST_MODULE_LOCATION[0]="/updates/dkms"
AUTOINSTALL="yes"
# DKMS always passes KERNELRELEASE on the command line, which selects the
# Makefile's kbuild branch. Drive kbuild directly instead of relying on a
# target that branch does not define.
MAKE[0]="make -C ${kernel_source_dir} M=${dkms_tree}/${PACKAGE_NAME}/${PACKAGE_VERSION}/build modules"
Loading