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 +1 -1
- remotedesktop/app.py +58 -10
- remotedesktop/backlog.py +103 -0
- remotedesktop/config.py +14 -0
- remotedesktop/frames.py +228 -14
- remotedesktop/sharing.py +192 -56
- {remotedesktop-2.2.0.dist-info → remotedesktop-2.4.0.dist-info}/METADATA +34 -11
- {remotedesktop-2.2.0.dist-info → remotedesktop-2.4.0.dist-info}/RECORD +11 -10
- {remotedesktop-2.2.0.dist-info → remotedesktop-2.4.0.dist-info}/WHEEL +0 -0
- {remotedesktop-2.2.0.dist-info → remotedesktop-2.4.0.dist-info}/entry_points.txt +0 -0
- {remotedesktop-2.2.0.dist-info → remotedesktop-2.4.0.dist-info}/licenses/LICENSE +0 -0
remotedesktop/__init__.py
CHANGED
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
|
-
|
|
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
|
-
|
|
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())
|
remotedesktop/backlog.py
ADDED
|
@@ -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
|
|
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
|
-
#
|
|
27
|
-
#
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return
|
|
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
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
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
|
-
#
|
|
160
|
-
#
|
|
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,
|
|
164
|
-
#
|
|
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
|
|
725
|
-
bands: list[tuple[int, int]] | None = []
|
|
815
|
+
bands: list[tuple[int, int]] = [] # DXGI: unchanged since the last tick
|
|
726
816
|
else:
|
|
727
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
757
|
-
self.
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
stream.
|
|
774
|
-
|
|
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.
|
|
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.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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
|
|
265
|
-
uv run pytest
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
32
|
-
remotedesktop-2.
|
|
33
|
-
remotedesktop-2.
|
|
34
|
-
remotedesktop-2.
|
|
35
|
-
remotedesktop-2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|