Files
gnome-jarvis/computer-use/py/portal_remote_desktop.py
T
snxraven 0e52942b8b
Rolling release / release (push) Successful in 8m58s
Better Search
2026-09-12 12:33:52 -04:00

281 lines
10 KiB
Python

#!/usr/bin/env python3
"""RemoteDesktop + ScreenCast broker: EIS input and a live PipeWire frame buffer."""
import json
import os
import select
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
import gi
gi.require_version('Gio', '2.0')
from gi.repository import Gio, GLib
except Exception as exc:
print(json.dumps({'type': 'error', 'reason': f'PyGObject unavailable: {exc}'}), flush=True)
raise SystemExit(1)
from libei_sender import LibeiSender
from pw_framebuffer import PipeWireFrameBuffer
BTN = {'left': 0x110, 'right': 0x111, 'middle': 0x112}
MODE = os.environ.get('JARVIS_CU_MODE', 'act')
def log_error(reason):
print(json.dumps({'type': 'error', 'reason': str(reason)}), flush=True)
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
def portal_proxy(iface):
return Gio.DBusProxy.new_sync(
bus, Gio.DBusProxyFlags.NONE, None,
'org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop',
iface, None,
)
remote = portal_proxy('org.freedesktop.portal.RemoteDesktop')
screencast = portal_proxy('org.freedesktop.portal.ScreenCast')
def request(proxy, method, signature, values, timeout_ms=120000):
request_path = proxy.call_sync(method, GLib.Variant(signature, values), Gio.DBusCallFlags.NONE, timeout_ms, None).unpack()[0]
loop = GLib.MainLoop()
result = {'code': 1, 'results': {}}
def response(_conn, _sender, _path, _interface, _member, params):
result['code'], result['results'] = params.unpack()
loop.quit()
sub = bus.signal_subscribe(None, 'org.freedesktop.portal.Request', 'Response', request_path, None, Gio.DBusSignalFlags.NONE, response)
loop.run()
bus.signal_unsubscribe(sub)
if result['code'] != 0:
raise RuntimeError(f'{method} portal response {result["code"]}')
return result['results']
def unix_fd(proxy, method, session):
incoming = Gio.UnixFDList.new()
variant, outgoing = proxy.call_with_unix_fd_list_sync(
method,
GLib.Variant('(oa{sv})', (session, {})),
Gio.DBusCallFlags.NONE,
15000,
incoming,
None,
)
if outgoing is not None and outgoing.get_length() > 0:
unpacked = variant.unpack()
index = unpacked[0] if isinstance(unpacked, (tuple, list)) else unpacked
if isinstance(index, int) and 0 <= index < outgoing.get_length():
return outgoing.get(index)
return outgoing.get(0)
unpacked = variant.unpack()
fd = unpacked[0] if isinstance(unpacked, (tuple, list)) else unpacked
if isinstance(fd, int) and fd >= 0:
return fd
raise RuntimeError(f'{method} returned no file descriptor')
def notify(method, signature, values):
remote.call_sync(method, GLib.Variant(signature, values), Gio.DBusCallFlags.NONE, 5000, None)
def stream_node_id(streams):
if not streams:
raise RuntimeError('ScreenCast Start returned no PipeWire streams')
first = streams[0]
if isinstance(first, (list, tuple)):
return int(first[0])
if isinstance(first, dict):
return int(first.get('node_id') or first.get('id'))
return int(first)
class PortalNotify:
def __init__(self, session):
self.session = session
def handle(self, action):
kind = action.get('type')
name = action.get('action')
opts = {}
session = self.session
if kind == 'pointer' and name in ('click', 'double_click', 'move'):
x = float(action.get('x') or 0)
y = float(action.get('y') or 0)
notify('NotifyPointerMotionAbsolute', '(oa{sv}udd)', (session, opts, 0, x, y))
if name != 'move':
button = BTN.get(str(action.get('button') or 'left'), BTN['left'])
repeats = 2 if name == 'double_click' else 1
for _ in range(repeats):
notify('NotifyPointerButton', '(oa{sv}iu)', (session, opts, button, 1))
notify('NotifyPointerButton', '(oa{sv}iu)', (session, opts, button, 0))
elif kind == 'pointer' and name == 'scroll':
notify('NotifyPointerAxisDiscrete', '(oa{sv}ui)', (session, opts, 0, int(action.get('dy') or 0)))
elif kind == 'keyboard' and name == 'type':
for ch in str(action.get('text') or ''):
keysym = 0xff0d if ch == '\n' else (0x020 if ch == ' ' else ord(ch))
notify('NotifyKeyboardKeysym', '(oa{sv}iu)', (session, opts, keysym, 1))
notify('NotifyKeyboardKeysym', '(oa{sv}iu)', (session, opts, keysym, 0))
if action.get('submit'):
notify('NotifyKeyboardKeysym', '(oa{sv}iu)', (session, opts, 0xff0d, 1))
notify('NotifyKeyboardKeysym', '(oa{sv}iu)', (session, opts, 0xff0d, 0))
elif kind == 'keyboard' and name == 'key':
combo = str(action.get('combo') or '').lower()
keysym = 0xff1b if 'esc' in combo else 0xff0d
notify('NotifyKeyboardKeysym', '(oa{sv}iu)', (session, opts, keysym, 1))
notify('NotifyKeyboardKeysym', '(oa{sv}iu)', (session, opts, keysym, 0))
else:
raise RuntimeError(f'unsupported portal notify action {kind} {name}')
class SessionHandler:
def __init__(self, input_handler, framebuffer):
self.input_handler = input_handler
self.framebuffer = framebuffer
def dispatch(self):
if hasattr(self.input_handler, 'dispatch'):
self.input_handler.dispatch()
def handle(self, action):
if action.get('type') == 'frame':
if not self.framebuffer:
raise RuntimeError('PipeWire ScreenCast is not attached')
path = action.get('path')
if not path:
raise RuntimeError('frame path is required')
self.framebuffer.capture(path)
print(json.dumps({'type': 'frame', 'path': path, 'source': 'pipewire'}), flush=True)
return
if self.input_handler is None:
raise RuntimeError('desktop input is observe-only')
self.input_handler.handle(action)
def run_loop(handler, extra_fd=None):
stdin_fd = sys.stdin.fileno()
while True:
fds = [stdin_fd]
if extra_fd is not None:
fds.append(extra_fd)
readable, _, _ = select.select(fds, [], [], 0.25)
if extra_fd is not None and extra_fd in readable and hasattr(handler, 'dispatch'):
handler.dispatch()
if stdin_fd in readable:
line = sys.stdin.readline()
if not line:
break
try:
action = json.loads(line)
except Exception:
continue
if action.get('type') == 'close':
break
try:
handler.handle(action)
except Exception as exc:
log_error(exc)
def attach_screencast():
token = f'jarvissc{GLib.get_real_time()}'
session = request(screencast, 'CreateSession', '(a{sv})', ({'session_handle_token': GLib.Variant('s', token)},))['session_handle']
request(screencast, 'SelectSources', '(oa{sv})', (session, {
'types': GLib.Variant('u', 1),
'multiple': GLib.Variant('b', False),
'cursor_mode': GLib.Variant('u', 2),
'persist_mode': GLib.Variant('u', 2),
}))
return session
def start_framebuffer(sc_session):
results = request(screencast, 'Start', '(osa{sv})', (sc_session, '', {}))
node_id = stream_node_id(results.get('streams') or [])
fd = unix_fd(screencast, 'OpenPipeWireRemote', sc_session)
return PipeWireFrameBuffer(fd, node_id)
injector = None
sender = None
framebuffer = None
session = None
try:
sc_session = attach_screencast()
input_handler = None
extra_fd = None
backend = 'none'
eis_error = None
frame_error = None
if MODE != 'observe':
token = f'jarvisrd{GLib.get_real_time()}'
session = request(remote, 'CreateSession', '(a{sv})', ({'session_handle_token': GLib.Variant('s', token)},))['session_handle']
request(remote, 'SelectDevices', '(oa{sv})', (session, {'types': GLib.Variant('u', 7), 'persist_mode': GLib.Variant('u', 2)}))
request(remote, 'Start', '(osa{sv})', (session, '', {}))
try:
eis_fd = unix_fd(remote, 'ConnectToEIS', session)
sender = LibeiSender(eis_fd)
if not sender.wait_ready():
raise RuntimeError('libei connected but no pointer or keyboard device appeared')
input_handler = sender
extra_fd = sender.fd
backend = 'portal-ei'
except Exception as exc:
eis_error = str(exc)
if sender:
try:
sender.close()
except Exception:
pass
sender = None
if input_handler is None and os.environ.get('JARVIS_LIBEI_BRIDGE'):
injector = subprocess.Popen(os.environ['JARVIS_LIBEI_BRIDGE'], shell=True, stdin=subprocess.PIPE, text=True)
class Bridge:
def handle(self, action):
injector.stdin.write(json.dumps(action) + '\n')
injector.stdin.flush()
input_handler = Bridge()
backend = 'portal-ei'
if input_handler is None:
input_handler = PortalNotify(session)
backend = 'portal-notify'
try:
framebuffer = start_framebuffer(sc_session)
except Exception as exc:
frame_error = str(exc)
framebuffer = None
print(json.dumps({
'type': 'ready',
'session': session or sc_session,
'restore_token_present': True,
'backend': backend,
'screen': framebuffer is not None,
'eis_error': eis_error,
'frame_error': frame_error,
}), flush=True)
run_loop(SessionHandler(input_handler, framebuffer), extra_fd)
except Exception as exc:
log_error(exc)
finally:
if framebuffer:
try:
framebuffer.close()
except Exception:
pass
if sender:
try:
sender.close()
except Exception:
pass
if injector:
injector.terminate()