remotedesktop 2.2.0__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
remotedesktop/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.4.0"
remotedesktop/app.py CHANGED
@@ -993,7 +993,10 @@ class MainWindow(QMainWindow):
993
993
  self.log("Restarting: freeing ports and launching a new process")
994
994
  _log.info("Restart requested — relaunching %s %s", program, " ".join(args))
995
995
  self.sharing_tab.shutdown()
996
- if not QProcess.startDetached(program, args):
996
+ # PySide6's static startDetached returns (started, pid) — the tuple is
997
+ # always truthy, so it must be unpacked to detect a failed launch.
998
+ started, _unused_pid = QProcess.startDetached(program, args)
999
+ if not started:
997
1000
  # Extremely unlikely (the program path exists); the app stays open —
998
1001
  # sharing is stopped, but the machine isn't left with nothing.
999
1002
  self.log("Restart failed: could not launch a new process — restart manually")
@@ -1165,6 +1168,8 @@ class MainWindow(QMainWindow):
1165
1168
  self._tabs.setCurrentWidget(session.page)
1166
1169
  self.log(f"Already connected to {session.name} ({key})")
1167
1170
  return
1171
+ if session is None:
1172
+ session = self._adopt_moved_pairing(server)
1168
1173
  if session is None:
1169
1174
  session = self._create_session(key, server.name)
1170
1175
  else:
@@ -1172,6 +1177,42 @@ class MainWindow(QMainWindow):
1172
1177
  self._tabs.setTabText(self._sessions.index(session), session.name)
1173
1178
  self._connect_session(session, server.host, server.port)
1174
1179
 
1180
+ def _adopt_moved_pairing(self, server: ServerInfo) -> ServerSession | None:
1181
+ """A discovered server whose certificate is already paired, but at
1182
+ another address (DHCP moved it since the last visit): carry the
1183
+ stored token over so this connection needs no fresh approval.
1184
+
1185
+ A session still retrying the old address migrates whole (returned,
1186
+ for the caller to connect); otherwise only the pairing moves. A
1187
+ session *connected* at the old address means the server is reachable
1188
+ at both, so the pairing is copied and nothing is torn down.
1189
+ """
1190
+ new_key = f"{server.host}:{server.port}"
1191
+ if not server.fingerprint or self._known_servers.get(new_key) is not None:
1192
+ return None
1193
+ old_key = self._known_servers.key_for_fingerprint(server.fingerprint)
1194
+ if old_key is None:
1195
+ return None
1196
+ old_session = self._session_for_key(old_key)
1197
+ if old_session is not None and not old_session.connected:
1198
+ self._move_session(old_session, server)
1199
+ self.log(
1200
+ f"[{old_session.name}] Found at new address {new_key} (was {old_key}) "
1201
+ "— reusing its pairing"
1202
+ )
1203
+ return old_session
1204
+ record = self._known_servers.get(old_key)
1205
+ if record is None:
1206
+ return None
1207
+ self._known_servers.remember(new_key, server.fingerprint, record["token"])
1208
+ if old_session is None:
1209
+ # The old address is stale: drop its pairing and history row so
1210
+ # they don't linger as a permanently-unreachable duplicate.
1211
+ self._known_servers.forget(old_key)
1212
+ self.client_inventory.remove(old_key)
1213
+ self.log(f"{server.name} at {new_key} was paired at {old_key} — reusing that pairing")
1214
+ return None
1215
+
1175
1216
  def _create_session(self, key: str, name: str) -> ServerSession:
1176
1217
  viewer = ViewerWidget()
1177
1218
  # The tab page is a scroll area: fit mode (widgetResizable) sizes the
@@ -1492,9 +1533,23 @@ class MainWindow(QMainWindow):
1492
1533
  def _migrate_session(self, session: ServerSession, server: ServerInfo) -> None:
1493
1534
  """Move a session (and its stored pairing) to the server's new
1494
1535
  address, then reconnect immediately."""
1495
- old_key, new_key = session.key, f"{server.host}:{server.port}"
1496
- if self._session_for_key(new_key) is not None:
1536
+ if self._session_for_key(f"{server.host}:{server.port}") is not None:
1497
1537
  return # the user already opened a session at the new address
1538
+ old_key = session.key
1539
+ self._move_session(session, server)
1540
+ new_key = session.key
1541
+ message = f"Found {session.name} at new address {new_key} (was {old_key}) — reconnecting"
1542
+ self._set_session_status(session, message)
1543
+ self.log(f"[{session.name}] {message}")
1544
+ # A new address deserves a fresh backoff; the connect failure path
1545
+ # re-arms it (and further re-discovery) if this address fails too.
1546
+ self._cancel_reconnect(session)
1547
+ session.client.connect_to(server.host, server.port)
1548
+
1549
+ def _move_session(self, session: ServerSession, server: ServerInfo) -> None:
1550
+ """Re-key a session, its stored pairing, and its history row to the
1551
+ server's new address (no connection attempt)."""
1552
+ old_key, new_key = session.key, f"{server.host}:{server.port}"
1498
1553
  record = self._known_servers.get(old_key)
1499
1554
  if record is not None:
1500
1555
  # Re-key the pairing so the stored token still applies — the
@@ -1513,13 +1568,6 @@ class MainWindow(QMainWindow):
1513
1568
  new_key, "attempt", name=session.name, address=new_key, detail=new_key
1514
1569
  )
1515
1570
  self._persist_sessions()
1516
- message = f"Found {session.name} at new address {new_key} (was {old_key}) — reconnecting"
1517
- self._set_session_status(session, message)
1518
- self.log(f"[{session.name}] {message}")
1519
- # A new address deserves a fresh backoff; the connect failure path
1520
- # re-arms it (and further re-discovery) if this address fails too.
1521
- self._cancel_reconnect(session)
1522
- session.client.connect_to(server.host, server.port)
1523
1571
 
1524
1572
  def _request_server_log(self) -> None:
1525
1573
  session = self._session_for_page(self._tabs.currentWidget())
