remotedesktop 0.17.0__tar.gz → 0.18.0__tar.gz

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.
Files changed (62) hide show
  1. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/CLAUDE.md +1 -1
  2. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/PKG-INFO +1 -1
  3. remotedesktop-0.18.0/docs/media/client-demo.gif +0 -0
  4. remotedesktop-0.18.0/docs/media/server-demo.gif +0 -0
  5. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/__init__.py +1 -1
  6. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/modal_loop.py +52 -8
  7. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/server.py +20 -4
  8. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_modal_loop.py +45 -1
  9. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_server_window.py +27 -0
  10. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tools/make_demo_gifs.py +13 -7
  11. remotedesktop-0.17.0/docs/media/client-demo.gif +0 -0
  12. remotedesktop-0.17.0/docs/media/server-demo.gif +0 -0
  13. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/.github/workflows/ci.yml +0 -0
  14. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/.gitignore +0 -0
  15. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/LICENSE +0 -0
  16. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/pyproject.toml +0 -0
  17. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/readme.md +0 -0
  18. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/run_claude.bat +0 -0
  19. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/run_client.bat +0 -0
  20. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/run_server.bat +0 -0
  21. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/scripts/make_coverage_badge.py +0 -0
  22. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/scripts/make_venv.bat +0 -0
  23. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/autostart.py +0 -0
  24. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/client.py +0 -0
  25. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/clipboard.py +0 -0
  26. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/config.py +0 -0
  27. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/db.py +0 -0
  28. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/discovery.py +0 -0
  29. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/dxgi.py +0 -0
  30. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/frames.py +0 -0
  31. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/icon.py +0 -0
  32. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/input_injection.py +0 -0
  33. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/inventory.py +0 -0
  34. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/logs.py +0 -0
  35. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/performance.py +0 -0
  36. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/preferences.py +0 -0
  37. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/protocol.py +0 -0
  38. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/sharing.py +0 -0
  39. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/tls.py +0 -0
  40. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/viewer.py +0 -0
  41. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/src/remotedesktop/window_state.py +0 -0
  42. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/conftest.py +0 -0
  43. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_autostart.py +0 -0
  44. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_client_window.py +0 -0
  45. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_clipboard.py +0 -0
  46. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_config.py +0 -0
  47. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_discovery.py +0 -0
  48. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_dxgi.py +0 -0
  49. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_frames.py +0 -0
  50. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_icon.py +0 -0
  51. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_input.py +0 -0
  52. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_input_injection.py +0 -0
  53. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_inventory.py +0 -0
  54. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_logs.py +0 -0
  55. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_performance.py +0 -0
  56. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_preferences.py +0 -0
  57. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_protocol.py +0 -0
  58. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_sharing.py +0 -0
  59. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_smoke.py +0 -0
  60. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_tls.py +0 -0
  61. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/tests/test_window_state.py +0 -0
  62. {remotedesktop-0.17.0 → remotedesktop-0.18.0}/uv.lock +0 -0
