diff --git a/README.md b/README.md index 9ff86f9a..f68277bc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 1b678d84..df0c01aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/host-battery.md b/docs/host-battery.md new file mode 100644 index 00000000..f8ffae19 --- /dev/null +++ b/docs/host-battery.md @@ -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 ~//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 | diff --git a/guest/build.sh b/guest/build.sh index 9a6348ca..1aa710f4 100755 --- a/guest/build.sh +++ b/guest/build.sh @@ -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" diff --git a/guest/native-module/try-omarchy-battery/Makefile b/guest/native-module/try-omarchy-battery/Makefile new file mode 100644 index 00000000..667d0b48 --- /dev/null +++ b/guest/native-module/try-omarchy-battery/Makefile @@ -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 diff --git a/guest/native-module/try-omarchy-battery/dkms.conf b/guest/native-module/try-omarchy-battery/dkms.conf new file mode 100644 index 00000000..59ee2fd7 --- /dev/null +++ b/guest/native-module/try-omarchy-battery/dkms.conf @@ -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" diff --git a/guest/native-module/try-omarchy-battery/try-omarchy-battery.c b/guest/native-module/try-omarchy-battery/try-omarchy-battery.c new file mode 100644 index 00000000..0730b84c --- /dev/null +++ b/guest/native-module/try-omarchy-battery/try-omarchy-battery.c @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Mirror the host Mac's battery into the guest as BAT0/ADP0. + * + * A root-only agent writes one whole snapshot per write() to the `state` + * attribute: + * + * present=1 status=discharging capacity=57 ac=0 time_to_empty=8100 time_to_full=-1 + * present=0 ac=1 + * + * One write is one consistent snapshot: consumers can never observe a new + * percentage beside a stale charging flag. -1 means no estimate. A malformed + * line is rejected whole and the previous state is retained. + * + * Lock ordering: tob_register_lock -> tob_state_lock. get_property() takes + * only tob_state_lock; power_supply registration calls take only + * tob_register_lock, never while tob_state_lock is held, because + * power_supply_unregister() waits for readers holding tob_state_lock. + */ + +#include +#include +#include +#include +#include +#include +#include + +struct tob_state { + bool present; + int status; + int capacity; + bool ac_online; + int time_to_empty; + int time_to_full; +}; + +static struct platform_device *tob_pdev; +static struct power_supply *tob_ac; +static struct power_supply *tob_bat; +static DEFINE_MUTEX(tob_register_lock); /* serializes writers + registration */ +static DEFINE_MUTEX(tob_state_lock); /* guards tob_state */ +static struct tob_state tob_state = { + .present = false, + .status = POWER_SUPPLY_STATUS_UNKNOWN, + .capacity = 0, + .ac_online = true, + .time_to_empty = -1, + .time_to_full = -1, +}; + +static const struct { + const char *token; + int status; +} tob_status_tokens[] = { + { "charging", POWER_SUPPLY_STATUS_CHARGING }, + { "discharging", POWER_SUPPLY_STATUS_DISCHARGING }, + { "full", POWER_SUPPLY_STATUS_FULL }, + { "not-charging", POWER_SUPPLY_STATUS_NOT_CHARGING }, + { "unknown", POWER_SUPPLY_STATUS_UNKNOWN }, +}; + +static const char *tob_status_token(int status) +{ + size_t index; + + for (index = 0; index < ARRAY_SIZE(tob_status_tokens); index++) + if (tob_status_tokens[index].status == status) + return tob_status_tokens[index].token; + return "unknown"; +} + +static enum power_supply_property tob_bat_properties[] = { + POWER_SUPPLY_PROP_STATUS, + POWER_SUPPLY_PROP_PRESENT, + POWER_SUPPLY_PROP_CAPACITY, + POWER_SUPPLY_PROP_TIME_TO_EMPTY_AVG, + POWER_SUPPLY_PROP_TIME_TO_FULL_AVG, + POWER_SUPPLY_PROP_TECHNOLOGY, + POWER_SUPPLY_PROP_MANUFACTURER, + POWER_SUPPLY_PROP_MODEL_NAME, +}; + +static enum power_supply_property tob_ac_properties[] = { + POWER_SUPPLY_PROP_ONLINE, +}; + +static int tob_bat_get_property(struct power_supply *psy, + enum power_supply_property psp, + union power_supply_propval *val) +{ + int error = 0; + + mutex_lock(&tob_state_lock); + switch (psp) { + case POWER_SUPPLY_PROP_STATUS: + val->intval = tob_state.status; + break; + case POWER_SUPPLY_PROP_PRESENT: + val->intval = tob_state.present ? 1 : 0; + break; + case POWER_SUPPLY_PROP_CAPACITY: + val->intval = tob_state.capacity; + break; + case POWER_SUPPLY_PROP_TIME_TO_EMPTY_AVG: + if (tob_state.time_to_empty < 0) + error = -ENODATA; + else + val->intval = tob_state.time_to_empty; + break; + case POWER_SUPPLY_PROP_TIME_TO_FULL_AVG: + if (tob_state.time_to_full < 0) + error = -ENODATA; + else + val->intval = tob_state.time_to_full; + break; + case POWER_SUPPLY_PROP_TECHNOLOGY: + val->intval = POWER_SUPPLY_TECHNOLOGY_LION; + break; + case POWER_SUPPLY_PROP_MANUFACTURER: + val->strval = "Apple"; + break; + case POWER_SUPPLY_PROP_MODEL_NAME: + val->strval = "Mac Battery"; + break; + default: + error = -EINVAL; + break; + } + mutex_unlock(&tob_state_lock); + return error; +} + +static int tob_ac_get_property(struct power_supply *psy, + enum power_supply_property psp, + union power_supply_propval *val) +{ + if (psp != POWER_SUPPLY_PROP_ONLINE) + return -EINVAL; + mutex_lock(&tob_state_lock); + val->intval = tob_state.ac_online ? 1 : 0; + mutex_unlock(&tob_state_lock); + return 0; +} + +static const struct power_supply_desc tob_bat_desc = { + .name = "BAT0", + .type = POWER_SUPPLY_TYPE_BATTERY, + .properties = tob_bat_properties, + .num_properties = ARRAY_SIZE(tob_bat_properties), + .get_property = tob_bat_get_property, +}; + +static const struct power_supply_desc tob_ac_desc = { + .name = "ADP0", + .type = POWER_SUPPLY_TYPE_MAINS, + .properties = tob_ac_properties, + .num_properties = ARRAY_SIZE(tob_ac_properties), + .get_property = tob_ac_get_property, +}; + +static int tob_parse(const char *buf, size_t count, struct tob_state *next) +{ + bool saw_present = false, saw_ac = false; + bool saw_status = false, saw_capacity = false; + char *copy, *cursor, *token; + int error = -EINVAL; + + next->present = false; + next->status = POWER_SUPPLY_STATUS_UNKNOWN; + next->capacity = 0; + next->ac_online = false; + next->time_to_empty = -1; + next->time_to_full = -1; + + copy = kstrndup(buf, count, GFP_KERNEL); + if (!copy) + return -ENOMEM; + cursor = copy; + while ((token = strsep(&cursor, " \n")) != NULL) { + char *value; + + if (!*token) + continue; + value = strchr(token, '='); + if (!value) + goto out; + *value++ = '\0'; + if (!strcmp(token, "present")) { + if (kstrtobool(value, &next->present)) + goto out; + saw_present = true; + } else if (!strcmp(token, "ac")) { + if (kstrtobool(value, &next->ac_online)) + goto out; + saw_ac = true; + } else if (!strcmp(token, "status")) { + size_t index; + + for (index = 0; index < ARRAY_SIZE(tob_status_tokens); index++) + if (!strcmp(value, tob_status_tokens[index].token)) + break; + if (index == ARRAY_SIZE(tob_status_tokens)) + goto out; + next->status = tob_status_tokens[index].status; + saw_status = true; + } else if (!strcmp(token, "capacity")) { + if (kstrtoint(value, 10, &next->capacity) || + next->capacity < 0 || next->capacity > 100) + goto out; + saw_capacity = true; + } else if (!strcmp(token, "time_to_empty")) { + if (kstrtoint(value, 10, &next->time_to_empty) || + next->time_to_empty < -1) + goto out; + } else if (!strcmp(token, "time_to_full")) { + if (kstrtoint(value, 10, &next->time_to_full) || + next->time_to_full < -1) + goto out; + } else { + goto out; + } + } + if (!saw_present || !saw_ac) + goto out; + if (next->present && (!saw_status || !saw_capacity)) + goto out; + error = 0; +out: + kfree(copy); + return error; +} + +static ssize_t state_show(struct device *dev, struct device_attribute *attr, + char *buf) +{ + struct tob_state snapshot; + + mutex_lock(&tob_state_lock); + snapshot = tob_state; + mutex_unlock(&tob_state_lock); + if (!snapshot.present) + return sysfs_emit(buf, "present=0 ac=%d\n", + snapshot.ac_online ? 1 : 0); + return sysfs_emit(buf, + "present=1 status=%s capacity=%d ac=%d time_to_empty=%d time_to_full=%d\n", + tob_status_token(snapshot.status), snapshot.capacity, + snapshot.ac_online ? 1 : 0, snapshot.time_to_empty, + snapshot.time_to_full); +} + +static ssize_t state_store(struct device *dev, struct device_attribute *attr, + const char *buf, size_t count) +{ + struct tob_state next; + bool ac_changed, bat_changed; + int error; + + error = tob_parse(buf, count, &next); + if (error) + return error; + + mutex_lock(&tob_register_lock); + mutex_lock(&tob_state_lock); + ac_changed = next.ac_online != tob_state.ac_online; + bat_changed = next.present != tob_state.present || + next.status != tob_state.status || + next.capacity != tob_state.capacity || + next.time_to_empty != tob_state.time_to_empty || + next.time_to_full != tob_state.time_to_full; + tob_state = next; + mutex_unlock(&tob_state_lock); + + /* Registration outside tob_state_lock: unregister waits for readers. */ + if (next.present && !tob_bat) { + struct power_supply_config config = {}; + struct power_supply *battery; + + battery = power_supply_register(&tob_pdev->dev, &tob_bat_desc, + &config); + if (IS_ERR(battery)) { + error = PTR_ERR(battery); + mutex_lock(&tob_state_lock); + tob_state.present = false; + mutex_unlock(&tob_state_lock); + mutex_unlock(&tob_register_lock); + return error; + } + tob_bat = battery; + bat_changed = false; /* registration already notified */ + } else if (!next.present && tob_bat) { + power_supply_unregister(tob_bat); + tob_bat = NULL; + bat_changed = false; + } + if (bat_changed && tob_bat) + power_supply_changed(tob_bat); + if (ac_changed && tob_ac) + power_supply_changed(tob_ac); + mutex_unlock(&tob_register_lock); + return count; +} + +static DEVICE_ATTR_ADMIN_RW(state); + +static int __init tob_init(void) +{ + struct power_supply_config config = {}; + int error; + + tob_pdev = platform_device_register_simple("try-omarchy-battery", -1, + NULL, 0); + if (IS_ERR(tob_pdev)) + return PTR_ERR(tob_pdev); + + error = device_create_file(&tob_pdev->dev, &dev_attr_state); + if (error) + goto unregister_pdev; + + tob_ac = power_supply_register(&tob_pdev->dev, &tob_ac_desc, &config); + if (IS_ERR(tob_ac)) { + error = PTR_ERR(tob_ac); + tob_ac = NULL; + goto remove_file; + } + /* BAT0 appears on the first present=1 snapshot; a desktop Mac never + * creates it, so the guest bar has nothing to render. */ + return 0; + +remove_file: + device_remove_file(&tob_pdev->dev, &dev_attr_state); +unregister_pdev: + platform_device_unregister(tob_pdev); + return error; +} + +static void __exit tob_exit(void) +{ + /* Removing the attribute drains in-flight state_store writers, so + * nothing can touch the supplies or the platform device below. */ + device_remove_file(&tob_pdev->dev, &dev_attr_state); + mutex_lock(&tob_register_lock); + if (tob_bat) { + power_supply_unregister(tob_bat); + tob_bat = NULL; + } + mutex_unlock(&tob_register_lock); + power_supply_unregister(tob_ac); + platform_device_unregister(tob_pdev); +} + +module_init(tob_init); +module_exit(tob_exit); + +MODULE_AUTHOR("Try Omarchy"); +MODULE_DESCRIPTION("Mirror the host Mac's battery as guest BAT0/ADP0"); +MODULE_LICENSE("GPL"); +MODULE_VERSION("1.0.0"); diff --git a/guest/native-overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf b/guest/native-overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf new file mode 100644 index 00000000..32bdc608 --- /dev/null +++ b/guest/native-overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf @@ -0,0 +1,6 @@ +# The Mac's own low-power handling is the only authority. Omarchy shows the +# low/critical warnings but the VM never suspends or powers off on its own. +# upower 1.91.4 classifies Ignore as risky and requires the explicit allow. +[UPower] +AllowRiskyCriticalPowerAction=true +CriticalPowerAction=Ignore diff --git a/guest/native-overlay/etc/modules-load.d/95-try-omarchy-battery.conf b/guest/native-overlay/etc/modules-load.d/95-try-omarchy-battery.conf new file mode 100644 index 00000000..0268e58d --- /dev/null +++ b/guest/native-overlay/etc/modules-load.d/95-try-omarchy-battery.conf @@ -0,0 +1 @@ +try_omarchy_battery diff --git a/guest/native-overlay/etc/udev/rules.d/95-omarchy-native-battery.rules b/guest/native-overlay/etc/udev/rules.d/95-omarchy-native-battery.rules new file mode 100644 index 00000000..031be584 --- /dev/null +++ b/guest/native-overlay/etc/udev/rules.d/95-omarchy-native-battery.rules @@ -0,0 +1 @@ +SUBSYSTEM=="virtio-ports", ATTR{name}=="dev.tryomarchy.battery", MODE="0600" diff --git a/guest/native-overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service b/guest/native-overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service new file mode 100644 index 00000000..b2df54b0 --- /dev/null +++ b/guest/native-overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service @@ -0,0 +1,19 @@ +[Unit] +Description=Mirror the macOS battery into Omarchy +ConditionPathExists=/dev/virtio-ports/dev.tryomarchy.battery +# A guest that took the app update but not the module retrofit has no sysfs +# device to feed; the agent would exit 1 immediately and respawn every second +# forever. systemd re-evaluates conditions on every start attempt, so a later +# retrofit still brings the unit up. +ConditionPathExists=/sys/devices/platform/try-omarchy-battery/state +# The agent exits non-zero whenever the host bridge disconnects; keep +# retrying for as long as the launcher keeps restarting that bridge. +StartLimitIntervalSec=0 + +[Service] +ExecStart=/usr/local/bin/omarchy-native-battery-bridge +Restart=always +RestartSec=1 + +[Install] +WantedBy=multi-user.target diff --git a/guest/native-overlay/usr/local/bin/omarchy-native-battery-bridge b/guest/native-overlay/usr/local/bin/omarchy-native-battery-bridge new file mode 100755 index 00000000..add393f2 --- /dev/null +++ b/guest/native-overlay/usr/local/bin/omarchy-native-battery-bridge @@ -0,0 +1,136 @@ +#!/usr/bin/python3 +"""Mirror the Mac's battery into the guest power_supply device. + +The host sends one JSON object per line on the dev.tryomarchy.battery +virtserialport: + + {"type": "state", "present": true, "percentage": 57, + "state": "discharging", "acConnected": false, + "timeToEmptySeconds": 8100, "timeToFullSeconds": null} + +Each accepted snapshot is written as one line to the try-omarchy-battery +kernel module, which republishes it as BAT0/ADP0. This agent sends exactly +one request on start ({"type":"refresh"}), because a guest opening the port +is invisible on the host's socket chardev. Nothing else flows guest-to-host. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +from typing import Any + + +PORT = Path("/dev/virtio-ports/dev.tryomarchy.battery") +STATE_FILE = Path("/sys/devices/platform/try-omarchy-battery/state") +STATES = ("charging", "discharging", "full", "not-charging", "unknown") +MAX_LINE_BYTES = 4096 +REFRESH_LINE = b'{"type":"refresh"}\n' +SCHEMA = { + "type", "present", "percentage", "state", + "acConnected", "timeToEmptySeconds", "timeToFullSeconds", +} + + +def log(message: str) -> None: + print(f"omarchy-native-battery-bridge: {message}", file=sys.stderr, flush=True) + + +def decode_message(line: bytes) -> dict[str, Any]: + """Return a validated snapshot dict, or raise ValueError.""" + message = json.loads(line) + if not isinstance(message, dict) or message.get("type") != "state": + raise ValueError("host sent an invalid battery message") + if set(message) != SCHEMA: + raise ValueError("host battery message has an unexpected schema") + if not isinstance(message["present"], bool) or not isinstance(message["acConnected"], bool): + raise ValueError("battery presence flags must be booleans") + if message["state"] not in STATES: + raise ValueError("host sent an unknown battery state") + percentage = message["percentage"] + if message["present"]: + if not isinstance(percentage, int) or isinstance(percentage, bool) \ + or not 0 <= percentage <= 100: + raise ValueError("battery percentage must be an integer 0-100") + elif percentage is not None: + raise ValueError("an absent battery cannot carry a percentage") + for key in ("timeToEmptySeconds", "timeToFullSeconds"): + value = message[key] + if value is not None and ( + not isinstance(value, int) or isinstance(value, bool) or value < 0 + ): + raise ValueError(f"{key} must be null or a non-negative integer") + return message + + +def format_state_line(message: dict[str, Any]) -> bytes: + """Render one whole-snapshot line for the kernel module.""" + ac = 1 if message["acConnected"] else 0 + if not message["present"]: + return f"present=0 ac={ac}\n".encode() + empty = message["timeToEmptySeconds"] + full = message["timeToFullSeconds"] + return ( + f"present=1 status={message['state']} capacity={message['percentage']} " + f"ac={ac} time_to_empty={-1 if empty is None else empty} " + f"time_to_full={-1 if full is None else full}\n" + ).encode() + + +def unknown_state_line(last: dict[str, Any] | None) -> bytes: + """The honest line for a dead host bridge: keep presence, drop claims.""" + if last is None or not last["present"]: + ac = 1 if last is None or last["acConnected"] else 0 + return f"present=0 ac={ac}\n".encode() + ac = 1 if last["acConnected"] else 0 + return ( + f"present=1 status=unknown capacity={last['percentage']} " + f"ac={ac} time_to_empty=-1 time_to_full=-1\n" + ).encode() + + +def write_state(line: bytes) -> None: + STATE_FILE.write_bytes(line) + + +def run() -> int: + if not STATE_FILE.exists(): + log("try-omarchy-battery module is not loaded; nothing to feed") + return 1 + last: dict[str, Any] | None = None + try: + with PORT.open("r+b", buffering=0) as port: + port.write(REFRESH_LINE) + buffer = b"" + while True: + chunk = port.read(4096) + if not chunk: + break + buffer += chunk + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + if not line: + continue + try: + message = decode_message(line) + except ValueError as error: + log(f"rejected host line: {error}") + continue + write_state(format_state_line(message)) + last = message + if len(buffer) > MAX_LINE_BYTES: + log("host line exceeds the size limit") + break + except OSError as error: + log(f"battery channel failed: {error}") + try: + write_state(unknown_state_line(last)) + except OSError as error: + log(f"could not mark the battery unknown: {error}") + log("host battery bridge disconnected") + return 1 + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/guest/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index 3603a7f7..64eeb786 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -83,6 +83,7 @@ chmod 0755 \ "$root/usr/local/bin/omarchy-pkg-unavailable-arm" \ "$root/usr/local/bin/omarchy-pkg-refuse-aarch64-unavailable" \ "$root/usr/local/bin/omarchy-native-audio-bridge" \ + "$root/usr/local/bin/omarchy-native-battery-bridge" \ "$root/usr/local/bin/omarchy-native-camera-bridge" \ "$root/usr/local/bin/omarchy-native-clipboard-bridge" \ "$root/usr/local/bin/omarchy-native-cursor-restore" \ diff --git a/guest/scripts/finalize-rootfs.sh b/guest/scripts/finalize-rootfs.sh index 6b68895e..99c510fe 100755 --- a/guest/scripts/finalize-rootfs.sh +++ b/guest/scripts/finalize-rootfs.sh @@ -115,6 +115,7 @@ printf '%s %s\n' "$expected_vivaldi_key_sha256" "$vivaldi_key" | sha256sum -c - systemctl enable omarchy-provision-owner.service systemctl enable sddm.service systemctl enable omarchy-native-mac-share.service +systemctl enable omarchy-native-battery-bridge.service # The app expands only the writable APFS clone to 24 GiB. Grow ext4 online so # Omarchy's update-safety check sees that working capacity. diff --git a/guest/scripts/install-battery-into-existing-guest.sh b/guest/scripts/install-battery-into-existing-guest.sh new file mode 100755 index 00000000..e55b8d9d --- /dev/null +++ b/guest/scripts/install-battery-into-existing-guest.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Install the host battery integration into an EXISTING Try Omarchy guest. +# +# Run INSIDE the guest as root, against files staged through the shared Mac +# folder (never the network): +# +# 1. On the Mac, copy these repo paths into the shared folder, preserving +# the layout below. +# 2. In the guest: sudo ~//battery-retrofit/install-battery-into-existing-guest.sh +# +# Expected staging layout (--source defaults to this script's directory): +# 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 +# +# A factory reset is never required; the DKMS hook rebuilds the module on +# every guest kernel update from then on. + +set -euo pipefail + +fail() { + echo "install-battery: $*" >&2 + exit 1 +} + +source_dir=$(cd "$(dirname "$0")" && pwd -P) +while (($#)); do + case "$1" in + --source) + source_dir=${2:-} + shift 2 + ;; + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +(( EUID == 0 )) || fail "run as root (sudo)" +[[ -e /dev/virtio-ports/dev.tryomarchy.battery ]] || + fail "no battery port; update the Try Omarchy app on the Mac first" +for command in dkms install modprobe systemctl udevadm; do + command -v "$command" >/dev/null || fail "$command is required" +done + +module_source="$source_dir/native-module/try-omarchy-battery" +overlay="$source_dir/native-overlay" +for file in \ + "$module_source/try-omarchy-battery.c" \ + "$module_source/Makefile" \ + "$module_source/dkms.conf" \ + "$overlay/usr/local/bin/omarchy-native-battery-bridge" \ + "$overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service" \ + "$overlay/etc/udev/rules.d/95-omarchy-native-battery.rules" \ + "$overlay/etc/modules-load.d/95-try-omarchy-battery.conf" \ + "$overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf"; do + [[ -f $file ]] || fail "staged file is missing: $file" +done + +version=1.0.0 +module_dest=/usr/src/try-omarchy-battery-1.0.0 +[[ $module_dest == "/usr/src/try-omarchy-battery-$version" ]] || + fail "module destination does not match version $version" +install -d -m 0755 "$module_dest" +install -m 0644 "$module_source/try-omarchy-battery.c" /usr/src/try-omarchy-battery-1.0.0/try-omarchy-battery.c +install -m 0644 "$module_source/Makefile" /usr/src/try-omarchy-battery-1.0.0/Makefile +install -m 0644 "$module_source/dkms.conf" /usr/src/try-omarchy-battery-1.0.0/dkms.conf +install -m 0755 "$overlay/usr/local/bin/omarchy-native-battery-bridge" \ + /usr/local/bin/omarchy-native-battery-bridge +install -m 0644 "$overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service" \ + /usr/lib/systemd/system/omarchy-native-battery-bridge.service +install -m 0644 "$overlay/etc/udev/rules.d/95-omarchy-native-battery.rules" \ + /etc/udev/rules.d/95-omarchy-native-battery.rules +install -m 0644 "$overlay/etc/modules-load.d/95-try-omarchy-battery.conf" \ + /etc/modules-load.d/95-try-omarchy-battery.conf +install -d -m 0755 /etc/UPower/UPower.conf.d +install -m 0644 "$overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf" \ + /etc/UPower/UPower.conf.d/90-try-omarchy.conf + +if ! dkms status "try-omarchy-battery/$version" 2>/dev/null | grep -q installed; then + [[ $version == 1.0.0 ]] || fail "unexpected module version: $version" + dkms install try-omarchy-battery/1.0.0 +fi +modprobe try_omarchy_battery +udevadm control --reload +udevadm trigger --subsystem-match=virtio-ports +systemctl daemon-reload +systemctl enable --now omarchy-native-battery-bridge.service +systemctl try-restart upower.service 2>/dev/null || true + +echo "install-battery: done — the bar battery appears within 30 seconds" diff --git a/guest/scripts/register-local-repository.sh b/guest/scripts/register-local-repository.sh index c2ae641e..e020befe 100755 --- a/guest/scripts/register-local-repository.sh +++ b/guest/scripts/register-local-repository.sh @@ -63,19 +63,24 @@ hyprland = spec["supplyChain"]["hyprland"] print(f'{hyprland["version"]}-{hyprland["pkgrel"]}') voxtype = spec["supplyChain"]["voxtype"] print(f'{voxtype["version"]}-{voxtype["pkgrel"]}') +battery = spec["supplyChain"]["tryOmarchyBattery"] +print(f'{battery["version"]}-{battery["pkgrel"]}') PY ) -(( ${#metadata[@]} == 4 )) || fail "could not read local repository contract" +(( ${#metadata[@]} == 5 )) || fail "could not read local repository contract" source_date_epoch=${metadata[0]} profile=${metadata[1]} expected_hyprland_version=${metadata[2]} expected_voxtype_version=${metadata[3]} +expected_battery_version=${metadata[4]} [[ $source_date_epoch =~ ^[0-9]+$ ]] || fail "invalid source date epoch" [[ $profile == factory ]] || fail "native guest profile must be factory" [[ $expected_hyprland_version =~ ^[0-9]+\.[0-9]+\.[0-9]+-[0-9.]+$ ]] || fail "invalid patched Hyprland package version" [[ $expected_voxtype_version =~ ^[0-9]+\.[0-9]+\.[0-9]+-[1-9][0-9]*$ ]] || fail "invalid Voxtype package version" +[[ $expected_battery_version =~ ^[0-9]+\.[0-9]+\.[0-9]+-[1-9][0-9]*$ ]] || + fail "invalid battery module package version" repo_name=try-omarchy repo_dir="$root/usr/share/try-omarchy/repo" @@ -83,7 +88,7 @@ repo_dir="$root/usr/share/try-omarchy/repo" shopt -s nullglob archives=("$repo_dir"/*.pkg.tar.zst) shopt -u nullglob -expected_archive_count=6 +expected_archive_count=7 (( ${#archives[@]} == expected_archive_count )) || fail "local repository expected $expected_archive_count package archive(s), found ${#archives[@]}" [[ ${archives[*]} == *'/try-omarchy-runtime-'* ]] || fail "local repository is missing the Omarchy runtime" @@ -94,6 +99,8 @@ expected_archive_count=6 fail "factory repository is missing patched Hyprland" [[ ${archives[*]} == *"/voxtype-bin-$expected_voxtype_version-aarch64.pkg.tar.zst"* ]] || fail "factory repository is missing pinned Voxtype" +[[ ${archives[*]} == *"/try-omarchy-battery-dkms-$expected_battery_version-aarch64.pkg.tar.zst"* ]] || + fail "factory repository is missing the battery DKMS module" temporary=$(mktemp -d "$root/usr/share/try-omarchy/.repo-db.XXXXXX") cleanup() { diff --git a/guest/scripts/register-native-battery-module.sh b/guest/scripts/register-native-battery-module.sh new file mode 100755 index 00000000..387eb1a3 --- /dev/null +++ b/guest/scripts/register-native-battery-module.sh @@ -0,0 +1,222 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: register-native-battery-module.sh --root ROOT --work WORK --spec SPEC --pacman-config CONFIG + +Packages the in-repo try-omarchy-battery DKMS sources as a reproducible pacman +package, installs it into the staged root (the DKMS transaction hook builds the +module against the pinned kernel), and stages the archive for the guest's +immutable local repository. +USAGE +} + +fail() { + echo "register-native-battery-module: $*" >&2 + exit 1 +} + +root="" +work="" +spec="" +pacman_config="" + +while (($#)); do + case "$1" in + --root) + root=${2:-} + shift 2 + ;; + --work) + work=${2:-} + shift 2 + ;; + --spec) + spec=${2:-} + shift 2 + ;; + --pacman-config) + pacman_config=${2:-} + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +[[ $root == /* && -d $root ]] || fail "--root must be an absolute staged root" +case "$root" in + /|/bin|/boot|/etc|/home|/opt|/root|/usr|/var) + fail "refusing unsafe root: $root" + ;; +esac +[[ $work == /* && -d $work ]] || fail "--work must be an absolute directory" +[[ -f $spec ]] || fail "spec not found: $spec" +[[ -f $pacman_config ]] || fail "pacman config not found: $pacman_config" +root=$(cd "$root" && pwd -P) +work=$(cd "$work" && pwd -P) +case "$root" in + /|/bin|/boot|/etc|/home|/opt|/root|/usr|/var) + fail "refusing canonical unsafe root: $root" + ;; +esac +[[ $root != "$work" && $work != "$root/"* ]] || fail "work directory must be outside the staged root" +[[ $root != *$'\n'* && $work != *$'\n'* ]] || fail "root and work paths cannot contain newlines" + +for command in bsdtar find gzip install mount pacman python3 sha256sum sort tar touch umount zstd; do + command -v "$command" >/dev/null || fail "$command is required" +done + +guest_dir=$(cd "$(dirname "$0")/.." && pwd -P) +module_dir="$guest_dir/native-module/try-omarchy-battery" +for file in try-omarchy-battery.c Makefile dkms.conf; do + [[ -f $module_dir/$file && ! -L $module_dir/$file ]] || + fail "module source is missing or unsafe: $file" +done + +mapfile -t metadata < <(python3 - "$spec" <<'PY' +import json +import pathlib +import sys + +spec = json.loads(pathlib.Path(sys.argv[1]).read_text()) +battery = spec["supplyChain"]["tryOmarchyBattery"] +print(battery["version"]) +print(battery["pkgrel"]) +print(battery["license"]) +print(spec["image"]["sourceDateEpoch"]) +PY +) +(( ${#metadata[@]} == 4 )) || fail "could not read the battery module contract" +version=${metadata[0]} +pkgrel=${metadata[1]} +license=${metadata[2]} +source_date_epoch=${metadata[3]} +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "invalid module version" +[[ $pkgrel =~ ^[1-9][0-9]*$ ]] || fail "invalid module pkgrel" +[[ $source_date_epoch =~ ^[0-9]+$ ]] || fail "invalid source date epoch" +grep -q "PACKAGE_VERSION=\"$version\"" "$module_dir/dkms.conf" || + fail "dkms.conf version does not match the spec pin" + +package_name=try-omarchy-battery-dkms +package_version="$version-$pkgrel" +stage=$(mktemp -d "$work/battery-module.XXXXXX") +package_root="$stage/root" +source_target="usr/src/try-omarchy-battery-$version" +install -d -m 0755 "$package_root/$source_target" +for file in try-omarchy-battery.c Makefile dkms.conf; do + install -m 0644 "$module_dir/$file" "$package_root/$source_target/$file" +done + +installed_size=$(find "$package_root" -type f -exec wc -c {} + | awk 'END {print $1}') +cat >"$package_root/.PKGINFO" <.MTREE +) +chmod 0644 "$package_root/.MTREE" + +package_archive="$stage/$package_name-$package_version-aarch64.pkg.tar.zst" +tar \ + --sort=name \ + --mtime="@$source_date_epoch" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --format=gnu \ + -C "$package_root" \ + -cf - .PKGINFO .MTREE usr | + zstd --force --quiet -12 --threads=1 -o "$package_archive" + +archive_query=$(pacman --config "$pacman_config" -Qp "$package_archive") +[[ $archive_query == "$package_name $package_version" ]] || + fail "battery module package identity mismatch: $archive_query" +# This is the first transaction whose hooks actually run programs inside the +# staged root: the DKMS hook compiles the module and mkinitcpio inspects the +# system. pacstrap has already torn down its own mounts, so give the hooks the +# API filesystems arch-chroot would have given them. Without this the hooks' +# `>/dev/null` materializes a stray regular file in the shipped rootfs and +# mkinitcpio aborts with "/proc must be mounted!". +api_mounts=() +unmount_api_filesystems() { + local index + for (( index = ${#api_mounts[@]} - 1; index >= 0; index-- )); do + umount --recursive "${api_mounts[index]}" || + fail "could not unmount ${api_mounts[index]} from the staged root" + done + api_mounts=() +} +for directory in proc sys dev run; do + [[ -d $root/$directory && ! -L $root/$directory ]] || + fail "staged root is missing its /$directory mount point" +done +trap 'unmount_api_filesystems' EXIT +mount -t proc -o nosuid,noexec,nodev proc "$root/proc" +api_mounts+=("$root/proc") +mount -t sysfs -o nosuid,noexec,nodev,ro sys "$root/sys" +api_mounts+=("$root/sys") +mount -t devtmpfs -o mode=0755,nosuid udev "$root/dev" +api_mounts+=("$root/dev") +mount -t tmpfs -o mode=0755,nosuid,nodev run "$root/run" +api_mounts+=("$root/run") + +pacman \ + --noconfirm \ + --config "$pacman_config" \ + --root "$root" \ + --dbpath "$root/var/lib/pacman" \ + --logfile "$root/var/log/pacman.log" \ + -U "$package_archive" + +unmount_api_filesystems +trap - EXIT + +query=$(pacman --config "$pacman_config" --root "$root" --dbpath "$root/var/lib/pacman" -Q "$package_name") +[[ $query == "$package_name $package_version" ]] || + fail "battery module package was not installed: $query" +pacman --config "$pacman_config" --root "$root" --dbpath "$root/var/lib/pacman" -Qkk "$package_name" >/dev/null || + fail "installed battery module package failed its ownership check" + +# The DKMS transaction hook must have produced the module for the pinned +# kernel. An empty glob here means the hook did not run or the compile failed. +built_module=$(find "$root/usr/lib/modules" -path '*/updates/dkms/try_omarchy_battery.ko*' -print -quit) +[[ -n $built_module ]] || fail "DKMS did not build try_omarchy_battery.ko" + +repo_dir="$root/usr/share/try-omarchy/repo" +install -d -m 0755 "$repo_dir" +repo_archive="$repo_dir/$(basename "$package_archive")" +[[ ! -L $repo_archive ]] || fail "refusing symlinked immutable repository archive" +install -m 0644 "$package_archive" "$repo_archive" + +echo "Registered $query and built $(basename "$built_module")" diff --git a/guest/spec.json b/guest/spec.json index 8b02ac07..14b9c717 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -92,6 +92,11 @@ "reportedVersion": "2026.8.11 linux-arm64 (2026-08-23)", "license": "MIT" }, + "tryOmarchyBattery": { + "version": "1.0.0", + "pkgrel": "1", + "license": "GPL-2.0-only" + }, "ttfx": { "version": "0.3.2", "pkgrel": 1, @@ -579,6 +584,14 @@ "protocolVersion": 1, "width": 1280 }, + "battery": { + "activation": "always-on", + "device": "virtserialport", + "direction": "host-to-guest", + "guestSupplies": ["ADP0", "BAT0"], + "port": "dev.tryomarchy.battery", + "protocolVersion": 1 + }, "storage": { "device": "virtio-blk-pci", "format": "raw", diff --git a/guest/tests/native-module/tob_parse_harness.c b/guest/tests/native-module/tob_parse_harness.c new file mode 100644 index 00000000..81efa132 --- /dev/null +++ b/guest/tests/native-module/tob_parse_harness.c @@ -0,0 +1,138 @@ +/* + * Userspace harness for the kernel module's tob_parse(). + * + * The parser under test is NOT copied here. test_tob_parse.py slices the + * struct, the status-token table and tob_parse() verbatim out of + * guest/native-module/try-omarchy-battery/try-omarchy-battery.c into + * tob_parse_extract.c, which this file compiles against the handful of kernel + * helpers the parser uses. A drift between the shipped module and the tested + * parser is therefore impossible. + * + * Reads one state line on stdin. Prints the parsed fields, or "ERR ". + */ + +#include +#include +#include +#include +#include +#include + +#define ARRAY_SIZE(array) (sizeof(array) / sizeof((array)[0])) +#define GFP_KERNEL 0 + +/* power_supply.h status values; only their distinctness matters here. */ +enum { + POWER_SUPPLY_STATUS_UNKNOWN = 0, + POWER_SUPPLY_STATUS_CHARGING, + POWER_SUPPLY_STATUS_DISCHARGING, + POWER_SUPPLY_STATUS_NOT_CHARGING, + POWER_SUPPLY_STATUS_FULL, +}; + +static char *kstrndup(const char *source, size_t max, int flags) +{ + (void)flags; + return strndup(source, max); +} + +static void kfree(void *pointer) +{ + free(pointer); +} + +/* lib/kstrtox.c: accepts y/Y/1, n/N/0, on/off; one trailing newline is fine. */ +static int kstrtobool(const char *text, bool *result) +{ + if (!text) + return -EINVAL; + switch (text[0]) { + case 'y': + case 'Y': + case '1': + *result = true; + break; + case 'n': + case 'N': + case '0': + *result = false; + break; + case 'o': + case 'O': + switch (text[1]) { + case 'n': + case 'N': + *result = true; + return 0; + case 'f': + case 'F': + *result = false; + return 0; + default: + return -EINVAL; + } + default: + return -EINVAL; + } + if (text[1] == '\0' || text[1] == '\n') + return 0; + return -EINVAL; +} + +/* lib/kstrtox.c: no leading space, digits only, one trailing newline. */ +static int kstrtoint(const char *text, unsigned int base, int *result) +{ + const char *cursor = text; + long long value = 0; + bool negative = false; + bool digits = false; + + if (base != 10 || !text) + return -EINVAL; + if (*cursor == '-') { + negative = true; + cursor++; + } else if (*cursor == '+') { + cursor++; + } + for (; *cursor >= '0' && *cursor <= '9'; cursor++) { + digits = true; + value = value * 10 + (*cursor - '0'); + if (value > 4294967296LL) + return -ERANGE; + } + if (!digits) + return -EINVAL; + if (*cursor == '\n') + cursor++; + if (*cursor != '\0') + return -EINVAL; + if (negative) + value = -value; + if (value < -2147483648LL || value > 2147483647LL) + return -ERANGE; + *result = (int)value; + return 0; +} + +#include "tob_parse_extract.c" + +int main(void) +{ + static char buffer[8192]; + struct tob_state parsed; + size_t count = fread(buffer, 1, sizeof(buffer) - 1, stdin); + int error; + + buffer[count] = '\0'; + error = tob_parse(buffer, count, &parsed); + if (error) { + printf("ERR %d\n", -error); + return 0; + } + printf("OK present=%d status=%d capacity=%d ac=%d time_to_empty=%d time_to_full=%d\n", + parsed.present ? 1 : 0, parsed.status, parsed.capacity, + parsed.ac_online ? 1 : 0, parsed.time_to_empty, + parsed.time_to_full); + return 0; +} diff --git a/guest/tests/test_native_battery_bridge.py b/guest/tests/test_native_battery_bridge.py new file mode 100644 index 00000000..e5283384 --- /dev/null +++ b/guest/tests/test_native_battery_bridge.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Behavior tests for the guest side of macOS battery mirroring.""" + +from __future__ import annotations + +import importlib.util +from importlib.machinery import SourceFileLoader +import json +from pathlib import Path +import unittest + + +BRIDGE_PATH = ( + Path(__file__).resolve().parents[1] + / "native-overlay/usr/local/bin/omarchy-native-battery-bridge" +) +LOADER = SourceFileLoader("omarchy_native_battery_bridge", str(BRIDGE_PATH)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot import {BRIDGE_PATH}") +bridge = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(bridge) + + +def state(**overrides) -> bytes: + message = { + "type": "state", + "present": True, + "percentage": 57, + "state": "discharging", + "acConnected": False, + "timeToEmptySeconds": 8100, + "timeToFullSeconds": None, + } + message.update(overrides) + return json.dumps(message).encode() + + +class DecodeTests(unittest.TestCase): + def test_accepts_a_complete_snapshot(self) -> None: + decoded = bridge.decode_message(state()) + self.assertEqual(decoded["percentage"], 57) + self.assertEqual(decoded["state"], "discharging") + + def test_accepts_a_desktop_mac_snapshot(self) -> None: + decoded = bridge.decode_message( + state(present=False, percentage=None, state="unknown", + acConnected=True, timeToEmptySeconds=None) + ) + self.assertFalse(decoded["present"]) + self.assertTrue(decoded["acConnected"]) + + def test_rejects_malformed_messages(self) -> None: + for line in ( + b"[]", + b'{"type":"refresh"}', + state(percentage=101), + state(percentage="57"), + state(state="melting"), + state(timeToEmptySeconds=-5), + json.dumps({"type": "state", "present": True}).encode(), + state() + b"garbage", + ): + with self.assertRaises(ValueError): + bridge.decode_message(line) + + def test_extra_keys_are_rejected(self) -> None: + message = json.loads(state()) + message["extra"] = 1 + with self.assertRaises(ValueError): + bridge.decode_message(json.dumps(message).encode()) + + +class StateLineTests(unittest.TestCase): + def test_full_snapshot_line(self) -> None: + decoded = bridge.decode_message(state()) + self.assertEqual( + bridge.format_state_line(decoded), + b"present=1 status=discharging capacity=57 ac=0 " + b"time_to_empty=8100 time_to_full=-1\n", + ) + + def test_charging_snapshot_line(self) -> None: + decoded = bridge.decode_message( + state(state="charging", acConnected=True, + timeToEmptySeconds=None, timeToFullSeconds=2700) + ) + self.assertEqual( + bridge.format_state_line(decoded), + b"present=1 status=charging capacity=57 ac=1 " + b"time_to_empty=-1 time_to_full=2700\n", + ) + + def test_desktop_mac_omits_battery_keys(self) -> None: + decoded = bridge.decode_message( + state(present=False, percentage=None, state="unknown", + acConnected=True, timeToEmptySeconds=None) + ) + self.assertEqual(bridge.format_state_line(decoded), b"present=0 ac=1\n") + + def test_unknown_line_preserves_last_snapshot(self) -> None: + decoded = bridge.decode_message(state()) + self.assertEqual( + bridge.unknown_state_line(decoded), + b"present=1 status=unknown capacity=57 ac=0 " + b"time_to_empty=-1 time_to_full=-1\n", + ) + + def test_unknown_line_without_history_reports_absent(self) -> None: + self.assertEqual(bridge.unknown_state_line(None), b"present=0 ac=1\n") + + +class RefreshTests(unittest.TestCase): + def test_refresh_request_shape(self) -> None: + self.assertEqual(bridge.REFRESH_LINE, b'{"type":"refresh"}\n') + + +if __name__ == "__main__": + unittest.main() diff --git a/guest/tests/test_tob_parse.py b/guest/tests/test_tob_parse.py new file mode 100644 index 00000000..87d41898 --- /dev/null +++ b/guest/tests/test_tob_parse.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Drive the kernel module's real tob_parse() from userspace. + +tob_parse() and the Python agent's format_state_line() are two independently +written halves of one wire contract, and nothing else in the suite executes +anything under guest/native-module/. This test slices the parser verbatim out +of the shipped module source, compiles it against small shims for the kernel +helpers it uses, and feeds it the exact lines the agent emits. + +Nothing here is a copy of the parser: if the extraction markers stop matching, +the test fails rather than silently testing stale code. +""" + +from __future__ import annotations + +import importlib.util +from importlib.machinery import SourceFileLoader +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +GUEST = Path(__file__).resolve().parents[1] +MODULE_SOURCE = GUEST / "native-module/try-omarchy-battery/try-omarchy-battery.c" +HARNESS = GUEST / "tests/native-module/tob_parse_harness.c" +BRIDGE_PATH = GUEST / "native-overlay/usr/local/bin/omarchy-native-battery-bridge" + +LOADER = SourceFileLoader("omarchy_native_battery_bridge", str(BRIDGE_PATH)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot import {BRIDGE_PATH}") +bridge = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(bridge) + + +def _slice(source: str, start_marker: str, end_marker: str, *, search_back: str = "") -> str: + """Return the verbatim source block containing start_marker.""" + anchor = source.index(start_marker) + start = source.rindex(search_back, 0, anchor) if search_back else anchor + end = source.index(end_marker, anchor) + len(end_marker) + return source[start:end] + + +def extract_parser() -> str: + """Slice struct tob_state, the status table and tob_parse() verbatim.""" + source = MODULE_SOURCE.read_text() + blocks = [ + _slice(source, "struct tob_state {", "};"), + _slice(source, "} tob_status_tokens[] = {", "};", search_back="static const struct {"), + _slice(source, "static int tob_parse(", "\n}\n"), + ] + for block in blocks: + if not block.strip(): + raise RuntimeError("tob_parse extraction produced an empty block") + if "next->time_to_full" not in blocks[2]: + raise RuntimeError("tob_parse extraction did not capture the whole parser") + return "\n\n".join(blocks) + "\n" + + +def module_status_tokens() -> list[str]: + """The status tokens the shipped module accepts, read from its table.""" + table = _slice( + MODULE_SOURCE.read_text(), + "} tob_status_tokens[] = {", + "};", + search_back="static const struct {", + ) + return [ + line.split('"')[1] + for line in table.splitlines() + if line.lstrip().startswith("{ \"") + ] + + +COMPILER = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang") + + +@unittest.skipIf(COMPILER is None, "no C compiler available to build the tob_parse harness") +class TobParseTests(unittest.TestCase): + binary: Path + _directory: tempfile.TemporaryDirectory + + @classmethod + def setUpClass(cls) -> None: + cls._directory = tempfile.TemporaryDirectory() + workspace = Path(cls._directory.name) + (workspace / "tob_parse_extract.c").write_text(extract_parser()) + cls.binary = workspace / "tob_parse_harness" + subprocess.run( + [ + COMPILER, "-std=gnu11", "-Wall", "-Wextra", "-Werror", + "-D_GNU_SOURCE", "-I", str(workspace), + str(HARNESS), "-o", str(cls.binary), + ], + check=True, + ) + + @classmethod + def tearDownClass(cls) -> None: + cls._directory.cleanup() + + def parse(self, line: bytes) -> str: + completed = subprocess.run( + [str(self.binary)], input=line, stdout=subprocess.PIPE, check=True + ) + return completed.stdout.decode().strip() + + def assertRejected(self, line: bytes) -> None: + self.assertEqual(self.parse(line), "ERR 22", f"expected -EINVAL for {line!r}") + + def fields(self, line: bytes) -> dict[str, int]: + result = self.parse(line) + self.assertTrue(result.startswith("OK "), result) + return { + key: int(value) + for key, value in (pair.split("=") for pair in result.split()[1:]) + } + + # The two line forms the agent actually emits. + + def test_accepts_the_agents_present_line(self) -> None: + message = bridge.decode_message( + b'{"type":"state","present":true,"percentage":57,"state":"discharging",' + b'"acConnected":false,"timeToEmptySeconds":8100,"timeToFullSeconds":null}' + ) + parsed = self.fields(bridge.format_state_line(message)) + self.assertEqual(parsed["present"], 1) + self.assertEqual(parsed["capacity"], 57) + self.assertEqual(parsed["ac"], 0) + self.assertEqual(parsed["time_to_empty"], 8100) + self.assertEqual(parsed["time_to_full"], -1) + + def test_accepts_the_agents_absent_line(self) -> None: + message = bridge.decode_message( + b'{"type":"state","present":false,"percentage":null,"state":"unknown",' + b'"acConnected":true,"timeToEmptySeconds":null,"timeToFullSeconds":null}' + ) + parsed = self.fields(bridge.format_state_line(message)) + self.assertEqual(parsed["present"], 0) + self.assertEqual(parsed["ac"], 1) + + def test_accepts_the_agents_disconnect_line(self) -> None: + last = bridge.decode_message( + b'{"type":"state","present":true,"percentage":42,"state":"charging",' + b'"acConnected":true,"timeToEmptySeconds":null,"timeToFullSeconds":600}' + ) + parsed = self.fields(bridge.unknown_state_line(last)) + self.assertEqual(parsed["capacity"], 42) + self.assertEqual(parsed["time_to_empty"], -1) + self.assertEqual(parsed["time_to_full"], -1) + + # The status-token table is written twice, once per language. + + def test_module_and_agent_agree_on_the_status_tokens(self) -> None: + self.assertEqual(sorted(module_status_tokens()), sorted(bridge.STATES)) + + def test_every_status_token_parses_to_a_distinct_value(self) -> None: + values = {} + for token in bridge.STATES: + line = f"present=1 status={token} capacity=50 ac=0\n".encode() + values[token] = self.fields(line)["status"] + self.assertEqual(len(set(values.values())), len(bridge.STATES), values) + + def test_rejects_an_unknown_status_token(self) -> None: + self.assertRejected(b"present=1 status=melting capacity=50 ac=0\n") + + # Malformed input is rejected whole. + + def test_rejects_an_unknown_key(self) -> None: + self.assertRejected(b"present=1 status=full capacity=50 ac=1 voltage=12\n") + + def test_rejects_a_token_without_an_equals_sign(self) -> None: + self.assertRejected(b"present=1 status=full capacity ac=1\n") + + def test_rejects_missing_required_keys(self) -> None: + self.assertRejected(b"status=full capacity=50 ac=1\n") + self.assertRejected(b"present=1 status=full capacity=50\n") + self.assertRejected(b"present=1 ac=1\n") + self.assertRejected(b"present=1 capacity=50 ac=1\n") + self.assertRejected(b"present=1 status=full ac=1\n") + + def test_rejects_out_of_range_capacity(self) -> None: + self.assertRejected(b"present=1 status=full capacity=101 ac=1\n") + self.assertRejected(b"present=1 status=full capacity=-1 ac=1\n") + self.assertRejected(b"present=1 status=full capacity=abc ac=1\n") + + def test_rejects_times_below_minus_one(self) -> None: + self.assertRejected( + b"present=1 status=discharging capacity=50 ac=0 time_to_empty=-2\n" + ) + self.assertRejected( + b"present=1 status=charging capacity=50 ac=1 time_to_full=-2\n" + ) + + def test_accepts_minus_one_times(self) -> None: + parsed = self.fields( + b"present=1 status=unknown capacity=50 ac=1 time_to_empty=-1 time_to_full=-1\n" + ) + self.assertEqual(parsed["time_to_empty"], -1) + self.assertEqual(parsed["time_to_full"], -1) + + def test_absent_battery_needs_neither_status_nor_capacity(self) -> None: + parsed = self.fields(b"present=0 ac=0\n") + self.assertEqual(parsed["present"], 0) + self.assertEqual(parsed["ac"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/guest/tests/verify.py b/guest/tests/verify.py index 26786140..56482528 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -828,6 +828,27 @@ def main() -> None: and "com.apple.security.device.camera" in camera_entitlements, "Mac launcher carries the camera entitlement and supervised virtio bridge", ) + battery = spec["runtime"]["battery"] + check( + battery + == { + "activation": "always-on", + "device": "virtserialport", + "direction": "host-to-guest", + "guestSupplies": ["ADP0", "BAT0"], + "port": "dev.tryomarchy.battery", + "protocolVersion": 1, + }, + "battery contract mirrors the Mac battery one way over virtio", + ) + battery_launcher = read(REPO / "macos/run-qemu-gpu.sh") + check( + "virtserialport,bus=omarchy-serial.0,nr=5" in battery_launcher + and "name=dev.tryomarchy.battery" in battery_launcher + and "--bridge-native-battery" in battery_launcher + and "battery_bridge_restarts < 5" in battery_launcher, + "Mac launcher carries the supervised battery virtio bridge", + ) check( '"$root/usr/local/bin/omarchy-native-mac-share"' in configure and "default.target.wants/omarchy-native-mac-share-link.service" in configure, @@ -863,11 +884,12 @@ def main() -> None: "pacman recovery files snapshot the final local-repository configuration", ) check( - "expected_archive_count=6" in local_repository + "expected_archive_count=7" in local_repository and "factory repository is missing pinned ttfx" in local_repository and "factory repository is missing pinned yay" in local_repository and "factory repository is missing patched Hyprland" in local_repository and "factory repository is missing pinned Voxtype" in local_repository + and "factory repository is missing the battery DKMS module" in local_repository and "immutable local repository does not have priority" in local_repository and "resolve patched and ARM64-only packages locally" in local_repository and "refusing canonical unsafe root" in local_repository, @@ -1302,6 +1324,102 @@ def main() -> None: and 'MODE="0600"' in authentication_rule, "Touch ID authorization port is root-only", ) + battery_bridge = GUEST / "native-overlay/usr/local/bin/omarchy-native-battery-bridge" + check(battery_bridge.stat().st_mode & stat.S_IXUSR != 0, "native battery bridge is executable") + with tempfile.TemporaryDirectory() as temporary: + py_compile.compile(str(battery_bridge), cfile=str(Path(temporary) / "battery.pyc"), doraise=True) + check(True, "native battery bridge compiles") + battery_unit = read( + GUEST / "native-overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service" + ) + check( + "ConditionPathExists=/dev/virtio-ports/dev.tryomarchy.battery" in battery_unit + and "ConditionPathExists=/sys/devices/platform/try-omarchy-battery/state" in battery_unit + and "Restart=always" in battery_unit + and "StartLimitIntervalSec=0" in battery_unit, + "battery agent follows the virtio port and the module, and keeps retrying", + ) + battery_rule = read(GUEST / "native-overlay/etc/udev/rules.d/95-omarchy-native-battery.rules") + check( + 'ATTR{name}=="dev.tryomarchy.battery"' in battery_rule + and 'MODE="0600"' in battery_rule + and "GROUP=" not in battery_rule, + "battery port is root-only", + ) + check( + read(GUEST / "native-overlay/etc/modules-load.d/95-try-omarchy-battery.conf").strip() + == "try_omarchy_battery", + "battery module loads at boot", + ) + upower_dropin = read(GUEST / "native-overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf") + check( + "CriticalPowerAction=Ignore" in upower_dropin + and "AllowRiskyCriticalPowerAction=true" in upower_dropin, + "critical Mac battery warns without suspending the guest", + ) + module_source = read(GUEST / "native-module/try-omarchy-battery/try-omarchy-battery.c") + check( + '.name = "BAT0"' in module_source + and '.name = "ADP0"' in module_source + and "DEVICE_ATTR_ADMIN_RW(state)" in module_source + and "power_supply_unregister" in module_source, + "battery module exposes BAT0/ADP0 behind a root-only state attribute", + ) + dkms_conf = read(GUEST / "native-module/try-omarchy-battery/dkms.conf") + check( + 'PACKAGE_VERSION="1.0.0"' in dkms_conf, + "battery module DKMS version matches the spec pin", + ) + # DKMS always passes KERNELRELEASE on its make command line, which selects + # the Makefile's kbuild branch — a branch with no `modules` target. The + # build line must drive kbuild directly instead. + make_line = next( + (line for line in dkms_conf.splitlines() if line.startswith("MAKE[0]=")), "" + ) + check( + "-C ${kernel_source_dir}" in make_line and " M=" in make_line, + "battery module DKMS build line drives kbuild directly", + ) + finalize = read(GUEST / "scripts/finalize-rootfs.sh") + check( + "systemctl enable omarchy-native-battery-bridge.service" in finalize, + "battery agent is enabled in the factory image", + ) + configure = read(GUEST / "scripts/configure-rootfs.sh") + check( + "omarchy-native-battery-bridge" in configure, + "battery agent is made executable during rootfs configuration", + ) + retrofit = read(GUEST / "scripts/install-battery-into-existing-guest.sh") + check( + "dkms install try-omarchy-battery/1.0.0" in retrofit + and "systemctl enable --now omarchy-native-battery-bridge.service" in retrofit + and "curl" not in retrofit, + "existing guests retrofit the battery from staged files, never the network", + ) + retrofit_destinations = [ + "/usr/src/try-omarchy-battery-1.0.0/try-omarchy-battery.c", + "/usr/src/try-omarchy-battery-1.0.0/Makefile", + "/usr/src/try-omarchy-battery-1.0.0/dkms.conf", + "/usr/local/bin/omarchy-native-battery-bridge", + "/usr/lib/systemd/system/omarchy-native-battery-bridge.service", + "/etc/udev/rules.d/95-omarchy-native-battery.rules", + "/etc/modules-load.d/95-try-omarchy-battery.conf", + "/etc/UPower/UPower.conf.d/90-try-omarchy.conf", + ] + check( + all(destination in retrofit for destination in retrofit_destinations), + "retrofit script installs all eight battery files to their real system paths", + ) + check( + retrofit.index("dkms install try-omarchy-battery/1.0.0") + < retrofit.index("systemctl enable --now omarchy-native-battery-bridge.service"), + "retrofit script builds the DKMS module before enabling the service that depends on it", + ) + check( + "set -euo pipefail" in retrofit, + "retrofit script aborts on the first failure instead of limping into a half-installed state", + ) mac_share = GUEST / "native-overlay/usr/local/bin/omarchy-native-mac-share" check(mac_share.stat().st_mode & stat.S_IXUSR != 0, "native Mac share mounter is executable") with tempfile.TemporaryDirectory() as temporary: diff --git a/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift b/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift new file mode 100644 index 00000000..ba246440 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift @@ -0,0 +1,308 @@ +import Darwin +import Foundation +import IOKit.ps + +/// One complete host power snapshot, the only message the guest receives. +/// Built from IOPSGetPowerSourceDescription dictionaries so the IOKit-free +/// tests can drive every branch. +struct HostBatterySnapshot: Equatable { + let present: Bool + let percentage: Int? + let state: String + let acConnected: Bool + let timeToEmptySeconds: Int? + let timeToFullSeconds: Int? + + init(descriptions: [[String: Any]]) { + let internalBattery = descriptions.first { description in + description[kIOPSTypeKey] as? String == kIOPSInternalBatteryType + && description[kIOPSIsPresentKey] as? Bool != false + } + guard let battery = internalBattery else { + present = false + percentage = nil + state = "unknown" + acConnected = true + timeToEmptySeconds = nil + timeToFullSeconds = nil + return + } + present = true + let current = battery[kIOPSCurrentCapacityKey] as? Int ?? 0 + let maximum = battery[kIOPSMaxCapacityKey] as? Int ?? 100 + percentage = maximum > 0 ? min(100, max(0, current * 100 / maximum)) : 0 + let onMains = battery[kIOPSPowerSourceStateKey] as? String == kIOPSACPowerValue + acConnected = onMains + if battery[kIOPSIsChargingKey] as? Bool == true { + state = "charging" + } else if battery[kIOPSIsChargedKey] as? Bool == true { + state = "full" + } else if onMains { + state = "not-charging" + } else { + state = "discharging" + } + func seconds(_ key: String) -> Int? { + guard let minutes = battery[key] as? Int, minutes >= 0 else { return nil } + return minutes * 60 + } + timeToEmptySeconds = state == "discharging" ? seconds(kIOPSTimeToEmptyKey) : nil + timeToFullSeconds = state == "charging" ? seconds(kIOPSTimeToFullChargeKey) : nil + } + + func encode() -> Data { + let object: [String: Any] = [ + "type": "state", + "present": present, + "percentage": percentage as Any? ?? NSNull(), + "state": state, + "acConnected": acConnected, + "timeToEmptySeconds": timeToEmptySeconds as Any? ?? NSNull(), + "timeToFullSeconds": timeToFullSeconds as Any? ?? NSNull(), + ] + var data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + data.append(0x0A) + return data + } + + static func capture() -> HostBatterySnapshot { + guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let list = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef] else { + return HostBatterySnapshot(descriptions: []) + } + let descriptions = list.compactMap { + IOPSGetPowerSourceDescription(blob, $0)?.takeUnretainedValue() as? [String: Any] + } + return HostBatterySnapshot(descriptions: descriptions) + } +} + +/// Dedupe by value so an IOKit notification burst cannot spam the port; a +/// forced send (guest refresh, 30-second heartbeat) always goes through. +struct BatterySendPolicy { + private var lastSent: HostBatterySnapshot? + + func shouldSend(_ snapshot: HostBatterySnapshot, forced: Bool) -> Bool { + forced || snapshot != lastSent + } + + mutating func markSent(_ snapshot: HostBatterySnapshot) { + lastSent = snapshot + } +} + +/// Splits a raw guest byte stream into complete newline-delimited lines. +/// A line that exceeds the wire limit is dropped rather than surfaced — +/// every guest byte other than a well-formed refresh is ignored per the +/// wire contract, and an oversized line is guest input like any other; it +/// must not be able to terminate the bridge. Parsing resumes cleanly at the +/// next newline once the oversized line ends. +struct GuestLineReader { + static let maximumLineBytes = 4096 + + private var buffer = Data() + private var isSkippingOverflow = false + + /// Returns each complete line found in `chunk`, in order. A line that + /// overflowed while buffering is silently omitted. + mutating func feed(_ chunk: ArraySlice) -> [Data] { + var lines: [Data] = [] + var start = chunk.startIndex + for index in chunk.indices where chunk[index] == 0x0A { + if !isSkippingOverflow { + buffer.append(contentsOf: chunk[start.. Self.maximumLineBytes { + buffer.removeAll(keepingCapacity: true) + isSkippingOverflow = true + } + } + return lines + } +} + +final class NativeBatteryBridge: @unchecked Sendable { + static let heartbeatSeconds = 30.0 + + private let descriptor: Int32 + private let stateQueue = DispatchQueue(label: "dev.tryomarchy.native.battery-bridge-state") + private let stopLock = NSLock() + private var policy = BatterySendPolicy() + private var heartbeat: DispatchSourceTimer? + private var powerSource: CFRunLoopSource? + private var notificationRunLoop: CFRunLoop? + private var stopped = false + + init(targetPID: pid_t, socketPath: String) throws { + guard let processIdentity = KernelProcessIdentity.capture(processIdentifier: targetPID), + processIdentity.isQEMUSystemProcess else { + throw HelperError.io("native battery bridge target is not a QEMU system process") + } + descriptor = try NativeBridgeSocket.connectSecure(path: socketPath, label: "battery bridge") + } + + deinit { + stop() + } + + static func isRefreshRequest(_ line: Data) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { + return false + } + return object["type"] as? String == "refresh" + } + + func run() throws { + startPowerNotifications() + startHeartbeat() + send(forced: true) + var reader = GuestLineReader() + var chunk = [UInt8](repeating: 0, count: 4096) + while true { + let count = chunk.withUnsafeMutableBytes { Darwin.read(descriptor, $0.baseAddress, $0.count) } + if count > 0 { + for line in reader.feed(chunk[0.. Bool { + stopLock.lock() + defer { stopLock.unlock() } + return stopped + } + + /// Records the run loop source under `stopLock` so `stop()` can tear it + /// down without racing the detached notification thread that installs + /// it. Returns false if `stop()` already ran, so the caller can drop the + /// source it just created instead of leaking it into a torn-down bridge. + private func registerPowerSource(_ source: CFRunLoopSource, on loop: CFRunLoop) -> Bool { + stopLock.lock() + defer { stopLock.unlock() } + guard !stopped else { return false } + powerSource = source + notificationRunLoop = loop + return true + } + + /// Services IOKit power notifications on a dedicated detached thread's + /// own run loop, rather than the main run loop. `run()` blocks the + /// current thread reading the virtio socket, and under `--run-qemu`'s + /// child-process invocation there is no guarantee anything is pumping + /// `CFRunLoopGetMain()`; a private run loop on the servicing thread + /// always exists and is always drained by that same thread's loop. + private func startPowerNotifications() { + Thread.detachNewThread { [weak self] in + guard let self else { return } + let context = Unmanaged.passUnretained(self).toOpaque() + guard let source = IOPSNotificationCreateRunLoopSource({ context in + guard let context else { return } + let bridge = Unmanaged.fromOpaque(context).takeUnretainedValue() + bridge.send(forced: false) + }, context)?.takeRetainedValue() else { + fputs("[battery-bridge] IOKit power notifications are unavailable; relying on the heartbeat\n", stderr) + return + } + let loop = CFRunLoopGetCurrent()! + guard self.registerPowerSource(source, on: loop) else { return } + CFRunLoopAddSource(loop, source, .defaultMode) + while !self.hasStopped() { + CFRunLoopRunInMode(.defaultMode, 1.0, false) + } + } + } + + private func startHeartbeat() { + let timer = DispatchSource.makeTimerSource(queue: stateQueue) + timer.schedule( + deadline: .now() + Self.heartbeatSeconds, + repeating: Self.heartbeatSeconds, + leeway: .seconds(1) + ) + timer.setEventHandler { [weak self] in + // Already running on stateQueue; call the body directly rather + // than through `send`/`sendSync` so this can never dispatch + // onto the queue it is already executing on. + self?.sendOnQueue(forced: true) + } + timer.resume() + heartbeat = timer + } + + /// Queues a snapshot send without blocking the caller. Used by the + /// notification callback and the initial send in `run()`, neither of + /// which need — or should wait on — completion. + private func send(forced: Bool) { + stateQueue.async { [weak self] in + self?.sendOnQueue(forced: forced) + } + } + + /// Queues a snapshot send and blocks the caller until it completes. + /// The guest's refresh request uses this so a guest that floods refresh + /// lines without draining its side is throttled by the blocking socket + /// write instead of piling up unbounded closures on `stateQueue`. Must + /// never be called from `stateQueue` itself (the heartbeat timer calls + /// `sendOnQueue` directly for exactly that reason) or this deadlocks. + private func sendSync(forced: Bool) { + stateQueue.sync { + sendOnQueue(forced: forced) + } + } + + /// The actual send. Must only run on `stateQueue`. + private func sendOnQueue(forced: Bool) { + guard !hasStopped() else { return } + let snapshot = HostBatterySnapshot.capture() + guard policy.shouldSend(snapshot, forced: forced) else { return } + do { + try NativeBridgeSocket.writeAll(snapshot.encode(), to: descriptor, label: "battery") + policy.markSent(snapshot) + } catch { + fputs("[battery-bridge] \(error.localizedDescription)\n", stderr) + stop() + } + } +} diff --git a/macos/Sources/OmarchyVMHelper/main.swift b/macos/Sources/OmarchyVMHelper/main.swift index cd782e63..93dc0ea5 100644 --- a/macos/Sources/OmarchyVMHelper/main.swift +++ b/macos/Sources/OmarchyVMHelper/main.swift @@ -5,7 +5,7 @@ import Foundation private var terminationSignalSources: [DispatchSourceSignal] = [] private func usage() -> Never { - fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET\n", stderr) + fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-battery QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET\n", stderr) exit(64) } @@ -146,6 +146,29 @@ do { exit(0) } + if arguments.first == "--bridge-native-battery" { + guard arguments.count == 3, + let processIdentifier = Int32(arguments[1]), + processIdentifier > 1 else { usage() } + let bridge = try NativeBatteryBridge( + targetPID: processIdentifier, + socketPath: arguments[2] + ) + for signalNumber in [SIGINT, SIGTERM] { + Darwin.signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource( + signal: signalNumber, + queue: .global(qos: .userInitiated) + ) + source.setEventHandler { bridge.stop() } + source.resume() + terminationSignalSources.append(source) + } + fputs("[battery-bridge] The Mac battery is mirrored inside Omarchy.\n", stderr) + try bridge.run() + exit(0) + } + if arguments.first == "--bridge-command-super" { guard arguments.count == 3, let processIdentifier = Int32(arguments[1]), diff --git a/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift b/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift new file mode 100644 index 00000000..4e03e78a --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift @@ -0,0 +1,184 @@ +import Foundation +import Testing + +@testable import OmarchyVMHelper + +@Suite struct HostBatterySnapshotTests { + private func description( + percent: Int = 57, + max: Int = 100, + state: String = "Battery Power", + charging: Bool = false, + charged: Bool = false, + toEmptyMinutes: Int = 135, + toFullMinutes: Int = -1 + ) -> [String: Any] { + [ + "Type": "InternalBattery", + "Is Present": true, + "Current Capacity": percent, + "Max Capacity": max, + "Power Source State": state, + "Is Charging": charging, + "Is Charged": charged, + "Time to Empty": toEmptyMinutes, + "Time to Full Charge": toFullMinutes, + ] + } + + @Test func dischargingSnapshotEncodesTheWireContract() throws { + let snapshot = HostBatterySnapshot(descriptions: [description()]) + #expect(snapshot.present) + #expect(snapshot.percentage == 57) + #expect(snapshot.state == "discharging") + #expect(!snapshot.acConnected) + #expect(snapshot.timeToEmptySeconds == 135 * 60) + #expect(snapshot.timeToFullSeconds == nil) + let line = String(data: snapshot.encode(), encoding: .utf8)! + #expect(line.hasSuffix("\n")) + let object = try JSONSerialization.jsonObject( + with: snapshot.encode()) as! [String: Any] + #expect(object["type"] as? String == "state") + #expect(object["percentage"] as? Int == 57) + #expect(object["timeToFullSeconds"] is NSNull) + // Pin the wire contract's key set exactly; the guest agent parses + // by these keys, and neither extras nor omissions are safe to add + // silently. + #expect( + Set(object.keys) == [ + "acConnected", "percentage", "present", "state", + "timeToEmptySeconds", "timeToFullSeconds", "type", + ] + ) + } + + @Test func chargingAndChargedMapToTheProtocolTokens() { + let charging = HostBatterySnapshot(descriptions: [ + description(state: "AC Power", charging: true, toEmptyMinutes: -1, toFullMinutes: 45) + ]) + #expect(charging.state == "charging") + #expect(charging.acConnected) + #expect(charging.timeToFullSeconds == 45 * 60) + let full = HostBatterySnapshot(descriptions: [ + description(percent: 100, state: "AC Power", charged: true, toEmptyMinutes: -1) + ]) + #expect(full.state == "full") + let idle = HostBatterySnapshot(descriptions: [ + description(state: "AC Power", toEmptyMinutes: -1) + ]) + #expect(idle.state == "not-charging") + } + + @Test func desktopMacReportsNoBatteryOnMains() { + let snapshot = HostBatterySnapshot(descriptions: []) + #expect(!snapshot.present) + #expect(snapshot.percentage == nil) + #expect(snapshot.acConnected) + #expect(snapshot.state == "unknown") + } + + @Test func absentInternalBatteryIsSkipped() { + // The predicate accepts a missing "Is Present" key but must reject an + // explicit false, or a removed battery would be mirrored as present. + var absent = description() + absent["Is Present"] = false + let snapshot = HostBatterySnapshot(descriptions: [absent]) + #expect(!snapshot.present) + #expect(snapshot.percentage == nil) + #expect(snapshot.state == "unknown") + #expect(snapshot.acConnected) + } + + @Test func percentageIsScaledByMaxCapacity() { + let snapshot = HostBatterySnapshot(descriptions: [ + description(percent: 40, max: 80) + ]) + #expect(snapshot.percentage == 50) + } +} + +@Suite struct BatterySendPolicyTests { + @Test func duplicateSnapshotsAreCoalescedUntilForced() { + var policy = BatterySendPolicy() + let snapshot = HostBatterySnapshot(descriptions: []) + #expect(policy.shouldSend(snapshot, forced: false)) + policy.markSent(snapshot) + #expect(!policy.shouldSend(snapshot, forced: false)) + #expect(policy.shouldSend(snapshot, forced: true)) + } + + @Test func changedSnapshotAlwaysSends() { + var policy = BatterySendPolicy() + let mains = HostBatterySnapshot(descriptions: []) + policy.markSent(mains) + let battery = HostBatterySnapshot(descriptions: [[ + "Type": "InternalBattery", + "Is Present": true, + "Current Capacity": 12, + "Max Capacity": 100, + "Power Source State": "Battery Power", + "Is Charging": false, + "Is Charged": false, + "Time to Empty": -1, + "Time to Full Charge": -1, + ]]) + #expect(policy.shouldSend(battery, forced: false)) + } +} + +@Suite struct BatteryGuestRequestTests { + @Test func refreshLineIsRecognizedAndOthersAreIgnored() { + #expect(NativeBatteryBridge.isRefreshRequest(Data(#"{"type":"refresh"}"#.utf8))) + #expect(!NativeBatteryBridge.isRefreshRequest(Data(#"{"type":"state"}"#.utf8))) + #expect(!NativeBatteryBridge.isRefreshRequest(Data("garbage".utf8))) + } +} + +@Suite struct GuestLineReaderTests { + @Test func decodesLinesSplitAcrossFeedCalls() { + var reader = GuestLineReader() + let first = Array(#"{"type":"ref"#.utf8) + let second = Array("resh\"}\n".utf8) + #expect(reader.feed(first[...]).isEmpty) + let lines = reader.feed(second[...]) + #expect(lines.count == 1) + #expect(NativeBatteryBridge.isRefreshRequest(lines[0])) + } + + @Test func decodesMultipleLinesInOneFeedCall() { + var reader = GuestLineReader() + let chunk = Array(#"{"type":"refresh"}"# + "\n" + #"{"type":"refresh"}"# + "\n") + .map { UInt8($0.asciiValue!) } + let lines = reader.feed(chunk[...]) + #expect(lines.count == 2) + #expect(lines.allSatisfy(NativeBatteryBridge.isRefreshRequest)) + } + + /// Guards Finding 2: an oversized guest line must never be treated as + /// fatal — the wire contract requires every non-refresh guest byte to + /// be ignored, not to terminate the bridge. This drops the overflow + /// silently and keeps parsing the next line normally. + @Test func anOversizedLineIsDroppedAndParsingResumesAfterItsNewline() { + var reader = GuestLineReader() + let overflow = [UInt8](repeating: UInt8(ascii: "x"), count: GuestLineReader.maximumLineBytes + 1) + #expect(reader.feed(overflow[...]).isEmpty) + // The overflow line's own terminating newline, immediately followed + // by a well-formed refresh request: the guest keeps talking right + // after sending garbage, and that refresh must still be honored. + let rest = Array(("\n" + #"{"type":"refresh"}"# + "\n").utf8) + let lines = reader.feed(rest[...]) + #expect(lines.count == 1) + #expect(NativeBatteryBridge.isRefreshRequest(lines[0])) + } + + @Test func anOversizedLineSpanningMultipleFeedCallsIsStillDropped() { + var reader = GuestLineReader() + let half = [UInt8](repeating: UInt8(ascii: "x"), count: GuestLineReader.maximumLineBytes) + #expect(reader.feed(half[...]).isEmpty) + #expect(reader.feed(half[...]).isEmpty) // now well past the limit, still no newline + let rest = Array(("\n" + #"{"type":"refresh"}"# + "\n").utf8) + let lines = reader.feed(rest[...]) + #expect(lines.count == 1) + #expect(NativeBatteryBridge.isRefreshRequest(lines[0])) + } +} diff --git a/macos/Tests/run-qemu-ssh-contract.test.sh b/macos/Tests/run-qemu-ssh-contract.test.sh index af375f39..9e60f269 100755 --- a/macos/Tests/run-qemu-ssh-contract.test.sh +++ b/macos/Tests/run-qemu-ssh-contract.test.sh @@ -62,7 +62,8 @@ fi if [[ ${1:-} == --bridge-native-audio \ || ${1:-} == --bridge-native-authentication \ || ${1:-} == --bridge-native-clipboard \ - || ${1:-} == --bridge-native-camera ]]; then + || ${1:-} == --bridge-native-camera \ + || ${1:-} == --bridge-native-battery ]]; then while kill -0 "$2" 2>/dev/null; do sleep 0.02 done @@ -463,6 +464,10 @@ assert_contains "$disabled_qemu" \ 'socket,id=omarchy-authentication-bridge,path=' assert_contains "$disabled_qemu" \ 'virtserialport,bus=omarchy-serial.0,nr=3,chardev=omarchy-authentication-bridge,name=dev.tryomarchy.authentication' +assert_contains "$disabled_qemu" \ + 'socket,id=omarchy-battery-bridge,path=' +assert_contains "$disabled_qemu" \ + 'virtserialport,bus=omarchy-serial.0,nr=5,chardev=omarchy-battery-bridge,name=dev.tryomarchy.battery' assert_contains "$(<"$test_root/disabled/storage.log")" select-existing assert_contains "$(<"$test_root/disabled/storage.log")" create assert_line_pair "$test_root/disabled/qemu.log" -smp '8,sockets=1,cores=8,threads=1' diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 8fbda9a6..21948909 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -368,6 +368,7 @@ runtime = exact_keys( { "audio", "authentication", + "battery", "camera", "clipboard", "compressedDisk", @@ -477,6 +478,14 @@ camera = { "protocolVersion": 1, "width": 1280, } +battery = { + "activation": "always-on", + "device": "virtserialport", + "direction": "host-to-guest", + "guestSupplies": ["ADP0", "BAT0"], + "port": "dev.tryomarchy.battery", + "protocolVersion": 1, +} storage = { "device": "virtio-blk-pci", "format": "raw", @@ -498,6 +507,7 @@ if ( or runtime.get("network") != network or runtime.get("audio") != audio or runtime.get("camera") != camera + or runtime.get("battery") != battery or runtime.get("storage") != storage or runtime.get("clipboard") != clipboard or runtime.get("authentication") != authentication @@ -535,6 +545,7 @@ supply_chain_keys = { "mise", "omarchyPackagesCommit", "omarchyPackagesRepository", + "tryOmarchyBattery", "ttfx", "vivaldi", "voxtype", @@ -1071,6 +1082,7 @@ qemu_pid="" audio_bridge_pid="" authentication_bridge_pid="" camera_bridge_pid="" +battery_bridge_pid="" clipboard_bridge_pid="" network_link_bridge_pid="" @@ -1112,6 +1124,9 @@ cleanup() { if [[ $camera_bridge_pid =~ ^[0-9]+$ ]]; then terminate_child "$camera_bridge_pid" 20 fi + if [[ $battery_bridge_pid =~ ^[0-9]+$ ]]; then + terminate_child "$battery_bridge_pid" 20 + fi if [[ $clipboard_bridge_pid =~ ^[0-9]+$ ]]; then terminate_child "$clipboard_bridge_pid" 20 fi @@ -1371,6 +1386,7 @@ qmp_socket="/tmp/${work_dir##*/}/qmp.sock" audio_bridge_socket="/tmp/${work_dir##*/}/audio.sock" authentication_bridge_socket="/tmp/${work_dir##*/}/authentication.sock" camera_bridge_socket="/tmp/${work_dir##*/}/camera.sock" +battery_bridge_socket="/tmp/${work_dir##*/}/battery.sock" clipboard_bridge_socket="/tmp/${work_dir##*/}/clipboard.sock" audio_route_dir="/tmp/${work_dir##*/}/audio-routes" mkdir -m 700 "$work_dir/audio-routes" @@ -1563,6 +1579,8 @@ qemu_args=( -device 'virtserialport,bus=omarchy-serial.0,nr=3,chardev=omarchy-authentication-bridge,name=dev.tryomarchy.authentication' -chardev "socket,id=omarchy-camera-bridge,path=$camera_bridge_socket,server=on,wait=off" -device 'virtserialport,bus=omarchy-serial.0,nr=4,chardev=omarchy-camera-bridge,name=dev.tryomarchy.camera' + -chardev "socket,id=omarchy-battery-bridge,path=$battery_bridge_socket,server=on,wait=off" + -device 'virtserialport,bus=omarchy-serial.0,nr=5,chardev=omarchy-battery-bridge,name=dev.tryomarchy.battery' ) if [[ -n $shared_folder ]]; then @@ -1599,6 +1617,8 @@ if [[ ${OMARCHY_QEMU_GPU_DRY_RUN:-0} == 1 ]]; then "$native_bridge" "$authentication_bridge_socket" >&2 printf '\n[qemu-gpu] camera bridge command: %q --bridge-native-camera QEMU_PID %q' \ "$native_bridge" "$camera_bridge_socket" >&2 + printf '\n[qemu-gpu] battery bridge command: %q --bridge-native-battery QEMU_PID %q' \ + "$native_bridge" "$battery_bridge_socket" >&2 if [[ -n $shared_folder ]]; then printf '\n[qemu-gpu] shared folder: %q' "$shared_folder" >&2 else @@ -1628,7 +1648,7 @@ printf '%s\n' "$qemu_pid" >"$work_dir/.qemu.pid" chmod 600 "$work_dir/.qemu.pid" for ((attempt = 0; attempt < 100; attempt++)); do - if [[ -S $qmp_socket && -S $audio_bridge_socket && -S $authentication_bridge_socket && -S $camera_bridge_socket && -S $clipboard_bridge_socket ]]; then + if [[ -S $qmp_socket && -S $audio_bridge_socket && -S $authentication_bridge_socket && -S $camera_bridge_socket && -S $battery_bridge_socket && -S $clipboard_bridge_socket ]]; then break fi kill -0 "$qemu_pid" 2>/dev/null || fail "QEMU exited before creating its private QMP socket" @@ -1638,6 +1658,7 @@ done [[ -S $audio_bridge_socket ]] || fail "QEMU did not create its private audio bridge socket" [[ -S $authentication_bridge_socket ]] || fail "QEMU did not create its private authentication bridge socket" [[ -S $camera_bridge_socket ]] || fail "QEMU did not create its private camera bridge socket" +[[ -S $battery_bridge_socket ]] || fail "QEMU did not create its private battery bridge socket" [[ -S $clipboard_bridge_socket ]] || fail "QEMU did not create its private clipboard bridge socket" echo "[qemu-gpu] Ready. QMP: $qmp_socket" >&2 @@ -1677,6 +1698,14 @@ start_camera_bridge() { start_camera_bridge camera_bridge_restarts=0 +start_battery_bridge() { + "$native_bridge" --bridge-native-battery \ + "$qemu_pid" "$battery_bridge_socket" 9>&- & + battery_bridge_pid=$! +} +start_battery_bridge +battery_bridge_restarts=0 + # Bash 3.2 has no `wait -n`. The native-audio bridge is required for the guest # transport, so watch it alongside QEMU and fail if it exits unexpectedly. while true; do @@ -1770,6 +1799,27 @@ while true; do fi fi fi + # Battery mirroring is optional. A failed IOKit backend must not stop the + # VM; reconnect it so a transient failure can recover in this session. + if [[ $battery_bridge_pid =~ ^[0-9]+$ ]]; then + battery_bridge_state=$(ps -p "$battery_bridge_pid" -o state= 2>/dev/null || true) + if [[ -z $battery_bridge_state || $battery_bridge_state == *Z* ]]; then + if wait "$battery_bridge_pid"; then + battery_bridge_status=0 + else + battery_bridge_status=$? + fi + battery_bridge_pid="" + if (( battery_bridge_restarts < 5 )); then + battery_bridge_restarts=$((battery_bridge_restarts + 1)) + echo "[qemu-gpu] battery bridge exited (status $battery_bridge_status); restarting ($battery_bridge_restarts/5)" >&2 + sleep 1 + start_battery_bridge + else + echo "[qemu-gpu] battery mirroring is unavailable for the rest of this session" >&2 + fi + fi + fi sleep 0.1 done @@ -1804,4 +1854,8 @@ if [[ $camera_bridge_pid =~ ^[0-9]+$ ]]; then terminate_child "$camera_bridge_pid" 20 fi camera_bridge_pid="" +if [[ $battery_bridge_pid =~ ^[0-9]+$ ]]; then + terminate_child "$battery_bridge_pid" 20 +fi +battery_bridge_pid="" exit "$qemu_status"