@@ -0,0 +1,103 @@
1
+ """Per-viewer send-backlog governor.
2
+
3
+ Unsent bytes sitting in a viewer's socket are pure latency: the viewer must
4
+ receive and render all of them before it shows anything current. So the
5
+ server withholds frames from a viewer whose queue is longer than a cap —
6
+ and the right cap depends on the link. 2 MiB is ~200 ms of queue on 100
7
+ Mbit/s but ~17 ms on gigabit, so a fixed number is either laggy on the slow
8
+ link or needlessly strict on the fast one.
9
+
10
+ `BacklogGovernor` sizes the cap from what it observes on the stream:
11
+
12
+ - **Bandwidth**: the socket's drain rate, sampled over intervals in which
13
+ the queue never emptied (so the sample reflects the link, not idleness).
14
+ A fast-up / slow-down average, starting from 100 Mbit/s — the slowest LAN
15
+ the app is designed for — so a gigabit link is recognized within a few
16
+ busy ticks. The cap is that throughput times `QUEUE_TARGET_SECONDS`.
17
+ - **Latency**: the in-band round-trip time (from the performance monitor's
18
+ pings, which queue behind frame data like everything else) against its
19
+ running floor. Excess above the target means bytes the byte counter can't
20
+ see are queued somewhere (the kernel's send buffer, the network), so the
21
+ cap is halved; it recovers geometrically while the excess stays small.
22
+ The floor drifts upward slowly so a genuinely changed baseline is
23
+ re-learned within about a minute.
24
+
25
+ Everything is pure arithmetic with an injectable clock — no Qt, no I/O.
26
+ """
27
+
28
+ import time
29
+ from collections.abc import Callable
30
+
31
+ # How much queued frame data a viewer may have before frames are withheld
32
+ # from it, expressed as time on the wire. 50 ms keeps the added latency
33
+ # below what a person notices while still letting one keyframe drain.
34
+ QUEUE_TARGET_SECONDS = 0.05
35
+ # The link assumed until measured: 100 Mbit/s, the slowest LAN the app
36
+ # targets (readme: "a modern LAN, often wired — at least 100 Mbit/s").
37
+ ASSUMED_BYTES_PER_SECOND = 100_000_000 / 8
38
+ # Bounds on the cap whatever the estimates say: never so small that a
39
+ # healthy link is throttled by measurement noise, never unbounded.
40
+ CAP_MIN = 256 * 1024
41
+ CAP_MAX = 16 * 1024 * 1024
42
+ # Throughput smoothing: believe a faster drain quickly, a slower one slowly
43
+ # (a single slow sample is more often noise than a slower link).
44
+ _ALPHA_UP = 0.25
45
+ _ALPHA_DOWN = 0.1
46
+ # Latency feedback: halve on excess, recover by a quarter per good sample.
47
+ _SCALE_MIN = 0.125
48
+ _FLOOR_DRIFT_MS = 1.0 # per RTT sample; samples arrive about once a second
49
+
50
+
51
+ class BacklogGovernor:
52
+ def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None:
53
+ self._clock = clock
54
+ self._throughput = ASSUMED_BYTES_PER_SECOND
55
+ self._last: tuple[float, int, int] | None = None # (time, pending, sent_total)
56
+ self._rtt_floor_ms: float | None = None
57
+ self._latency_scale = 1.0
58
+
59
+ @property
60
+ def throughput(self) -> float:
61
+ """Estimated drain rate of the viewer's socket, bytes per second."""
62
+ return self._throughput
63
+
64
+ @property
65
+ def latency_scale(self) -> float:
66
+ """1.0 while the round-trip time sits at its floor; smaller while
67
+ excess latency says the queue is longer than the byte count shows."""
68
+ return self._latency_scale
69
+
70
+ @property
71
+ def cap(self) -> int:
72
+ """Unsent bytes a viewer may have queued before frames are withheld."""
73
+ cap = self._throughput * QUEUE_TARGET_SECONDS * self._latency_scale
74
+ return int(min(CAP_MAX, max(CAP_MIN, cap)))
75
+
76
+ def observe(self, pending: int, sent_total: int) -> None:
77
+ """Sample the socket: `pending` unsent bytes right now and the
78
+ cumulative `sent_total` handed to it. Call once per broadcast tick."""
79
+ now = self._clock()
80
+ if self._last is not None:
81
+ then, pending_then, sent_then = self._last
82
+ elapsed = now - then
83
+ # Only an interval the queue never emptied measures the link;
84
+ # otherwise part of it was idle and the rate would read low.
85
+ if pending_then > 0 and pending > 0 and elapsed > 0:
86
+ drained = (sent_total - sent_then) - (pending - pending_then)
87
+ rate = max(0.0, drained / elapsed)
88
+ alpha = _ALPHA_UP if rate > self._throughput else _ALPHA_DOWN
89
+ self._throughput += alpha * (rate - self._throughput)
90
+ self._last = (now, pending, sent_total)
91
+
92
+ def observe_rtt(self, rtt_ms: float) -> None:
93
+ """Feed one round-trip-time sample (milliseconds)."""
94
+ if self._rtt_floor_ms is None:
95
+ self._rtt_floor_ms = rtt_ms
96
+ else:
97
+ self._rtt_floor_ms = min(rtt_ms, self._rtt_floor_ms + _FLOOR_DRIFT_MS)
98
+ excess = rtt_ms - self._rtt_floor_ms
99
+ target_ms = QUEUE_TARGET_SECONDS * 1000.0
100
+ if excess > target_ms:
101
+ self._latency_scale = max(_SCALE_MIN, self._latency_scale * 0.5)
102
+ elif excess < target_ms / 2:
103
+ self._latency_scale = min(1.0, self._latency_scale * 1.25)
remotedesktop/config.py CHANGED
@@ -132,6 +132,20 @@ class KnownServers:
132
132
  )
133
133
  self._db.commit()
134
134
 
135
+ def key_for_fingerprint(self, fingerprint: str) -> str | None:
136
+ """The "host:port" a server with this certificate was paired at, if any.
137
+
138
+ The certificate is the server's stable identity across an address
139
+ change; records without a pin never match.
140
+ """
141
+ if not fingerprint:
142
+ return None
143
+ row = self._db.execute(
144
+ "SELECT key FROM known_servers WHERE fingerprint = ? ORDER BY key LIMIT 1",
145
+ (fingerprint,),
146
+ ).fetchone()
147
+ return row[0] if row is not None else None
148
+
135
149
  def forget(self, key: str) -> None:
136
150
  """Drop a server's stored token and pin, so the next connection re-pairs."""
137
151
  self._db.execute("DELETE FROM known_servers WHERE key = ?", (key,))
remotedesktop/frames.py CHANGED
@@ -4,27 +4,42 @@ Consecutive captures are compared in horizontal bands (BAND_HEIGHT rows,
4
4
  adjacent changed bands merged); only changed bands are encoded and shipped
5
5
  in a delta payload, which the client patches onto its previous frame. Bands
6
6
  keep the comparison and the crop cheap: a band is a contiguous byte range of
7
- the image, so diffing is a C-speed memoryview compare.
7
+ the image, so diffing is one C memcmp per band (~3.5 ms for a 4K frame; the
8
+ pure-Python memoryview compare it falls back to is ~12x slower).
8
9
 
9
10
  Delta bands and keyframes are PNG — lossless, so the client's canvas is