@@ -47,7 +47,7 @@ Managed with `uv` (hatchling build backend, src layout):
47
47
  - **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; the callback gets the selected peer's key. The server's tab wires "Revoke access" → `ShareServer.revoke_client(client_id)` (removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` reports state "revoked"; a `_revoked` set distinguishes it from a plain disconnect). The client's tab wires "Forget server" → `KnownServers.forget(key)` + disconnect. After either, the next connection needs a fresh approval. Server and client each use a *separate table* (`server_peers` / `client_peers`, selected via the `table=` arg) so a machine running both apps doesn't commingle them; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
48
48
  - Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
49
49
  - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
50
- - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for the server this deadlocked when a *remote* click grabbed the server window's own title bar (the mouse-up sat unread on the socket). `ServerWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). Tests inject a fake `timers` backend — never let them create real Win32 timers.
50
+ - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for the server this deadlocked when a *remote* click grabbed the server window's own title bar (the mouse-up sat unread on the socket). `ServerWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). The caption buttons (min/max/close) are the exception — their tracking loop dispatches only mouse messages, so no timer can pump inside it; the pump instead performs those via its `caption_action` callback on press and consumes the message (nativeEvent must return True when `handle_native_event` does), so DefWindowProc's button tracking never starts. Tests inject a fake `timers` backend — never let them create real Win32 timers.
51
51
  - **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the windows pass `logs.read_log_tail("server"/"client")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. Both windows have a "Get client log"/"Get server log" button in the Connection log tab; the reply opens a `logs.PeerLogDialog`. The server requests from the most recently admitted stream.
52
52
  - **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
53
53
  - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.17.0
3
+ Version: 0.18.0
4
4
  Summary: Remote desktop client/server for Windows computers on the same LAN, with autodiscovery. Provides screen, keyboard, mouse, and clipboard sharing without RDP or Microsoft authentication.
5
5
  Author-email: James Abel <j@abel.co>
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.17.0"
3
+ __version__ = "0.18.0"
@@ -17,9 +17,18 @@ callback pumps Qt events (user input excluded) every few milliseconds. A
17
17
  side benefit: screen sharing keeps streaming while the local user drags
18
18
  the server window.
19
19
 
20
+ The caption buttons (minimize/maximize/close) are the exception: their
21
+ tracking loop dispatches only mouse messages, so no timer — and therefore
22
+ no pump — can run inside it, and a remote click on the server's own
23
+ minimize button deadlocked just like the title-bar drag once did. Those
24
+ presses are instead handled here directly (`caption_action` callback) and
25
+ the message is consumed, so DefWindowProc's button tracking never starts:
26
+ the action happens on press, for local and remote clicks alike.
27
+
20
28
  Feed every message from the window's `nativeEvent` to
21
- `handle_native_event`; the pump is inert off Windows. Tests inject a fake
22
- `timers` backend so no native timer is ever created.
29
+ `handle_native_event` and consume the message when it returns True; the
30
+ pump is inert off Windows. Tests inject a fake `timers` backend so no
31
+ native timer is ever created.
23
32
  """
24
33
 
25
34
  import ctypes
@@ -33,9 +42,15 @@ from PySide6.QtCore import QCoreApplication, QEventLoop
33
42
  _log = logging.getLogger("remotedesktop.modal_loop")
34
43
 
35
44
  WM_NCLBUTTONDOWN = 0x00A1
45
+ WM_NCLBUTTONUP = 0x00A2
36
46
  WM_CAPTURECHANGED = 0x0215
37
47
  WM_ENTERSIZEMOVE = 0x0231
38
48
  WM_EXITSIZEMOVE = 0x0232
49
+ # WM_NCLBUTTONDOWN/-UP hit-test codes for the caption buttons.
50
+ HTMINBUTTON = 8
51
+ HTMAXBUTTON = 9
52
+ HTCLOSE = 20
53
+ _CAPTION_BUTTONS = (HTMINBUTTON, HTMAXBUTTON, HTCLOSE)
39
54
  _TIMER_ID = 0x5244 # arbitrary but stable; scoped to the window's hwnd
40
55
  _TIMER_INTERVAL_MS = 15 # comfortably under the 33 ms capture tick
41
56
 
@@ -65,21 +80,49 @@ class _NativeTimers:
65
80
 
66
81
  class ModalLoopPump:
67
82
  """Runs a native timer that pumps Qt events while a window of ours sits
68
- in the modal move/size loop."""
69
-
70
- def __init__(self, *, pump: Callable[[], None] | None = None, timers=None) -> None:
83
+ in the modal move/size loop, and short-circuits caption-button clicks
84
+ (whose tracking loop the timer cannot penetrate) to `caption_action`."""
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ pump: Callable[[], None] | None = None,
90
+ caption_action: Callable[[int], None] | None = None,
91
+ timers=None,
92
+ ) -> None:
71
93
  self._pump = pump if pump is not None else _pump_qt
94
+ self._caption_action = caption_action
72
95
  if timers is None and sys.platform == "win32":
73
96
  timers = _NativeTimers(self._on_timer)
74
97
  self._timers = timers # None: inert (non-Windows)
75
98
  self._hwnd: int | None = None
76
99
  self._pumping = False
77
100
 
78
- def handle_native_event(self, event_type, message) -> None:
79
- """Call from QWidget.nativeEvent with its arguments verbatim."""
101
+ def handle_native_event(self, event_type, message) -> bool:
102
+ """Call from QWidget.nativeEvent with its arguments verbatim.
103
+
104
+ Returns True when the message was handled here and must be consumed
105
+ (returned True from nativeEvent) instead of reaching DefWindowProc.
106
+ """
80
107
  if self._timers is None or bytes(event_type) != b"windows_generic_MSG":
81
- return
108
+ return False
82
109
  msg = wintypes.MSG.from_address(int(message))
110
+ # Minimize/maximize/close presses must never reach DefWindowProc:
111
+ # its caption-button tracking loop dispatches only mouse messages,
112
+ # so no timer can pump Qt inside it, and a remote press deadlocks
113
+ # exactly like the title-bar drag once did. Perform the action on
114
+ # press and swallow the message (the matching -UP too).
115
+ if (
116
+ self._caption_action is not None
117
+ and msg.message in (WM_NCLBUTTONDOWN, WM_NCLBUTTONUP)
118
+ and msg.wParam in _CAPTION_BUTTONS
119
+ ):
120
+ if msg.message == WM_NCLBUTTONDOWN:
121
+ _log.debug(
122
+ "Caption button %d pressed — handled without native tracking", msg.wParam
123
+ )
124
+ self._caption_action(int(msg.wParam))
125
+ return True
83
126
  # WM_ENTERSIZEMOVE alone is not enough: a press-and-hold on the title
84
127
  # bar that never moves blocks the thread inside DefWindowProc's click
85
128
  # tracking WITHOUT ever sending WM_ENTERSIZEMOVE, so the pump must
@@ -90,6 +133,7 @@ class ModalLoopPump:
90
133
  self._enter(msg.hWnd or 0)
91
134
  elif msg.message in (WM_CAPTURECHANGED, WM_EXITSIZEMOVE):
92
135
  self._exit()
136
+ return False
93
137
 
94
138
  def _enter(self, hwnd: int) -> None:
95
139
  if self._hwnd == hwnd: # NCLBUTTONDOWN then ENTERSIZEMOVE: already armed
@@ -7,7 +7,7 @@ import sqlite3
7
7
  import sys
8
8
  import time
9
9
 
10
- from PySide6.QtCore import QProcess, Qt
10
+ from PySide6.QtCore import QProcess, Qt, QTimer
11
11
  from PySide6.QtGui import QCloseEvent, QShowEvent
12
12
  from PySide6.QtWidgets import (
13
13
  QAbstractItemView,
@@ -37,7 +37,7 @@ from remotedesktop.discovery import (
37
37
  )
38
38
  from remotedesktop.inventory import ConnectionInventory, InventoryTab
39
39
  from remotedesktop.logs import PeerLogDialog, read_log_tail
40
- from remotedesktop.modal_loop import ModalLoopPump
40
+ from remotedesktop.modal_loop import HTCLOSE, HTMAXBUTTON, HTMINBUTTON, ModalLoopPump
41
41
  from remotedesktop.performance import PerformanceMonitor, PerformanceTab, format_ms, format_rate
42
42
  from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
43
43
  from remotedesktop.sharing import ShareServer
@@ -167,7 +167,9 @@ class ServerWindow(QMainWindow):
167
167
  # drag — including one driven by an injected remote click), Qt stops
168
168
  # running; the pump keeps sockets and timers serviced so a remote
169
169
  # mouse-up can still arrive and end the drag instead of deadlocking.
170
- self._modal_pump = ModalLoopPump()
170
+ # Caption-button presses (minimize/maximize/close) are handled by
171
+ # the pump directly — their native tracking loop cannot be pumped.
172
+ self._modal_pump = ModalLoopPump(caption_action=self._on_caption_button)
171
173
  self.restart_button = QPushButton("Restart server")
172
174
  self.restart_button.setToolTip(
173
175
  "Relaunch this app (e.g. after updating the software). It can be "
@@ -339,8 +341,22 @@ class ServerWindow(QMainWindow):
339
341
  title = f'Log from client "{client_name}"' if client_name else "Log from client"
340
342
  PeerLogDialog(title, text, self).show()
341
343
 
344
+ def _on_caption_button(self, hit_code: int) -> None:
345
+ # Deferred: the action (especially close) must not run inside the
346
+ # native message handler that reported the press.
347
+ if hit_code == HTMINBUTTON:
348
+ action = self.showMinimized
349
+ elif hit_code == HTMAXBUTTON:
350
+ action = self.showNormal if self.isMaximized() else self.showMaximized
351
+ elif hit_code == HTCLOSE:
352
+ action = self.close
353
+ else:
354
+ return
355
+ QTimer.singleShot(0, action)
356
+
342
357
  def nativeEvent(self, event_type, message):
343
- self._modal_pump.handle_native_event(event_type, message)
358
+ if self._modal_pump.handle_native_event(event_type, message):
359
+ return True, 0
344
360
  return super().nativeEvent(event_type, message)
345
361
 
346
362
  def _ask_approval(self, client_id: str, client_name: str) -> bool:
@@ -8,10 +8,13 @@ import ctypes
8
8
  from ctypes import wintypes
9
9
 
10
10
  from remotedesktop.modal_loop import (
11
+ HTMAXBUTTON,
12
+ HTMINBUTTON,
11
13
  WM_CAPTURECHANGED,
12
14
  WM_ENTERSIZEMOVE,
13
15
  WM_EXITSIZEMOVE,
14
16
  WM_NCLBUTTONDOWN,
17
+ WM_NCLBUTTONUP,
15
18
  ModalLoopPump,
16
19
  )
17
20
 
@@ -27,14 +30,19 @@ class FakeTimers:
27
30
  self.calls.append(("stop", hwnd))
28
31
 
29
32
 
30
- def native_message(message_id, hwnd=0xBEEF):
33
+ def native_message(message_id, hwnd=0xBEEF, wparam=0):
31
34
  """A wintypes.MSG whose address stands in for Qt's nativeEvent pointer."""
32
35
  msg = wintypes.MSG()
33
36
  msg.hWnd = hwnd
34
37
  msg.message = message_id
38
+ msg.wParam = wparam
35
39
  return msg
36
40
 
37
41
 
42
+ def send(pump, msg):
43
+ return pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(msg))
44
+
45
+
38
46
  def test_enter_and_exit_bound_the_timer():
39
47
  timers = FakeTimers()
40
48
  pump = ModalLoopPump(pump=lambda: None, timers=timers)
@@ -102,6 +110,42 @@ def test_capture_change_without_press_is_ignored():
102
110
  assert timers.calls == []
103
111
 
104
112
 
113
+ def test_caption_button_press_is_consumed_and_dispatched():
114
+ # Min/max/close tracking cannot be pumped (it dispatches only mouse
115
+ # messages), so the press must be handled directly and swallowed —
116
+ # the native tracking loop must never start.
117
+ timers = FakeTimers()
118
+ actions = []
119
+ pump = ModalLoopPump(pump=lambda: None, caption_action=actions.append, timers=timers)
120
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=HTMINBUTTON)) is True
121
+ assert actions == [HTMINBUTTON]
122
+ assert timers.calls == [] # no tracking, so nothing to pump
123
+ assert send(pump, native_message(WM_NCLBUTTONUP, wparam=HTMINBUTTON)) is True
124
+ assert actions == [HTMINBUTTON] # the release performs nothing extra
125
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=HTMAXBUTTON)) is True
126
+ assert actions == [HTMINBUTTON, HTMAXBUTTON]
127
+
128
+
129
+ def test_title_bar_press_still_arms_the_pump_not_the_caption_action():
130
+ timers = FakeTimers()
131
+ actions = []
132
+ pump = ModalLoopPump(pump=lambda: None, caption_action=actions.append, timers=timers)
133
+ # HTCAPTION (2): a title-bar press is not a caption button — the pump
134
+ # arms as before and the message passes through to DefWindowProc.
135
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=2)) is False
136
+ assert actions == []
137
+ assert timers.calls == [("start", 0xBEEF)]
138
+
139
+
140
+ def test_caption_buttons_pass_through_without_a_caption_action():
141
+ timers = FakeTimers()
142
+ pump = ModalLoopPump(pump=lambda: None, timers=timers)
143
+ # No handler installed: behave exactly as before (arm the pump, don't
144
+ # consume) so the buttons keep working for pump users without one.
145
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=HTMINBUTTON)) is False
146
+ assert timers.calls == [("start", 0xBEEF)]
147
+
148
+
105
149
  def test_timer_callback_pumps_but_never_reentrantly():
106
150
  pumped = []
107
151
  pump = ModalLoopPump(pump=lambda: pumped.append(True) or pump._on_timer(), timers=FakeTimers())
@@ -75,6 +75,33 @@ def test_native_size_move_messages_drive_the_modal_pump(qapp, credentials, tmp_p
75
75
  window.close()
76
76
 
77
77
 
78
+ def test_caption_button_press_minimizes_without_native_tracking(qapp, credentials, tmp_path):
79
+ import ctypes
80
+ from ctypes import wintypes
81
+
82
+ from shiboken6 import VoidPtr
83
+
84
+ from remotedesktop.modal_loop import HTMINBUTTON, WM_NCLBUTTONDOWN, ModalLoopPump
85
+ from test_modal_loop import FakeTimers
86
+
87
+ window = make_window(credentials, tmp_path)
88
+ try:
89
+ timers = FakeTimers()
90
+ window._modal_pump = ModalLoopPump(
91
+ pump=lambda: None, caption_action=window._on_caption_button, timers=timers
92
+ )
93
+ msg = wintypes.MSG()
94
+ msg.hWnd, msg.message, msg.wParam = 0xBEEF, WM_NCLBUTTONDOWN, HTMINBUTTON
95
+ result = window.nativeEvent(b"windows_generic_MSG", VoidPtr(ctypes.addressof(msg)))
96
+ assert result[0] is True # consumed: DefWindowProc's tracking never starts
97
+ assert timers.calls == [] # and the pump never needed to arm
98
+ for _ in range(5):
99
+ qapp.processEvents() # the deferred action runs on the event loop
100
+ assert window.isMinimized()
101
+ finally:
102
+ window.close()
103
+
104
+
78
105
  def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
79
106
  window = make_window(credentials, tmp_path)
80
107
  try:
@@ -38,8 +38,9 @@ from remotedesktop import client as client_module
38
38
 
39
39
  SCREEN_W, SCREEN_H = 1280, 800
40
40
  GIF_WIDTH = 900
41
- CAPTURE_INTERVAL = 0.15 # seconds per frame, also the GIF frame duration
42
- TOTAL_SECONDS = 11.0
41
+ CAPTURE_INTERVAL = 0.18 # seconds per frame, also the GIF frame duration
42
+ TOTAL_SECONDS = 26.5
43
+ _GIF_COLORS = 96 # adaptive-palette size; the UI + fake desktop quantize well
43
44
  _CODE_LINES = [
44
45
  "def changed_bands(previous, current):",
45
46
  ' """Bands of `current` that differ from `previous`."""',
@@ -190,7 +191,9 @@ def to_gif_frame(window) -> Image.Image:
190
191
 
191
192
 
192
193
  def save_gif(frames: list[Image.Image], path: Path) -> None:
193
- quantized = [f.convert("P", palette=Image.Palette.ADAPTIVE, colors=128) for f in frames]
194
+ quantized = [
195
+ f.convert("P", palette=Image.Palette.ADAPTIVE, colors=_GIF_COLORS) for f in frames
196
+ ]
194
197
  quantized[0].save(
195
198
  path,
196
199
  save_all=True,
@@ -251,13 +254,16 @@ def main() -> None:
251
254
  server_tabs = server_window.centralWidget()
252
255
  assert isinstance(client_tabs, QTabWidget) and isinstance(server_tabs, QTabWidget)
253
256
 
257
+ # The live remote-screen section is the product's whole point, so it
258
+ # gets the lion's share of the runtime (~19 s of streaming before
259
+ # the brief Performance-tab detour, plus the closing shot).
254
260
  script = [
255
261
  (0.8, lambda: client_window.discovery_panel.refresh()),
256
262
  (2.0, lambda: client_window.discovery_panel.serverActivated.emit(info)),
257
- (6.8, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Performance"))),
258
- (7.2, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Performance"))),
259
- (9.2, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Remote Screen"))),
260
- (9.6, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Status"))),
263
+ (21.2, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Performance"))),
264
+ (21.6, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Performance"))),
265
+ (23.6, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Remote Screen"))),
266
+ (24.0, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Status"))),
261
267
  ]
262
268
  client_frames: list[Image.Image] = []
263
269
  server_frames: list[Image.Image] = []
File without changes
File without changes
File without changes