diff --git a/README.md b/README.md index 51495896..b014c442 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/host-battery.md b/docs/host-battery.md index b8cf0e2e..8980d44c 100644 --- a/docs/host-battery.md +++ b/docs/host-battery.md @@ -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} @@ -127,13 +146,6 @@ Then, in the guest: 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: diff --git a/docs/integration-updates.md b/docs/integration-updates.md index 71593a42..70fe02c4 100644 --- a/docs/integration-updates.md +++ b/docs/integration-updates.md @@ -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. @@ -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 diff --git a/guest/scripts/install-battery-into-existing-guest.sh b/guest/scripts/install-battery-into-existing-guest.sh index e55b8d9d..f2302bf3 100755 --- a/guest/scripts/install-battery-into-existing-guest.sh +++ b/guest/scripts/install-battery-into-existing-guest.sh @@ -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 diff --git a/guest/tests/test_integration_bundle.py b/guest/tests/test_integration_bundle.py index 7caff566..1509fc12 100644 --- a/guest/tests/test_integration_bundle.py +++ b/guest/tests/test_integration_bundle.py @@ -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): @@ -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) @@ -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) @@ -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()) diff --git a/integrations/DESIGN.md b/integrations/DESIGN.md index d83cc3b6..4e470e6c 100644 --- a/integrations/DESIGN.md +++ b/integrations/DESIGN.md @@ -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. diff --git a/integrations/build-bundle.py b/integrations/build-bundle.py index f297678a..82c2d6ba 100755 --- a/integrations/build-bundle.py +++ b/integrations/build-bundle.py @@ -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', ] diff --git a/integrations/updater.py b/integrations/updater.py index 4bbfe0d3..f2f44ff2 100644 --- a/integrations/updater.py +++ b/integrations/updater.py @@ -21,7 +21,15 @@ PORT = Path('/dev/virtio-ports/dev.tryomarchy.integrations') COMPONENTS = { 'sudo': ('Touch ID support for sudo (pairing remains optional)', 'install-touch-id-sudo.sh'), + 'battery': ('Mac battery in the Omarchy bar', 'install-battery-into-existing-guest.sh'), } +# Matches PACKAGE_VERSION in the module's dkms.conf and the factory package. +BATTERY_VERSION = '1.0.0' +BATTERY_MODULE_FILES = ('try-omarchy-battery.c', 'Makefile', 'dkms.conf') +BATTERY_PORT = Path('/dev/virtio-ports/dev.tryomarchy.battery') +BATTERY_STATE = Path('/sys/devices/platform/try-omarchy-battery/state') +BATTERY_SERVICE = 'omarchy-native-battery-bridge.service' +KERNEL_MODULES = Path('/usr/lib/modules') BOOTSTRAP_FILES = { 'try-omarchy-integrations': '/usr/local/bin/try-omarchy-integrations', 'try-omarchy-integrations.service': '/usr/lib/systemd/system/try-omarchy-integrations.service', @@ -99,6 +107,18 @@ def component_paths(name, directory=None): 'usr/local/sbin/try-omarchy-touch-id-control', 'usr/local/bin/try-omarchy-touch-id', 'usr/local/bin/try-omarchy-touch-id-test', 'etc/udev/rules.d/93-omarchy-native-authentication.rules'] + elif name == 'battery': + # DKMS sources live where the factory image's package puts them, so a + # factory guest already reports current and is not reinstalled. + module = directory / 'guest/native-module/try-omarchy-battery' + sources = Path(f'/usr/src/try-omarchy-battery-{BATTERY_VERSION}') + names = ['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'] + return ([(module / file, sources / file) for file in BATTERY_MODULE_FILES] + + [(overlay / name, Path('/') / name) for name in names]) else: raise RuntimeError('Unsupported integration component: ' + name) return [(overlay / name, Path('/') / name) for name in names] @@ -136,15 +156,47 @@ def backup_component(name, directory, backup): shutil.copy2(target, dest) +def unavailable_reason(name): + """Why a component cannot be installed in this guest, or None.""" + if name != 'battery': + return None + if not BATTERY_PORT.exists(): + return 'this app did not provide the battery port; restart Try Omarchy and retry' + if shutil.which('dkms') is None: + return 'this VM has no DKMS to build the battery module; a newer VM image is required' + # Missing after a kernel update until reboot, and on images without headers. + if not (KERNEL_MODULES / os.uname().release / 'build').exists() and not battery_module_built(): + return 'no kernel headers for the running kernel; restart Omarchy after a kernel update and retry' + return None + + +def battery_module_built(): + result = run(['dkms', 'status', '-k', os.uname().release, f'try-omarchy-battery/{BATTERY_VERSION}'], + check=False, capture_output=True, text=True) + return result.returncode == 0 and 'installed' in result.stdout + + +def battery_install_complete(): + # A completed DKMS build alone does not prove the module was loaded or the + # bridge was enabled. A failed retrofit may have stopped between those steps. + return (battery_module_built() and BATTERY_STATE.exists() + and run(['systemctl', 'is-enabled', '--quiet', BATTERY_SERVICE], check=False).returncode == 0) + + def guest_status(loaded_identity=None): data = manifest(BUNDLE) progress = json.loads((STATE / 'progress.json').read_text()) if (STATE / 'progress.json').exists() else {'status': 'complete'} components = {} for name in ['bootstrap', *COMPONENTS]: try: - components[name] = 'current' if files_current(name) else 'repair' + current = files_current(name) + if name == 'battery' and current: + current = battery_install_complete() + components[name] = 'current' if current else 'repair' except (OSError, ValueError): components[name] = 'repair' + if components[name] == 'repair' and unavailable_reason(name): + components[name] = 'disabled' if progress.get('status') != 'complete' or (loaded_identity is not None and loaded_identity != data['identity']): components['bootstrap'] = 'repair' return {'schema': 1, 'version': data['version'], 'identity': loaded_identity or data['identity'], @@ -205,11 +257,23 @@ def install(user, selected): # Resume only a verified completed step; a receipt alone is insufficient. try: healthy = files_current(name, payload) + if name == 'battery' and healthy: + healthy = battery_install_complete() except (OSError, ValueError): healthy = False if state['completed'].get(name) == data['identity'] and healthy: print(f'{name}: already verified; retained.') continue + if name == 'battery' and healthy: + # Factory and retrofitted guests: leave package-owned files alone. + print('battery: already installed; retained.') + state['completed'][name] = data['identity'] + atomic_json(state_path, state) + continue + reason = unavailable_reason(name) + if reason: + print(f'{name}: skipped; {reason}.') + continue atomic_json(STATE / 'progress.json', {'component': name, 'status': 'installing'}) backup = Path(tempfile.mkdtemp(prefix=f'{name}-backup-', dir=STATE)) backup_component(name, payload, backup) @@ -220,8 +284,10 @@ def install(user, selected): # The existing installer otherwise invokes a user helper inside # our root-private staging tree. Publish menus only after STORE. environment['SUDO_USER'] = 'root' + elif name == 'battery': + args += ['--source', str(payload / 'guest')] run(args, env=environment) - if not files_current(name, payload): + if not files_current(name, payload) or (name == 'battery' and not battery_install_complete()): raise RuntimeError(f'{name}: installed files did not pass verification. Backup: {backup}') state['completed'][name] = data['identity'] atomic_json(state_path, state) @@ -279,6 +345,9 @@ def review(): state = 'installed' if files_current(name) else 'available or needs repair' except (OSError, ValueError): state = 'available or needs repair' + reason = unavailable_reason(name) if state != 'installed' else None + if reason: + state = 'unavailable (' + reason + ')' print(f' {COMPONENTS[name][0]}: {state}') print('\n1. Install/update integration support\n2. Set up or test Touch ID for sudo\n3. Exit') choice = input('\nChoose [1-3]: ').strip() diff --git a/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift b/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift index 6d5a6c8a..8a3703ae 100644 --- a/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift +++ b/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift @@ -9,10 +9,14 @@ struct GuestIntegrationReport: Codable, Equatable { let components: [String: String] let paired: Bool + /// Components this app's bundle installs. Anything else was installed by a + /// newer app, so this app must not offer its smaller bundle over it. + static let supportedComponents: Set = ["bootstrap", "sudo", "battery"] + static func decode(_ data: Data) throws -> Self { guard data.count <= 4096 else { throw HelperError.io("integration status exceeds limit") } let value = try JSONDecoder().decode(Self.self, from: data) - let allowed = Set(["bootstrap", "sudo", "clock", "holds", "onepassword"]) + let allowed = supportedComponents.union(["clock", "holds", "onepassword"]) guard value.schema == 1, value.version > 0, value.version <= 100000, value.identity.count == 64, value.identity.allSatisfy({ "0123456789abcdef".contains($0) }), @@ -25,14 +29,14 @@ struct GuestIntegrationReport: Codable, Equatable { } func needsReview(expectedIdentity: String) -> Bool { - version <= 1 && Set(components.keys).isSubset(of: Set(["bootstrap", "sudo"])) + version <= 1 && Set(components.keys).isSubset(of: Self.supportedComponents) && (identity != expectedIdentity || components.values.contains("repair")) } func summary(expectedIdentity: String?) -> String { if version > 1 { return "Newer guest integration version" } guard let expectedIdentity else { return "Bundle status unavailable" } - if !Set(components.keys).isSubset(of: Set(["bootstrap", "sudo"])) { + if !Set(components.keys).isSubset(of: Self.supportedComponents) { return "Additional guest integrations · use matching app" } if identity != expectedIdentity { return "Updates available" } @@ -88,7 +92,7 @@ enum GuestIntegrationSetup { static func show(window: NSWindow? = nil) { let alert = NSAlert() alert.messageText = "Review VM integrations" - alert.informativeText = "Inside Omarchy, open Setup > Try Omarchy Integrations. If that entry is missing, copy the command below and paste it into an Omarchy terminal.\n\nReview and install sudo Touch ID support before pairing. Have your Linux password ready. Your existing VM is preserved." + alert.informativeText = "Inside Omarchy, open Setup > Try Omarchy Integrations. If that entry is missing, copy the command below and paste it into an Omarchy terminal.\n\nReview and install sudo Touch ID support and the Mac battery mirror. Install Touch ID support before pairing. Have your Linux password ready. Your existing VM is preserved." alert.addButton(withTitle: "Copy setup command") alert.addButton(withTitle: "Close") let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 440, height: 64)) diff --git a/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift b/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift index 9ba3a076..8b66edeb 100644 --- a/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift @@ -22,6 +22,32 @@ struct GuestIntegrationStatusTests { #expect(repair.summary(expectedIdentity: identity) == "Repair available") } + @Test("Battery is a supported component, and an unbuildable battery is not a repair") + func battery() throws { + let current = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["bootstrap": "current", "sudo": "current", "battery": "current"], paired: true) + let decoded = try GuestIntegrationReport.decode(JSONEncoder().encode(current)) + #expect(decoded.summary(expectedIdentity: identity) == "Up to date") + #expect(!decoded.needsReview(expectedIdentity: identity)) + let disabled = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["bootstrap": "current", "sudo": "current", "battery": "disabled"], paired: true) + #expect(disabled.summary(expectedIdentity: identity) == "Up to date") + #expect(!disabled.needsReview(expectedIdentity: identity)) + let repair = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["bootstrap": "current", "sudo": "current", "battery": "repair"], paired: true) + #expect(repair.summary(expectedIdentity: identity) == "Repair available") + #expect(repair.needsReview(expectedIdentity: identity)) + } + + @Test("A guest with only the earlier sudo bundle is offered the battery update") + func earlierBundle() { + let report = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["bootstrap": "current", "sudo": "current"], paired: true) + let bundled = String(repeating: "b", count: 64) + #expect(report.summary(expectedIdentity: bundled) == "Updates available") + #expect(report.needsReview(expectedIdentity: bundled)) + } + @Test("Additional guest integrations are not offered a smaller bundle") func additionalIntegrations() { let report = GuestIntegrationReport(schema: 1, version: 1, identity: identity,