10
11
  pixel-identical to the capture and patches never accumulate artifacts.
12
+ Encoding is the expensive step (~13 ms for a small change, ~110 ms for a
13
+ full 4K keyframe), so `FrameEncoder` runs it on a worker thread and hands
14
+ the bytes back to the GUI thread through a queued signal.
11
15
 
12
16
  Delta payload wire format: 4-byte big-endian header length, a JSON header
13
17
  {"w", "h", "bands": [{"y", "h", "len"}, ...]}, then the bands' PNG bytes
14
18
  concatenated in order.
15
19
  """
16
20
 
21
+ import ctypes
22
+ import ctypes.util
17
23
  import json
24
+ import logging
18
25
  import struct
26
+ import sys
27
+ import threading
28
+ import time
29
+ from collections.abc import Callable, Iterable
30
+ from concurrent.futures import Future, ThreadPoolExecutor
31
+ from dataclasses import dataclass
19
32
 
20
- from PySide6.QtCore import QBuffer
33
+ from PySide6.QtCore import QBuffer, QObject, Signal
21
34
  from PySide6.QtGui import QImage, QPainter
22
35
 
36
+ _log = logging.getLogger(__name__)
37
+
23
38
  BAND_HEIGHT = 64
24
39
  # PNG is lossless at any "quality" — the setting only picks the zlib effort.
25
40
  # 80 encodes a 4K frame ~35% faster than the default for ~20% more bytes;
26
- # encoding happens on the GUI thread every tick, so time matters more than
27
- # size on a LAN.
41
+ # time matters more than size on a LAN, and 80 is close to PNG's floor
42
+ # (zero compression is no faster, just 17x bigger).
28
43
  PNG_QUALITY = 80
29
44
  _HEADER_LEN = struct.Struct(">I")
30
45
 
@@ -36,6 +51,82 @@ def encode_image(image: QImage, image_format: str = "PNG", quality: int = -1) ->
36
51
  return bytes(buffer.data()) # ty: ignore[invalid-argument-type]
37
52
 
38
53
 
54
+ class _PyBuffer(ctypes.Structure):
55
+ """CPython's Py_buffer — to borrow the address of a read-only
56
+ memoryview (ctypes' from_buffer insists on a writable one)."""
57
+
58
+ _fields_ = [
59
+ ("buf", ctypes.c_void_p),
60
+ ("obj", ctypes.c_void_p),
61
+ ("len", ctypes.c_ssize_t),
62
+ ("itemsize", ctypes.c_ssize_t),
63
+ ("readonly", ctypes.c_int),
64
+ ("ndim", ctypes.c_int),
65
+ ("format", ctypes.c_char_p),
66
+ ("shape", ctypes.c_void_p),
67
+ ("strides", ctypes.c_void_p),
68
+ ("suboffsets", ctypes.c_void_p),
69
+ ("internal", ctypes.c_void_p),
70
+ ]
71
+
72
+
73
+ _get_buffer = ctypes.pythonapi.PyObject_GetBuffer
74
+ _get_buffer.argtypes = (ctypes.py_object, ctypes.POINTER(_PyBuffer), ctypes.c_int)
75
+ _get_buffer.restype = ctypes.c_int
76
+ _release_buffer = ctypes.pythonapi.PyBuffer_Release
77
+ _release_buffer.argtypes = (ctypes.POINTER(_PyBuffer),)
78
+ _release_buffer.restype = None
79
+
80
+
81
+ class _BufferAddress:
82
+ """Context manager yielding the address of a memoryview's bytes."""
83
+
84
+ def __init__(self, view: bytes | bytearray | memoryview) -> None:
85
+ self._view = view
86
+ self._buffer = _PyBuffer()
87
+
88
+ def __enter__(self) -> int:
89
+ _get_buffer(self._view, ctypes.byref(self._buffer), 0) # raises on failure
90
+ return self._buffer.buf or 0
91
+
92
+ def __exit__(self, *_exc_info: object) -> None:
93
+ _release_buffer(ctypes.byref(self._buffer))
94
+
95
+
96
+ def _load_memcmp() -> Callable[[int, int, int], int] | None:
97
+ try:
98
+ if sys.platform == "win32":
99
+ lib = ctypes.cdll.msvcrt
100
+ else:
101
+ name = ctypes.util.find_library("c")
102
+ if name is None:
103
+ return None
104
+ lib = ctypes.CDLL(name)
105
+ fn = lib.memcmp
106
+ except (OSError, AttributeError):
107
+ return None
108
+ fn.restype = ctypes.c_int
109
+ fn.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t)
110
+ return fn
111
+
112
+
113
+ _memcmp = _load_memcmp()
114
+
115
+
116
+ def _scan_bands(height: int, differs: Callable[[int, int], bool]) -> list[tuple[int, int]]:
117
+ bands: list[tuple[int, int]] = []
118
+ y = 0
119
+ while y < height:
120
+ h = min(BAND_HEIGHT, height - y)
121
+ if differs(y, h):
122
+ if bands and bands[-1][0] + bands[-1][1] == y:
123
+ bands[-1] = (bands[-1][0], bands[-1][1] + h)
124
+ else:
125
+ bands.append((y, h))
126
+ y += h
127
+ return bands
128
+
129
+
39
130
  def changed_bands(previous: QImage, current: QImage) -> list[tuple[int, int]] | None:
