Skip to content

Commit f6b50be

Browse files
committed
Add bidirectional chunked file transfer for Remote Desktop
Three new message types form one transfer: FILE_BEGIN carries JSON metadata (transfer_id, dest_path, size); FILE_CHUNK is a 36-byte ASCII transfer id followed by raw bytes; FILE_END carries a JSON status / error string. Sender path (utils/remote_desktop/file_transfer.send_file) opens the file synchronously, picks a UUID, streams 256 KiB chunks, and fires an on_progress(transfer_id, bytes_done, total) callback per chunk. The caller wraps in a thread for non-blocking uploads. Receiver (FileReceiver) demultiplexes by transfer_id so multiple in-flight files on one channel work, expanduser's ~ in dest_path, and creates parent directories. There is no aggregate size limit and no destination-path restriction — token holders are trusted users. Host: set_file_receiver attaches a custom receiver (with progress / complete callbacks); send_file_to_viewers streams a local file to every authenticated viewer. Viewer: send_file streams a local file to the host; set_file_receiver attaches a receiver for files pushed from the host. Receiver callbacks fire on the receive thread, so GUI consumers must marshal back to the UI thread (which is what the upcoming Remote Desktop tab does via Qt signals).
1 parent 3ec8ff3 commit f6b50be

6 files changed

Lines changed: 572 additions & 0 deletions

File tree

