Add terminal VM menu and focused session handoff

This commit is contained in:
Codex
2026-10-13 15:18:03 +03:00
parent b547d6c535
commit c6099103b2
5 changed files with 675 additions and 65 deletions
+246
View File
@@ -0,0 +1,246 @@
"""Readable terminal control; displaying this menu never starts guests."""
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import tempfile
import time
from .profiles import load_profile, validate_name
from .runtime import Runtime, RuntimeError, _private_dir
from .session import request_refresh
def open_vm(runtime, name, **kwargs):
from .launch import open_vm as launch
return launch(runtime, name, **kwargs)
def run_source(runtime, source, **kwargs):
from .launch import run_source as launch
return launch(runtime, source, **kwargs)
def download_iso(url, **kwargs):
from .media import download_iso as download
return download(url, **kwargs)
def _safe(value) -> str:
# Profile errors and terminal input can contain control sequences.
return ''.join(char if char.isprintable() else '?' for char in str(value))
def _last_selected(runtime: Runtime) -> str | None:
try:
data = json.loads((runtime.state / 'menu.json').read_text(encoding='utf-8'))
return validate_name(data['last_selected'])
except (OSError, ValueError, TypeError, KeyError):
return None
def _remember(runtime: Runtime, name: str) -> None:
_private_dir(runtime.state)
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=runtime.state,
prefix='.menu-', delete=False) as stream:
temporary = Path(stream.name)
try:
json.dump({'last_selected': validate_name(name)}, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, runtime.state / 'menu.json')
finally:
temporary.unlink(missing_ok=True)
def _profiles(runtime: Runtime):
profiles = []
for path in sorted(runtime.config.glob('*.toml')):
try:
profiles.append(load_profile(path))
except (OSError, ValueError) as exc:
print(f'Profile error — {_safe(path.name)}: {_safe(exc)}; repair with :shell.')
return profiles
def _render(runtime: Runtime, profiles, last: str | None):
print('\nHydra OS — virtual machines\n')
if not profiles:
print('No VM profiles. Use :run to create one from an ISO path or direct URL.')
else:
print(f' # {"Name":<32} {"State":<16} RAM')
for number, profile in enumerate(profiles, 1):
try:
status = runtime.status(profile.name)['status']
except (RuntimeError, OSError, ValueError) as exc:
status = 'error'
print(f' {_safe(profile.name)}: {_safe(exc)}')
marker = '*' if profile.name == last else ' '
print(f'{marker}{number:>2} {profile.name:<32} {_safe(status):<16} {profile.memory} MiB')
print('\nNumber/name opens one VM; Enter opens the last (*) or only VM.')
print(':run / :new :download :network :shell :help :refresh :quit')
print(':shutdown NAME requests guest shutdown; :detach NAME removes ISO while stopped.')
def _preview(argv: list[str]):
print('Command: ' + _safe(shlex.join(argv)))
def _source_prompt(label: str, supplied: str | None = None) -> str:
value = supplied if supplied is not None else input(label).strip()
if not value:
raise ValueError('Action cancelled; no changes made.')
return value
def _guided_run(runtime: Runtime, manifest: Path | None, source: str | None):
source = _source_prompt('ISO path or direct HTTP(S) URL (blank cancels)> ', source)
name = validate_name(_source_prompt('New VM name (blank cancels)> '))
# Reject duplicates before asking for options or downloading an image.
if (runtime.config / (name + '.toml')).exists():
raise ValueError(f'{name}: profile exists; enter its name or use hydra open {name}.')
memory = int(input('RAM in MiB [4096]> ').strip() or '4096')
cpus = int(input('CPUs [2]> ').strip() or '2')
disk_size = input('Disk size [40G]> ').strip() or '40G'
sha256 = input('SHA-256 (optional)> ').strip() or None
_preview(['hydra', 'run', _redact_url(source), '--name', name, '--memory', str(memory),
'--cpus', str(cpus), '--disk-size', disk_size] + (['--sha256', sha256] if sha256 else []))
code = run_source(runtime, source, name=name, memory=memory, cpus=cpus,
disk_size=disk_size, sha256=sha256, manifest=manifest,
defaults=Path('/etc/hydra/defaults.toml'), progress=_progress())
return code, name
def _progress():
previous = -1.0
def report(received: int, total: int | None):
nonlocal previous
now = time.monotonic()
if previous >= 0 and now - previous < 1 and received != total:
return
previous = now
amount = f'{received / 1048576:.1f}'
if total is not None:
amount += f' / {total / 1048576:.1f}'
print(f'Download: {amount} MiB', flush=True)
return report
def _redact_url(source: str) -> str:
from urllib.parse import urlsplit, urlunsplit
try:
parsed = urlsplit(source)
if parsed.scheme.lower() in ('http', 'https'):
# Query strings and credentials must never appear in previews.
return urlunsplit((parsed.scheme, parsed.hostname or '', parsed.path,
'redacted' if parsed.query else '', ''))
except ValueError:
return '<invalid URL>'
return source
def run_menu(runtime: Runtime, *, manifest: Path | None = None) -> int:
last = _last_selected(runtime)
while True:
profiles = _profiles(runtime)
names = [profile.name for profile in profiles]
_render(runtime, profiles, last)
try:
choice = input('hydra> ').strip()
except (EOFError, KeyboardInterrupt):
print()
return 0
try:
if not choice:
choice = last if last in names else (names[0] if len(names) == 1 else '')
if choice in names or choice.isdecimal():
name = choice
if choice.isdecimal():
index = int(choice) - 1
if not 0 <= index < len(names):
raise ValueError('Choose a listed VM number or name.')
name = names[index]
_preview(['hydra', 'open', name])
code = open_vm(runtime, name, manifest=manifest)
if code == 0:
try:
_remember(runtime, name)
except (OSError, RuntimeError) as exc:
print(f'Could not remember selection: {_safe(exc)}')
last = name
if manifest is not None:
return 0
else:
print(f'{name}: open returned {code}; retry or use :shell for diagnostics.')
continue
command = shlex.split(choice)
action = command[0] if command else ''
arguments = command[1:]
if action in (':quit', ':help', ':shell', ':network', ':refresh') and arguments:
raise ValueError(f'{action} takes no arguments.')
if action == ':quit':
return 0
if action == ':shell':
_preview([os.environ.get('SHELL', '/bin/sh')])
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif action == ':network':
nmtui = shutil.which('nmtui')
sudo = shutil.which('sudo')
if not nmtui:
raise ValueError('Network setup needs nmtui (NetworkManager); use :shell to inspect networking.')
argv = [nmtui] if os.geteuid() == 0 else [sudo, nmtui] if sudo else None
if argv is None:
raise ValueError('Network setup needs sudo; use :shell to inspect networking.')
_preview(argv)
result = subprocess.run(argv)
if result.returncode:
print(f'Network setup exited with {result.returncode}; menu remains available.')
elif action == ':refresh':
if manifest is not None:
return request_refresh(manifest)
print('Refreshing the VM list.')
elif action in (':run', ':new'):
if len(arguments) > 1:
raise ValueError('Use :run followed by one quoted ISO path or URL, or follow the prompts.')
code, name = _guided_run(runtime, manifest, arguments[0] if arguments else None)
if code == 0:
_remember(runtime, name)
last = name
if manifest is not None:
return 0
else:
print(f'{name}: launch returned {code}; enter its name to retry.')
elif action == ':download':
if len(arguments) > 1:
raise ValueError('Use :download followed by one quoted direct URL, or follow the prompts.')
url = _source_prompt('Direct ISO HTTP(S) URL (blank cancels)> ', arguments[0] if arguments else None)
sha256 = input('SHA-256 (optional)> ').strip() or None
_preview(['hydra', 'download', _redact_url(url)] + (['--sha256', sha256] if sha256 else []))
print('Downloading ISO; Ctrl+C cancels. Use a direct image URL, not a download page.')
print('ISO saved: ' + _safe(download_iso(url, sha256=sha256, progress=_progress())))
elif action in (':shutdown', ':detach'):
if len(arguments) != 1:
raise ValueError(f'Use {action} NAME.')
name = validate_name(arguments[0])
runtime.profile(name)
_preview(['hydra', 'shutdown', name] if action == ':shutdown' else ['hydra', 'media', name, '--detach'])
if action == ':shutdown':
runtime.shutdown(name)
print(f'{name}: guest shutdown requested; the guest controls completion.')
else:
runtime.media(name, None)
print(f'{name}: installation ISO detached.')
elif action == ':help':
print('Open explicitly starts a stopped VM or reconnects a running VM. Unknown identities need inspection.')
print('Ctrl+Alt+Shift+Left/Right switches VMs; Escape opens this menu; R releases input; U opens USB.')
print('Use :run for a new VM from a local ISO or direct URL. Blank source/name cancels.')
print('Use :shell for hydra status NAME, editing profiles, and advanced CLI options.')
else:
print('Choose a VM number/name, or a colon command. Use :help for details.')
except (EOFError, KeyboardInterrupt):
print('\nAction cancelled; menu remains available.')
except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as exc:
print('Error: ' + _safe(exc))
+97 -38
View File
@@ -15,8 +15,8 @@ from pathlib import Path
from urllib.parse import quote
from xml.etree import ElementTree as ET
from .profiles import validate_name
from .runtime import Runtime, VMPaths, _private_dir
from .profiles import load_profile, validate_name
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir
class SessionError(ValueError):
@@ -24,7 +24,7 @@ class SessionError(ValueError):
def viewer_title(name: str) -> str:
return 'HydraOS: ' + validate_name(name)
return 'Hydra OS: ' + validate_name(name)
def build_viewer_argv(paths: VMPaths, viewer: str, *, windowed=False) -> list[str]:
@@ -101,7 +101,7 @@ 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 HydraOS toolbar/USB adaptation; use dev/tools.nix')
raise SessionError('Viewer lacks Hydra OS toolbar/USB adaptation; use dev/tools.nix')
return viewer
@@ -120,7 +120,7 @@ def _viewer_environment(directory: Path) -> dict[str, str]:
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 HydraOS.
# A dedicated backend prevents accidental XWayland input behavior in Hydra OS.
if env.get('WAYLAND_DISPLAY'):
env['GDK_BACKEND'] = 'wayland'
return env
@@ -162,7 +162,7 @@ def _module_argv(command: str, manifest: Path) -> list[str]:
def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
return [terminal, '--title=HydraOS-recovery'] + _module_argv('recover', manifest)
return [terminal, '--title=Hydra OS recovery'] + _module_argv('recover', manifest)
def launch_session(runtime: Runtime, *, nested=False) -> int:
@@ -173,11 +173,25 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
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('HydraOS requires labwc >= 0.20.0 for reserved host input bindings')
raise SessionError('Hydra OS requires labwc >= 0.20.0 for reserved host input bindings')
_private_dir(runtime.runtime)
selected = None
while True:
profiles = runtime.profiles()
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
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'
@@ -192,6 +206,7 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
(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:
@@ -210,12 +225,13 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
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 session_helper(command: str, manifest: Path) -> int:
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()
@@ -224,38 +240,79 @@ def session_helper(command: str, manifest: Path) -> int:
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':
fd = os.open(manifest.parent / 'refresh.request', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
os.close(fd)
print('Refreshing workspaces and viewers; VMs keep running.')
return 0
return request_refresh(manifest)
if command == 'recover':
print('HydraOS local recovery. VMs keep running independently.\n'
'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
'Enter a VM name to reconnect, :shell for a host shell, :refresh to rebuild workspaces/viewers, or :quit.')
if not data['names']:
print('No VM profiles yet. Use :shell to run hydra add NAME --iso PATH and hydra start NAME.\n'
'After adding or removing profiles, leave the shell and enter :refresh; running VMs are preserved.')
while True:
try:
print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
choice = input('hydra recovery> ').strip()
except (EOFError, KeyboardInterrupt):
return 0
if choice == ':quit':
return 0
if choice == ':refresh':
return session_helper('refresh', manifest)
if choice == ':shell':
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif choice in data['names']:
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
else:
print('Enter a configured VM name, :shell, :refresh, or :quit.')
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
@@ -275,9 +332,11 @@ def session_helper(command: str, manifest: Path) -> int:
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
for name in data['names']:
try:
if runtime.status(name)['status'] == 'running':
# 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 (OSError, ValueError) as exc:
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {name}: {exc}', file=sys.stderr)
if not processes:
open_recovery()