40
131
  """Bands of `current` that differ from `previous`, as (y, height) pairs.
41
132
 
@@ -49,17 +140,51 @@ def changed_bands(previous: QImage, current: QImage) -> list[tuple[int, int]] |
49
140
  cur_bits = current.constBits()
50
141
  stride = current.bytesPerLine()
51
142
  height = current.height()
52
- bands: list[tuple[int, int]] = []
53
- y = 0
54
- while y < height:
143
+ if _memcmp is not None:
144
+ memcmp = _memcmp
145
+ with _BufferAddress(prev_bits) as prev_addr, _BufferAddress(cur_bits) as cur_addr:
146
+ return _scan_bands(
147
+ height,
148
+ lambda y, h: memcmp(prev_addr + y * stride, cur_addr + y * stride, h * stride)
149
+ != 0,
150
+ )
151
+ return _changed_bands_python(prev_bits, cur_bits, stride, height)
152
+
153
+
154
+ def _changed_bands_python(
155
+ prev_bits: bytes | bytearray | memoryview,
156
+ cur_bits: bytes | bytearray | memoryview,
157
+ stride: int,
158
+ height: int,
159
+ ) -> list[tuple[int, int]]:
160
+ """The portable fallback (and the reference the memcmp path is tested against)."""
161
+ return _scan_bands(
162
+ height,
163
+ lambda y, h: prev_bits[y * stride : (y + h) * stride]
164
+ != cur_bits[y * stride : (y + h) * stride],
165
+ )
166
+
167
+
168
+ def merge_bands(
169
+ first: Iterable[tuple[int, int]], second: Iterable[tuple[int, int]], height: int
170
+ ) -> list[tuple[int, int]]:
171
+ """The union of two band lists (as `changed_bands` produces them),
172
+ normalized: sorted, adjacent bands merged, clipped to `height`."""
173
+ rows: set[int] = set()
174
+ for bands in (first, second):
175
+ for y, h in bands:
176
+ rows.update(range(y // BAND_HEIGHT, (y + h + BAND_HEIGHT - 1) // BAND_HEIGHT))
177
+ merged: list[tuple[int, int]] = []
178
+ for row in sorted(rows):
179
+ y = row * BAND_HEIGHT
55
180
  h = min(BAND_HEIGHT, height - y)
56
- if prev_bits[y * stride : (y + h) * stride] != cur_bits[y * stride : (y + h) * stride]:
57
- if bands and bands[-1][0] + bands[-1][1] == y:
58
- bands[-1] = (bands[-1][0], bands[-1][1] + h)
59
- else:
60
- bands.append((y, h))
61
- y += h
62
- return bands
181
+ if h <= 0:
182
+ continue
183
+ if merged and merged[-1][0] + merged[-1][1] == y:
184
+ merged[-1] = (merged[-1][0], merged[-1][1] + h)
185
+ else:
186
+ merged.append((y, h))
187
+ return merged
63
188
 
64
189
 
65
190
  def encode_delta(image: QImage, bands: list[tuple[int, int]]) -> bytes:
@@ -108,3 +233,92 @@ def apply_delta(canvas: QImage, payload: bytes) -> QImage | None:
108
233
  finally:
109
234
  painter.end()
110
235
  return canvas
236
+
237
+
238
+ @dataclass
239
+ class EncodeResult:
240
+ """What a FrameEncoder job produced. `deltas` maps each requested band
241
+ tuple to its payload. `failed` means nothing usable was produced (the
242
+ encode raised) — the sender should start its viewers over from a
243
+ keyframe."""
244
+
245
+ image: QImage | None
246
+ keyframe: bytes | None
247
+ deltas: dict[tuple[tuple[int, int], ...], bytes]
248
+ seconds: float
249
+ failed: bool = False
250
+ thread: str = ""
251
+
252
+
253
+ class FrameEncoder(QObject):
254
+ """PNG-encodes one frame at a time on a worker thread.
255
+
256
+ The one place the sharing pipeline leaves the GUI thread: encoding a
257
+ changed 4K frame costs tens to a hundred milliseconds, which on the GUI
258
+ thread would delay every incoming input event by that much. PySide6
259
+ releases the GIL inside QImage.save, so the worker really runs in
260
+ parallel. `submit` accepts one job while idle (`busy`); the result comes
261
+ back on the GUI thread through `encoded`. Nothing is shared with the
262
+ worker but the (immutable, implicitly shared) QImage.
263
+ """
264
+
265
+ encoded = Signal(object) # EncodeResult, delivered on the GUI thread
266
+ _finished = Signal(object) # worker -> GUI hop
267
+
268
+ def __init__(self, parent: QObject | None = None) -> None:
269
+ super().__init__(parent)
270
+ self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="frame-encoder")
271
+ self._busy = False
272
+ self._closed = False
273
+ self._finished.connect(self._on_finished)
274
+
275
+ @property
276
+ def busy(self) -> bool:
277
+ return self._busy
278
+
279
+ def submit(
280
+ self, image: QImage, *, keyframe: bool, deltas: Iterable[tuple[tuple[int, int], ...]]
281
+ ) -> None:
282
+ if self._busy:
283
+ raise RuntimeError("an encode is already in flight")
284
+ self._busy = True
285
+ future = self._executor.submit(self._encode, image, keyframe, tuple(deltas))
286
+ future.add_done_callback(self._on_done)
287
+
288
+ @staticmethod
289
+ def _encode(
290
+ image: QImage, keyframe: bool, deltas: tuple[tuple[tuple[int, int], ...], ...]
291
+ ) -> EncodeResult:
292
+ started = time.perf_counter()
293
+ png = encode_image(image, "PNG", PNG_QUALITY) if keyframe else None
294
+ payloads = {bands: encode_delta(image, list(bands)) for bands in deltas}
295
+ return EncodeResult(
296
+ image,
297
+ png,
298
+ payloads,
299
+ time.perf_counter() - started,
300
+ thread=threading.current_thread().name,
301
+ )
302
+
303
+ def _on_done(self, future: Future) -> None: # runs on the worker thread
304
+ if future.cancelled():
305
+ return
306
+ error = future.exception()
307
+ if error is not None:
308
+ _log.error("Frame encode failed: %r", error)
309
+ result = EncodeResult(None, None, {}, 0.0, failed=True)
310
+ else:
311
+ result = future.result()
312
+ if not self._closed:
313
+ self._finished.emit(result)
314
+
315
+ def _on_finished(self, result: EncodeResult) -> None: # GUI thread
316
+ self._busy = False
317
+ if not self._closed:
318
+ self.encoded.emit(result)
319
+
320
+ def close(self) -> None:
321
+ """Stop accepting jobs and wait for any in-flight encode, so no
322
+ result is ever delivered to a torn-down owner."""
323
+ self._closed = True
324
+ self._executor.shutdown(wait=True, cancel_futures=True)
remotedesktop/sharing.py CHANGED
@@ -44,9 +44,9 @@ from remotedesktop.config import (
44
44
  )
45
45
  from remotedesktop.clipboard import describe_payload
46
46
  from remotedesktop.discovery import DEFAULT_CONNECT_PORT
47
- from remotedesktop import dxgi, frames
47
+ from remotedesktop import backlog, dxgi, frames
48
48
  from remotedesktop.input_injection import InputInjector
49
- from remotedesktop.performance import PerformanceMonitor
49
+ from remotedesktop.performance import PerformanceMonitor, format_rate
50
50
  from remotedesktop.protocol import MAX_PAYLOAD, PROTOCOL_VERSION, MessageStream
51
51
  from remotedesktop import db, tls
52
52
 
@@ -56,12 +56,14 @@ _log = logging.getLogger("remotedesktop.sharing")
56
56
  # Affordable since inter-frame compression: an unchanged screen costs only
57
57
  # a capture and a memory compare per tick — nothing is encoded or sent.
58
58
  DEFAULT_FPS = 30
59
- # Skip sending to a client whose socket buffer is this far behind. Unsent
60
- # bytes are queued latency (the client renders them all before showing
61
- # anything current), so the cap is kept tight: ~160 ms of queue on 100 Mbit
62
- # WiFi, with headroom above one 4K keyframe (~1 MB) so a fresh keyframe
63
- # never trips it by itself.
64
- _MAX_SEND_BACKLOG = 2 * 1024 * 1024
59
+ # A viewer whose socket holds more unsent bytes than its BacklogGovernor's
60
+ # cap (backlog.py — sized from the link's measured throughput and latency)
61
+ # is skipped for the tick; the bands it missed accumulate and ship as one
62
+ # merged delta once it drains. Brief withholding is normal flow control, so
63
+ # only a viewer that stays behind this long is reported in the status log.
64
+ _BACKLOG_REPORT_SECONDS = 1.0
65
+ # Cadence of the debug-log summary of encode timings and flow control.
66
+ _STATS_INTERVAL_SECONDS = 10.0
65
67
  # Until a client passes the approval handshake it may only send small
66
68
  # messages (a hello is well under 1 KB); the cap is lifted on admission.
67
69
  _PREAUTH_MAX_PAYLOAD = 64 * 1024
@@ -156,13 +158,33 @@ class ShareServer(QObject):
156
158
  self._final: set[MessageStream] = set()
157
159
  # What each controlling stream currently holds down: (buttons, vks).
158
160
  self._pressed: dict[MessageStream, tuple[set[str], set[int]]] = {}
159
- # Streams currently too far behind to receive frames (see
160
- # _broadcast_frame); entering/leaving this set emits a status message.
161
+ # Flow control per admitted stream (see _ready_streams): its backlog
162
+ # governor, when it first fell behind in the current episode, the
163
+ # last RTT sample fed to the governor, and the streams whose "not
164
+ # keeping up" status line is currently outstanding.
165
+ self._governors: dict[MessageStream, backlog.BacklogGovernor] = {}
166
+ self._backlog_since: dict[MessageStream, float] = {}
167
+ self._last_rtt_fed: dict[MessageStream, float] = {}
161
168
  self._backlogged: set[MessageStream] = set()
162
169
  # Inter-frame compression state: which streams need a full keyframe
163
- # next broadcast (just admitted, just caught up after a backlog, or
164
- # asked for one), and the previous capture to diff.
170
+ # next broadcast (just admitted, or asked for one), the bands each
171
+ # stream is still owed (accumulated while it was behind), and the
172
+ # previous capture to diff against.
165
173
  self._needs_keyframe: set[MessageStream] = set()
174
+ self._dirty: dict[MessageStream, list[tuple[int, int]]] = {}
175
+ # Encoding runs off the GUI thread; at most one job is in flight and
176
+ # _takers records who gets its output (keyframe takers, delta takers
177
+ # by band tuple). Ticks that land while it runs skip the capture.
178
+ self._encoder = frames.FrameEncoder(self)
179
+ self._encoder.encoded.connect(self._on_encoded)
180
+ self._takers: tuple[list[MessageStream], dict[tuple, list[MessageStream]]] | None = None
181
+ # Debug-log summary counters (see _log_stats).
182
+ self._stats_since: float | None = None
183
+ self._encodes = 0
184
+ self._encode_seconds = 0.0
185
+ self._encode_max = 0.0
186
+ self._ticks_skipped = 0
187
+ self._frames_withheld = 0
166
188
  # What each stream's hello said about the machine behind it
167
189
  # (name/user/host/os), for the server UI's viewers table.
168
190
  self._viewer_info: dict[MessageStream, dict] = {}
@@ -243,7 +265,17 @@ class ShareServer(QObject):
243
265
  self._final.clear()
244
266
  self._pressed.clear()
245
267
  self._backlogged.clear()
268
+ self._backlog_since.clear()
269
+ self._last_rtt_fed.clear()
270
+ self._governors.clear()
246
271
  self._needs_keyframe.clear()
272
+ self._dirty.clear()
273
+ self._takers = None
274
+ # Waits for an in-flight encode so nothing lands after teardown; a
275
+ # fresh encoder keeps a re-listen (tests) working.
276
+ self._encoder.close()
277
+ self._encoder = frames.FrameEncoder(self)
278
+ self._encoder.encoded.connect(self._on_encoded)
247
279
  self._viewer_info.clear()
248
280
  self._previous_frame = None
249
281
  self._cursor_shape = None
@@ -480,6 +512,8 @@ class ShareServer(QObject):
480
512
  stream.max_payload = MAX_PAYLOAD # pre-auth cap lifted once approved
481
513
  stream.send_json(welcome)
482
514
  self._needs_keyframe.add(stream) # its first frame must be a full one
515
+ self._dirty[stream] = []
516
+ self._governors[stream] = backlog.BacklogGovernor()
483
517
  self._streams.append(stream)
484
518
  self._broadcast_cursor(new_stream=stream) # start with the right cursor
485
519
  self._broadcast_lock(new_stream=stream) # tell it if the session is locked
@@ -597,6 +631,10 @@ class ShareServer(QObject):
597
631
  self._all_streams.discard(stream)
598
632
  self._prompting.discard(stream)
599
633
  self._backlogged.discard(stream)
634
+ self._backlog_since.pop(stream, None)
635
+ self._last_rtt_fed.pop(stream, None)
636
+ self._governors.pop(stream, None)
637
+ self._dirty.pop(stream, None)
600
638
  self._needs_keyframe.discard(stream)
601
639
  self._viewer_info.pop(stream, None)
602
640
  self._release_input(stream)
@@ -711,67 +749,165 @@ class ShareServer(QObject):
711
749
  elif new_stream is not None and locked:
712
750
  new_stream.send_json({"type": "session_lock", "locked": True})
713
751
 
752
+ def _send_cap(self, stream: MessageStream) -> int:
753
+ """Unsent bytes `stream` may have queued before frames are withheld
754
+ from it (its governor's cap; a seam for tests)."""
755
+ governor = self._governors.get(stream)
756
+ return governor.cap if governor is not None else backlog.CAP_MIN
757
+
758
+ def _ready_streams(self, now: float) -> list[MessageStream]:
759
+ """The admitted streams that can take a frame this tick.
760
+
761
+ Feeds every stream's backlog governor (unsent bytes, bytes handed to
762
+ the socket, and the latest in-band RTT when a monitor is attached)
763
+ and withholds frames from streams over their cap. A stream that
764
+ stays behind for _BACKLOG_REPORT_SECONDS gets one status line, and
765
+ one more when it catches up.
766
+ """
767
+ ready: list[MessageStream] = []
768
+ for stream in self._streams:
769
+ pending = stream.socket.bytesToWrite()
770
+ governor = self._governors[stream]
771
+ governor.observe(pending, stream.bytes_sent)
772
+ if self._performance is not None:
773
+ rtt = self._performance.metrics_for(stream).get("rtt_ms")
774
+ if rtt is not None and rtt != self._last_rtt_fed.get(stream):
775
+ self._last_rtt_fed[stream] = rtt
776
+ governor.observe_rtt(rtt)
777
+ cap = self._send_cap(stream)
778
+ if pending > cap:
779
+ self._frames_withheld += 1
780
+ since = self._backlog_since.setdefault(stream, now)
781
+ if stream not in self._backlogged and now - since >= _BACKLOG_REPORT_SECONDS:
782
+ self._backlogged.add(stream)
783
+ self.status.emit(
784
+ f"Viewer at {_peer(stream.socket)} is not keeping up "
785
+ f"({humanize.naturalsize(pending, binary=True)} unsent, cap "
786
+ f"{humanize.naturalsize(cap, binary=True)}) — withholding frames "
787
+ "until it drains"
788
+ )
789
+ continue
790
+ self._backlog_since.pop(stream, None)
791
+ if stream in self._backlogged:
792
+ self._backlogged.discard(stream)
793
+ self.status.emit(f"Viewer at {_peer(stream.socket)} caught up — resuming frames")
794
+ ready.append(stream)
795
+ return ready
796
+
714
797
  def _broadcast_frame(self) -> None:
715
798
  if not self._streams:
716
799
  return
717
800
  self._broadcast_cursor() # polled at the frame rate, sent on change
718
801
  self._broadcast_lock() # likewise
802
+ now = time.monotonic()
803
+ self._log_stats(now)
804
+ if self._encoder.busy:
805
+ # Last tick's encode is still running: skip the capture (DXGI
806
+ # keeps accumulating changes) so the next tick diffs everything
807
+ # that changed since the frame being encoded.
808
+ self._ticks_skipped += 1
809
+ return
719
810
  image = self._capture()
720
811
  if image is None:
721
812
  self.status.emit("Screen capture failed (null image)")
722
813
  return
723
814
  if image is self._previous_frame:
724
- # DXGI reported no change since the last tick: skip the diff.
725
- bands: list[tuple[int, int]] | None = []
815
+ bands: list[tuple[int, int]] = [] # DXGI: unchanged since the last tick
726
816
  else:
727
- # bands is None when there is no comparable previous capture
728
- # (first frame, or the resolution changed): everyone gets a
729
- # full frame.
730
- bands = (
817
+ changed = (
731
818
  frames.changed_bands(self._previous_frame, image)
732
819
  if self._previous_frame is not None
733
820
  else None
734
821
  )
822
+ if changed is None:
823
+ # No comparable previous capture (first frame, or the
824
+ # resolution changed): everyone starts over from a keyframe.
825
+ self._needs_keyframe.update(self._streams)
826
+ bands = []
827
+ else:
828
+ bands = changed
735
829
  self._previous_frame = image
736
- # Encode each variant at most once per tick, shared by all takers.
737
- keyframe_png: bytes | None = None
738
- delta_payload: bytes | None = None
739
- for stream in self._streams:
740
- if stream.socket.bytesToWrite() > _MAX_SEND_BACKLOG:
741
- # Client is not keeping up; drop frames for it (and say so —
742
- # to the viewer this looks like a frozen or flaky connection).
743
- # Its canvas will be stale once it catches up, so it must
744
- # restart from a keyframe.
745
- self._needs_keyframe.add(stream)
746
- if stream not in self._backlogged:
747
- self._backlogged.add(stream)
748
- unsent = humanize.naturalsize(
749
- stream.socket.bytesToWrite(), binary=True
750
- )
751
- self.status.emit(
752
- f"Viewer at {_peer(stream.socket)} is not keeping up "
753
- f"({unsent} unsent) — dropping frames for it"
754
- )
830
+ if bands:
831
+ height = image.height()
832
+ for stream in self._streams:
833
+ if stream not in self._needs_keyframe:
834
+ self._dirty[stream] = frames.merge_bands(self._dirty[stream], bands, height)
835
+ keyframe_takers: list[MessageStream] = []
836
+ delta_takers: dict[tuple, list[MessageStream]] = {}
837
+ for stream in self._ready_streams(now):
838
+ if stream in self._needs_keyframe:
839
+ keyframe_takers.append(stream)
840
+ self._dirty[stream] = [] # a keyframe supersedes anything owed
841
+ elif self._dirty[stream]:
842
+ owed = tuple(self._dirty[stream])
843
+ self._dirty[stream] = []
844
+ delta_takers.setdefault(owed, []).append(stream)
845
+ if not keyframe_takers and not delta_takers:
846
+ return # nothing changed for anyone who could take it
847
+ self._takers = (keyframe_takers, delta_takers)
848
+ self._encoder.submit(image, keyframe=bool(keyframe_takers), deltas=delta_takers)
849
+
850
+ def _on_encoded(self, result: frames.EncodeResult) -> None:
851
+ takers, self._takers = self._takers, None
852
+ if result.failed or takers is None:
853
+ # Nothing reached the viewers; forget the baseline so the next
854
+ # tick starts everyone over from a keyframe.
855
+ self._previous_frame = None
856
+ return
857
+ keyframe_takers, delta_takers = takers
858
+ self._encodes += 1
859
+ self._encode_seconds += result.seconds
860
+ self._encode_max = max(self._encode_max, result.seconds)
861
+ if result.keyframe is not None:
862
+ _log.debug(
863
+ "Keyframe: %s PNG (%.0f ms encode)",
864
+ humanize.naturalsize(len(result.keyframe), binary=True),
865
+ result.seconds * 1000,
866
+ )
867
+ for stream in keyframe_takers:
868
+ if stream in self._streams:
869
+ stream.send_frame(result.keyframe)
870
+ self._needs_keyframe.discard(stream)
871
+ for owed, streams in delta_takers.items():
872
+ payload = result.deltas.get(owed)
873
+ if payload is None:
755
874
  continue
756
- if stream in self._backlogged:
757
- self._backlogged.discard(stream)
758
- self.status.emit(
759
- f"Viewer at {_peer(stream.socket)} caught up — resuming frames"
760
- )
761
- if stream in self._needs_keyframe or bands is None:
762
- if keyframe_png is None:
763
- keyframe_png = frames.encode_image(image, "PNG", frames.PNG_QUALITY)
764
- _log.debug(
765
- "Keyframe: %s PNG",
766
- humanize.naturalsize(len(keyframe_png), binary=True),
767
- )
768
- stream.send_frame(keyframe_png)
769
- self._needs_keyframe.discard(stream)
770
- elif bands:
771
- if delta_payload is None:
772
- delta_payload = frames.encode_delta(image, bands)
773
- stream.send_delta(delta_payload)
774
- # else: nothing changed since the last frame — send nothing.
875
+ for stream in streams:
876
+ if stream in self._streams:
877
+ stream.send_delta(payload)
878
+
879
+ def _log_stats(self, now: float) -> None:
880
+ """A debug-log line every _STATS_INTERVAL_SECONDS while frames flow:
881
+ encode timings and flow control, plus each viewer's link estimate."""
882
+ if self._stats_since is None:
883
+ self._stats_since = now
884
+ return
885
+ if now - self._stats_since < _STATS_INTERVAL_SECONDS:
886
+ return
887
+ if self._encodes or self._ticks_skipped or self._frames_withheld:
888
+ mean_ms = self._encode_seconds / self._encodes * 1000 if self._encodes else 0.0
889
+ links = ", ".join(
890
+ f"{_peer(stream.socket)} ~{format_rate(governor.throughput)} "
891
+ f"cap {humanize.naturalsize(governor.cap, binary=True)}"
892
+ for stream, governor in self._governors.items()
893
+ )
894
+ _log.debug(
895
+ "Frames: %d encoded in %.0f s (mean %.0f ms, max %.0f ms), "
896
+ "%d tick(s) skipped while encoding, %d frame(s) withheld for backlog; %s",
897
+ self._encodes,
898
+ now - self._stats_since,
899
+ mean_ms,
900
+ self._encode_max * 1000,
901
+ self._ticks_skipped,
902
+ self._frames_withheld,
903
+ links or "no viewers",
904
+ )
905
+ self._stats_since = now
906
+ self._encodes = 0
907
+ self._encode_seconds = 0.0
908
+ self._encode_max = 0.0
909
+ self._ticks_skipped = 0
910
+ self._frames_withheld = 0
775
911
 
776
912
 
777
913
  class ShareClient(QObject):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: remotedesktop
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
5
5
  Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
6
6
  Project-URL: Repository, https://github.com/jamesabel/remotedesktop
@@ -84,7 +84,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
84
84
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
85
85
  - 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
86
86
  - 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
87
- - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt. Connections that were open when the app closed are restored on the next start.
87
+ - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt (and choosing the moved server from the discovery list by hand reuses that pairing too). Connections that were open when the app closed are restored on the next start.
88
88
  - 🚀 **Hands-off operation** — start-at-login (per-user, no admin rights) with a choice of minimized (the default and recommended — sharing resumes after a reboot with no clicks), normal, maximized, or not starting at all; close-to-tray while sharing (the screen stays available with the window closed); and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
89
89
  - 🗃️ **Persistent peer inventory** — a SQLite-backed history of every peer seen on the LAN, with one-click *Revoke* / *Forget*.
90
90
  - 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts (Preferences on *File ▸ Preferences*, Ctrl+,; About on the Help menu), a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
@@ -190,12 +190,26 @@ the pointer you see is always sharp and moves with zero latency.
190
190
  **Screen transfer.** Frames are captured at up to 30 fps and compared with
191
191
  the previous capture in 64-row bands; only the bands that changed are
192
192
  encoded — losslessly, as PNG — and sent as a delta the client patches onto
193
- its last frame. An unchanged screen sends nothing at all. Full PNG
194
- keyframes go to clients that just connected, fell behind (a client whose
195
- socket backlog grows gets frames dropped, then a fresh keyframe once it
196
- catches up), or asked for one because a delta failed to apply — so a
197
- desynced stream heals itself. The frame always travels at the server's
198
- full resolution; scaling to the viewer window happens on the client.
193
+ its last frame. An unchanged screen sends nothing at all. The comparison
194
+ is a C memory compare per band (a few milliseconds for a 4K frame), and
195
+ the PNG encoding — the expensive step — runs on a worker thread, so the
196
+ GUI thread that services the viewers' input never waits on it; a tick
197
+ that arrives mid-encode is simply skipped. Full PNG keyframes go to
198
+ clients that just connected or asked for one because a delta failed to
199
+ apply — so a desynced stream heals itself. The frame always travels at
200
+ the server's full resolution; scaling to the viewer window happens on the
201
+ client.
202
+
203
+ **Flow control.** Unsent bytes queued in a viewer's socket are latency the
204
+ viewer will see, so each viewer has a cap on how much may be queued before
205
+ frames are withheld from it. The cap adapts to the link: the server
206
+ measures how fast that viewer's socket drains and allows about 50 ms
207
+ worth, starting from a 100 Mbit/s assumption (the app assumes a modern
208
+ LAN — at least 100 Mbit/s, usually a wired gigabit connection) and
209
+ tightening further if the round-trip time rises above its usual floor.
210
+ Withheld frames aren't lost: the bands the viewer missed accumulate and
211
+ ship as one merged delta once it drains, so a slow link gets fewer, larger
212
+ updates instead of a stale picture or a fresh keyframe.
199
213
 
200
214
  **Input injection.** The client's viewer widget captures your mouse and
201
215
  keyboard events, maps mouse positions to coordinates normalized 0..1 over
@@ -254,17 +268,26 @@ fingerprint. The fingerprint is the server's stable identity: when a
254
268
  reconnecting client can no longer reach a server at its last known address
255
269
  (typically a new DHCP lease after a reboot), it re-scans and matches the
256
270
  fingerprint to follow the server to its new address, keeping the stored
257
- pairing token so no fresh approval is needed. Windows Firewall
271
+ pairing token so no fresh approval is needed. The same applies when you
272
+ pick a server from the discovery list by hand: if its fingerprint matches
273
+ a pairing stored under an old address, that pairing moves to the new
274
+ address and the connection is admitted without a prompt. Windows Firewall
258
275
  must allow Python to receive inbound UDP on that port for a server to be
259
276
  discoverable from other machines.
260
277
 
261
278
  ## Development
262
279
 
263
280
  ```
264
- uv sync # set up the environment
265
- uv run pytest # run the tests
281
+ uv sync # set up the environment
282
+ uv run pytest # run the tests
283
+ uv run ruff check . # lint
284
+ uv run ty check . # type-check
266
285
  ```
267
286
 
287
+ All three are required checks on every pull request. `ruff` and `ty` are
288
+ pinned in `uv.lock`; Dependabot opens a weekly PR when newer versions are
289
+ available.
290
+
268
291
  Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
269
292
  MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
270
293
  From PowerShell, Qt uses the Windows schannel backend as intended.
@@ -1,17 +1,18 @@
1
- remotedesktop/__init__.py,sha256=u2ClDvV49VmCDrIduAONdKUtfp5pewVgOStW3k8-2eU,100
1
+ remotedesktop/__init__.py,sha256=E6Bun2Lfg0bxKy4XTduKyNXMm3G39vQcaqjeIFqUnag,100
2
2
  remotedesktop/__main__.py,sha256=m4gvhT01Wd3_kNl8lNQihy7eG2O3mYBbYP__AwBnyos,158
3
3
  remotedesktop/about.py,sha256=gofXchMgQ2Sikv1kFBIl42M4L-LY4bFKMARd5HeEyio,3701
4
- remotedesktop/app.py,sha256=7_TakYMXX0ZRD74MRQWK6ni2fpAtlANGfmB_1cj7kTM,74790
4
+ remotedesktop/app.py,sha256=rXDcjyp2mRiYd9C7V7o8l5AJqRzi6pbfGEUQv-mTFPc,77295
5
5
  remotedesktop/autostart.py,sha256=pxpKpm_4IvH73ZbAbzM1TOQVKyRnm7DxtcqRDWvpw2Q,4539
6
+ remotedesktop/backlog.py,sha256=Fbu3YHMFyvQVsTeCttu781TtQX8LUv0V5JzjW2yATSQ,5071
6
7
  remotedesktop/client.py,sha256=PgmGDHgrWWeiyrT8OZQr9E7hFQV_NoS7mfK1YsnXlkM,10086
7
8
  remotedesktop/clipboard.py,sha256=x2OBCn8xtLFyvAjvLflJYr85aB0tiqWN30ZhlMHc6QU,14013
8
9
  remotedesktop/compat.py,sha256=i9_iobDCluK-7QXm-ZkXBCU3Qt0GTb0LobZ8XuyK9uk,1694
9
- remotedesktop/config.py,sha256=biU9FQHxB9oXFivIAhZVM2eeL6lUEpbB7cEcCGfWt_8,5022
10
+ remotedesktop/config.py,sha256=gkmycXVT1wkRsM-LMckycBHXiK0fXbn01ma3PEG3zek,5595
10
11
  remotedesktop/cursor_shape.py,sha256=kgcAeM-4CebkmtF3c_quhZ9ZIq3sn7sw_YvydzW6LBU,3369
11
12
  remotedesktop/db.py,sha256=ppV366kZjEpqNXTuRrrBIE976cSoUddx3WSroygXeow,1732
12
13
  remotedesktop/discovery.py,sha256=CzLOUo6L1xwmEmgIBs1WKFRZAyCMg6iqZNliwQdbo0g,7540
13
14
  remotedesktop/dxgi.py,sha256=-2fFsBcmbE3ps3_HBdP42D0LUKpZyqBjCZqdEgWFkAU,13573
14
- remotedesktop/frames.py,sha256=aUdOoWG7HoozKfD4Z1pVNBnmNoP7DAGGQEYgokSL0rM,4617
15
+ remotedesktop/frames.py,sha256=1XnoGhh5lgTCGV5jd9gbyppHB_p0AOzKUM1WS8xrXqw,12319
15
16
  remotedesktop/icon.py,sha256=6Gfqj2liZIj7wC_vdv-gMHr2uFupRYbQMntHXXjTBgU,2476
16
17
  remotedesktop/input_injection.py,sha256=p-MRpnS7WDt5S8JK4gNC5cgAjM9DMqV-kzqJkLdtKTw,4581
17
18
  remotedesktop/inventory.py,sha256=3KIjJdLzJ2Dg0SC_NhDBcb0Ag2-69ZNoRci9ht7vmJg,9126
@@ -22,14 +23,14 @@ remotedesktop/preferences.py,sha256=mVKnR8fgWliyM5b5V7FXVBQH7jW15V4W2L93xwA9LMw,
22
23
  remotedesktop/protocol.py,sha256=Q-5n-s0YmZB7yzlICHzyc73JLvax-SO__5c7Bjg5xGw,3975
23
24
  remotedesktop/server.py,sha256=wlE-sQtuveW97ErcB-tl0TfSNqtyLxzLQnqvRzeflT4,17769
24
25
  remotedesktop/session_lock.py,sha256=W5_MGYVgn3fnWT7Nzgxtt-aqJsnvyrOyfG2aCZTFcnM,1881
25
- remotedesktop/sharing.py,sha256=HDFCWnONftVbp7s_R9-_DUzj_q1Q7PTKRYGSJPt2u0c,49282
26
+ remotedesktop/sharing.py,sha256=LiTn9aBzz-J3bIKWMcE_qgf_xuZ3EIWha9Gu8jg_Cc4,55948
26
27
  remotedesktop/single_instance.py,sha256=G14dXIKctGFv0CvyCjdbeqkXTdW2y88MS5FEgxZNFz8,3002
27
28
  remotedesktop/tls.py,sha256=AGrBpDq1gdjQm7TZ74y2W5-RxyEcbsFDPlNdRvadaAk,3934
28
29
  remotedesktop/viewer.py,sha256=arrIpQIazqhbWlElAbXyqeHaHZ0JqxlNbPwavpPixyo,14545
29
30
  remotedesktop/visual_effects.py,sha256=NQPb6qpwlW6SUycYbw-7mZOOmlPzC5YsjolwcxNukGI,7468
30
31
  remotedesktop/window_state.py,sha256=LvTGg5ZFSuDmISxYU0xotrGZbL89oVqKo8LI7xQGKok,1579
31
- remotedesktop-2.2.0.dist-info/METADATA,sha256=I7vDfRLUDtj-9KflaEYJHTYMdJc9KvlkNaJ4WCNSym8,17766
32
- remotedesktop-2.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
33
- remotedesktop-2.2.0.dist-info/entry_points.txt,sha256=t079osY5GlKUJP9JU5JVXlKrSrplLalw2koAslXWQWc,53
34
- remotedesktop-2.2.0.dist-info/licenses/LICENSE,sha256=6rWgMfDohGLDuJ0UFOyLphM28Nn0fuN26MczIQldMkc,1067
35
- remotedesktop-2.2.0.dist-info/RECORD,,
32
+ remotedesktop-2.4.0.dist-info/METADATA,sha256=__kvGAJFIsOHPabymZX7CuvfNeDbdf0YwfuQ4a_XlTc,19173
33
+ remotedesktop-2.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
34
+ remotedesktop-2.4.0.dist-info/entry_points.txt,sha256=t079osY5GlKUJP9JU5JVXlKrSrplLalw2koAslXWQWc,53
35
+ remotedesktop-2.4.0.dist-info/licenses/LICENSE,sha256=6rWgMfDohGLDuJ0UFOyLphM28Nn0fuN26MczIQldMkc,1067
36
+ remotedesktop-2.4.0.dist-info/RECORD,,