je_auto_control/utils/remote_desktop/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from je_auto_control.utils.remote_desktop.clipboard_sync import (
1717
ClipboardSyncError,
1818
)
19+
from je_auto_control.utils.remote_desktop.file_transfer import (
20+
FileReceiver, FileSendResult, FileTransferError, send_file,
21+
)
1922
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
2023
from je_auto_control.utils.remote_desktop.host_id import (
2124
HostIdError, format_host_id, generate_host_id, load_or_create_host_id,
@@ -46,4 +49,5 @@
4649
"AudioBackendError", "AudioCapture", "AudioPlayer",
4750
"is_audio_backend_available",
4851
"ClipboardSyncError",
52+
"FileReceiver", "FileSendResult", "FileTransferError", "send_file",
4953
]
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""Chunked file transfer over the typed-message channel.
2+
3+
Three message types form a transfer:
4+
5+
* ``FILE_BEGIN`` — JSON ``{transfer_id, dest_path, size}`` announces a new
6+
stream. ``transfer_id`` is a 36-character UUID hex string so the
7+
receiver can demultiplex multiple in-flight transfers on one channel.
8+
* ``FILE_CHUNK`` — first 36 bytes are the ASCII transfer id, the rest is
9+
raw payload. Chunks arrive in order; the receiver writes them
10+
sequentially and accumulates ``bytes_done``.
11+
* ``FILE_END`` — JSON ``{transfer_id, status, error?}`` finalises the
12+
stream. The receiver closes the file and fires ``on_complete`` with
13+
success / failure info.
14+
15+
There is no central per-host file-size limit — operators relying on
16+
this should keep ``trusted token holders == trusted users`` in mind, and
17+
treat the dropbox / destination filesystem accordingly.
18+
"""
19+
import json
20+
import os
21+
import threading
22+
import uuid
23+
from dataclasses import dataclass, field
24+
from pathlib import Path
25+
from typing import Any, Callable, Dict, Optional, Tuple
26+
27+
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
28+
from je_auto_control.utils.remote_desktop.protocol import MessageType
29+
30+
DEFAULT_CHUNK_SIZE = 256 * 1024
31+
TRANSFER_ID_LEN = 36 # str(uuid.uuid4()) length
32+
33+
ProgressCallback = Callable[[str, int, int], None]
34+
CompleteCallback = Callable[[str, bool, Optional[str], str], None]
35+
36+
37+
class FileTransferError(RuntimeError):
38+
"""Raised when a file-transfer payload is malformed."""
39+
40+
41+
def new_transfer_id() -> str:
42+
"""Return a fresh 36-character ASCII transfer ID."""
43+
return str(uuid.uuid4())
44+
45+
46+
def encode_begin(transfer_id: str, dest_path: str, size: int) -> bytes:
47+
if len(transfer_id) != TRANSFER_ID_LEN:
48+
raise FileTransferError("transfer_id must be a 36-char UUID string")
49+
return json.dumps({
50+
"transfer_id": transfer_id,
51+
"dest_path": str(dest_path),
52+
"size": int(size),
53+
}, ensure_ascii=False).encode("utf-8")
54+
55+
56+
def decode_begin(payload: bytes) -> Tuple[str, str, int]:
57+
body = _decode_json(payload)
58+
transfer_id = body.get("transfer_id")
59+
dest_path = body.get("dest_path")
60+
size = body.get("size")
61+
if (not isinstance(transfer_id, str)
62+
or len(transfer_id) != TRANSFER_ID_LEN):
63+
raise FileTransferError("FILE_BEGIN missing valid transfer_id")
64+
if not isinstance(dest_path, str) or not dest_path:
65+
raise FileTransferError("FILE_BEGIN missing dest_path")
66+
if not isinstance(size, int) or size < 0:
67+
raise FileTransferError("FILE_BEGIN missing valid size")
68+
return transfer_id, dest_path, size
69+
70+
71+
def encode_chunk(transfer_id: str, chunk: bytes) -> bytes:
72+
if len(transfer_id) != TRANSFER_ID_LEN:
73+
raise FileTransferError("transfer_id must be a 36-char UUID string")
74+
return transfer_id.encode("ascii") + bytes(chunk)
75+
76+
77+
def decode_chunk(payload: bytes) -> Tuple[str, bytes]:
78+
if len(payload) < TRANSFER_ID_LEN:
79+
raise FileTransferError("FILE_CHUNK shorter than transfer id header")
80+
transfer_id = payload[:TRANSFER_ID_LEN].decode("ascii", errors="replace")
81+
return transfer_id, bytes(payload[TRANSFER_ID_LEN:])
82+
83+
84+
def encode_end(transfer_id: str, status: str = "ok",
85+
error: Optional[str] = None) -> bytes:
86+
if len(transfer_id) != TRANSFER_ID_LEN:
87+
raise FileTransferError("transfer_id must be a 36-char UUID string")
88+
body: Dict[str, Any] = {"transfer_id": transfer_id, "status": status}
89+
if error is not None:
90+
body["error"] = str(error)
91+
return json.dumps(body, ensure_ascii=False).encode("utf-8")
92+
93+
94+
def decode_end(payload: bytes) -> Tuple[str, str, Optional[str]]:
95+
body = _decode_json(payload)
96+
transfer_id = body.get("transfer_id")
97+
status = body.get("status", "ok")
98+
if (not isinstance(transfer_id, str)
99+
or len(transfer_id) != TRANSFER_ID_LEN):
100+
raise FileTransferError("FILE_END missing valid transfer_id")
101+
if not isinstance(status, str):
102+
raise FileTransferError("FILE_END status must be a string")
103+
error = body.get("error")
104+
return transfer_id, status, error if isinstance(error, str) else None
105+
106+
107+
def _decode_json(payload: bytes) -> Dict[str, Any]:
108+
try:
109+
body = json.loads(payload.decode("utf-8"))
110+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
111+
raise FileTransferError(f"invalid JSON: {error}") from error
112+
if not isinstance(body, dict):
113+
raise FileTransferError("payload must be a JSON object")
114+
return body
115+
116+
117+
@dataclass
118+
class _Incoming:
119+
"""Per-transfer state owned by ``FileReceiver``."""
120+
121+
transfer_id: str
122+
dest_path: Path
123+
total_size: int
124+
handle: Any # file object
125+
bytes_done: int = 0
126+
error: Optional[str] = None
127+
128+
129+
class FileReceiver:
130+
"""Demultiplex incoming FILE_* messages into one or more file writes."""
131+
132+
def __init__(self, on_progress: Optional[ProgressCallback] = None,
133+
on_complete: Optional[CompleteCallback] = None) -> None:
134+
self._on_progress = on_progress
135+
self._on_complete = on_complete
136+
self._active: Dict[str, _Incoming] = {}
137+
self._lock = threading.Lock()
138+
139+
def handle_begin(self, payload: bytes) -> None:
140+
transfer_id, dest_path, total_size = decode_begin(payload)
141+
path = Path(os.path.expanduser(dest_path))
142+
path.parent.mkdir(parents=True, exist_ok=True)
143+
try:
144+
handle = open(path, "wb") # noqa: SIM115 managed manually
145+
except OSError as error:
146+
self._fire_complete(transfer_id, False, str(error), str(path))
147+
return
148+
with self._lock:
149+
self._active[transfer_id] = _Incoming(
150+
transfer_id=transfer_id, dest_path=path,
151+
total_size=total_size, handle=handle,
152+
)
153+
if self._on_progress is not None:
154+
self._on_progress(transfer_id, 0, total_size)
155+
156+
def handle_chunk(self, payload: bytes) -> None:
157+
transfer_id, chunk = decode_chunk(payload)
158+
with self._lock:
159+
incoming = self._active.get(transfer_id)
160+
if incoming is None:
161+
autocontrol_logger.info(
162+
"remote_desktop FILE_CHUNK for unknown transfer %s",
163+
transfer_id,
164+
)
165+
return
166+
try:
167+
incoming.handle.write(chunk)
168+
except OSError as error:
169+
incoming.error = str(error)
170+
self._abort(incoming)
171+
return
172+
incoming.bytes_done += len(chunk)
173+
if self._on_progress is not None:
174+
self._on_progress(
175+
transfer_id, incoming.bytes_done, incoming.total_size,
176+
)
177+
178+
def handle_end(self, payload: bytes) -> None:
179+
transfer_id, status, error = decode_end(payload)
180+
with self._lock:
181+
incoming = self._active.pop(transfer_id, None)
182+
if incoming is None:
183+
return
184+
try:
185+
incoming.handle.close()
186+
except OSError:
187+
pass
188+
ok = (status == "ok") and incoming.error is None
189+
message = error or incoming.error
190+
self._fire_complete(
191+
transfer_id, ok, message, str(incoming.dest_path),
192+
)
193+
194+
def _abort(self, incoming: _Incoming) -> None:
195+
try:
196+
incoming.handle.close()
197+
except OSError:
198+
pass
199+
with self._lock:
200+
self._active.pop(incoming.transfer_id, None)
201+
self._fire_complete(
202+
incoming.transfer_id, False, incoming.error,
203+
str(incoming.dest_path),
204+
)
205+
206+
def _fire_complete(self, transfer_id: str, ok: bool,
207+
error: Optional[str], dest_path: str) -> None:
208+
if self._on_complete is None:
209+
return
210+
try:
211+
self._on_complete(transfer_id, ok, error, dest_path)
212+
except Exception: # noqa: BLE001
213+
autocontrol_logger.exception(
214+
"remote_desktop FileReceiver.on_complete callback raised"
215+
)
216+
217+
218+
@dataclass
219+
class FileSendResult:
220+
"""Outcome of one outbound transfer."""
221+
222+
transfer_id: str
223+
success: bool
224+
error: Optional[str] = None
225+
bytes_sent: int = 0
226+
227+
228+
def send_file(channel, source_path: str, dest_path: str,
229+
on_progress: Optional[ProgressCallback] = None,
230+
chunk_size: int = DEFAULT_CHUNK_SIZE,
231+
transfer_id: Optional[str] = None) -> FileSendResult:
232+
"""Stream ``source_path`` to ``dest_path`` over ``channel``.
233+
234+
Synchronous: the caller's thread does the I/O. Wrap in a thread for
235+
background uploads. ``on_progress(transfer_id, bytes_done, total)``
236+
fires after every chunk (and once at the start with ``bytes_done=0``).
237+
"""
238+
transfer_id = transfer_id or new_transfer_id()
239+
source = Path(os.path.expanduser(source_path))
240+
if not source.is_file():
241+
raise FileTransferError(f"source not found: {source}")
242+
total_size = source.stat().st_size
243+
channel.send_typed(MessageType.FILE_BEGIN,
244+
encode_begin(transfer_id, dest_path, total_size))
245+
if on_progress is not None:
246+
on_progress(transfer_id, 0, total_size)
247+
bytes_sent = 0
248+
try:
249+
with open(source, "rb") as handle:
250+
while True:
251+
chunk = handle.read(int(chunk_size))
252+
if not chunk:
253+
break
254+
channel.send_typed(
255+
MessageType.FILE_CHUNK, encode_chunk(transfer_id, chunk),
256+
)
257+
bytes_sent += len(chunk)
258+
if on_progress is not None:
259+
on_progress(transfer_id, bytes_sent, total_size)
260+
except (OSError, ConnectionError) as error:
261+
channel.send_typed(
262+
MessageType.FILE_END,
263+
encode_end(transfer_id, status="error", error=str(error)),
264+
)
265+
return FileSendResult(transfer_id=transfer_id, success=False,
266+
error=str(error), bytes_sent=bytes_sent)
267+
channel.send_typed(MessageType.FILE_END, encode_end(transfer_id))
268+
return FileSendResult(transfer_id=transfer_id, success=True,
269+
bytes_sent=bytes_sent)

je_auto_control/utils/remote_desktop/host.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from je_auto_control.utils.remote_desktop.clipboard_sync import (
2121
ClipboardSyncError, decode as decode_clipboard, encode_image, encode_text,
2222
)
23+
from je_auto_control.utils.remote_desktop.file_transfer import (
24+
FileReceiver, FileTransferError, send_file,
25+
)
2326
from je_auto_control.utils.remote_desktop.host_id import (
2427
load_or_create_host_id, validate_host_id,
2528
)
@@ -210,11 +213,31 @@ def _recv_loop(self) -> None:
210213
if msg_type is MessageType.CLIPBOARD:
211214
self._handle_clipboard_payload(payload)
212215
continue
216+
if msg_type in (MessageType.FILE_BEGIN, MessageType.FILE_CHUNK,
217+
MessageType.FILE_END):
218+
self._handle_file_payload(msg_type, payload)
219+
continue
213220
autocontrol_logger.info(
214221
"remote_desktop unexpected msg %s from %s",
215222
msg_type.name, self._address,
216223
)
217224

225+
def _handle_file_payload(self, msg_type: MessageType,
226+
payload: bytes) -> None:
227+
receiver = self._host._ensure_file_receiver()
228+
try:
229+
if msg_type is MessageType.FILE_BEGIN:
230+
receiver.handle_begin(payload)
231+
elif msg_type is MessageType.FILE_CHUNK:
232+
receiver.handle_chunk(payload)
233+
elif msg_type is MessageType.FILE_END:
234+
receiver.handle_end(payload)
235+
except FileTransferError as error:
236+
autocontrol_logger.info(
237+
"remote_desktop bad file message from %s: %r",
238+
self._address, error,
239+
)
240+
218241
def _handle_clipboard_payload(self, payload: bytes) -> None:
219242
try:
220243
kind, data = decode_clipboard(payload)
@@ -303,6 +326,7 @@ def __init__(self, token: str,
303326
frame_provider or _default_frame_provider(region, int(quality))
304327
)
305328
self._dispatch: InputDispatcher = input_dispatcher or dispatch_input
329+
self._file_receiver: Optional[FileReceiver] = None
306330
self._audio_enabled = bool(enable_audio)
307331
self._audio_device = audio_device
308332
self._audio_sample_rate = int(audio_sample_rate)
@@ -476,6 +500,37 @@ def _broadcast_clipboard_payload(self, payload: bytes) -> int:
476500
client.stop()
477501
return sent
478502

503+
def set_file_receiver(self, receiver: FileReceiver) -> None:
504+
"""Replace the default ``FileReceiver`` (e.g. to wire progress callbacks)."""
505+
self._file_receiver = receiver
506+
507+
def _ensure_file_receiver(self) -> FileReceiver:
508+
if self._file_receiver is None:
509+
self._file_receiver = FileReceiver()
510+
return self._file_receiver
511+
512+
def send_file_to_viewers(self, source_path: str, dest_path: str,
513+
on_progress=None) -> int:
514+
"""Stream ``source_path`` to every authenticated viewer.
515+
516+
Returns the number of viewers the transfer was attempted on.
517+
Each viewer gets its own ``transfer_id`` so progress callbacks
518+
can be demultiplexed in the GUI.
519+
"""
520+
with self._clients_lock:
521+
clients = [c for c in self._clients
522+
if c.authenticated and not c._shutdown.is_set()]
523+
for client in clients:
524+
try:
525+
send_file(client._channel, source_path, dest_path,
526+
on_progress=on_progress)
527+
except (OSError, ConnectionError, FileTransferError) as error:
528+
autocontrol_logger.info(
529+
"remote_desktop file send to %s failed: %r",
530+
client.address, error,
531+
)
532+
return len(clients)
533+
479534
def _apply_clipboard(self, kind: str, data: Any) -> None:
480535
"""Set this host's local clipboard from a decoded CLIPBOARD payload.
481536

je_auto_control/utils/remote_desktop/protocol.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class MessageType(enum.IntEnum):
3434
AUDIO = 0x11 # host -> viewer: PCM audio chunk
3535
INPUT = 0x20 # viewer -> host: JSON input message
3636
CLIPBOARD = 0x21 # either way: clipboard payload (text or image)
37+
FILE_BEGIN = 0x22 # either way: JSON metadata for an incoming transfer
38+
FILE_CHUNK = 0x23 # either way: 36-byte transfer id + chunk bytes
39+
FILE_END = 0x24 # either way: JSON status for a finished transfer
3740
PING = 0x30 # either way: liveness
3841

3942

0 commit comments

Comments
 (0)