Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,8 @@ review before requesting the Linux administrator password. SSH and personal
folder sharing are not required.

After setup, use **Omarchy Menu → Setup → Try Omarchy Integrations** or run
`try-omarchy-integrations`. The initial guide installs or updates the sudo Touch ID support already bundled
`try-omarchy-integrations`. The guide installs or updates the sudo Touch ID support and the
[Mac battery mirror](docs/host-battery.md#retrofitting-an-existing-guest) already bundled
with Try Omarchy. Biometric pairing remains a separate explicit choice. It does
not install pending integrations or upgrade the guest OS.

Expand Down
36 changes: 24 additions & 12 deletions docs/host-battery.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,33 @@ happens to the battery.
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`,
launch. No factory reset is needed: images from v0.3.0 onward carry `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:
Install it with the app's [VM integrations](integration-updates.md): open
**VM integrations > Review…** in the launcher, or **Setup > Try Omarchy
Integrations** inside Omarchy, and choose **Install/update integration
support**. The integration runs
`guest/scripts/install-battery-into-existing-guest.sh` from the app's read-only
integration bundle. It installs eight files (the three DKMS sources under
`/usr/src/try-omarchy-battery-1.0.0/`, the bridge and its unit, and the udev,
module-load, and UPower drop-ins), 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.

A guest that already has the module, from the factory image or an earlier
retrofit, is left untouched. When the module cannot be built — no DKMS on an
image before v0.3.0, or a kernel update that has not been followed by a restart
— the battery reports `disabled` with the reason and the other integrations
still install.

Without the integration bundle, the same script can run against files staged
through the shared Mac folder instead of the network. Stage these repo paths,
preserving the layout:

```text
native-module/try-omarchy-battery/{try-omarchy-battery.c,Makefile,dkms.conf}
Expand All @@ -127,13 +146,6 @@ Then, in the guest:
sudo ~/<folder>/battery-retrofit/install-battery-into-existing-guest.sh
```

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

## Failure modes

All are non-fatal to the VM, matching the camera bridge's posture:
Expand Down
13 changes: 11 additions & 2 deletions docs/integration-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ Integrations**, or with `try-omarchy-integrations` in the guest terminal.
## Features and boundaries

- sudo Touch ID: installs support; pairing is explicit and can be tested or repaired.
- Mac battery: installs the [host battery](host-battery.md) module and bridge, so
the Mac's charge appears in the Omarchy bar. The guest builds the module with
DKMS. VMs that already have it, including factory images that ship it, are left
as they are.

The initial bundle contains only upstream sudo Touch ID support. Additional
integrations can be added after their own upstream review. The manager does not
The bundle contains upstream sudo Touch ID support and the Mac battery mirror.
Additional integrations can be added after their own upstream review. The manager does not
replace the kernel, upgrade the graphics stack, repair package holds, install
1Password integration, or reproduce every change in a newer factory image. Ordinary package
updates remain with Omarchy Update. No VM reset is required for these integrations.
Expand All @@ -33,6 +37,11 @@ updates remain with Omarchy Update. No VM reset is required for these integratio
A dedicated virtio port carries bounded status reports to the host every ten
seconds. Every VM launch starts a new check. After 120 seconds without a valid
report the host shows that setup or repair may be needed and continues listening.
A component that cannot be installed in this VM reports `disabled` rather than
needing repair: the battery module needs DKMS and headers for the running kernel,
which images before v0.3.0 lack and which are missing after a kernel update until
Omarchy restarts. The review names the reason, and installation skips the battery
without failing the other integrations.
An older, slow, or stopped guest agent cannot be distinguished by silence alone.

When setup, updates, or repairs may be needed, the app offers a review once per
Expand Down
2 changes: 1 addition & 1 deletion guest/scripts/install-battery-into-existing-guest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ 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
if ! dkms status -k "$(uname -r)" "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
Expand Down
160 changes: 157 additions & 3 deletions guest/tests/test_integration_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ def guest_stat(path, *args, **kwargs):
with patch.object(updater, 'BUNDLE', self.bundle), \
patch.object(updater, 'STATE', state), \
patch.object(updater, 'component_paths', side_effect=lambda name, directory=None: pairs[name]), \
patch.object(updater, 'battery_install_complete', return_value=True), \
patch.object(Path, 'stat', guest_stat):
self.assertEqual(updater.guest_status()['components'], {'bootstrap': 'current', 'sudo': 'current'})
self.assertEqual(updater.guest_status()['components'], {'bootstrap': 'current', 'sudo': 'current', 'battery': 'current'})
for source, target in pairs['bootstrap']:
for damage in ('missing', 'content', 'mode', 'owner', 'symlink'):
with self.subTest(file=target.name, damage=damage):
Expand All @@ -172,7 +173,7 @@ def guest_stat(path, *args, **kwargs):
else:
target.unlink()
target.symlink_to(source)
self.assertEqual(updater.guest_status()['components'], {'bootstrap': 'repair', 'sudo': 'current'})
self.assertEqual(updater.guest_status()['components'], {'bootstrap': 'repair', 'sudo': 'current', 'battery': 'current'})
wrong_owner = None
target.unlink(missing_ok=True)
shutil.copy2(source, target)
Expand Down Expand Up @@ -243,7 +244,7 @@ def test_install_result_waits_after_success_or_failure(self):
events = []
def install(args, **kwargs):
self.assertEqual(args[0], 'sudo')
self.assertEqual(args[6:], ['sudo'])
self.assertEqual(args[6:], ['sudo', 'battery'])
events.append('install')
if not succeeds:
raise subprocess.CalledProcessError(1, args)
Expand Down Expand Up @@ -320,6 +321,159 @@ def answer(prompt):
updater.review()
self.assertEqual(events, ['result', 'acknowledge'] if interactive else ['result'])

def test_bundle_carries_every_file_the_battery_installer_stages(self):
import re
script = (ROOT / 'guest/scripts/install-battery-into-existing-guest.sh').read_text()
staged = set(re.findall(r'"\$(module_source|overlay)/([^"]+)"', script))
self.assertEqual(len(staged), 8)
files = updater.manifest(self.bundle)['files']
self.assertIn('guest/scripts/install-battery-into-existing-guest.sh', files)
for kind, name in staged:
prefix = 'guest/native-module/try-omarchy-battery/' if kind == 'module_source' else 'guest/native-overlay/'
self.assertIn(prefix + name, files)
# Every file the installer writes is checked, so a factory guest can report current.
managed = {str(target) for _, target in updater.component_paths('battery', self.bundle)}
self.assertEqual(len(managed), 8)
for source, _ in updater.component_paths('battery', self.bundle):
self.assertTrue(source.is_file())

def test_battery_sources_match_the_factory_dkms_package(self):
spec = json.loads((ROOT / 'guest/spec.json').read_text())
self.assertEqual(spec['supplyChain']['tryOmarchyBattery']['version'], updater.BATTERY_VERSION)
dkms = (ROOT / 'guest/native-module/try-omarchy-battery/dkms.conf').read_text()
self.assertIn(f'PACKAGE_VERSION="{updater.BATTERY_VERSION}"', dkms)
script = (ROOT / 'guest/scripts/install-battery-into-existing-guest.sh').read_text()
self.assertIn(f'module_dest=/usr/src/try-omarchy-battery-{updater.BATTERY_VERSION}', script)
targets = {str(target) for _, target in updater.component_paths('battery', self.bundle)}
for name in updater.BATTERY_MODULE_FILES:
self.assertIn(f'/usr/src/try-omarchy-battery-{updater.BATTERY_VERSION}/{name}', targets)

def test_battery_status_requires_a_complete_install(self):
for files, installed, reason, expected in ((True, True, 'no headers', 'current'),
(True, False, 'no headers', 'disabled'),
(True, False, None, 'repair'),
(False, False, 'no headers', 'disabled')):
with self.subTest(files=files, installed=installed, reason=reason), \
patch.object(updater, 'BUNDLE', self.bundle), \
patch.object(updater, 'STATE', self.bundle.parent / 'no-state'), \
patch.object(updater, 'files_current', side_effect=lambda name, directory=None: name != 'battery' or files), \
patch.object(updater, 'battery_install_complete', return_value=installed), \
patch.object(updater, 'unavailable_reason', side_effect=lambda name: reason if name == 'battery' else None):
components = updater.guest_status()['components']
self.assertEqual(components, {'bootstrap': 'current', 'sudo': 'current', 'battery': expected})

def test_battery_install_requires_current_kernel_build_loaded_module_and_enabled_service(self):
state = self.bundle.parent / 'battery-state'
enabled = False

def check(args, **kwargs):
self.assertEqual(args, ['systemctl', 'is-enabled', '--quiet', updater.BATTERY_SERVICE])
return subprocess.CompletedProcess(args, 0 if enabled else 1)

with patch.object(updater, 'BATTERY_STATE', state), \
patch.object(updater, 'battery_module_built', return_value=False) as built, \
patch.object(updater, 'run', side_effect=check) as run:
self.assertFalse(updater.battery_install_complete())
run.assert_not_called()
built.return_value = True
self.assertFalse(updater.battery_install_complete())
state.write_text('')
self.assertFalse(updater.battery_install_complete())
enabled = True
self.assertTrue(updater.battery_install_complete())

def test_battery_unavailable_reasons(self):
port = self.bundle.parent / 'battery-port'
modules = self.bundle.parent / 'modules'
release = 'test-release'
with patch.object(updater, 'BATTERY_PORT', port), patch.object(updater, 'KERNEL_MODULES', modules), \
patch.object(updater.os, 'uname', return_value=SimpleNamespace(release=release)), \
patch.object(updater.shutil, 'which', return_value='/usr/bin/dkms') as which:
self.assertIn('battery port', updater.unavailable_reason('battery'))
port.write_text('')
which.return_value = None
self.assertIn('DKMS', updater.unavailable_reason('battery'))
which.return_value = '/usr/bin/dkms'
with patch.object(updater, 'battery_module_built', return_value=False):
self.assertIn('kernel headers', updater.unavailable_reason('battery'))
with patch.object(updater, 'battery_module_built', return_value=True):
self.assertIsNone(updater.unavailable_reason('battery'))
(modules / release / 'build').mkdir(parents=True)
self.assertIsNone(updater.unavailable_reason('battery'))
self.assertIsNone(updater.unavailable_reason('sudo'))

def install_battery(self, reason, healthy, installed, receipt=False):
"""Run install() for the battery alone inside a fake guest; return installer calls and output."""
state = Path(tempfile.mkdtemp(dir=self.bundle.parent))
original_read = Path.read_text
calls, output = [], []

def guest_read(path, *args, **kwargs):
if str(path) == '/proc/cmdline':
return 'omarchy.qemu_virgl=1'
return original_read(path, *args, **kwargs)

def record(args, **kwargs):
calls.append(list(map(str, args)))
return subprocess.CompletedProcess(args, 0, '', '')

def battery_current(name, directory=None):
ran = any(call[0] == '/bin/bash' for call in calls)
return name != 'battery' or healthy or ran

def battery_complete():
return installed or any(call[0] == '/bin/bash' for call in calls)

if receipt:
identity = updater.manifest(self.bundle)['identity']
(state / 'state.json').write_text(json.dumps({'completed': {'battery': identity}}))

with patch.object(updater, 'BUNDLE', self.bundle), \
patch.object(updater, 'STATE', state), \
patch.object(updater, 'STORE', self.bundle.parent / 'installed'), \
patch.object(updater, 'component_paths', return_value=[]), \
patch.object(updater, 'files_current', side_effect=battery_current), \
patch.object(updater, 'unavailable_reason', return_value=reason), \
patch.object(updater, 'battery_install_complete', side_effect=battery_complete), \
patch.object(updater, 'safe_destination'), \
patch.object(updater.os, 'geteuid', return_value=0), \
patch.object(updater.os, 'chown'), \
patch.object(updater.pwd, 'getpwnam', return_value=SimpleNamespace(pw_uid=1000)), \
patch.dict(os.environ, {'SUDO_UID': '1000'}), \
patch.object(Path, 'read_text', guest_read), \
patch.object(updater, 'run', side_effect=record), \
patch('builtins.print', side_effect=lambda *args, **kwargs: output.append(' '.join(map(str, args)))):
updater.install('guest', ['battery'])
completed = json.loads((state / 'state.json').read_text())['completed'] if (state / 'state.json').exists() else {}
installers = [call for call in calls if call[0] == '/bin/bash']
return installers, output, completed

def test_battery_installer_runs_from_the_staged_bundle(self):
installers, _, completed = self.install_battery(reason=None, healthy=False, installed=False)
self.assertEqual(len(installers), 1)
script, source = installers[0][1], installers[0][3]
self.assertTrue(script.endswith('/payload/guest/scripts/install-battery-into-existing-guest.sh'))
self.assertEqual(installers[0][2], '--source')
self.assertTrue(source.endswith('/payload/guest'))
self.assertIn('battery', completed)

def test_unbuildable_battery_is_skipped_without_failing_setup(self):
installers, output, completed = self.install_battery(reason='no kernel headers', healthy=False, installed=False)
self.assertEqual(installers, [])
self.assertTrue(any('battery: skipped; no kernel headers' in line for line in output))
self.assertNotIn('battery', completed)

def test_installed_battery_is_retained_without_rerunning_the_installer(self):
installers, output, completed = self.install_battery(reason=None, healthy=True, installed=True)
self.assertEqual(installers, [])
self.assertIn('battery: already installed; retained.', output)
self.assertIn('battery', completed)
# A receipt or finished DKMS build cannot hide a missing runtime step.
installers, _, _ = self.install_battery(reason=None, healthy=True, installed=False)
self.assertEqual(len(installers), 1)
installers, _, _ = self.install_battery(reason=None, healthy=True, installed=False, receipt=True)
self.assertEqual(len(installers), 1)

def test_future_bundle_is_not_installed_by_old_updater(self):
path = self.bundle / 'manifest.json'
data = json.loads(path.read_text())
Expand Down
2 changes: 1 addition & 1 deletion integrations/DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Existing VM integration updates

Status: standalone integration delivery on current upstream; initial payload is sudo Touch ID support.
Status: standalone integration delivery on current upstream; the payload is sudo Touch ID support and the Mac battery mirror.

The app bundles a reviewed integration payload independently of the factory disk.
A dedicated read-only 9p share (tryomarchy-updates) exposes it to old guests.
Expand Down
9 changes: 9 additions & 0 deletions integrations/build-bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
'native-overlay/usr/local/bin/try-omarchy-touch-id',
'native-overlay/usr/local/bin/try-omarchy-touch-id-test',
'native-overlay/etc/udev/rules.d/93-omarchy-native-authentication.rules',
'scripts/install-battery-into-existing-guest.sh',
'native-module/try-omarchy-battery/try-omarchy-battery.c',
'native-module/try-omarchy-battery/Makefile',
'native-module/try-omarchy-battery/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',
]


Expand Down
Loading
Loading