Files

354 lines
16 KiB
Python

"""Minimal labwc session; viewer and compositor lifetimes never control QEMU."""
import fcntl
import json
import os
import re
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from urllib.parse import quote
from xml.etree import ElementTree as ET
from .profiles import load_profile, validate_name
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir
class SessionError(ValueError):
"""An actionable viewer/session error safe to present at the CLI."""
def viewer_title(name: str) -> str:
return 'Hydra OS: ' + validate_name(name)
def build_viewer_argv(paths: VMPaths, viewer: str, *, windowed=False) -> list[str]:
argv = [viewer, '--title', viewer_title(paths.name), '--hotkeys',
'release-cursor=ctrl+alt+shift+r,usb-device-select=ctrl+alt+shift+u',
'--spice-usbredir-auto-redirect-filter=-1,-1,-1,-1,0',
'--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0']
if not windowed:
argv += ['--full-screen', '--hide-toolbar']
# QEMU denies clipboard/file transfer; no shared-dir/CD flag is added here.
return argv + ['spice+unix://' + quote(str(paths.spice), safe='/')]
def build_labwc_config(names: list[str], recovery_argv: list[str],
brightness='brightnessctl', *, initial: str | None = None) -> str:
names = [validate_name(name) for name in names]
if len(set(names)) != len(names):
raise SessionError('Duplicate VM workspace name')
if initial is not None and initial not in names:
raise SessionError('Initial VM workspace must be configured')
root = ET.Element('labwc_config')
core = ET.SubElement(root, 'core')
ET.SubElement(core, 'primarySelection').text = 'no'
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
if names:
labels = ET.SubElement(desktops, 'names')
for name in names:
ET.SubElement(labels, 'name').text = name
if initial is not None:
ET.SubElement(desktops, 'initial').text = str(names.index(initial) + 1)
ET.SubElement(desktops, 'popupTime').text = '0'
theme = ET.SubElement(root, 'theme')
ET.SubElement(theme, 'keepBorder').text = 'no'
keyboard = ET.SubElement(root, 'keyboard')
for key, direction in (('C-A-S-Left', 'left'), ('C-A-S-Right', 'right')):
binding = ET.SubElement(keyboard, 'keybind', key=key, overrideInhibition='yes')
ET.SubElement(binding, 'action', name='GoToDesktop', to=direction, wrap='yes')
commands = [('C-A-S-Escape', recovery_argv),
('XF86MonBrightnessUp', [brightness, '-c', 'backlight', 'set', '+5%']),
('XF86MonBrightnessDown', [brightness, '-c', 'backlight', 'set', '5%-'])]
for key, argv in commands:
binding = ET.SubElement(keyboard, 'keybind', key=key, overrideInhibition='yes')
ET.SubElement(binding, 'action', name='Execute', command=shlex.join(argv))
# An empty mouse section restores labwc defaults. A binding with no action
# suppresses that fallback without consuming guest window pointer events.
context = ET.SubElement(ET.SubElement(root, 'mouse'), 'context', name='Root')
ET.SubElement(context, 'mousebind', button='Left', action='Press')
device = ET.SubElement(ET.SubElement(root, 'libinput'), 'device', category='touchpad')
ET.SubElement(device, 'tap').text = 'yes'
rules = ET.SubElement(root, 'windowRules')
for index, name in enumerate(names, 1):
rule = ET.SubElement(rules, 'windowRule', title=viewer_title(name),
serverDecoration='no')
ET.SubElement(rule, 'action', name='SendToDesktop', to=str(index), follow='no')
ET.SubElement(rule, 'action', name='SetDecorations', decorations='none')
ET.indent(root)
return ET.tostring(root, encoding='unicode', xml_declaration=True) + '\n'
def _tool(variable: str, default: str) -> str:
value = os.environ.get(variable, default)
executable = shutil.which(value)
if not executable:
raise SessionError(f'Missing {default} tool ({variable}); enter the temporary dev/tools.nix environment')
return executable
def _display() -> None:
if not os.environ.get('WAYLAND_DISPLAY') and not os.environ.get('DISPLAY'):
raise SessionError('No graphical display; start a session or use a graphical host terminal')
def _viewer() -> str:
viewer = _tool('HYDRA_VIEWER', 'remote-viewer')
result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10)
if result.returncode or '--hide-toolbar' not in result.stdout:
raise SessionError('Viewer lacks Hydra OS toolbar/USB adaptation; use dev/tools.nix')
return viewer
def _socket(paths: VMPaths) -> None:
try:
mode = paths.spice.lstat().st_mode
except FileNotFoundError as exc:
raise SessionError(f'{paths.name}: no SPICE socket; run hydra start {paths.name}, then retry') from exc
if not stat.S_ISSOCK(mode):
raise SessionError(f'{paths.name}: SPICE path is not a Unix socket')
def _viewer_environment(directory: Path) -> dict[str, str]:
config = directory / 'config'
settings = config / 'virt-viewer'
settings.mkdir(parents=True, mode=0o700)
(settings / 'settings').write_text('[virt-viewer]\nshare-clipboard=false\n', encoding='utf-8')
env = dict(os.environ, XDG_CONFIG_HOME=str(config), GSETTINGS_BACKEND='memory')
# A dedicated backend prevents accidental XWayland input behavior in Hydra OS.
if env.get('WAYLAND_DISPLAY'):
env['GDK_BACKEND'] = 'wayland'
return env
def view(runtime: Runtime, name: str, *, windowed=False) -> int:
_display()
viewer = _viewer()
runtime.profile(name)
paths = runtime.paths(name)
_socket(paths)
_private_dir(runtime.runtime)
with tempfile.TemporaryDirectory(prefix='viewer-', dir=runtime.runtime) as tmp:
return subprocess.run(build_viewer_argv(paths, viewer, windowed=windowed),
env=_viewer_environment(Path(tmp))).returncode
def compositor_environment(environ: dict[str, str], *, nested=False) -> dict[str, str]:
env = dict(environ)
if nested:
if env.get('WAYLAND_DISPLAY'):
env['WLR_BACKENDS'] = 'wayland'
elif env.get('DISPLAY'):
env['WLR_BACKENDS'] = 'x11'
else:
raise SessionError('Nested session requires an existing graphical display')
else:
env.pop('WAYLAND_DISPLAY', None)
env.pop('DISPLAY', None)
env['WLR_BACKENDS'] = 'drm,libinput'
env['XDG_CURRENT_DESKTOP'] = 'HydraOS'
return env
def _module_argv(command: str, manifest: Path) -> list[str]:
# Absolute source launcher also works when labwc changes the working directory.
return [sys.executable, str(Path(__file__).resolve().parents[1] / 'bin/hydra'),
'_session-helper', command, str(manifest)]
def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
return [terminal, '--title=Hydra OS recovery'] + _module_argv('recover', manifest)
def launch_session(runtime: Runtime, *, nested=False) -> int:
env = compositor_environment(dict(os.environ), nested=nested)
labwc = _tool('HYDRA_LABWC', 'labwc')
terminal = _tool('HYDRA_TERMINAL', 'foot')
brightness = _tool('HYDRA_BRIGHTNESS', 'brightnessctl')
version = subprocess.run([labwc, '--version'], capture_output=True, text=True, timeout=10)
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
raise SessionError('Hydra OS requires labwc >= 0.20.0 for reserved host input bindings')
_private_dir(runtime.runtime)
selected = None
while True:
profiles = []
for path in sorted(runtime.config.glob('*.toml')):
try:
profiles.append(load_profile(path))
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {path.name}: {exc}', file=sys.stderr)
initial = selected if selected in [p.name for p in profiles] else None
if initial is None:
for profile in profiles:
try:
if runtime.status(profile.name)['status'] not in ('stopped', 'unknown'):
initial = profile.name
break
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {profile.name}: {exc}', file=sys.stderr)
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
directory = Path(tmp)
config = directory / 'labwc'
config.mkdir(mode=0o700)
manifest = directory / 'session.json'
manifest.write_text(json.dumps({'config': str(runtime.config), 'state': str(runtime.state),
'runtime': str(runtime.runtime), 'names': [p.name for p in profiles],
'terminal': terminal, 'directory': str(directory)}), encoding='utf-8')
(config / 'rc.xml').write_text(build_labwc_config([p.name for p in profiles],
_recovery_argv(manifest, terminal), brightness,
initial=initial), encoding='utf-8')
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
(config / 'environment').touch()
env['PYTHONUNBUFFERED'] = '1'
env['HYDRA_SESSION_MANIFEST'] = str(manifest)
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
refresh = False
try:
while True:
if (directory / 'refresh.request').exists():
refresh = True
break
if compositor.poll() is not None:
# A requesting recovery terminal can exit before polling.
refresh = (directory / 'refresh.request').exists()
break
time.sleep(.1)
finally:
if compositor.poll() is None:
compositor.terminate()
compositor.wait(timeout=10)
if not refresh:
return compositor.returncode
selected = _refresh_selection(directory / 'refresh.request')
# Only our compositor was stopped. Its viewers disconnect; detached
# QEMU instances retain their identity and running workloads.
def _manifest_data(manifest: Path) -> dict:
data = json.loads(manifest.read_text(encoding='utf-8'))
if (not isinstance(data, dict) or
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
for key in ('config', 'state', 'runtime', 'terminal')) or
not isinstance(data.get('names'), list)):
raise SessionError('Invalid session manifest')
for name in data['names']:
validate_name(name)
return data
def request_refresh(manifest: Path, *, selected: str | None = None) -> int:
"""Publish a complete request without controlling any guest process."""
manifest = Path(manifest)
data = _manifest_data(manifest)
if selected is not None:
validate_name(selected)
# Profiles can be created after the compositor's original snapshot.
Runtime(Path(data['config']), Path(data['state']), Path(data['runtime'])).profile(selected)
payload = '' if selected is None else json.dumps({'selected': selected})
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=manifest.parent,
prefix='.refresh-', delete=False) as stream:
temporary = Path(stream.name)
try:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, manifest.parent / 'refresh.request')
finally:
temporary.unlink(missing_ok=True)
print('Refreshing workspaces and viewers; VMs keep running.')
return 0
def _refresh_selection(request: Path) -> str | None:
try:
payload = request.read_text(encoding='utf-8')
if not payload:
return None
data = json.loads(payload)
if not isinstance(data, dict) or set(data) != {'selected'}:
raise SessionError('Invalid refresh request')
return validate_name(data['selected'])
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: refresh selection ignored: {exc}', file=sys.stderr)
return None
def _emergency_recovery(manifest: Path) -> int:
print('Hydra OS emergency recovery. :shell, :refresh or :quit; VMs keep running.')
while True:
try:
choice = input('hydra recovery> ').strip()
if choice == ':quit':
return 0
if choice == ':shell':
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif choice == ':refresh':
return request_refresh(manifest)
except (EOFError, KeyboardInterrupt):
return 0
except (OSError, ValueError, subprocess.SubprocessError) as exc:
print(f'hydra: {exc}')
def session_helper(command: str, manifest: Path) -> int:
data = _manifest_data(manifest)
if command not in ('clients', 'recover', 'refresh') and (
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
raise SessionError('Invalid session helper operation')
runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
if command == 'refresh':
return request_refresh(manifest)
if command == 'recover':
try:
from .menu import run_menu
return run_menu(runtime, manifest=manifest)
except Exception as exc:
# Recovery is the last local escape hatch, including menu defects.
print(f'hydra: menu unavailable: {exc}')
return _emergency_recovery(manifest)
if command.startswith('reconnect:'):
name = command.split(':', 1)[1]
# One viewer per VM inside this session; independent host view commands
# retain their separate lifecycle and never share VM manager locks.
with (manifest.parent / (name + '.viewer.lock')).open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print(f'{name}: viewer is already connected; close recovery or switch workspace')
return 0
return view(runtime, name)
processes = []
recovery = None
def open_recovery():
nonlocal recovery
if recovery is None or recovery.poll() is not None:
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
for name in data['names']:
try:
# Verified paused/prelaunch guests also have a live SPICE server;
# reconnecting must not imply a guest start or resume.
if runtime.status(name)['status'] not in ('stopped', 'unknown'):
processes.append(subprocess.Popen(_module_argv('reconnect:' + name, manifest)))
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {name}: {exc}', file=sys.stderr)
if not processes:
open_recovery()
# No signals to QEMU or viewers: compositor disconnect tears down clients,
# while the VM manager's detached QEMU processes retain their lifetime.
while processes or (recovery is not None and recovery.poll() is None):
for process in processes[:]:
code = process.poll()
if code is not None:
processes.remove(process)
print(f'hydra: viewer exited ({code}); reconnect from local recovery', file=sys.stderr)
open_recovery()
time.sleep(0.2)
return 0