From b77a7eba762bdc0dfe19b8ee05d2284297078942 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 14:14:44 -0400 Subject: [PATCH 01/17] Add the guest battery agent that feeds host snapshots into sysfs Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../local/bin/omarchy-native-battery-bridge | 136 ++++++++++++++++++ guest/tests/test_native_battery_bridge.py | 119 +++++++++++++++ 2 files changed, 255 insertions(+) create mode 100755 guest/native-overlay/usr/local/bin/omarchy-native-battery-bridge create mode 100644 guest/tests/test_native_battery_bridge.py 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/tests/test_native_battery_bridge.py b/guest/tests/test_native_battery_bridge.py new file mode 100644 index 00000000..f5015bb7 --- /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',"extra":1}'[:0] + 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() From 4f7ef760f5a7012a1341b5fae20c83c34c1b1de7 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 14:20:01 -0400 Subject: [PATCH 02/17] Add the try-omarchy-battery DKMS module exposing BAT0 and ADP0 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../try-omarchy-battery/Makefile | 15 + .../try-omarchy-battery/dkms.conf | 7 + .../try-omarchy-battery/try-omarchy-battery.c | 352 ++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 guest/native-module/try-omarchy-battery/Makefile create mode 100644 guest/native-module/try-omarchy-battery/dkms.conf create mode 100644 guest/native-module/try-omarchy-battery/try-omarchy-battery.c diff --git a/guest/native-module/try-omarchy-battery/Makefile b/guest/native-module/try-omarchy-battery/Makefile new file mode 100644 index 00000000..dbcdece3 --- /dev/null +++ b/guest/native-module/try-omarchy-battery/Makefile @@ -0,0 +1,15 @@ +# Standard two-phase kbuild Makefile: DKMS invokes the else-branch with an +# explicit KVER 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 + +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..45c900b1 --- /dev/null +++ b/guest/native-module/try-omarchy-battery/dkms.conf @@ -0,0 +1,7 @@ +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" +MAKE[0]="make KVER=${kernelver} modules" +CLEAN="make KVER=${kernelver} clean" 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..e8097fd5 --- /dev/null +++ b/guest/native-module/try-omarchy-battery/try-omarchy-battery.c @@ -0,0 +1,352 @@ +// 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)) { + mutex_unlock(&tob_register_lock); + return PTR_ERR(battery); + } + 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) +{ + 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); + device_remove_file(&tob_pdev->dev, &dev_attr_state); + 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"); From 6d23126bf5ab5142fee700f9cca61d9c537c9221 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 14:25:53 -0400 Subject: [PATCH 03/17] Fix teardown ordering and failed-registration rollback in try-omarchy-battery tob_exit now removes the state attribute (draining in-flight writers) before tearing down the power supplies and platform device, closing a use-after-free/leak window a concurrent state_store could hit during module unload. state_store now rolls tob_state.present back to false when BAT0 registration fails, so a failed write does not leave state claiming a phantom battery until the next differing snapshot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../try-omarchy-battery/try-omarchy-battery.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/guest/native-module/try-omarchy-battery/try-omarchy-battery.c b/guest/native-module/try-omarchy-battery/try-omarchy-battery.c index e8097fd5..0730b84c 100644 --- a/guest/native-module/try-omarchy-battery/try-omarchy-battery.c +++ b/guest/native-module/try-omarchy-battery/try-omarchy-battery.c @@ -279,8 +279,12 @@ static ssize_t state_store(struct device *dev, struct device_attribute *attr, 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 PTR_ERR(battery); + return error; } tob_bat = battery; bat_changed = false; /* registration already notified */ @@ -332,6 +336,9 @@ static int __init tob_init(void) 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); @@ -339,7 +346,6 @@ static void __exit tob_exit(void) } mutex_unlock(&tob_register_lock); power_supply_unregister(tob_ac); - device_remove_file(&tob_pdev->dev, &dev_attr_state); platform_device_unregister(tob_pdev); } From 8f41688856e849d89424a905d65958137514ce52 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 14:33:58 -0400 Subject: [PATCH 04/17] Build and install the battery DKMS module in the factory image Package the in-repo try-omarchy-battery DKMS sources as a reproducible pacman package (try-omarchy-battery-dkms 1.0.0-1) and register it in the factory build so DKMS compiles try_omarchy_battery.ko against the pinned kernel and stages the archive in the local repository (archive count 6 -> 7). Also updates macos/run-qemu-gpu.sh's exact supplyChain key set so VM launch validation accepts the new tryOmarchyBattery pin, and fixes guest/tests/verify.py's existing archive-count/name assertion that the new seventh archive would otherwise break. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- guest/build.sh | 5 + guest/scripts/register-local-repository.sh | 11 +- .../scripts/register-native-battery-module.sh | 180 ++++++++++++++++++ guest/spec.json | 5 + guest/tests/verify.py | 3 +- macos/run-qemu-gpu.sh | 1 + 6 files changed, 202 insertions(+), 3 deletions(-) create mode 100755 guest/scripts/register-native-battery-module.sh 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/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..096872a5 --- /dev/null +++ b/guest/scripts/register-native-battery-module.sh @@ -0,0 +1,180 @@ +#!/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) +for command in bsdtar find gzip install pacman python3 sha256sum sort tar touch 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" +pacman \ + --noconfirm \ + --config "$pacman_config" \ + --root "$root" \ + --dbpath "$root/var/lib/pacman" \ + --logfile "$root/var/log/pacman.log" \ + -U "$package_archive" + +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" + +# 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..e06ab3a9 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, diff --git a/guest/tests/verify.py b/guest/tests/verify.py index 26786140..5d3bddf1 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -863,11 +863,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, diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 8fbda9a6..5ecdc704 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -535,6 +535,7 @@ supply_chain_keys = { "mise", "omarchyPackagesCommit", "omarchyPackagesRepository", + "tryOmarchyBattery", "ttfx", "vivaldi", "voxtype", From 9c428e5102e8a9309cb91651a9395f2a197a4dc8 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 14:40:18 -0400 Subject: [PATCH 05/17] Recheck root safety after canonicalization in the battery register script register-native-battery-module.sh canonicalized --root/--work with pwd -P but only checked the unsafe-root case statement before resolution, letting a symlinked --root that resolves to /usr, /etc, etc. slip through. Mirror register-patched-hyprland.sh: re-run the unsafe-root case after canonicalization, reject a --work inside the staged root, and reject newlines in either path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- guest/scripts/register-native-battery-module.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/guest/scripts/register-native-battery-module.sh b/guest/scripts/register-native-battery-module.sh index 096872a5..a4801c3d 100755 --- a/guest/scripts/register-native-battery-module.sh +++ b/guest/scripts/register-native-battery-module.sh @@ -62,6 +62,14 @@ esac [[ -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 pacman python3 sha256sum sort tar touch zstd; do command -v "$command" >/dev/null || fail "$command is required" done From ee139af313a0f9ab369d38f3ab7179ebeb671904 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 14:44:35 -0400 Subject: [PATCH 06/17] Enable the battery agent, root-only port, and warn-only UPower policy Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../UPower/UPower.conf.d/90-try-omarchy.conf | 6 +++ .../95-try-omarchy-battery.conf | 1 + .../rules.d/95-omarchy-native-battery.rules | 1 + .../omarchy-native-battery-bridge.service | 14 +++++ guest/scripts/configure-rootfs.sh | 1 + guest/scripts/finalize-rootfs.sh | 1 + guest/tests/verify.py | 54 +++++++++++++++++++ 7 files changed, 78 insertions(+) create mode 100644 guest/native-overlay/etc/UPower/UPower.conf.d/90-try-omarchy.conf create mode 100644 guest/native-overlay/etc/modules-load.d/95-try-omarchy-battery.conf create mode 100644 guest/native-overlay/etc/udev/rules.d/95-omarchy-native-battery.rules create mode 100644 guest/native-overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service 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..ea7f8caa --- /dev/null +++ b/guest/native-overlay/usr/lib/systemd/system/omarchy-native-battery-bridge.service @@ -0,0 +1,14 @@ +[Unit] +Description=Mirror the macOS battery into Omarchy +ConditionPathExists=/dev/virtio-ports/dev.tryomarchy.battery +# 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/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/tests/verify.py b/guest/tests/verify.py index 5d3bddf1..d0e922b0 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -1303,6 +1303,60 @@ 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 "Restart=always" in battery_unit + and "StartLimitIntervalSec=0" in battery_unit, + "battery agent follows the virtio port 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", + ) + check( + 'PACKAGE_VERSION="1.0.0"' in read(GUEST / "native-module/try-omarchy-battery/dkms.conf"), + "battery module DKMS version matches the spec pin", + ) + 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", + ) 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: From 9e50bf9804aa80e7b1081fa06e59e8cf67af3bc6 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 14:49:40 -0400 Subject: [PATCH 07/17] Add the host battery bridge fed by IOKit power notifications Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../OmarchyVMHelper/NativeBatteryBridge.swift | 252 ++++++++++++++++++ macos/Sources/OmarchyVMHelper/main.swift | 25 +- .../BatteryBridgeTests.swift | 114 ++++++++ 3 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift diff --git a/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift b/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift new file mode 100644 index 00000000..06815b94 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift @@ -0,0 +1,252 @@ +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 + } +} + +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 line = Data() + var chunk = [UInt8](repeating: 0, count: 4096) + while true { + let count = chunk.withUnsafeMutableBytes { Darwin.read(descriptor, $0.baseAddress, $0.count) } + if count > 0 { + var start = 0 + for index in 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 + self?.send(forced: true) + } + timer.resume() + heartbeat = timer + } + + private func send(forced: Bool) { + stateQueue.async { [weak self] in + guard let self, !self.hasStopped() else { return } + let snapshot = HostBatterySnapshot.capture() + guard self.policy.shouldSend(snapshot, forced: forced) else { return } + do { + try NativeBridgeSocket.writeAll(snapshot.encode(), to: self.descriptor, label: "battery") + self.policy.markSent(snapshot) + } catch { + fputs("[battery-bridge] \(error.localizedDescription)\n", stderr) + self.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..6cbaf636 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift @@ -0,0 +1,114 @@ +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) + } + + @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 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))) + } +} From ef1efd8aad035523411faefb75a52fab9427b964 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 16:14:44 -0400 Subject: [PATCH 08/17] Fix guest-input backpressure, oversized-line handling, and pin the battery wire contract Review round 1: the refresh path now dispatches onto stateQueue synchronously (via a new sendSync/sendOnQueue split) so a guest flooding refresh requests without draining its side is throttled by the blocking socket write instead of queuing unbounded work; the heartbeat still calls sendOnQueue directly to avoid a self-deadlock. An oversized guest line is now dropped by a new GuestLineReader instead of throwing and terminating the bridge, matching the "ignore everything but a well-formed refresh" wire contract. Also pins the encoded snapshot's key set in a test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../OmarchyVMHelper/NativeBatteryBridge.swift | 100 ++++++++++++++---- .../BatteryBridgeTests.swift | 58 ++++++++++ 2 files changed, 136 insertions(+), 22 deletions(-) diff --git a/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift b/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift index 06815b94..ba246440 100644 --- a/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift +++ b/macos/Sources/OmarchyVMHelper/NativeBatteryBridge.swift @@ -91,6 +91,43 @@ struct BatterySendPolicy { } } +/// 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 @@ -126,25 +163,21 @@ final class NativeBatteryBridge: @unchecked Sendable { startPowerNotifications() startHeartbeat() send(forced: true) - var line = Data() + 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 { - var start = 0 - for index in 0.. Date: Tue, 15 Sep 2026 16:24:46 -0400 Subject: [PATCH 09/17] Wire the battery bridge into the launcher and build contracts Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- guest/spec.json | 8 ++++ guest/tests/verify.py | 21 +++++++++ macos/Tests/run-qemu-ssh-contract.test.sh | 7 ++- macos/run-qemu-gpu.sh | 55 ++++++++++++++++++++++- 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/guest/spec.json b/guest/spec.json index e06ab3a9..14b9c717 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -584,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/verify.py b/guest/tests/verify.py index d0e922b0..ad4f701c 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, 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 5ecdc704..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 @@ -1072,6 +1082,7 @@ qemu_pid="" audio_bridge_pid="" authentication_bridge_pid="" camera_bridge_pid="" +battery_bridge_pid="" clipboard_bridge_pid="" network_link_bridge_pid="" @@ -1113,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 @@ -1372,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" @@ -1564,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 @@ -1600,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 @@ -1629,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" @@ -1639,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 @@ -1678,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 @@ -1771,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 @@ -1805,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" From 4fc54c11be3a89a562422b4467a0dd8f3a44a26a Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 16:30:52 -0400 Subject: [PATCH 10/17] Add the battery retrofit script for existing persistent guests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing guest disks keep their old rootfs across app updates, so they never receive the DKMS battery module from a factory image rebuild — only the virtio port arrives immediately via the host's QEMU launch command. This script lets a user install the eight staged battery files, build the module with the guest's already-present dkms/gcc/ kernel-headers toolchain, and enable the bridge service without a factory reset or any network fetch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../install-battery-into-existing-guest.sh | 95 +++++++++++++++++++ guest/tests/verify.py | 7 ++ 2 files changed, 102 insertions(+) create mode 100755 guest/scripts/install-battery-into-existing-guest.sh 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..361db24d --- /dev/null +++ b/guest/scripts/install-battery-into-existing-guest.sh @@ -0,0 +1,95 @@ +#!/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 +install -d -m 0755 "/usr/src/try-omarchy-battery-$version" +for file in try-omarchy-battery.c Makefile dkms.conf; do + install -m 0644 "$module_source/$file" "/usr/src/try-omarchy-battery-$version/$file" +done +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/tests/verify.py b/guest/tests/verify.py index ad4f701c..618eb2e9 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -1378,6 +1378,13 @@ def main() -> None: "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", + ) 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: From 8bb5d2843106da7a464b4c32eee0cda21fe5407e Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 16:38:01 -0400 Subject: [PATCH 11/17] Strengthen the retrofit verify.py check beyond a pure substring test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior check only proved three literal strings existed somewhere in the retrofit script; it would still pass if a destination path pointed at the wrong file, or if the service were enabled before the DKMS module was built. Since this script runs as root and modifies system paths, it can never run in CI, so this static check is the only automated guard it gets. Add assertions that all eight real destination paths appear in the script, that the DKMS install precedes the systemctl enable --now, and that set -euo pipefail is present so a mid-script failure cannot continue into a half-installed state. Also hardcode the three module-source destination paths under /usr/src/try-omarchy-battery-1.0.0/ instead of building them through variable interpolation, guarded by an assertion that the literal matches $version — consistent with the existing dkms install literal and needed so the new check can see the destination paths as text. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../install-battery-into-existing-guest.sh | 11 +++++--- guest/tests/verify.py | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/guest/scripts/install-battery-into-existing-guest.sh b/guest/scripts/install-battery-into-existing-guest.sh index 361db24d..e55b8d9d 100755 --- a/guest/scripts/install-battery-into-existing-guest.sh +++ b/guest/scripts/install-battery-into-existing-guest.sh @@ -65,10 +65,13 @@ for file in \ done version=1.0.0 -install -d -m 0755 "/usr/src/try-omarchy-battery-$version" -for file in try-omarchy-battery.c Makefile dkms.conf; do - install -m 0644 "$module_source/$file" "/usr/src/try-omarchy-battery-$version/$file" -done +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" \ diff --git a/guest/tests/verify.py b/guest/tests/verify.py index 618eb2e9..e72cdf84 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -1385,6 +1385,31 @@ def main() -> None: 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( + "dkms install try-omarchy-battery/1.0.0" in retrofit + and "systemctl enable --now omarchy-native-battery-bridge.service" in retrofit + and 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: From e130693dc014fe334d664989323db9e695b05aba Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 16:48:33 -0400 Subject: [PATCH 12/17] Document the host battery mirror and its retrofit path Adds docs/host-battery.md covering the JSON snapshot protocol, the sysfs state-line grammar, why the UPower critical-battery drop-in needs both CriticalPowerAction=Ignore and AllowRiskyCriticalPowerAction=true, and the no-reset retrofit procedure for existing guests. Cross-links it from a new architecture.md paragraph and adds a README highlight line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- README.md | 1 + docs/architecture.md | 13 +++++ docs/host-battery.md | 136 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 docs/host-battery.md diff --git a/README.md b/README.md index 9ff86f9a..ea31fe79 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 state, and time estimates 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..9b692418 --- /dev/null +++ b/docs/host-battery.md @@ -0,0 +1,136 @@ +# 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 with no configuration. 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. + +## 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) | Agent logs and exits; nothing else notices | +| 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 | From 0064dd7434af8cc5953b8126160f1b2e27e229ec Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 17:31:59 -0400 Subject: [PATCH 13/17] Make the battery module's DKMS build line actually build it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DKMS always passes KERNELRELEASE on its make command line, which selects the Makefile's kbuild branch — the branch that defines only obj-m and has no `modules` target. Both `dkms install` paths, factory image and retrofit, failed with "No rule to make target 'modules'". Drive kbuild directly from MAKE[0] instead, and drop the CLEAN line dkms 3.4.3 reports as deprecated and which carried the identical trap. Reproduced in an ARM64 container with the pinned dkms 3.4.3-2 and linux-aarch64-headers 7.2.6-1 running the real register script against a staged root: failing before, try_omarchy_battery.ko built after. Also correct the Makefile comment, which stated the opposite of what DKMS does, and mark the manual targets .PHONY. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- guest/native-module/try-omarchy-battery/Makefile | 9 +++++++-- guest/native-module/try-omarchy-battery/dkms.conf | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/guest/native-module/try-omarchy-battery/Makefile b/guest/native-module/try-omarchy-battery/Makefile index dbcdece3..667d0b48 100644 --- a/guest/native-module/try-omarchy-battery/Makefile +++ b/guest/native-module/try-omarchy-battery/Makefile @@ -1,5 +1,8 @@ -# Standard two-phase kbuild Makefile: DKMS invokes the else-branch with an -# explicit KVER so the build never depends on the builder's running kernel. +# 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 @@ -7,6 +10,8 @@ else KVER ?= $(shell uname -r) KDIR ?= /usr/lib/modules/$(KVER)/build +.PHONY: modules clean + modules: $(MAKE) -C $(KDIR) M=$(CURDIR) modules diff --git a/guest/native-module/try-omarchy-battery/dkms.conf b/guest/native-module/try-omarchy-battery/dkms.conf index 45c900b1..59ee2fd7 100644 --- a/guest/native-module/try-omarchy-battery/dkms.conf +++ b/guest/native-module/try-omarchy-battery/dkms.conf @@ -3,5 +3,7 @@ PACKAGE_VERSION="1.0.0" BUILT_MODULE_NAME[0]="try_omarchy_battery" DEST_MODULE_LOCATION[0]="/updates/dkms" AUTOINSTALL="yes" -MAKE[0]="make KVER=${kernelver} modules" -CLEAN="make KVER=${kernelver} clean" +# 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" From 511039f7272e7591c989191b63eec2f377ade281 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 17:32:09 -0400 Subject: [PATCH 14/17] Give the battery DKMS transaction the API filesystems its hooks need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pacstrap tears down its devtmpfs before build.sh reaches the register scripts, and this is the first transaction whose hooks actually run programs in the staged root. With $root/dev empty, the hooks' >/dev/null created a 39-byte regular file at $root/dev/null — unowned by pacman, content dependent on hook output, shipped in the rootfs — and mkinitcpio aborted with "/proc must be mounted!". Mount proc, sysfs, devtmpfs and a run tmpfs around the single pacman -U, the way arch-chroot does for every other chroot invocation in the build, and unmount them before the verification queries. $root/dev is empty again afterwards and the initcpio hook completes. Also add the pacman -Qkk integrity check the three sibling register scripts run; this script hand-generates its .MTREE, so it is exactly where that check pays. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../scripts/register-native-battery-module.sh | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/guest/scripts/register-native-battery-module.sh b/guest/scripts/register-native-battery-module.sh index a4801c3d..387eb1a3 100755 --- a/guest/scripts/register-native-battery-module.sh +++ b/guest/scripts/register-native-battery-module.sh @@ -70,7 +70,7 @@ 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 pacman python3 sha256sum sort tar touch zstd; do +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 @@ -162,6 +162,35 @@ tar \ 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" \ @@ -170,9 +199,14 @@ 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. From 9212bb14c68fd748d277bfbcc17ebf1a448d4963 Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 17:32:21 -0400 Subject: [PATCH 15/17] Stop the battery agent restart-looping on an un-retrofitted guest Restart=always with RestartSec=1 and StartLimitIntervalSec=0 meant a guest that took the app update but not the module retrofit respawned Python once a second forever, writing a journal line each time, because the agent exits 1 immediately when the sysfs state attribute is missing. Condition the unit on that attribute alongside the virtio port. systemd re-evaluates conditions on every start attempt, so a later retrofit still brings the unit up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- .../lib/systemd/system/omarchy-native-battery-bridge.service | 5 +++++ 1 file changed, 5 insertions(+) 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 index ea7f8caa..b2df54b0 100644 --- 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 @@ -1,6 +1,11 @@ [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 From 27dd1baf0ae73e99e62a752f84bdcb5562c4544f Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 17:32:21 -0400 Subject: [PATCH 16/17] Correct the battery docs on time estimates and the absent module The time estimates are plumbed end to end into sysfs as time_to_empty_avg and time_to_full_avg, but the pinned upower 1.91.4 reads neither property, and the capacity-only device gives it no energy, charge or power values to derive an estimate from. Percentage, charge state and AC presence do reach the bar; tools reading sysfs directly see the times. Say so in the README highlight, docs/host-battery.md and the spec's fidelity decision. Also update the "module absent" failure mode in both tables to the unit condition that now covers it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- README.md | 2 +- docs/host-battery.md | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ea31fe79..f68277bc 100644 --- a/README.md +++ b/README.md @@ -21,7 +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 state, and time estimates mirrored into the Omarchy bar +- 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/host-battery.md b/docs/host-battery.md index 9b692418..f8ffae19 100644 --- a/docs/host-battery.md +++ b/docs/host-battery.md @@ -3,9 +3,11 @@ 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 with no configuration. 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. +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 @@ -66,6 +68,16 @@ 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: @@ -130,7 +142,7 @@ All are non-fatal to the VM, matching the camera bridge's posture: | --- | --- | | 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) | Agent logs and exits; nothing else notices | +| 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 | From b02468e47a0bab60d7d65d5ad685771e4b52fd9c Mon Sep 17 00:00:00 2001 From: NimbleAINinja Date: Tue, 15 Sep 2026 17:32:31 -0400 Subject: [PATCH 17/17] Close the battery test gaps the whole-branch review found Nothing in the suite executed anything under guest/native-module/, and tob_parse plus the agent's format_state_line carry two independently written copies of the status-token table. Add a userspace harness that slices struct tob_state, the status table and tob_parse verbatim out of the shipped module source, compiles them against shims for the six kernel helpers the parser uses, and drives them from guest/tests/test_tob_parse.py: both line forms the agent emits, the disconnect line, every status token, an unknown key, a missing '=', each missing required key, out-of-range capacity, and -1 times. The extraction fails loudly rather than testing a stale copy. Pin MAKE[0]'s shape in verify.py so the DKMS build defect cannot recur, pin the unit's new module condition, add the "Is Present": false case the Swift snapshot predicate needs, and drop two pieces of dead test code: an unconditionally empty byte slice and a re-assertion of two strings an earlier check already covers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA --- guest/tests/native-module/tob_parse_harness.c | 138 ++++++++++++ guest/tests/test_native_battery_bridge.py | 2 +- guest/tests/test_tob_parse.py | 211 ++++++++++++++++++ guest/tests/verify.py | 20 +- .../BatteryBridgeTests.swift | 12 + 5 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 guest/tests/native-module/tob_parse_harness.c create mode 100644 guest/tests/test_tob_parse.py 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 index f5015bb7..e5283384 100644 --- a/guest/tests/test_native_battery_bridge.py +++ b/guest/tests/test_native_battery_bridge.py @@ -59,7 +59,7 @@ def test_rejects_malformed_messages(self) -> None: state(state="melting"), state(timeToEmptySeconds=-5), json.dumps({"type": "state", "present": True}).encode(), - state() + b',"extra":1}'[:0] + b"garbage", + state() + b"garbage", ): with self.assertRaises(ValueError): bridge.decode_message(line) 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 e72cdf84..56482528 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -1334,9 +1334,10 @@ def main() -> None: ) 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 keeps retrying", + "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( @@ -1364,10 +1365,21 @@ def main() -> None: 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 read(GUEST / "native-module/try-omarchy-battery/dkms.conf"), + '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, @@ -1400,9 +1412,7 @@ def main() -> None: "retrofit script installs all eight battery files to their real system paths", ) check( - "dkms install try-omarchy-battery/1.0.0" in retrofit - and "systemctl enable --now omarchy-native-battery-bridge.service" in retrofit - and retrofit.index("dkms install try-omarchy-battery/1.0.0") + 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", ) diff --git a/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift b/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift index e56de6d9..4e03e78a 100644 --- a/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/BatteryBridgeTests.swift @@ -77,6 +77,18 @@ import Testing #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)