diff --git a/README.md b/README.md index 241cb908..ff3124aa 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,25 @@ all of that app's factory-image changes to an existing VM, and an in-guest update should not be assumed to reproduce them. A confirmed reset is the deliberate, destructive way to start again from the newest bundled factory. +### Updating integrations in an existing VM + +The Mac launcher’s **VM integrations → Review…** action explains how to add +new Try Omarchy features to an existing VM. It offers a one-time setup command +for guests that do not yet have the integration manager. Run that command in an +Omarchy terminal; it mounts the app’s dedicated read-only bundle and opens a +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 guide offers sudo Touch ID support, recovery after +Mac sleep, and package compatibility repairs. Touch ID pairing and the optional +1Password integration are separate explicit choices. + +The app checks integration status after every VM launch. The launcher labels +cached results **Last check**. A guest that does not respond may need setup or +repair; a timeout is not proof that its components are absent. See +[integration updates](docs/integration-updates.md) for scope and recovery details. + ### Repairing update holds in an older guest Older guests may fail Omarchy Update with conflicting `libaquamarine.so` diff --git a/docs/architecture.md b/docs/architecture.md index 1b678d84..31ac0c6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -262,10 +262,10 @@ but the direct-boot kernel and matching headers, the packaged `try-omarchy-runtime`, and reviewed compatibility backports remain pinned in Try Omarchy's prioritized local repository. Reusing a disk therefore does not silently import a newer app's factory contents, and running the in-guest updater -must not be described as reproducing every factory-image change. Delivering -new Try Omarchy runtime or backport revisions to existing disks requires an -explicitly designed in-guest migration channel; today a factory reset is the -way to opt into the complete new factory. +must not be described as reproducing every factory-image change. The bundled integration manager provides an explicit migration channel for +reviewed guest integrations, with user-approved installation and per-VM status +reporting. It does not replace the pinned kernel or reproduce every factory +change. Factory reset remains the way to opt into the complete new factory. Optional, user-initiated installers run after the factory image has been built and are a separate trust boundary. They may resolve a mutable current release diff --git a/docs/integration-updates.md b/docs/integration-updates.md new file mode 100644 index 00000000..c5d23f1c --- /dev/null +++ b/docs/integration-updates.md @@ -0,0 +1,75 @@ +# Integration updates for existing VMs + +App upgrades retain existing guest disks. The integration manager delivers +reviewed guest features independently of the bundled factory image. + +## First setup + +Open **VM integrations > Review…** in the Mac launcher. Launch Omarchy and paste +the supplied command into an Omarchy terminal. It mounts the app's dedicated +read-only 9p share at `/mnt/try-omarchy-updates` and opens a review. The share is +separate from the optional personal shared folder and needs no SSH connection. + +Choose **Install/update integration support** and review replacements before +confirming. Installation asks for the Linux user's sudo authorization, retains +backups, and verifies each component before recording it as complete. Biometric +enrollment remains a separate action. Existing PAM enrollment is preserved. + +The guide is then available under **Omarchy Menu > Setup > Try Omarchy +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. +- Clock recovery: enables the RTC-based recovery timer and verifies it is active. +- Package compatibility: repairs both saved and active holds without upgrading packages. +- 1Password: optional per-user activation after installation, sign-in, system + authentication enablement, and sudo Touch ID pairing. Service activation does + not prove that a biometric authorization succeeded; test by locking 1Password + without quitting it and using its unlock control. + +The manager does not replace the kernel, upgrade the graphics stack, install +1Password, or reproduce every change in a newer factory image. Ordinary package +updates remain with Omarchy Update. No VM reset is required for these integrations. + +## Status + +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. +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 +bundled integration revision for that disk. Choosing Later leaves the VM running +and keeps the review action available. Checks still run on every launch. + +The Mac menu bar provides a live integration status and review action. The +launcher shows the last check for the selected persistent disk. A report of +current components means installed files and relevant services passed inspection; +it does not attest that Touch ID was successfully used. Status messages never +execute commands or authorize host or guest installation. + +## Failure and retry + +The updater verifies the exact bundle inventory and hashes before installation, +then stages a root-private copy. The app signature covers the bundle and manifest; +hashes detect corruption and do not independently establish trust in an app. + +Previous files, the previous installed bundle, and progress are retained under +`/var/lib/try-omarchy/integrations`. A component is marked complete only after +verification. Rerunning skips a previously completed step only when its files and +required services still match. This is resumable installation, not a transactional +rollback of all PAM or systemd effects. A failed step prints its error and leaves +progress and backups available for repair. + +Installation lists existing integration files that differ before asking to +replace them. Unrelated menu entries and package-configuration settings are +preserved. Unsupported or unsafe paths stop the operation. Close Omarchy Update +before installing integrations; the package-hold repair also uses pacman's lock. + +Guest status diagnostics: + +```sh +systemctl status try-omarchy-integrations.service --no-pager +sudo journalctl -u try-omarchy-integrations.service -b -n 40 --no-pager +``` diff --git a/guest/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index 2b80db97..13da5e3e 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -181,6 +181,18 @@ mkdir -p "$root/usr/local/lib/try-omarchy" install -m 0755 "$guest_dir/scripts/finalize-rootfs.sh" "$root/usr/local/lib/try-omarchy/finalize-rootfs" install -m 0644 "$spec" "$root/usr/share/try-omarchy/build-spec.json" +# Fresh guests report integration status from their first boot. Older guests +# receive the same bundle through the app's explicit bootstrap flow. +python3 "$guest_dir/../integrations/build-bundle.py" "$root/usr/local/share/try-omarchy/integrations" +install -m 0755 "$guest_dir/../integrations/try-omarchy-integrations" "$root/usr/local/bin/try-omarchy-integrations" +install -m 0644 "$guest_dir/../integrations/try-omarchy-integrations.service" "$root/usr/lib/systemd/system/try-omarchy-integrations.service" +mkdir -p "$root/etc/systemd/system/multi-user.target.wants" +ln -s /usr/lib/systemd/system/try-omarchy-integrations.service "$root/etc/systemd/system/multi-user.target.wants/try-omarchy-integrations.service" +python3 "$root/usr/local/share/try-omarchy/integrations/updater.py" stage-menu "$root/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc" +mkdir -p "$root/etc/systemd/system/timers.target.wants" +ln -s /usr/lib/systemd/system/try-omarchy-clock-recovery.timer "$root/etc/systemd/system/timers.target.wants/try-omarchy-clock-recovery.timer" + + # Record content digests before the user overlay is copied into $HOME. This is # the machine-readable proof that the compositor/shell runtime came from the # pinned Omarchy tree rather than a frontend reproduction. diff --git a/guest/scripts/install-onepassword-touch-id.sh b/guest/scripts/install-onepassword-touch-id.sh index 2687867b..c442a7d2 100755 --- a/guest/scripts/install-onepassword-touch-id.sh +++ b/guest/scripts/install-onepassword-touch-id.sh @@ -9,7 +9,7 @@ set -euo pipefail guest_user=$1 getent passwd "$guest_user" >/dev/null source_dir=$(cd "$(dirname "$0")/../native-overlay" && pwd) -python3 -I -c 'import gi; gi.require_version("Gtk", "3.0"); gi.require_version("PolkitAgent", "1.0"); from gi.repository import Gtk, PolkitAgent' +env -u DISPLAY -u WAYLAND_DISPLAY python3 -I -c 'import gi; gi.require_version("Gtk", "3.0"); gi.require_version("PolkitAgent", "1.0"); from gi.repository import Gtk, PolkitAgent' [[ -f /var/lib/try-omarchy/native-authentication.json ]] || { echo "Pair this guest using the Touch ID setup first." >&2 exit 1 @@ -32,5 +32,6 @@ install -o root -g root -m 644 "$source_dir$unit" "$unit" systemctl daemon-reload systemctl enable "try-omarchy-onepassword-touch-id@$guest_user.service" systemctl restart "try-omarchy-onepassword-touch-id@$guest_user.service" -printf '1Password Touch ID enabled. Previous files retained in %s\n' "$backup_dir" +printf '1Password Touch ID integration installed. Previous files retained in %s\n' "$backup_dir" printf 'Disable with: sudo systemctl disable --now try-omarchy-onepassword-touch-id@%s.service\n' "$guest_user" +printf 'To test: sign in to 1Password, enable system authentication, unlock with your account password, then lock without quitting and try Touch ID.\n' diff --git a/guest/scripts/install-touch-id-menu-entry.py b/guest/scripts/install-touch-id-menu-entry.py index 0f4683fa..91015c7d 100755 --- a/guest/scripts/install-touch-id-menu-entry.py +++ b/guest/scripts/install-touch-id-menu-entry.py @@ -26,7 +26,7 @@ def fail(message: str) -> None: raise SystemExit(f"install-touch-id-menu-entry: {message}") -def install(path: Path) -> None: +def install(path: Path, previous_entry: str | None = None) -> None: path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) try: info = path.stat(follow_symlinks=False) @@ -45,7 +45,7 @@ def install(path: Path) -> None: text = data.decode("utf-8") except UnicodeDecodeError: fail("menu extension is not UTF-8") - if ENTRY_ID in text: + if ENTRY_ID in text and (previous_entry is None or text.count(previous_entry) != 1): return opening = text.find("{") @@ -53,7 +53,10 @@ def install(path: Path) -> None: fail("menu extension does not start with a JSONC object") if text.rstrip()[-1:] != "}": fail("menu extension is not a JSONC object") - updated = text[: opening + 1] + "\n" + ENTRY + text[opening + 1 :] + if ENTRY_ID in text: + updated = text.replace(previous_entry, ENTRY, 1) + else: + updated = text[: opening + 1] + "\n" + ENTRY + text[opening + 1 :] directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) temporary = f".{path.name}.{secrets.token_hex(8)}" diff --git a/guest/tests/test_integration_bundle.py b/guest/tests/test_integration_bundle.py new file mode 100644 index 00000000..6448c43f --- /dev/null +++ b/guest/tests/test_integration_bundle.py @@ -0,0 +1,142 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +import subprocess +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] + +def module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + +builder = module('integration_builder', ROOT / 'integrations/build-bundle.py') +updater = module('integration_updater', ROOT / 'integrations/updater.py') + +class IntegrationBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + # macOS /var is a symlink; the production bundle must be canonical. + self.bundle = Path(self.temp.name).resolve() / 'bundle' + builder.build(self.bundle) + + def tearDown(self): + self.temp.cleanup() + + def test_complete_bundle_is_verifiable(self): + result = updater.manifest(self.bundle) + self.assertEqual(result['version'], 1) + self.assertIn('guest/scripts/install-onepassword-touch-id.sh', result['files']) + self.assertTrue((self.bundle / 'setup').stat().st_mode & 0o111) + + def test_corruption_cannot_execute(self): + (self.bundle / 'setup').write_text('changed') + with self.assertRaisesRegex(RuntimeError, 'verification failed'): + updater.manifest(self.bundle) + + def test_symlink_substitution_is_rejected(self): + target = self.bundle / 'setup' + original = target.read_bytes() + target.unlink() + other = self.bundle.parent / 'outside' + other.write_bytes(original) + target.symlink_to(other) + with self.assertRaisesRegex(RuntimeError, 'symlink'): + updater.manifest(self.bundle) + + def test_manifest_traversal_rejected(self): + path = self.bundle / 'manifest.json' + data = json.loads(path.read_text()) + data['files']['../outside'] = 'a' * 64 + path.write_text(json.dumps(data)) + with self.assertRaisesRegex(RuntimeError, 'path|unexpected'): + updater.manifest(self.bundle) + + def test_menu_refresh_preserves_entries_and_has_omarchy_environment(self): + home = self.bundle.parent / 'home' + menu = home / '.config/omarchy/extensions/omarchy-menu.jsonc' + menu.parent.mkdir(parents=True) + menu.write_text('{\n "custom": {"label":"Keep me","action":"true"},\n}\n') + with patch.object(updater, 'BUNDLE', self.bundle), patch.object(Path, 'home', return_value=home), patch.object(updater, 'run') as run: + run.return_value = subprocess.CompletedProcess([], 0, '', '') + with patch.dict(updater.os.environ, {}, clear=True): + updater.menu_entry() + updater.menu_entry() + self.assertEqual(run.call_args.kwargs['env']['OMARCHY_PATH'], str(home / '.local/share/omarchy')) + text = menu.read_text() + self.assertIn('Keep me', text) + self.assertEqual(text.count('"setup.try-omarchy-integrations"'), 1) + self.assertEqual(text.count('"setup.security.touch-id"'), 1) + + def test_menu_upgrade_replaces_only_the_previous_generated_entry(self): + menu = self.bundle.parent / 'menu.jsonc' + old = ' "setup.try-omarchy-integrations": {"label":"Try Omarchy Integrations","action":"omarchy-launch-floating-terminal-with-presentation /usr/local/bin/try-omarchy-integrations"},\n' + for custom in (False, True): + entry = old.replace('Try Omarchy Integrations', 'My custom label') if custom else old + menu.write_text('{\n' + entry + ' "custom": {"action":"true"},\n}\n') + with patch.object(updater, 'BUNDLE', self.bundle): + updater.menu_entry(menu, refresh=False) + text = menu.read_text() + self.assertIn('"custom": {"action":"true"}', text) + self.assertEqual(text.count('"setup.try-omarchy-integrations"'), 1) + if custom: + self.assertIn(entry, text) + else: + self.assertNotIn(old, text) + self.assertIn('xdg-terminal-exec', text) + + def test_incomplete_install_and_old_running_agent_are_not_current(self): + state = self.bundle.parent / 'state' + state.mkdir() + identity = updater.manifest(self.bundle)['identity'] + with patch.object(updater, 'BUNDLE', self.bundle), patch.object(updater, 'STATE', state), patch.object(updater, 'files_current', return_value=True), patch.object(updater, 'active', return_value=True): + (state / 'progress.json').write_text('{"status":"installing"}') + self.assertEqual(updater.guest_status(identity)['components']['bootstrap'], 'repair') + (state / 'progress.json').write_text('{"status":"complete"}') + self.assertEqual(updater.guest_status(identity)['components']['bootstrap'], 'current') + old = updater.guest_status('b' * 64) + self.assertEqual(old['components']['bootstrap'], 'repair') + self.assertEqual(old['identity'], 'b' * 64) + + def test_review_refreshes_user_menu_only_after_successful_install(self): + for succeeds in (True, False): + with self.subTest(succeeds=succeeds): + events = [] + def install(args, **kwargs): + self.assertEqual(args[0], 'sudo') + events.append('install') + if not succeeds: + raise subprocess.CalledProcessError(1, args) + with patch.object(updater, 'BUNDLE', self.bundle), \ + patch.object(updater, 'files_current', return_value=True), \ + patch.object(updater, 'active', return_value=False), \ + patch.object(updater, 'component_paths', return_value=[]), \ + patch.object(updater, 'run', side_effect=install), \ + patch.object(updater, 'menu_entry', side_effect=lambda: events.append('refresh')), \ + patch('builtins.input', side_effect=['1', 'y']), patch('builtins.print'): + if succeeds: + updater.review() + else: + with self.assertRaises(subprocess.CalledProcessError): + updater.review() + self.assertEqual(events, ['install', 'refresh'] if succeeds else ['install']) + + def test_unlisted_file_is_rejected(self): + (self.bundle / 'extra').write_text('unreviewed') + with self.assertRaisesRegex(RuntimeError, 'unexpected'): + updater.manifest(self.bundle) + + def test_future_bundle_is_not_installed_by_old_updater(self): + path = self.bundle / 'manifest.json' + data = json.loads(path.read_text()) + data['version'] = 2 + path.write_text(json.dumps(data)) + with self.assertRaisesRegex(RuntimeError, 'newer updater'): + updater.manifest(self.bundle) + +if __name__ == '__main__': + unittest.main() diff --git a/integrations/DESIGN.md b/integrations/DESIGN.md new file mode 100644 index 00000000..692b42a4 --- /dev/null +++ b/integrations/DESIGN.md @@ -0,0 +1,77 @@ +# Existing VM integration updates + +Status: implemented on the integrated installer branches; review and release gates below. + +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. +Users approve the first mount/install inside the guest with their Linux password. +No SSH, personal folder sharing, disk mutation from macOS, or typed-command +injection is required. The launcher offers the exact bootstrap command to copy. + +A root-owned guest service reports bounded JSON over a dedicated virtio port. +The host checks on every boot, retains last-known status alongside that VM's disk, +and distinguishes waiting, no response, updates available, and reported current. +Guest messages are advisory: they cannot select host paths or execute host code. +An absent response does not prove absence of the bootstrap. Installations remain +explicitly approved; biometric enrollment is never automatic. + +Versioned migrations cover sudo support, clock recovery, compatibility holds, +and an optional per-user 1Password setup. A root-owned installed bundle and root-private +journal support verification and retry after interrupted installation. Existing +configuration and service backups are retained. User modifications to managed +files require review instead of silent replacement. Kernel and graphics package +replacement are excluded. + +Validation must cover an old guest with no agent, current/older/newer agents, +malformed/oversized status, interrupted and repeated updates, per-VM state, +customized files, disabled optional features, and an actual old-guest bootstrap. + +## Implemented user journey + +The launcher has a VM integrations row even before bootstrap. Review opens +instructions and a Copy setup command button. It mounts only the app's dedicated +read-only bundle and starts a guest terminal guide. The guide inventories the +base integrations and offers a separate 1Password action. Linux sudo authorization +occurs only after the review confirmation. sudo biometric pairing remains a +separate explicit action. + +A live status menu appears on macOS while QEMU runs. It starts at Checking and +receives guest reports every ten seconds. At 120 seconds without a report it +shows Setup or repair needed; it continues listening for a late boot or repair. +The persisted result is labeled Last check in the launcher. Guest time is never +used to determine freshness. State lives at the VM storage root, keyed by the working disk's file identity, +so reset and alternate VM locations do not inherit another VM's result. The +strict disk-directory inventory remains unchanged. + +Bundle identity covers the exact payload inventory and hashes. The app signature +covers the distributed manifest and files. Hash checking detects corruption; it +is not a substitute for trusting the app supplying the bundle. Guest sudo is an +explicit approval to install that app's code. The status channel cannot request +installation or host actions. + +Progress is committed after each verified migration; retries skip completed, +still-healthy steps. Partial failures are reported and retain backups. This is +resumable installation, not a claim of transactional rollback of arbitrary +systemd/PAM effects. The old bundle is retained when the installed bundle changes. + +## Validation and release gates + +Validated in a disposable guest with the bootstrap disabled and compatibility +holds removed: automatic missing-agent detection, read-only bundle mounting, +base installation, preservation of custom menu entries, repeated installation, +and fresh current status after a VM restart. This fixture models an older guest; +it is not a substitute for testing every historical factory release. It uses a +test-only sudo policy, so interactive password authorization remains a manual +check. Optional 1Password authorization requires a configured account and remains +a manual release gate. + +The native application builds and passes signature and compatibility checks. +A fresh factory build remains blocked by rolling package repositories diverging +from the reviewed package lock. No package versions were changed for this feature. +The tested application uses a previously completed factory artifact and the new +integration bundle. Rebuild the factory from the final source before release. + +This change currently builds on the integration branches for 1Password, clock +recovery, compatibility-hold repair, and launcher layout. Review its own commit +against the integration baseline; resolve those dependencies before submitting a +standalone upstream change. diff --git a/integrations/build-bundle.py b/integrations/build-bundle.py new file mode 100755 index 00000000..ea50509a --- /dev/null +++ b/integrations/build-bundle.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Package reviewed guest integrations independently of the factory image.""" +import hashlib +import json +from pathlib import Path +import shutil +import sys + +ROOT = Path(__file__).resolve().parents[1] +FILES = [ + 'scripts/install-touch-id-sudo.sh', 'scripts/install-touch-id-menu-entry.py', + 'scripts/install-clock-recovery.sh', 'scripts/install-onepassword-touch-id.sh', + 'scripts/repair-update-holds.py', + 'native-overlay/usr/local/lib/try-omarchy/native-authentication-broker', + 'native-overlay/usr/local/lib/try-omarchy/onepassword-touch-id-agent', + 'native-overlay/usr/local/lib/try-omarchy/onepassword-password-dialog', + 'native-overlay/usr/local/lib/try-omarchy/guest-clock-recover', + 'native-overlay/usr/local/sbin/try-omarchy-touch-id-enroll', + 'native-overlay/usr/local/sbin/try-omarchy-touch-id-control', + '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', + 'native-overlay/usr/lib/systemd/system/try-omarchy-onepassword-touch-id@.service', + 'native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.service', + 'native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.timer', +] + +def build(destination): + destination.mkdir(parents=True, exist_ok=False) + for name in FILES: + target = destination / 'guest' / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / 'guest' / name, target) + for name in ('updater.py', 'setup', 'try-omarchy-integrations', 'try-omarchy-integrations.service'): + shutil.copy2(ROOT / 'integrations' / name, destination / name) + files = {str(p.relative_to(destination)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(destination.rglob('*')) if p.is_file()} + identity = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest() + (destination / 'manifest.json').write_text(json.dumps({ + 'schema': 1, 'version': 1, 'identity': identity, 'files': files, + }, sort_keys=True, indent=2) + '\n') + +if __name__ == '__main__': + build(Path(sys.argv[1])) diff --git a/integrations/setup b/integrations/setup new file mode 100755 index 00000000..622bc3dd --- /dev/null +++ b/integrations/setup @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +exec /usr/bin/python3 -I "$(dirname "$0")/updater.py" review diff --git a/integrations/try-omarchy-integrations b/integrations/try-omarchy-integrations new file mode 100755 index 00000000..702e95a3 --- /dev/null +++ b/integrations/try-omarchy-integrations @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +# Mount only the app's dedicated read-only bundle, never a personal shared folder. +mountpoint=/mnt/try-omarchy-updates +if ! mountpoint -q "$mountpoint"; then + sudo mkdir -p "$mountpoint" + sudo mount -t 9p -o trans=virtio,version=9p2000.L,ro tryomarchy-updates "$mountpoint" +fi +exec /usr/bin/python3 -I "$mountpoint/updater.py" review diff --git a/integrations/try-omarchy-integrations.service b/integrations/try-omarchy-integrations.service new file mode 100644 index 00000000..d44bf271 --- /dev/null +++ b/integrations/try-omarchy-integrations.service @@ -0,0 +1,16 @@ +[Unit] +Description=Report Try Omarchy integration status +After=systemd-udev-settle.service +ConditionPathExists=/dev/virtio-ports/dev.tryomarchy.integrations + +[Service] +ExecStart=/usr/bin/python3 -I /usr/local/share/try-omarchy/integrations/updater.py report +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target diff --git a/integrations/updater.py b/integrations/updater.py new file mode 100644 index 00000000..75fc6317 --- /dev/null +++ b/integrations/updater.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Review, install, and report the guest integrations bundled with Try Omarchy.""" +import fcntl +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import shutil +import stat +import subprocess +import sys +import tempfile +import time + +sys.dont_write_bytecode = True +BUNDLE = Path(__file__).resolve().parent +STORE = Path('/usr/local/share/try-omarchy/integrations') +STATE = Path('/var/lib/try-omarchy/integrations') +PORT = Path('/dev/virtio-ports/dev.tryomarchy.integrations') +COMPONENTS = { + 'sudo': ('Touch ID support for sudo (pairing remains optional)', 'install-touch-id-sudo.sh'), + 'clock': ('Clock recovery after Mac sleep', 'install-clock-recovery.sh'), + 'holds': ('Package-update compatibility repair', 'repair-update-holds.py'), + 'onepassword': ('Touch ID for 1Password (optional, for your account)', 'install-onepassword-touch-id.sh'), +} + + +def run(args, **kwargs): + kwargs.setdefault('check', True) + return subprocess.run(args, **kwargs) + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def manifest(directory): + data = json.loads((directory / 'manifest.json').read_text()) + if data.get('schema') != 1 or data.get('version') != 1 or not isinstance(data.get('files'), dict): + raise RuntimeError('This integration bundle needs a newer updater.') + if not 1 <= len(data['files']) <= 100: + raise RuntimeError('Invalid integration file inventory.') + actual = set() + for entry in directory.rglob('*'): + if entry.is_symlink() or not (entry.is_file() or entry.is_dir()): + raise RuntimeError('Integration bundle contains a symlink or special file.') + if entry.is_file() and entry.name != 'manifest.json': + actual.add(str(entry.relative_to(directory))) + if actual != set(data['files']): + raise RuntimeError('Integration bundle has unexpected or missing files.') + for name, expected in data['files'].items(): + relative = Path(name) + if relative.is_absolute() or '..' in relative.parts or not isinstance(expected, str): + raise RuntimeError('Invalid integration file path.') + path = directory / relative + if any(p.is_symlink() for p in [path, *path.parents] if p != directory.parent): + raise RuntimeError('Integration bundle contains a symlink.') + if not path.is_file() or digest(path) != expected: + raise RuntimeError(f'Integration bundle verification failed: {name}') + identity = hashlib.sha256(json.dumps(data['files'], sort_keys=True).encode()).hexdigest() + if identity != data.get('identity'): + raise RuntimeError('Integration manifest identity mismatch.') + return data + + +def atomic_json(path, value): + fd, temporary = tempfile.mkstemp(dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def safe_destination(path): + for parent in [path, *path.parents]: + if parent.is_symlink(): + raise RuntimeError(f'Refusing symlink destination: {path}') + if parent.exists(): + info = parent.stat() + if info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError(f'Destination must be root-owned and not user-writable: {parent}') + + +def component_paths(name, directory=BUNDLE): + overlay = directory / 'guest/native-overlay' + if name == 'sudo': + names = ['usr/local/lib/try-omarchy/native-authentication-broker', + 'usr/local/sbin/try-omarchy-touch-id-enroll', + '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 == 'clock': + names = ['usr/local/lib/try-omarchy/guest-clock-recover', + 'usr/lib/systemd/system/try-omarchy-clock-recovery.service', + 'usr/lib/systemd/system/try-omarchy-clock-recovery.timer'] + elif name == 'onepassword': + names = ['usr/local/lib/try-omarchy/native-authentication-broker', + 'usr/local/lib/try-omarchy/onepassword-touch-id-agent', + 'usr/local/lib/try-omarchy/onepassword-password-dialog', + 'usr/lib/systemd/system/try-omarchy-onepassword-touch-id@.service'] + else: + names = [] + return [(overlay / name, Path('/') / name) for name in names] + + +def files_current(name, directory=BUNDLE): + if name == 'holds': + spec = importlib.util.spec_from_file_location('holds', directory / 'guest/scripts/repair-update-holds.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return all((Path('/') / p).read_text() == module.add_holds((Path('/') / p).read_text()) + for p in module.CONFIGS) + return all(not target.is_symlink() and target.is_file() and digest(source) == digest(target) + and target.stat().st_uid == 0 and not target.stat().st_mode & 0o022 + and stat.S_IMODE(target.stat().st_mode) == stat.S_IMODE(source.stat().st_mode) + for source, target in component_paths(name, directory)) + + +def active(unit): + return subprocess.run(['systemctl', 'is-active', '--quiet', unit], check=False).returncode == 0 + + +def guest_status(loaded_identity=None): + data = manifest(BUNDLE) + installed = json.loads((STATE / 'state.json').read_text()) if (STATE / 'state.json').exists() else {} + progress = json.loads((STATE / 'progress.json').read_text()) if (STATE / 'progress.json').exists() else {'status': 'complete'} + components = {'bootstrap': 'current' if progress.get('status') == 'complete' else 'repair'} + if loaded_identity is not None and loaded_identity != data['identity']: + components['bootstrap'] = 'repair' + for name in ('sudo', 'clock', 'holds'): + try: + components[name] = 'current' if files_current(name) else 'repair' + except (OSError, ValueError): + components[name] = 'repair' + if components['clock'] == 'current' and not active('try-omarchy-clock-recovery.timer'): + components['clock'] = 'repair' + for user in installed.get('onepassword_users', []): + components['onepassword'] = 'current' if files_current('onepassword') else 'repair' + if not active(f'try-omarchy-onepassword-touch-id@{user}.service'): + components['onepassword'] = 'disabled' + return {'schema': 1, 'version': data['version'], 'identity': loaded_identity or data['identity'], + 'components': components, 'paired': Path('/var/lib/try-omarchy/native-authentication.json').is_file()} + + +def report(): + loaded_identity = manifest(BUNDLE)['identity'] + while True: + with PORT.open('wb', buffering=0) as channel: + while True: + channel.write(json.dumps(guest_status(loaded_identity), separators=(',', ':')).encode() + b'\n') + time.sleep(10) + + +def install(user, selected): + if os.geteuid() != 0: + raise RuntimeError('Installation requires the guest administrator password.') + if 'omarchy.qemu_virgl=1' not in Path('/proc/cmdline').read_text().split(): + raise RuntimeError('Run this installer inside Try Omarchy.') + account = pwd.getpwnam(user) + if account.pw_uid == 0 or os.environ.get('SUDO_UID') != str(account.pw_uid): + raise RuntimeError('Run this command with sudo from your normal Omarchy account.') + manifest(BUNDLE) + for path in (STATE, STORE, Path('/usr/local/bin/try-omarchy-integrations'), + Path('/usr/lib/systemd/system/try-omarchy-integrations.service')): + safe_destination(path) + if (STORE / 'manifest.json').is_file(): + installed_version = json.loads((STORE / 'manifest.json').read_text()).get('version') + if not isinstance(installed_version, int) or installed_version > manifest(BUNDLE)['version']: + raise RuntimeError('This guest has newer integration support. Use a matching or newer Mac app; downgrades are not installed.') + STATE.mkdir(parents=True, exist_ok=True, mode=0o700) + with (STATE / 'install.lock').open('w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + if Path('/var/lib/pacman/db.lck').exists(): + raise RuntimeError('Close the package updater before installing integrations.') + # Stage a private, verified copy before any privileged installer executes. + stage = Path(tempfile.mkdtemp(prefix='bundle-', dir=STATE)) + shutil.copytree(BUNDLE, stage / 'payload', symlinks=True) + payload = stage / 'payload' + data = manifest(payload) + for path in payload.rglob('*'): + os.chown(path, 0, 0) + path.chmod(0o755 if path.is_dir() or path.stat().st_mode & 0o111 else 0o644) + state_path = STATE / 'state.json' + state = json.loads(state_path.read_text()) if state_path.exists() else {'completed': {}, 'onepassword_users': []} + for name in selected: + # Resume only a verified completed step; a receipt alone is insufficient. + try: + healthy = files_current(name, payload) + except (OSError, ValueError): + healthy = False + if name == 'clock': + healthy = healthy and active('try-omarchy-clock-recovery.timer') + if name == 'onepassword': + healthy = healthy and active(f'try-omarchy-onepassword-touch-id@{user}.service') and user in state['onepassword_users'] + if state['completed'].get(name) == data['identity'] and healthy: + print(f'{name}: already verified; retained.') + continue + atomic_json(STATE / 'progress.json', {'component': name, 'status': 'installing'}) + backup = Path(tempfile.mkdtemp(prefix=f'{name}-backup-', dir=STATE)) + for source, target in component_paths(name, payload): + safe_destination(target) + if target.exists(): + dest = backup / str(target).lstrip('/') + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, dest) + script = payload / 'guest/scripts' / COMPONENTS[name][1] + args = ['/usr/bin/python3', '-I', str(script), '--apply'] if name == 'holds' else ['/bin/bash', str(script)] + if name == 'onepassword': + args.append(user) + environment = os.environ.copy() + if name == 'sudo': + # The existing installer otherwise invokes a user helper inside + # our root-private staging tree. Publish menus only after STORE. + environment['SUDO_USER'] = 'root' + run(args, env=environment) + if not files_current(name, payload): + raise RuntimeError(f'{name}: installed files did not pass verification. Backup: {backup}') + if name == 'clock' and not active('try-omarchy-clock-recovery.timer'): + raise RuntimeError('Clock recovery timer did not start.') + if name == 'onepassword' and not active(f'try-omarchy-onepassword-touch-id@{user}.service'): + raise RuntimeError('1Password integration service did not start.') + state['completed'][name] = data['identity'] + if name == 'onepassword' and user not in state['onepassword_users']: + state['onepassword_users'].append(user) + atomic_json(state_path, state) + STORE.parent.mkdir(parents=True, exist_ok=True) + replacement = Path(tempfile.mkdtemp(prefix='.integrations-', dir=STORE.parent)) / 'bundle' + shutil.copytree(payload, replacement) + manifest(replacement) + # Keep the previous bundle so an interrupted replacement can be repaired. + if STORE.exists(): + STORE.rename(stage / 'previous-bundle') + try: + replacement.rename(STORE) + except OSError: + if not STORE.exists() and (stage / 'previous-bundle').exists(): + (stage / 'previous-bundle').rename(STORE) + raise + for source, target in [('try-omarchy-integrations', '/usr/local/bin/try-omarchy-integrations'), + ('try-omarchy-integrations.service', '/usr/lib/systemd/system/try-omarchy-integrations.service')]: + shutil.copy2(payload / source, target) + # Preserve existing user menu entries, including the sudo entry just installed. + run(['runuser', '-u', user, '--', '/usr/bin/python3', '-I', str(STORE / 'updater.py'), 'menu-install']) + run(['systemctl', 'daemon-reload']) + run(['systemctl', 'enable', '--now', 'try-omarchy-integrations.service']) + run(['systemctl', 'restart', 'try-omarchy-integrations.service']) + atomic_json(STATE / 'progress.json', {'status': 'complete'}) + print('Integrations installed and checked. Backups retained in ' + str(STATE)) + print('Open Omarchy Menu > Setup > Try Omarchy Integrations for updates and optional features.') + + +def menu_entry(path=None, refresh=True): + spec = importlib.util.spec_from_file_location('integration_menu', BUNDLE / 'guest/scripts/install-touch-id-menu-entry.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + destination = path or Path.home() / '.config/omarchy/extensions/omarchy-menu.jsonc' + module.install(destination) + module.ENTRY_ID = '"setup.try-omarchy-integrations"' + module.ENTRY = ' "setup.try-omarchy-integrations": {"label":"Try Omarchy Integrations","action":"setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Try-Omarchy-Integrations -e /usr/local/bin/try-omarchy-integrations"},\n' + previous_entry = ' "setup.try-omarchy-integrations": {"label":"Try Omarchy Integrations","action":"omarchy-launch-floating-terminal-with-presentation /usr/local/bin/try-omarchy-integrations"},\n' + module.install(destination, previous_entry=previous_entry) + if refresh: + environment = os.environ.copy() + environment.setdefault('OMARCHY_PATH', str(Path.home() / '.local/share/omarchy')) + result = run(['omarchy', 'menu', 'refresh'], env=environment, check=False, capture_output=True, text=True) + if result.returncode: + detail = (result.stderr or result.stdout).strip() + print(f'Menu entries saved; live refresh deferred: {detail}. They will load when the Omarchy shell starts.') + + +def review(): + manifest(BUNDLE) + print('\nTry Omarchy Integrations\n') + for name in ('sudo', 'clock', 'holds'): + try: + state = 'installed' if files_current(name) else 'available or needs repair' + except (OSError, ValueError): + state = 'available or needs repair' + print(f' {COMPONENTS[name][0]}: {state}') + user = pwd.getpwuid(os.getuid()).pw_name + if not Path('/opt/1Password/1password').is_file(): + password_status = 'install 1Password first' + elif active(f'try-omarchy-onepassword-touch-id@{user}.service'): + password_status = 'integration installed; account setup and unlock test still required' + else: + password_status = 'setup available' + print(' Touch ID for 1Password: ' + password_status) + print('\n1. Install/update integration support\n2. Set up or test Touch ID for sudo\n3. Enable/update Touch ID for 1Password\n4. Exit') + choice = input('\nChoose [1-4]: ').strip() + if choice == '2': + run(['/usr/local/bin/try-omarchy-touch-id']) + return + if choice not in ('1', '3'): + return + selected = ['sudo', 'clock', 'holds'] if choice == '1' else ['onepassword'] + if choice == '3': + print('First pair Touch ID for sudo. Sign in to 1Password if needed, enable system authentication, unlock with your account password, and leave it running. Installing support does not sign you in or test an unlock.') + print('\nExisting integration files may be replaced; backups will be retained.') + for name in selected: + for source, target in component_paths(name): + if target.is_file() and digest(source) != digest(target): + print(' Replace with bundled version: ' + str(target)) + print('Your VM, applications, and personal files will be retained. No OS packages will be upgraded.') + if input('Continue with installation? [y/N] ').strip().lower() != 'y': + return + user = pwd.getpwuid(os.getuid()).pw_name + run(['sudo', '/usr/bin/python3', '-I', str(BUNDLE / 'updater.py'), 'install', user, *selected]) + menu_entry() + + +if __name__ == '__main__': + try: + action = sys.argv[1] if len(sys.argv) > 1 else 'review' + if action == 'report': + report() + elif action == 'review': + review() + elif action == 'menu': + menu_entry() + elif action == 'menu-install': + menu_entry(refresh=False) + elif action == 'stage-menu' and len(sys.argv) == 3: + menu_entry(Path(sys.argv[2]), refresh=False) + elif action == 'install' and len(sys.argv) >= 4 and all(s in COMPONENTS for s in sys.argv[3:]): + install(sys.argv[2], sys.argv[3:]) + else: + raise RuntimeError('Unknown integration action.') + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: + print('Integration setup could not complete: ' + str(error), file=sys.stderr) + print('Previous files and progress are retained. Resolve the reported problem and retry.', file=sys.stderr) + sys.exit(1) diff --git a/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift b/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift new file mode 100644 index 00000000..20e70d38 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift @@ -0,0 +1,247 @@ +import AppKit +import Darwin +import Foundation + +struct GuestIntegrationReport: Codable, Equatable { + let schema: Int + let version: Int + let identity: String + let components: [String: String] + let paired: Bool + + 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"]) + guard value.schema == 1, value.version > 0, value.version <= 100000, + value.identity.count == 64, + value.identity.allSatisfy({ "0123456789abcdef".contains($0) }), + Set(value.components.keys).isSubset(of: allowed), + Set(["sudo", "clock", "holds"]).isSubset(of: Set(value.components.keys)), + value.components.values.allSatisfy({ ["current", "repair", "disabled"].contains($0) }) else { + throw HelperError.io("invalid integration status") + } + return value + } + + func summary(expectedIdentity: String?) -> String { + if version > 1 { return "Newer guest integration version" } + guard let expectedIdentity else { return "Bundle status unavailable" } + if identity != expectedIdentity { return "Updates available" } + if components.values.contains("repair") { return "Repair available" } + if !paired { return "Current · Touch ID setup available" } + return "Up to date" + } +} + +struct GuestIntegrationCache: Codable { + let checkedAt: Date + let state: String + let report: GuestIntegrationReport? + + static func url(storageRoot: URL?) -> URL? { + guard let storageRoot, + let attributes = try? FileManager.default.attributesOfItem( + atPath: storageRoot.appendingPathComponent("disks/current/rootfs.ext4").path), + let inode = attributes[.systemFileNumber] as? NSNumber else { return nil } + return storageRoot.appendingPathComponent("integration-status-\(inode.uint64Value).json") + } + + static func read(_ url: URL?) -> Self? { + guard let url, let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + attributes[.type] as? FileAttributeType == .typeRegular, + (attributes[.size] as? NSNumber)?.intValue ?? 100000 > 0, + (attributes[.size] as? NSNumber)?.intValue ?? 100000 <= 8192, + let data = try? Data(contentsOf: url), + let value = try? JSONDecoder().decode(Self.self, from: data) else { return nil } + if let report = value.report, + (try? GuestIntegrationReport.decode(JSONEncoder().encode(report))) == nil { return nil } + return value + } + + static var bundledIdentity: String? { + guard let url = Bundle.main.resourceURL?.appendingPathComponent("integrations/manifest.json"), + let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return object["identity"] as? String + } + + var summary: String { + if state == "no-response" { return "Setup or repair may be needed" } + if state == "checking" { return "Check incomplete · retry on launch" } + return report?.summary(expectedIdentity: Self.bundledIdentity) ?? "Not checked yet" + } +} + +@MainActor +enum GuestIntegrationSetup { + static let command = "sudo mkdir -p /mnt/try-omarchy-updates && (mountpoint -q /mnt/try-omarchy-updates || sudo mount -t 9p -o trans=virtio,version=9p2000.L,ro tryomarchy-updates /mnt/try-omarchy-updates) && bash /mnt/try-omarchy-updates/setup" + + static func show(window: NSWindow? = nil) { + let alert = NSAlert() + alert.messageText = "Update 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 Touch ID, clock recovery, and compatibility updates before installing. 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)) + scroll.hasVerticalScroller = true + scroll.borderType = .bezelBorder + let field = NSTextView(frame: scroll.contentView.bounds) + field.string = command + field.isEditable = false + field.isSelectable = true + field.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + field.textContainerInset = NSSize(width: 6, height: 6) + field.autoresizingMask = [.width] + field.textContainer?.widthTracksTextView = true + scroll.documentView = field + alert.accessoryView = scroll + let completion: (NSApplication.ModalResponse) -> Void = { response in + if response == .alertFirstButtonReturn { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + } + if let window { alert.beginSheetModal(for: window, completionHandler: completion) } + else { completion(alert.runModal()) } + } +} + +@MainActor +final class GuestIntegrationBridge: NSObject { + private let descriptor: Int32 + private let cacheURL: URL + private var item: NSStatusItem? + private var timer: Timer? + private var buffer = Data() + private var discardingLine = false + private let started = ProcessInfo.processInfo.systemUptime + private var lastResponse: TimeInterval? + private var lastState = "" + private var latestReport: GuestIntegrationReport? + private var offeredReview = false + private let targetIdentity: KernelProcessIdentity + + init(targetPID: pid_t, socketPath: String, cachePath: String) throws { + guard let identity = KernelProcessIdentity.capture(processIdentifier: targetPID), identity.isQEMUSystemProcess else { + throw HelperError.io("integration target is not QEMU") + } + self.targetIdentity = identity + cacheURL = URL(fileURLWithPath: cachePath) + var info = stat() + let parent = cacheURL.deletingLastPathComponent().path + guard lstat(parent, &info) == 0, info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == getuid(), info.st_mode & 0o022 == 0 else { + throw HelperError.io("integration cache directory is not private to this user") + } + descriptor = try NativeBridgeSocket.connectSecure(path: socketPath, label: "integration status") + _ = fcntl(descriptor, F_SETFL, O_NONBLOCK) + super.init() + } + + func run() { + NSApp.setActivationPolicy(.accessory) + item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + item?.button?.image = NSImage(systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "VM integrations") + item?.button?.image?.isTemplate = true + let menu = NSMenu() + let status = NSMenuItem(title: "Checking…", action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + menu.addItem(.separator()) + let review = NSMenuItem(title: "Review VM integrations…", action: #selector(review), keyEquivalent: "") + review.target = self + menu.addItem(review) + item?.menu = menu + save(state: "checking", report: nil) + timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.tick() } + } + if let timer { + RunLoop.main.add(timer, forMode: .common) + RunLoop.main.add(timer, forMode: .modalPanel) + } + NSApp.run() + Darwin.close(descriptor) + } + + @objc private func review() { GuestIntegrationSetup.show() } + + private func save(state: String, report: GuestIntegrationReport?) { + let summary = report?.summary(expectedIdentity: GuestIntegrationCache.bundledIdentity) + ?? (state == "checking" ? "Checking…" : "Setup or repair needed") + item?.menu?.items.first?.title = summary + item?.button?.toolTip = "VM integrations: \(summary)" + item?.button?.setAccessibilityLabel("VM integrations: \(summary)") + let value = GuestIntegrationCache(checkedAt: Date(), state: state, report: report) + if let data = try? JSONEncoder().encode(value) { + do { try data.write(to: cacheURL, options: .atomic) } + catch { fputs("[integrations] Could not retain status: \(error.localizedDescription)\n", stderr) } + } + lastState = state + latestReport = report + } + + private func offerReviewIfNeeded() { + guard !offeredReview, let expected = GuestIntegrationCache.bundledIdentity else { return } + let elapsed = ProcessInfo.processInfo.systemUptime - started + let needsReview = lastState == "no-response" + || (latestReport.map { $0.version <= 1 && ($0.identity != expected || $0.components.values.contains("repair")) } ?? false) + guard needsReview, elapsed >= 30 else { return } + let noticeURL = cacheURL.deletingPathExtension().appendingPathExtension("notice") + if let attributes = try? FileManager.default.attributesOfItem(atPath: noticeURL.path), + (attributes[.size] as? NSNumber)?.intValue == 64, + let data = try? Data(contentsOf: noticeURL), data == Data(expected.utf8) { + offeredReview = true + return + } + offeredReview = true + // A repeating timer cannot fire again while its own callback presents a modal. + DispatchQueue.main.async { [weak self] in + self?.presentReview(expected: expected, noticeURL: noticeURL) + } + } + + private func presentReview(expected: String, noticeURL: URL) { + guard targetIdentity.isStillRunning else { return } + let alert = NSAlert() + alert.messageText = "Review your VM integrations" + alert.informativeText = lastState == "no-response" + ? "This VM has not answered its integration check. It may still be starting, or may need the setup included with this app. You can add new features without resetting your VM." + : "This app includes integration updates or repairs for your existing VM. Review them inside Omarchy when you are ready. Installation needs your Linux password." + alert.addButton(withTitle: "Review setup") + alert.addButton(withTitle: "Later") + NSApp.activate() + let response = alert.runModal() + do { try Data(expected.utf8).write(to: noticeURL, options: .atomic) } + catch { fputs("[integrations] Could not retain review preference.\n", stderr) } + if response == .alertFirstButtonReturn { GuestIntegrationSetup.show() } + } + + private func tick() { + if !targetIdentity.isStillRunning { NSApp.terminate(nil); return } + var bytes = [UInt8](repeating: 0, count: 4096) + let count = Darwin.read(descriptor, &bytes, bytes.count) + if count > 0 { + for byte in bytes.prefix(count) { + if byte == 10 { + if !discardingLine, let report = try? GuestIntegrationReport.decode(buffer) { + lastResponse = ProcessInfo.processInfo.systemUptime + save(state: "reported", report: report) + } + buffer.removeAll(keepingCapacity: true) + discardingLine = false + } else if !discardingLine { + buffer.append(byte) + if buffer.count > 4096 { + discardingLine = true + buffer.removeAll(keepingCapacity: true) + } + } + } + } + let elapsed = ProcessInfo.processInfo.systemUptime - (lastResponse ?? started) + if elapsed > 120 && lastState != "no-response" { save(state: "no-response", report: nil) } + offerReviewIfNeeded() + } +} diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index e9bc3c7c..a110a345 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -194,6 +194,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private let saveResources: (VMResources) -> Void private let immersiveMode: () -> Bool private let setImmersiveMode: (Bool) -> Void + private let integrationCacheURL: () -> URL? private let launch: () -> Void private let canResetStorage: Bool private let storageLocation: () -> String? @@ -280,6 +281,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { saveResources: @escaping (VMResources) -> Void = { _ in }, immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, + integrationCacheURL: @escaping () -> URL? = { nil }, launch: @escaping () -> Void ) { self.accessibilityStatus = accessibilityStatus @@ -307,6 +309,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.saveResources = saveResources self.immersiveMode = immersiveMode self.setImmersiveMode = setImmersiveMode + self.integrationCacheURL = integrationCacheURL self.launch = launch window = NSWindow( @@ -469,6 +472,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { return false } + @objc private func reviewIntegrations() { GuestIntegrationSetup.show(window: window) } + private func render() { let preservedScrollOffset = startMenuScrollView?.contentView.bounds.minY ?? 0 startMenuScrollView = nil @@ -649,6 +654,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { integrationRowViews.append(storageRow) } integrationRowViews.append(contentsOf: [resourceRow, portForwardingRow, immersiveRow]) + let integrationStatus = GuestIntegrationCache.read(integrationCacheURL()) + integrationRowViews.insert(permissionRow( + symbolName: "arrow.triangle.2.circlepath", title: "VM integrations", + detail: "Last check: \(integrationStatus?.summary ?? "Not checked yet"). Checked again after each VM launch.", + granted: false, statusLabels: ("", ""), + actions: [("REVIEW…", #selector(reviewIntegrations))] + ), at: 0) var permissionRowsAndSeparators: [NSView] = [] for (index, row) in permissionRowViews.enumerated() { diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index b269bd73..54913732 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -218,6 +218,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { FullscreenPreferences(isImmersive: isImmersive) ) }, + integrationCacheURL: { [weak self] in + guard let self else { return nil } + return GuestIntegrationCache.url(storageRoot: QEMUGPUStorageSpaceEstimate.storageRootURL( + environment: self.baseEnvironment, preference: self.storageLocationStore.load() + )) + }, launch: { [weak self] in self?.startVirtualMachine() } diff --git a/macos/Sources/OmarchyVMHelper/main.swift b/macos/Sources/OmarchyVMHelper/main.swift index 2c0046b4..fd7fd75f 100644 --- a/macos/Sources/OmarchyVMHelper/main.swift +++ b/macos/Sources/OmarchyVMHelper/main.swift @@ -19,6 +19,16 @@ private func effectiveArguments() -> [String] { let arguments = effectiveArguments() do { + if arguments.first == "--bridge-integrations" { + guard arguments.count == 4, let pid = Int32(arguments[1]), pid > 1 else { usage() } + NSApplication.shared.setActivationPolicy(.accessory) + try MainActor.assumeIsolated { + let bridge = try GuestIntegrationBridge(targetPID: pid, socketPath: arguments[2], cachePath: arguments[3]) + bridge.run() + } + exit(0) + } + if arguments.first == "--bridge-native-audio" { guard arguments.count == 4, let processIdentifier = Int32(arguments[1]), diff --git a/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift b/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift new file mode 100644 index 00000000..fee8ea12 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import OmarchyVMHelper + +@Suite("Guest integration status") +struct GuestIntegrationStatusTests { + private let identity = String(repeating: "a", count: 64) + + @Test("Only a complete current report can be up to date") + func states() throws { + let current = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["sudo": "current", "clock": "current", "holds": "current"], paired: true) + let decoded = try GuestIntegrationReport.decode(JSONEncoder().encode(current)) + #expect(decoded.summary(expectedIdentity: identity) == "Up to date") + #expect(decoded.summary(expectedIdentity: String(repeating: "b", count: 64)) == "Updates available") + #expect(decoded.summary(expectedIdentity: nil) == "Bundle status unavailable") + let unpaired = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: current.components, paired: false) + #expect(unpaired.summary(expectedIdentity: identity).contains("setup available")) + let repair = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["sudo": "current", "clock": "repair", "holds": "current"], paired: true) + #expect(repair.summary(expectedIdentity: identity) == "Repair available") + } + + @Test("Malformed and incomplete guest reports cannot establish status") + func malformed() { + for raw in ["{}", "[]", String(repeating: "x", count: 4097), + "{\"schema\":1,\"version\":1,\"identity\":\"\(identity)\",\"components\":{},\"paired\":true}"] { + #expect(throws: (any Error).self) { try GuestIntegrationReport.decode(Data(raw.utf8)) } + } + } + + @Test("Newer protocol versions do not trigger a downgrade claim") + func newer() { + let report = GuestIntegrationReport(schema: 1, version: 2, identity: identity, + components: ["sudo": "current", "clock": "current", "holds": "current"], paired: true) + #expect(report.summary(expectedIdentity: "older") == "Newer guest integration version") + } + + @Test("Cache stays outside disk inventory and changes when a disk is replaced") + func diskIdentity() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appendingPathComponent("disks/current") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let disk = directory.appendingPathComponent("rootfs.ext4") + try Data("first disk".utf8).write(to: disk) + let first = try #require(GuestIntegrationCache.url(storageRoot: root)) + #expect(first.deletingLastPathComponent().path == root.path) + try FileManager.default.moveItem(at: disk, to: root.appendingPathComponent("retained-disk")) + try Data("new disk".utf8).write(to: disk) + let second = try #require(GuestIntegrationCache.url(storageRoot: root)) + #expect(first != second) + } + + @Test("No response remains distinct from missing bootstrap") + func missing() { + let cache = GuestIntegrationCache(checkedAt: Date(), state: "no-response", report: nil) + #expect(cache.summary == "Setup or repair may be needed") + #expect(!cache.summary.contains("missing")) + } +} diff --git a/macos/build-app.sh b/macos/build-app.sh index c8ad8412..6fdbd368 100755 --- a/macos/build-app.sh +++ b/macos/build-app.sh @@ -180,6 +180,7 @@ install -m 0644 "$macos_dir/qemu-persistent-storage.sh" \ "$contents/Resources/scripts/qemu-persistent-storage.sh" install -m 0644 "$macos_dir/qemu-port-forwarding.sh" \ "$contents/Resources/scripts/qemu-port-forwarding.sh" +python3 "$repo_dir/integrations/build-bundle.py" "$contents/Resources/integrations" for guest_resource in \ LICENSE.omarchy \ SHA256SUMS \ diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 4fc2c86a..814f20fa 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -1070,6 +1070,7 @@ audio_bridge_pid="" authentication_bridge_pid="" camera_bridge_pid="" clipboard_bridge_pid="" +integration_bridge_pid="" terminate_child() { local pid=$1 @@ -1094,6 +1095,9 @@ cleanup() { local status=$? trap - EXIT HUP INT TERM set +e + if [[ $integration_bridge_pid =~ ^[0-9]+$ ]]; then + terminate_child "$integration_bridge_pid" 20 + fi if [[ $qemu_pid =~ ^[0-9]+$ ]]; then terminate_child "$qemu_pid" 40 fi @@ -1365,6 +1369,7 @@ audio_bridge_socket="/tmp/${work_dir##*/}/audio.sock" authentication_bridge_socket="/tmp/${work_dir##*/}/authentication.sock" camera_bridge_socket="/tmp/${work_dir##*/}/camera.sock" clipboard_bridge_socket="/tmp/${work_dir##*/}/clipboard.sock" +integration_bridge_socket="/tmp/${work_dir##*/}/integrations.sock" audio_route_dir="/tmp/${work_dir##*/}/audio-routes" mkdir -m 700 "$work_dir/audio-routes" @@ -1552,6 +1557,16 @@ qemu_args=( -device 'virtserialport,bus=omarchy-serial.0,nr=4,chardev=omarchy-camera-bridge,name=dev.tryomarchy.camera' ) +if [[ -f $resources_dir/integrations/manifest.json ]]; then + integration_share_option=${resources_dir//,/,,}/integrations + qemu_args+=( + -fsdev "local,id=omarchy-updates,path=$integration_share_option,security_model=none,readonly=on" + -device 'virtio-9p-pci,fsdev=omarchy-updates,mount_tag=tryomarchy-updates,romfile=' + -chardev "socket,id=omarchy-integrations,path=$integration_bridge_socket,server=on,wait=off" + -device 'virtserialport,bus=omarchy-serial.0,nr=5,chardev=omarchy-integrations,name=dev.tryomarchy.integrations' + ) +fi + if [[ -n $shared_folder ]]; then # security_model=none performs every host operation as this Mac user and # ignores guest chown requests, so the Mac keeps real modes and ownership. @@ -1658,6 +1673,17 @@ start_camera_bridge() { start_camera_bridge camera_bridge_restarts=0 +if [[ -f $resources_dir/integrations/manifest.json ]]; then + integration_cache="$work_dir/integration-status.json" + if [[ $QEMU_SELECTED_STORAGE_MODE == persistent ]]; then + integration_disk_inode=$(stat -f %i "$working_disk") + integration_cache="${QEMU_PERSISTENT_STORAGE_DISKS_ROOT%/disks}/integration-status-$integration_disk_inode.json" + fi + "$native_bridge" --bridge-integrations "$qemu_pid" "$integration_bridge_socket" \ + "$integration_cache" 9>&- & + integration_bridge_pid=$! +fi + # 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 diff --git a/scripts/build-cache.py b/scripts/build-cache.py index a741a173..010d66a4 100755 --- a/scripts/build-cache.py +++ b/scripts/build-cache.py @@ -98,7 +98,7 @@ def component_files(root: Path, component: str) -> list[Path]: path for path in regular_files(guest, {".work", "tests"}) if path.relative_to(guest).as_posix() not in {"README.md", "test"} - ] + ] + [p for p in regular_files(root / "integrations") if p.suffix != ".md" and p.name != ".DS_Store"] if component == "runtime": paths = [ @@ -127,6 +127,9 @@ def component_files(root: Path, component: str) -> list[Path]: for path in regular_files(macos, {".build", ".swiftpm", "Tests", "patches"}) if path.relative_to(macos).as_posix() not in excluded_names ] + paths.extend([p for p in regular_files(root / "integrations") if p.suffix != ".md" and p.name != ".DS_Store"]) + paths.extend(regular_files(root / "guest/scripts")) + paths.extend(regular_files(root / "guest/native-overlay")) paths.extend( [ root / ".build/state/guest.json",