2026-01-09 23:28:33 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Enable multiple displays on a SPICE VM by sending monitor configuration.
|
2026-01-10 08:42:37 +00:00
|
|
|
Retries until the SPICE agent in the guest is connected.
|
2026-01-09 23:28:33 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import gi
|
|
|
|
|
import sys
|
2026-01-10 08:42:37 +00:00
|
|
|
import time
|
2026-01-10 09:23:30 +00:00
|
|
|
import socket
|
|
|
|
|
import re
|
2026-01-09 23:28:33 +00:00
|
|
|
|
|
|
|
|
gi.require_version('SpiceClientGLib', '2.0')
|
|
|
|
|
from gi.repository import SpiceClientGLib, GLib
|
|
|
|
|
|
|
|
|
|
# Channel types (from spice-protocol)
|
|
|
|
|
CHANNEL_MAIN = 1
|
|
|
|
|
CHANNEL_DISPLAY = 2
|
|
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
def log(msg):
|
|
|
|
|
"""Print with flush for immediate output when backgrounded"""
|
|
|
|
|
print(msg, flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wait_for_port(host, port, timeout=60):
|
|
|
|
|
"""Wait for a TCP port to be available"""
|
|
|
|
|
start = time.time()
|
|
|
|
|
while time.time() - start < timeout:
|
|
|
|
|
try:
|
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
|
|
sock.settimeout(1)
|
|
|
|
|
result = sock.connect_ex((host, port))
|
|
|
|
|
sock.close()
|
|
|
|
|
if result == 0:
|
|
|
|
|
return True
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_spice_uri(uri):
|
|
|
|
|
"""Parse spice://host:port URI"""
|
|
|
|
|
match = re.match(r'spice://([^:]+):(\d+)', uri)
|
|
|
|
|
if match:
|
|
|
|
|
return match.group(1), int(match.group(2))
|
|
|
|
|
return 'localhost', 5930
|
|
|
|
|
|
2026-01-09 23:28:33 +00:00
|
|
|
class SpiceDisplayEnabler:
|
2026-01-10 08:42:37 +00:00
|
|
|
def __init__(self, uri, num_displays=3, width=1920, height=1080, timeout=60):
|
2026-01-09 23:28:33 +00:00
|
|
|
self.uri = uri
|
|
|
|
|
self.num_displays = num_displays
|
|
|
|
|
self.width = width
|
|
|
|
|
self.height = height
|
2026-01-10 08:42:37 +00:00
|
|
|
self.timeout = timeout
|
2026-01-09 23:28:33 +00:00
|
|
|
self.session = None
|
|
|
|
|
self.main_channel = None
|
|
|
|
|
self.display_channels = []
|
|
|
|
|
self.loop = GLib.MainLoop()
|
|
|
|
|
self.configured = False
|
2026-01-10 08:42:37 +00:00
|
|
|
self.agent_connected = False
|
|
|
|
|
self.config_sent = False
|
2026-01-10 09:23:30 +00:00
|
|
|
self.config_retries = 0
|
|
|
|
|
self.max_retries = 3
|
|
|
|
|
self.stabilization_scheduled = False
|
|
|
|
|
self.connection_retries = 0
|
|
|
|
|
self.max_connection_retries = 30 # Try reconnecting for up to 5 minutes
|
|
|
|
|
self.agent_check_count = 0
|
|
|
|
|
self.configure_count = 0 # Track how many times we've configured (for reboots)
|
2026-01-09 23:28:33 +00:00
|
|
|
|
|
|
|
|
def on_channel_new(self, session, channel):
|
|
|
|
|
"""Handle new channel creation"""
|
|
|
|
|
channel_type = channel.get_property('channel-type')
|
|
|
|
|
channel_id = channel.get_property('channel-id')
|
|
|
|
|
|
|
|
|
|
if channel_type == CHANNEL_MAIN:
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"Main channel received (id={channel_id})")
|
2026-01-09 23:28:33 +00:00
|
|
|
self.main_channel = channel
|
|
|
|
|
channel.connect_after('channel-event', self.on_channel_event)
|
2026-01-10 08:42:37 +00:00
|
|
|
# Check agent status periodically
|
|
|
|
|
GLib.timeout_add(500, self.check_agent_and_configure)
|
2026-01-09 23:28:33 +00:00
|
|
|
elif channel_type == CHANNEL_DISPLAY:
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"Display channel received (id={channel_id})")
|
2026-01-09 23:28:33 +00:00
|
|
|
self.display_channels.append((channel_id, channel))
|
|
|
|
|
|
|
|
|
|
def on_channel_event(self, channel, event):
|
|
|
|
|
"""Handle channel events"""
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"Channel event: {event}")
|
2026-01-09 23:28:33 +00:00
|
|
|
if event == SpiceClientGLib.ChannelEvent.OPENED:
|
2026-01-10 08:42:37 +00:00
|
|
|
# Start checking for agent
|
|
|
|
|
GLib.timeout_add(100, self.check_agent_and_configure)
|
|
|
|
|
|
|
|
|
|
def check_agent_and_configure(self):
|
|
|
|
|
"""Check if agent is connected and configure if ready"""
|
2026-01-10 09:23:30 +00:00
|
|
|
if self.stabilization_scheduled:
|
|
|
|
|
return True # Keep checking but don't act yet
|
2026-01-10 08:42:37 +00:00
|
|
|
|
|
|
|
|
if not self.main_channel:
|
|
|
|
|
return True # Keep checking
|
|
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
was_connected = self.agent_connected
|
2026-01-10 08:42:37 +00:00
|
|
|
self.agent_connected = self.main_channel.get_property('agent-connected')
|
2026-01-10 09:23:30 +00:00
|
|
|
self.agent_check_count += 1
|
2026-01-10 08:42:37 +00:00
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
# Detect agent disconnect (VM reboot)
|
|
|
|
|
if was_connected and not self.agent_connected:
|
|
|
|
|
log(f"Agent disconnected (VM may be rebooting)...")
|
|
|
|
|
self.configured = False
|
|
|
|
|
self.config_sent = False
|
|
|
|
|
self.config_retries = 0
|
2026-01-10 08:42:37 +00:00
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
# Log every 10 checks (5 seconds)
|
|
|
|
|
if self.agent_check_count % 10 == 0:
|
|
|
|
|
status = "connected" if self.agent_connected else "waiting"
|
|
|
|
|
log(f"Agent {status} (check #{self.agent_check_count}, configured={self.configure_count}x)")
|
|
|
|
|
|
|
|
|
|
if self.agent_connected and not self.config_sent and not self.stabilization_scheduled:
|
|
|
|
|
log(f"Agent connected! Waiting 2s for stabilization...")
|
|
|
|
|
self.stabilization_scheduled = True
|
|
|
|
|
# Wait 2 seconds for agent to fully initialize before configuring
|
|
|
|
|
GLib.timeout_add(2000, self.configure_monitors)
|
|
|
|
|
|
|
|
|
|
return True # Always keep checking for reboots
|
2026-01-09 23:28:33 +00:00
|
|
|
|
|
|
|
|
def configure_monitors(self):
|
|
|
|
|
"""Configure multiple monitors via SPICE protocol"""
|
2026-01-10 09:23:30 +00:00
|
|
|
if self.configured:
|
|
|
|
|
return False # Already done
|
2026-01-09 23:28:33 +00:00
|
|
|
|
|
|
|
|
if not self.main_channel:
|
2026-01-10 09:23:30 +00:00
|
|
|
log("No main channel!")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
self.config_retries += 1
|
|
|
|
|
attempt_str = f" (attempt {self.config_retries}/{self.max_retries})" if self.config_retries > 1 else ""
|
|
|
|
|
log(f"Configuring {self.num_displays} displays{attempt_str}...")
|
2026-01-09 23:28:33 +00:00
|
|
|
|
|
|
|
|
# Enable and configure each display
|
|
|
|
|
for i in range(self.num_displays):
|
|
|
|
|
x = i * self.width # Position displays side by side
|
|
|
|
|
y = 0
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-10 08:23:50 +00:00
|
|
|
self.main_channel.update_display_enabled(i, True, False)
|
|
|
|
|
self.main_channel.update_display(i, x, y, self.width, self.height, False)
|
2026-01-09 23:28:33 +00:00
|
|
|
except Exception as e:
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f" Error setting display {i}: {e}")
|
2026-01-09 23:28:33 +00:00
|
|
|
|
2026-01-10 08:42:37 +00:00
|
|
|
# Send the configuration
|
2026-01-09 23:28:33 +00:00
|
|
|
try:
|
|
|
|
|
self.main_channel.send_monitor_config()
|
2026-01-10 08:42:37 +00:00
|
|
|
self.config_sent = True
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"Sent config for {self.num_displays} displays at {self.width}x{self.height}")
|
2026-01-09 23:28:33 +00:00
|
|
|
except Exception as e:
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"Error sending config: {e}")
|
2026-01-09 23:28:33 +00:00
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
# Schedule verification/retry after 3 seconds
|
|
|
|
|
GLib.timeout_add(3000, self.verify_and_retry)
|
|
|
|
|
return False # Don't repeat this timeout
|
|
|
|
|
|
|
|
|
|
def verify_and_retry(self):
|
|
|
|
|
"""Verify configuration was applied, retry if needed"""
|
|
|
|
|
if self.configured:
|
|
|
|
|
return False # Already done
|
|
|
|
|
|
|
|
|
|
# Check if displays are actually enabled by re-checking agent state
|
|
|
|
|
if not self.main_channel:
|
|
|
|
|
log("Lost main channel during verification")
|
|
|
|
|
self.quit()
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# The SPICE protocol doesn't provide a direct way to verify display config
|
|
|
|
|
# was applied. We assume success if we sent config and agent is still connected.
|
|
|
|
|
agent_still_connected = self.main_channel.get_property('agent-connected')
|
|
|
|
|
|
|
|
|
|
if agent_still_connected and self.config_sent:
|
|
|
|
|
# Mark as configured and send again for good measure
|
|
|
|
|
if self.config_retries < self.max_retries:
|
|
|
|
|
log(f"Sending config again to ensure it takes effect...")
|
|
|
|
|
self.config_sent = False # Allow retry
|
|
|
|
|
self.configure_monitors()
|
|
|
|
|
else:
|
|
|
|
|
# We've tried enough, assume success
|
|
|
|
|
self.configured = True
|
|
|
|
|
self.configure_count += 1
|
|
|
|
|
self.stabilization_scheduled = False # Allow reconfiguration after reboot
|
|
|
|
|
log(f"Configuration complete (configured {self.configure_count}x total)")
|
|
|
|
|
# Don't quit - keep running to handle VM reboots
|
|
|
|
|
elif not agent_still_connected:
|
|
|
|
|
log("Agent disconnected during verification - will retry when reconnected")
|
|
|
|
|
self.config_sent = False
|
|
|
|
|
self.config_retries = 0
|
|
|
|
|
self.stabilization_scheduled = False
|
|
|
|
|
# Don't quit - agent will reconnect after reboot
|
|
|
|
|
else:
|
|
|
|
|
# Config not sent but agent connected - try again
|
|
|
|
|
if self.config_retries < self.max_retries:
|
|
|
|
|
log(f"Config not sent, retrying...")
|
|
|
|
|
self.configure_monitors()
|
|
|
|
|
else:
|
|
|
|
|
log(f"Failed after {self.config_retries} attempts")
|
|
|
|
|
self.quit()
|
|
|
|
|
|
|
|
|
|
return False # Don't repeat this timeout
|
2026-01-09 23:28:33 +00:00
|
|
|
|
|
|
|
|
def quit(self):
|
|
|
|
|
self.loop.quit()
|
|
|
|
|
return False
|
|
|
|
|
|
2026-01-10 08:42:37 +00:00
|
|
|
def on_timeout(self):
|
|
|
|
|
"""Handle overall timeout"""
|
|
|
|
|
if not self.configured:
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"Timeout after {self.timeout}s - agent not connected (checks={self.agent_check_count})")
|
2026-01-10 08:42:37 +00:00
|
|
|
self.quit()
|
|
|
|
|
return False
|
|
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
def check_connection_health(self):
|
|
|
|
|
"""Check if connection is healthy, reconnect if needed"""
|
|
|
|
|
log(f"Health check: configured={self.configure_count}x, main_channel={self.main_channel is not None}, agent={self.agent_connected}")
|
|
|
|
|
|
|
|
|
|
# Don't stop checking - we need to handle reboots
|
|
|
|
|
if self.stabilization_scheduled:
|
|
|
|
|
return True # Keep checking but don't reconnect during stabilization
|
2026-01-09 23:28:33 +00:00
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
# If we don't have a main channel after 10 seconds, reconnect
|
|
|
|
|
if not self.main_channel:
|
|
|
|
|
self.connection_retries += 1
|
|
|
|
|
if self.connection_retries > self.max_connection_retries:
|
|
|
|
|
log(f"Giving up after {self.connection_retries} connection attempts")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
log(f"No main channel received, reconnecting (attempt {self.connection_retries})...")
|
|
|
|
|
self.reconnect()
|
|
|
|
|
return True # Keep checking
|
|
|
|
|
|
|
|
|
|
return True # Keep checking connection health
|
|
|
|
|
|
|
|
|
|
def reconnect(self):
|
|
|
|
|
"""Disconnect and reconnect to SPICE"""
|
|
|
|
|
if self.session:
|
|
|
|
|
try:
|
|
|
|
|
self.session.disconnect()
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Reset state for new connection
|
|
|
|
|
self.main_channel = None
|
|
|
|
|
self.display_channels = []
|
|
|
|
|
|
|
|
|
|
# Create new session
|
2026-01-09 23:28:33 +00:00
|
|
|
self.session = SpiceClientGLib.Session()
|
|
|
|
|
self.session.set_property('uri', self.uri)
|
|
|
|
|
self.session.connect_after('channel-new', self.on_channel_new)
|
|
|
|
|
|
|
|
|
|
if not self.session.connect():
|
2026-01-10 09:23:30 +00:00
|
|
|
log(" Reconnection failed, will retry...")
|
|
|
|
|
|
|
|
|
|
def run(self):
|
|
|
|
|
log(f"Connecting to {self.uri}...")
|
|
|
|
|
log(f"Waiting up to {self.timeout}s for agent...")
|
|
|
|
|
|
|
|
|
|
# Wait for SPICE port to be available before connecting
|
|
|
|
|
host, port = parse_spice_uri(self.uri)
|
|
|
|
|
log(f"Waiting for SPICE server at {host}:{port}...")
|
|
|
|
|
if not wait_for_port(host, port, timeout=60):
|
|
|
|
|
log(f"SPICE server not available after 60s")
|
2026-01-09 23:28:33 +00:00
|
|
|
return False
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"SPICE port {port} is open, connecting...")
|
|
|
|
|
|
|
|
|
|
# Give SPICE server a moment to fully initialize after port opens
|
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
|
|
|
|
self.session = SpiceClientGLib.Session()
|
|
|
|
|
self.session.set_property('uri', self.uri)
|
|
|
|
|
self.session.connect_after('channel-new', self.on_channel_new)
|
|
|
|
|
|
|
|
|
|
if not self.session.connect():
|
|
|
|
|
log("Initial connection failed, will retry...")
|
|
|
|
|
|
|
|
|
|
# Check connection health every 10 seconds
|
|
|
|
|
GLib.timeout_add(10000, self.check_connection_health)
|
2026-01-09 23:28:33 +00:00
|
|
|
|
2026-01-10 08:42:37 +00:00
|
|
|
# Set overall timeout
|
|
|
|
|
GLib.timeout_add(self.timeout * 1000, self.on_timeout)
|
|
|
|
|
|
2026-01-10 09:23:30 +00:00
|
|
|
log("Entering main loop...")
|
2026-01-09 23:28:33 +00:00
|
|
|
self.loop.run()
|
2026-01-10 09:23:30 +00:00
|
|
|
log("Main loop exited")
|
2026-01-09 23:28:33 +00:00
|
|
|
|
2026-01-10 08:42:37 +00:00
|
|
|
if self.configured:
|
2026-01-10 09:23:30 +00:00
|
|
|
log(f"Success: {self.num_displays} displays enabled")
|
2026-01-10 08:42:37 +00:00
|
|
|
else:
|
2026-01-10 09:23:30 +00:00
|
|
|
log("Failed: Could not enable displays")
|
2026-01-10 08:42:37 +00:00
|
|
|
|
2026-01-09 23:28:33 +00:00
|
|
|
return self.configured
|
|
|
|
|
|
|
|
|
|
|
2026-01-10 08:42:37 +00:00
|
|
|
def main():
|
|
|
|
|
import argparse
|
|
|
|
|
parser = argparse.ArgumentParser(description='Enable SPICE VM displays')
|
|
|
|
|
parser.add_argument('uri', nargs='?', default='spice://localhost:5930',
|
|
|
|
|
help='SPICE URI (default: spice://localhost:5930)')
|
|
|
|
|
parser.add_argument('num_displays', nargs='?', type=int, default=3,
|
|
|
|
|
help='Number of displays to enable (default: 3)')
|
|
|
|
|
parser.add_argument('--timeout', '-t', type=int, default=60,
|
|
|
|
|
help='Timeout in seconds (default: 60)')
|
|
|
|
|
parser.add_argument('--width', '-W', type=int, default=1920,
|
|
|
|
|
help='Display width (default: 1920)')
|
|
|
|
|
parser.add_argument('--height', '-H', type=int, default=1080,
|
|
|
|
|
help='Display height (default: 1080)')
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
enabler = SpiceDisplayEnabler(
|
|
|
|
|
args.uri,
|
|
|
|
|
args.num_displays,
|
|
|
|
|
args.width,
|
|
|
|
|
args.height,
|
|
|
|
|
args.timeout
|
|
|
|
|
)
|
2026-01-09 23:28:33 +00:00
|
|
|
success = enabler.run()
|
|
|
|
|
sys.exit(0 if success else 1)
|
2026-01-10 08:42:37 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
main()
|