SelkiesStreamingApp
Session-level streaming state shared across transports.
Holds the display geometry, encoder/framerate/bitrate defaults, and the clipboard/cursor delivery helpers that broadcast over the data websocket. The heavy lifting (captures, relays, reconfiguration) lives in DataStreamingServer; this object is the small shared surface that the input handler and both transports address.
Attributes
attributeserver_enable_resize= ENABLE_RESIZEWhether clients may resize the primary display.
attributemode= modeattributedisplay_width= 1024Primary display geometry, seeded from a configured manual resolution (even-masked), else 1024x768, for captures that start before any client sized the display; on Wayland the capture start resizes the compositor output to it.
attributedisplay_height= 768See display_width.
attributepipeline_running= FalseCleared by stop_pipeline.
attributeasync_event_loop= async_event_loopattributeaudio_channels= int(getattr(settings, 'audio_channels', AUDIO_CHANNELS_DEFAULT) or AUDIO_CHANNELS_DEFAULT)Configured capture channel count (surround captures as multistream Opus).
attributegpu_id= GPU_ID_DEFAULTattributeaudio_bitrate= AUDIO_BITRATE_DEFAULTSession Opus bitrate in bps.
attributeencoder= encoderSession default encoder for later-registered displays.
attributeframerate= framerateSession default framerate for later-registered displays.
attributelast_cursor_sent= NoneCached cursor payload replayed to joining clients.
attributedata_streaming_server= data_streaming_serverattributestop_ws_pipeline= stop_pipelineFunctions
func__init__(self, async_event_loop, framerate, encoder, data_streaming_server=None, mode='websockets') -> NoneSource Code
def __init__(
self,
async_event_loop: asyncio.AbstractEventLoop,
framerate: int,
encoder: str,
data_streaming_server: Optional["DataStreamingServer"] = None,
mode: str = "websockets",
) -> None:
self.server_enable_resize = ENABLE_RESIZE
self.mode = mode
self.display_width = 1024
self.display_height = 768
if settings.manual_resolution[0]:
manual_w = int(settings.manual_width or 0)
manual_h = int(settings.manual_height or 0)
if manual_w > 0 and manual_h > 0:
self.display_width = manual_w - (manual_w % 2)
self.display_height = manual_h - (manual_h % 2)
self.pipeline_running = False
self.async_event_loop = async_event_loop
self.audio_channels = int(getattr(settings, 'audio_channels', AUDIO_CHANNELS_DEFAULT)
or AUDIO_CHANNELS_DEFAULT)
self.gpu_id = GPU_ID_DEFAULT
self.audio_bitrate = AUDIO_BITRATE_DEFAULT
self.encoder = encoder
self.framerate = framerate
self.last_cursor_sent = None
self.data_streaming_server = data_streaming_serverparamselfparamasync_event_loopasyncio.AbstractEventLoopparamframerateintparamencoderstrparamdata_streaming_serverOptional[DataStreamingServer]= Noneparammodestr= 'websockets'Returns
Nonefuncsend_ws_clipboard_data(self, data, mime_type='text/plain', reply_to=None, conn_id=None) -> NoneSend clipboard data to the session's clients, multipart when large.
Payload frames get the bulk tolerance the data channel's drain allows (a slow link is not a dead client); control frames keep the liveness bound, since one stalled client must not wedge clipboard delivery for all.
Source Code
async def send_ws_clipboard_data(
self,
data: Union[str, bytes],
mime_type: str = "text/plain",
reply_to: Optional[str] = None,
conn_id: Optional[int] = None,
) -> None:
"""Send clipboard data to the session's clients, multipart when large.
Args:
data: Clipboard text (str) or binary payload (bytes).
mime_type: The payload's MIME type; anything but "text/plain" is
treated as binary and gated on enable_binary_clipboard.
reply_to: Set to the requesting verb (e.g. "cr") when this send
answers a client fetch rather than announcing a server-side
clipboard change. A `clipboard_reply,<verb>` frame then
precedes the payload frames on the same ordered socket, so
clients can treat the payload cache-only without time
heuristics. Legacy clients route the unknown verb to their
input module, which ignores it.
conn_id: Connection that asked for this payload. An answer goes to
that client alone: every other one already holds the content or
is about to be told of a change, and a tagged reply they did
not ask for is read as their own fetch and cached without ever
reaching their clipboard.
Payload frames get the bulk tolerance the data channel's drain allows
(a slow link is not a dead client); control frames keep the liveness
bound, since one stalled client must not wedge clipboard delivery for
all.
"""
if not (self.data_streaming_server and self.data_streaming_server.clients):
data_logger.warning("Cannot send clipboard: no clients or server not ready.")
return
try:
is_binary = mime_type != "text/plain"
if is_binary and not self.data_streaming_server.enable_binary_clipboard:
data_logger.warning(
f"Attempted to send binary clipboard data ({mime_type}) but feature is disabled on server."
)
return
if reply_to:
await _broadcast_to_clients(
self.data_streaming_server.clients,
f"clipboard_reply,{reply_to}", per_client_timeout=2.0,
only=conn_id)
data_bytes = data.encode('utf-8') if not is_binary and isinstance(data, str) else data
total_size = len(data_bytes)
if total_size < CLIPBOARD_CHUNK_SIZE:
encoded_data = base64.b64encode(data_bytes).decode('ascii')
if is_binary:
message = f"clipboard_binary,{mime_type},{encoded_data}"
else:
message = f"clipboard,{encoded_data}"
await _broadcast_to_clients(self.data_streaming_server.clients, message,
per_client_timeout=BULK_DRAIN_TIMEOUT_S, only=conn_id)
else:
data_logger.info(f"Sending large clipboard data ({mime_type}, {total_size} bytes) via multipart.")
start_message = f"clipboard_start,{mime_type},{total_size}"
clients = self.data_streaming_server.clients
async def deliver(cid: int) -> None:
"""One pipeline per client, a chunk at a time: a slow link
paces only its own transfer and a dead one drops out of the
set without touching anyone else's."""
if await _broadcast_to_clients(clients, start_message,
per_client_timeout=2.0, only=cid):
return
offset = 0
while offset < total_size:
chunk = data_bytes[offset:offset + CLIPBOARD_CHUNK_SIZE]
data_message = "clipboard_data," + base64.b64encode(chunk).decode('ascii')
if await _broadcast_to_clients(clients, data_message,
per_client_timeout=BULK_DRAIN_TIMEOUT_S, only=cid):
return
offset += len(chunk)
await asyncio.sleep(0)
await _broadcast_to_clients(clients, "clipboard_finish",
per_client_timeout=2.0, only=cid)
recipients = [id(c) for c in list(clients) if conn_id is None or id(c) == conn_id]
await asyncio.gather(*(deliver(cid) for cid in recipients))
data_logger.info("Finished sending multi-part clipboard data.")
except Exception as e:
data_logger.error(f"Failed to send clipboard data: {e}", exc_info=True)paramselfparamdataUnion[str, bytes]Clipboard text (str) or binary payload (bytes).
parammime_typestr= 'text/plain'The payload's MIME type; anything but "text/plain" is treated as binary and gated on enable_binary_clipboard.
paramreply_toOptional[str]= NoneSet to the requesting verb (e.g. "cr") when this send
answers a client fetch rather than announcing a server-side
clipboard change. A clipboard_reply,\<verb> frame then
precedes the payload frames on the same ordered socket, so
clients can treat the payload cache-only without time
heuristics. Legacy clients route the unknown verb to their
input module, which ignores it.
paramconn_idOptional[int]= NoneConnection that asked for this payload. An answer goes to that client alone: every other one already holds the content or is about to be told of a change, and a tagged reply they did not ask for is read as their own fetch and cached without ever reaching their clipboard.
Returns
Nonefuncsend_ws_cursor_data(self, data) -> NoneBroadcast a cursor-change payload to all clients.
Thread-safe: called from pixelflux's cursor thread, so the broadcast is scheduled onto the event loop via run_coroutine_threadsafe rather than awaited. The payload is also cached as last_cursor_sent so late-joining clients receive the current cursor at connect.
Source Code
def send_ws_cursor_data(self, data: dict) -> None:
"""Broadcast a cursor-change payload to all clients.
Thread-safe: called from pixelflux's cursor thread, so the broadcast is
scheduled onto the event loop via run_coroutine_threadsafe rather than
awaited. The payload is also cached as last_cursor_sent so late-joining
clients receive the current cursor at connect.
"""
self.last_cursor_sent = data
if (
self.data_streaming_server
and hasattr(self.data_streaming_server, "clients")
and self.data_streaming_server.clients
and self.async_event_loop
and self.async_event_loop.is_running()
):
msg_str = json.dumps(data)
msg_to_broadcast = f"cursor,{msg_str}"
clients_ref = self.data_streaming_server.clients
async def _broadcast_cursor_helper():
"""Bounded: cursor changes arrive at high rate, and a stalled
client would otherwise accumulate one blocked coroutine each."""
await _broadcast_to_clients(clients_ref, msg_to_broadcast, per_client_timeout=2.0)
asyncio.run_coroutine_threadsafe(
_broadcast_cursor_helper(), self.async_event_loop
)
else:
data_logger.warning("Cannot broadcast cursor data: no clients connected or server not ready.")paramselfparamdatadictReturns
Nonefuncsend_system_action(self, action) -> NoneBroadcast a system action (e.g. command_error,\<text>) to clients.
Source Code
def send_system_action(self, action: str) -> None:
"""Broadcast a system action (e.g. ``command_error,<text>``) to clients."""
if (
self.data_streaming_server
and getattr(self.data_streaming_server, "clients", None)
and self.async_event_loop
and self.async_event_loop.is_running()
):
msg = "system," + json.dumps({"action": action})
clients_ref = self.data_streaming_server.clients
async def _broadcast_system_helper():
await _broadcast_to_clients(clients_ref, msg, per_client_timeout=2.0)
asyncio.run_coroutine_threadsafe(
_broadcast_system_helper(), self.async_event_loop
)paramselfparamactionstrReturns
Nonefuncstop_pipeline(self) -> NoneStop all pipelines by reconciling displays against current state.
Source Code
async def stop_pipeline(self) -> None:
"""Stop all pipelines by reconciling displays against current state."""
logger_app.info("Stopping pipelines (generic call)...")
if self.data_streaming_server:
await self.data_streaming_server.reconfigure_displays()
self.pipeline_running = False
logger_app.info("Pipelines stop signal processed.")paramselfReturns
Nonefuncset_framerate(self, framerate) -> NoneStore the session default framerate; applies at the next pipeline (re)start.
Source Code
def set_framerate(self, framerate: Union[int, float]) -> None:
"""Store the session default framerate; applies at the next pipeline (re)start."""
self.framerate = int(framerate)
logger_app.info(
f"Framerate for {self.encoder} set to {self.framerate}. Restart pipeline if active."
)paramselfparamframerateUnion[int, float]Returns
None