Add terminal VM menu and focused session handoff
This commit is contained in:
+97
-38
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user