remotedesktop 1.7.0__tar.gz → 1.8.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 (77) hide show
  1. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/CLAUDE.md +1 -0
  2. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/PKG-INFO +23 -1
  3. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/readme.md +22 -0
  4. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/app.py +1 -0
  6. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/server.py +2 -0
  7. remotedesktop-1.8.0/src/remotedesktop/session_lock.py +46 -0
  8. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/sharing.py +60 -0
  9. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/viewer.py +56 -25
  10. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_input.py +56 -0
  11. remotedesktop-1.8.0/tests/test_session_lock.py +18 -0
  12. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_sharing.py +32 -0
  13. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/.github/workflows/ci.yml +0 -0
  14. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/.github/workflows/release.yml +0 -0
  15. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/.gitignore +0 -0
  16. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/LICENSE +0 -0
  17. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/docs/media/client-demo.gif +0 -0
  18. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/docs/media/server-demo.gif +0 -0
  19. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/icon/remotedesktop.ico +0 -0
  20. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/pyproject.toml +0 -0
  21. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/run.bat +0 -0
  22. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/run_claude.bat +0 -0
  23. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/scripts/make_coverage_badge.py +0 -0
  24. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/scripts/make_installer.bat +0 -0
  25. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/scripts/make_venv.bat +0 -0
  26. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/__main__.py +0 -0
  27. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/about.py +0 -0
  28. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/autostart.py +0 -0
  29. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/client.py +0 -0
  30. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/clipboard.py +0 -0
  31. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/compat.py +0 -0
  32. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/config.py +0 -0
  33. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/cursor_shape.py +0 -0
  34. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/db.py +0 -0
  35. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/discovery.py +0 -0
  36. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/dxgi.py +0 -0
  37. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/frames.py +0 -0
  38. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/icon.py +0 -0
  39. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/input_injection.py +0 -0
  40. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/inventory.py +0 -0
  41. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/logs.py +0 -0
  42. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/modal_loop.py +0 -0
  43. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/performance.py +0 -0
  44. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/preferences.py +0 -0
  45. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/protocol.py +0 -0
  46. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/single_instance.py +0 -0
  47. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/tls.py +0 -0
  48. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/visual_effects.py +0 -0
  49. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/src/remotedesktop/window_state.py +0 -0
  50. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/conftest.py +0 -0
  51. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_about.py +0 -0
  52. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_autostart.py +0 -0
  53. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_clipboard.py +0 -0
  54. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_compat.py +0 -0
  55. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_config.py +0 -0
  56. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_cursor_shape.py +0 -0
  57. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_discovery.py +0 -0
  58. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_dxgi.py +0 -0
  59. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_frames.py +0 -0
  60. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_icon.py +0 -0
  61. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_input_injection.py +0 -0
  62. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_inventory.py +0 -0
  63. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_logs.py +0 -0
  64. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_main_window.py +0 -0
  65. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_modal_loop.py +0 -0
  66. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_performance.py +0 -0
  67. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_preferences.py +0 -0
  68. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_protocol.py +0 -0
  69. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_sharing_tab.py +0 -0
  70. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_single_instance.py +0 -0
  71. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_smoke.py +0 -0
  72. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_tls.py +0 -0
  73. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_visual_effects.py +0 -0
  74. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tests/test_window_state.py +0 -0
  75. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tools/make_demo_gifs.py +0 -0
  76. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/tools/make_icon.py +0 -0
  77. {remotedesktop-1.7.0 → remotedesktop-1.8.0}/uv.lock +0 -0
@@ -90,6 +90,7 @@ Managed with `uv` (hatchling build backend, src layout):
90
90
  - 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.
91
91
  - **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.
92
92
  - **Remote cursor shape** (`cursor_shape.py`): the captured frame never contains the mouse pointer (DXGI duplication and BitBlt both exclude it), so the client mirrors the server's cursor on its *local* cursor instead. `ShareServer` takes an opt-in `cursor_probe=` callable (the `clipboard=` pattern — only the GUI passes one, `SharingTab` wires `current_cursor_shape`: GetCursorInfo compared against the shared standard IDC_* handles; custom cursors → "arrow", not showing → "hidden"). The probe is polled each broadcast tick and `{"type": "cursor", "shape": ...}` is sent on change only, plus once to each just-admitted stream. `ShareClient.cursorShapeChanged` → `ViewerWidget.set_remote_cursor` maps names to Qt cursors (unknown names → arrow); `clear()` unsets the cursor. Peers without the feature never send / silently ignore the message.
93
+ - **Lock-screen notice** (`session_lock.py`): the Windows lock screen / secure desktop (also UAC, Ctrl+Alt+Del) can be neither captured nor injected into by a user-mode process, so remote unlock is impossible by design (documented in the readme; the assumption is the server machine is physically reachable to sign in). `ShareServer` takes an opt-in `lock_probe=` callable (the `cursor_probe=` pattern — only the GUI wires `is_session_locked`, which reads OpenInputDesktop failure as "secure desktop has input"). Polled each broadcast tick; `{"type": "session_lock", "locked": bool}` is sent on change, plus to just-admitted streams while locked (unlocked is the assumed start state, so it's never sent on admission). While locked the server drops remote input with a once-per-lock-episode status line ("Ignoring remote input while the session is locked"). `ShareClient.sessionLockChanged` → `ViewerWidget.set_session_locked`: the viewer dims whatever it shows under `viewer.LOCKED_MESSAGE`; `clear()` resets it (so reconnects start unlocked). Peers without the feature never send / silently ignore the message. Sharing tests inject a fake `lock_probe` — the default None means plain tests never poll the host's real desktop.
93
94
  - **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 a sharing instance this deadlocked when a *remote* click grabbed the window's own title bar (the mouse-up sat unread on the socket). `MainWindow.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.
94
95
  - **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 app passes `logs.read_log_tail("remotedesktop")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. The Connection log group on the Connections tab has both buttons — "Get server log" asks the server of the current session tab, "Get client log" asks the most recently admitted viewer; the reply opens a `logs.PeerLogDialog`.
95
96
  - **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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 1.7.0
3
+ Version: 1.8.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
@@ -139,6 +139,28 @@ access can be revoked at any time from the *Connections* tab. There is
139
139
  no dependency
140
140
  on Windows RDP or any Microsoft-based authentication.
141
141
 
142
+ ## The Windows lock screen
143
+
144
+ **A locked computer cannot be viewed or unlocked remotely.** The lock
145
+ screen, the PIN/password prompt, and UAC elevation prompts run on a
146
+ separate *secure desktop* that Windows deliberately walls off from
147
+ ordinary applications: nothing running as the signed-in user may capture
148
+ it or type into it, so no software can read or fill in a credential
149
+ prompt. This app intentionally runs as the signed-in user — no admin
150
+ rights, no system service — so it sits on the application side of that
151
+ wall (remote-access products that can show the login screen install a
152
+ privileged Windows service to cross it).
153
+
154
+ What happens instead: when the shared computer locks, viewers stay
155
+ connected and see a clear *"The remote computer is locked"* notice over
156
+ the last frame, and any input they send is ignored until the computer is
157
+ unlocked. The working assumption is that you (or someone nearby) can get
158
+ to the shared computer to sign it in; once its desktop is unlocked,
159
+ streaming and control resume on their own. If that round trip is a
160
+ nuisance, keep the shared computer signed in while you work on it
161
+ remotely — for example by lengthening its lock/sleep timeout in Windows
162
+ settings.
163
+
142
164
  ## How it works
143
165
 
144
166
  All of this is pure Python — the GUI is PySide6 (Qt), and the two places
@@ -115,6 +115,28 @@ access can be revoked at any time from the *Connections* tab. There is
115
115
  no dependency
116
116
  on Windows RDP or any Microsoft-based authentication.
117
117
 
118
+ ## The Windows lock screen
119
+
120
+ **A locked computer cannot be viewed or unlocked remotely.** The lock
121
+ screen, the PIN/password prompt, and UAC elevation prompts run on a
122
+ separate *secure desktop* that Windows deliberately walls off from
123
+ ordinary applications: nothing running as the signed-in user may capture
124
+ it or type into it, so no software can read or fill in a credential
125
+ prompt. This app intentionally runs as the signed-in user — no admin
126
+ rights, no system service — so it sits on the application side of that
127
+ wall (remote-access products that can show the login screen install a
128
+ privileged Windows service to cross it).
129
+
130
+ What happens instead: when the shared computer locks, viewers stay
131
+ connected and see a clear *"The remote computer is locked"* notice over
132
+ the last frame, and any input they send is ignored until the computer is
133
+ unlocked. The working assumption is that you (or someone nearby) can get
134
+ to the shared computer to sign it in; once its desktop is unlocked,
135
+ streaming and control resume on their own. If that round trip is a
136
+ nuisance, keep the shared computer signed in while you work on it
137
+ remotely — for example by lengthening its lock/sleep timeout in Windows
138
+ settings.
139
+
118
140
  ## How it works
119
141
 
120
142
  All of this is pure Python — the GUI is PySide6 (Qt), and the two places
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "1.7.0"
3
+ __version__ = "1.8.0"
@@ -1040,6 +1040,7 @@ class MainWindow(QMainWindow):
1040
1040
  client.disconnected.connect(lambda s=session: self._on_disconnected(s))
1041
1041
  client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
1042
1042
  client.cursorShapeChanged.connect(lambda shape, s=session: s.viewer.set_remote_cursor(shape))
1043
+ client.sessionLockChanged.connect(lambda locked, s=session: s.viewer.set_session_locked(locked))
1043
1044
  client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
1044
1045
  client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
1045
1046
  self._sessions.append(session)
@@ -29,6 +29,7 @@ from PySide6.QtWidgets import (
29
29
  from remotedesktop import __version__, compat, tls
30
30
  from remotedesktop.config import PairedClients, Settings, default_config_dir
31
31
  from remotedesktop.cursor_shape import current_cursor_shape
32
+ from remotedesktop.session_lock import is_session_locked
32
33
  from remotedesktop.discovery import (
33
34
  DEFAULT_CONNECT_PORT,
34
35
  DISCOVERY_PORT,
@@ -276,6 +277,7 @@ class SharingTab(QWidget):
276
277
  paired=self._paired,
277
278
  clipboard=self._clipboard,
278
279
  cursor_probe=current_cursor_shape,
280
+ lock_probe=is_session_locked,
279
281
  performance=self._performance,
280
282
  log_provider=lambda: read_log_tail("remotedesktop"),
281
283
  input_allowed=self._settings.get_bool(ALLOW_INPUT_KEY, True),
@@ -0,0 +1,46 @@
1
+ """Detect whether the Windows secure desktop is in control (server side).
2
+
3
+ While the workstation is locked — and during UAC prompts and the
4
+ Ctrl+Alt+Del screen — Windows switches to a separate "secure desktop" that
5
+ processes running as the signed-in user may neither capture nor inject
6
+ input into. That is a deliberate security boundary (nothing in the user's
7
+ session can read or type into a credential prompt), so remote unlock is
8
+ impossible for this app by design: screen capture yields a stale frame and
9
+ SendInput is silently discarded. The server uses this probe to tell
10
+ viewers what is going on instead of leaving them a frozen screen.
11
+
12
+ The check: `OpenInputDesktop` fails for an ordinary user process while the
13
+ input desktop is the secure desktop, and succeeds while the user's own
14
+ desktop has input.
15
+
16
+ On non-Windows platforms `is_session_locked()` returns None (the app
17
+ targets Windows).
18
+ """
19
+
20
+ import ctypes
21
+ import sys
22
+
23
+ _IS_WINDOWS = sys.platform == "win32"
24
+
25
+ # winuser.h desktop access right — the weakest thing OpenInputDesktop can be
26
+ # asked for, enough to answer "may this process touch the input desktop?".
27
+ _DESKTOP_SWITCHDESKTOP = 0x0100
28
+
29
+ if _IS_WINDOWS:
30
+ _user32 = ctypes.windll.user32
31
+ _user32.OpenInputDesktop.restype = ctypes.c_void_p
32
+ _user32.OpenInputDesktop.argtypes = (ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32)
33
+ _user32.CloseDesktop.argtypes = (ctypes.c_void_p,)
34
+
35
+
36
+ def is_session_locked() -> bool | None:
37
+ """True while the secure desktop (lock screen, UAC prompt) has the
38
+ input, False while the user's own desktop does, or None when it cannot
39
+ be determined (non-Windows)."""
40
+ if not _IS_WINDOWS:
41
+ return None
42
+ handle = _user32.OpenInputDesktop(0, False, _DESKTOP_SWITCHDESKTOP)
43
+ if not handle:
44
+ return True
45
+ _user32.CloseDesktop(handle)
46
+ return False
@@ -117,6 +117,7 @@ class ShareServer(QObject):
117
117
  injector: InputInjector | None = None,
118
118
  clipboard=None,
119
119
  cursor_probe: Callable[[], str | None] | None = None,
120
+ lock_probe: Callable[[], bool | None] | None = None,
120
121
  performance: PerformanceMonitor | None = None,
121
122
  log_provider: Callable[[], str] | None = None,
122
123
  input_allowed: bool = True,
@@ -137,6 +138,13 @@ class ShareServer(QObject):
137
138
  # sharing tests never poll (or depend on) the host's real cursor.
138
139
  self._cursor_probe = cursor_probe
139
140
  self._cursor_shape: str | None = None # last shape sent to viewers
141
+ # Opt-in for the same reason: only the GUI probes the real desktop.
142
+ # While the session is locked (secure desktop — see session_lock.py)
143
+ # the screen cannot be captured and injected input is discarded, so
144
+ # viewers are told and remote input is dropped here.
145
+ self._lock_probe = lock_probe
146
+ self._session_locked = False # last state sent to viewers
147
+ self._lock_input_reported = False # one status line per lock episode
140
148
  self._streams: list[MessageStream] = []
141
149
  self._all_streams: set[MessageStream] = set()
142
150
  self._controllers: set[MessageStream] = set()
@@ -243,6 +251,8 @@ class ShareServer(QObject):
243
251
  self._viewer_info.clear()
244
252
  self._previous_frame = None
245
253
  self._cursor_shape = None
254
+ self._session_locked = False
255
+ self._lock_input_reported = False
246
256
  if self._dxgi is not None:
247
257
  self._dxgi.close()
248
258
  self._dxgi = None
@@ -475,6 +485,7 @@ class ShareServer(QObject):
475
485
  self._needs_keyframe.add(stream) # its first frame must be a full one
476
486
  self._streams.append(stream)
477
487
  self._broadcast_cursor(new_stream=stream) # start with the right cursor
488
+ self._broadcast_lock(new_stream=stream) # tell it if the session is locked
478
489
  if self._performance is not None:
479
490
  self._performance.add_stream(stream)
480
491
  self.clientCountChanged.emit(len(self._streams))
@@ -490,6 +501,13 @@ class ShareServer(QObject):
490
501
  # View-only: the toggle already produced a status line; per-event
491
502
  # noise goes nowhere (not even the debug log — it's every move).
492
503
  return
504
+ if self._session_locked:
505
+ # The secure desktop discards injected input anyway; drop it here
506
+ # and say so once per lock episode (not per event — it's every move).
507
+ if not self._lock_input_reported:
508
+ self._lock_input_reported = True
509
+ self.status.emit("Ignoring remote input while the session is locked")
510
+ return
493
511
  action = message.get("action")
494
512
  x, y = message.get("x"), message.get("y")
495
513
  if stream not in self._controllers:
@@ -664,10 +682,40 @@ class ShareServer(QObject):
664
682
  elif new_stream is not None:
665
683
  new_stream.send_json({"type": "cursor", "shape": shape})
666
684
 
685
+ def _broadcast_lock(self, new_stream: MessageStream | None = None) -> None:
686
+ """Tell viewers when the session locks or unlocks (secure desktop —
687
+ see session_lock.py): the screen can no longer be captured and input
688
+ cannot be injected, so the client shows a notice instead of leaving
689
+ its user a silently frozen frame.
690
+
691
+ Sent on change to everyone, plus to a just-admitted stream while
692
+ locked (unlocked is every stream's starting assumption).
693
+ """
694
+ if self._lock_probe is None:
695
+ return
696
+ locked = self._lock_probe()
697
+ if locked is None:
698
+ return
699
+ if locked != self._session_locked:
700
+ self._session_locked = locked
701
+ if locked:
702
+ self.status.emit(
703
+ "Session locked — the lock screen cannot be captured or "
704
+ "controlled remotely; viewers are notified"
705
+ )
706
+ else:
707
+ self._lock_input_reported = False
708
+ self.status.emit("Session unlocked — resuming normal streaming")
709
+ for stream in self._streams:
710
+ stream.send_json({"type": "session_lock", "locked": locked})
711
+ elif new_stream is not None and locked:
712
+ new_stream.send_json({"type": "session_lock", "locked": True})
713
+
667
714
  def _broadcast_frame(self) -> None:
668
715
  if not self._streams:
669
716
  return
670
717
  self._broadcast_cursor() # polled at the frame rate, sent on change
718
+ self._broadcast_lock() # likewise
671
719
  image = self._capture()
672
720
  if image is None:
673
721
  self.status.emit("Screen capture failed (null image)")
@@ -743,6 +791,10 @@ class ShareClient(QObject):
743
791
  # "size_we"); the viewer mirrors it on the local cursor. Servers without
744
792
  # the feature (pre-1.6) simply never emit it.
745
793
  cursorShapeChanged = Signal(str)
794
+ # The server's session locked (True) or unlocked (False) — the secure
795
+ # desktop cannot be captured or controlled, so the viewer shows a notice.
796
+ # Servers without the feature (pre-1.8) simply never emit it.
797
+ sessionLockChanged = Signal(bool)
746
798
  status = Signal(str)
747
799
  logReceived = Signal(str) # server log text answering request_log
748
800
 
@@ -943,6 +995,14 @@ class ShareClient(QObject):
943
995
  # Too frequent for the status log; unknown names are the
944
996
  # viewer's problem (it falls back to the arrow).
945
997
  self.cursorShapeChanged.emit(str(message.get("shape", "")) or "arrow")
998
+ case "session_lock":
999
+ locked = bool(message.get("locked"))
1000
+ self.status.emit(
1001
+ "Server session is locked — sign in at the server machine"
1002
+ if locked
1003
+ else "Server session unlocked"
1004
+ )
1005
+ self.sessionLockChanged.emit(locked)
946
1006
  case "ping" | "pong":
947
1007
  if self._performance is not None:
948
1008
  self._performance.handle_message(self._stream, message)
@@ -34,6 +34,16 @@ _BUTTON_NAMES = {
34
34
  Qt.MouseButton.MiddleButton: "middle",
35
35
  }
36
36
 
37
+ # Shown (over a dimmed last frame) while the server's session is locked: the
38
+ # secure desktop cannot be captured or controlled remotely, so signing in
39
+ # has to happen at the server machine itself.
40
+ LOCKED_MESSAGE = (
41
+ "The remote computer is locked.\n"
42
+ "Its sign-in screen cannot be viewed or controlled remotely —\n"
43
+ "sign in at that computer to continue."
44
+ )
45
+ _LOCK_DIM = QColor(0, 0, 0, 160)
46
+
37
47
  # Wire cursor-shape names (cursor_shape.py on the server side) -> the local
38
48
  # cursor mirroring the remote one. Names this map doesn't know (a newer
39
49
  # server's vocabulary) fall back to the arrow.
@@ -78,6 +88,9 @@ class ViewerWidget(QWidget):
78
88
  # display-scaling factor (125-150% on most monitors), blurring text.
79
89
  self._scaled: QPixmap | None = None
80
90
  self._message = "Not connected"
91
+ # The server's session is locked (secure desktop): the frame is
92
+ # stale, so it is dimmed under a LOCKED_MESSAGE notice.
93
+ self._session_locked = False
81
94
  # Buttons/keys currently held, so releases can be forwarded even when
82
95
  # they happen outside the frame or when the widget loses focus —
83
96
  # otherwise the server would keep them pressed forever.
@@ -128,11 +141,24 @@ class ViewerWidget(QWidget):
128
141
  self._frame = None
129
142
  self._scaled = None
130
143
  self._message = message
144
+ self._session_locked = False
131
145
  self._pressed_buttons.clear()
132
146
  self._pressed_keys.clear()
133
147
  self.unsetCursor() # a disconnected viewer shows the normal cursor
134
148
  self.update()
135
149
 
150
+ @property
151
+ def session_locked(self) -> bool:
152
+ return self._session_locked
153
+
154
+ def set_session_locked(self, locked: bool) -> None:
155
+ """Dim the view under a lock notice while the server's session is
156
+ locked (the secure desktop is neither captured nor controllable)."""
157
+ if self._session_locked == locked:
158
+ return
159
+ self._session_locked = locked
160
+ self.update()
161
+
136
162
  def set_remote_cursor(self, shape: str) -> None:
137
163
  """Mirror the server's cursor shape on the local cursor (the frame
138
164
  itself never contains the pointer — screen capture excludes it)."""
@@ -270,32 +296,37 @@ class ViewerWidget(QWidget):
270
296
  painter = QPainter(self)
271
297
  painter.fillRect(self.rect(), Qt.GlobalColor.black)
272
298
  if self._frame is None:
299
+ if not self._session_locked: # the lock notice below says it all
300
+ painter.setPen(Qt.GlobalColor.white)
301
+ painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._message)
302
+ else:
303
+ rect = self._display_rect().toRect()
304
+ dpr = self.devicePixelRatioF()
305
+ target = QSize(round(rect.width() * dpr), round(rect.height() * dpr))
306
+ if (
307
+ self._scaled is None
308
+ or self._scaled.size() != target
309
+ or self._scaled.devicePixelRatio() != dpr
310
+ ):
311
+ if self._frame.size() == target:
312
+ # Displayed at exactly 1:1 device pixels — no resample at all.
313
+ self._scaled = QPixmap(self._frame)
314
+ else:
315
+ self._scaled = self._frame.scaled(
316
+ target,
317
+ Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
318
+ Qt.TransformationMode.SmoothTransformation,
319
+ )
320
+ self._scaled.setDevicePixelRatio(dpr)
321
+ painter.drawPixmap(rect.topLeft(), self._scaled)
322
+ # Thin outline marking where the remote screen ends and the app
323
+ # background begins (they can otherwise blend together).
324
+ painter.setPen(self._border_color())
325
+ painter.drawRect(rect.adjusted(0, 0, -1, -1))
326
+ if self._session_locked:
327
+ painter.fillRect(self.rect(), _LOCK_DIM)
273
328
  painter.setPen(Qt.GlobalColor.white)
274
- painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._message)
275
- return
276
- rect = self._display_rect().toRect()
277
- dpr = self.devicePixelRatioF()
278
- target = QSize(round(rect.width() * dpr), round(rect.height() * dpr))
279
- if (
280
- self._scaled is None
281
- or self._scaled.size() != target
282
- or self._scaled.devicePixelRatio() != dpr
283
- ):
284
- if self._frame.size() == target:
285
- # Displayed at exactly 1:1 device pixels — no resample at all.
286
- self._scaled = QPixmap(self._frame)
287
- else:
288
- self._scaled = self._frame.scaled(
289
- target,
290
- Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
291
- Qt.TransformationMode.SmoothTransformation,
292
- )
293
- self._scaled.setDevicePixelRatio(dpr)
294
- painter.drawPixmap(rect.topLeft(), self._scaled)
295
- # Thin outline marking where the remote screen ends and the app
296
- # background begins (they can otherwise blend together).
297
- painter.setPen(self._border_color())
298
- painter.drawRect(rect.adjusted(0, 0, -1, -1))
329
+ painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, LOCKED_MESSAGE)
299
330
 
300
331
  def _border_color(self) -> QColor:
301
332
  """Outline shade for the current theme (follows the window background)."""
@@ -253,6 +253,62 @@ def test_viewer_mirrors_remote_cursor_shape(qapp):
253
253
  assert viewer.cursor().shape() == Qt.CursorShape.ArrowCursor
254
254
 
255
255
 
256
+ def test_input_dropped_while_session_locked(qapp, credentials, tmp_path):
257
+ injector = RecordingInjector()
258
+ locked = [False]
259
+ server = make_server(
260
+ credentials,
261
+ tmp_path,
262
+ approve=lambda *_: True,
263
+ injector=injector,
264
+ lock_probe=lambda: locked[0],
265
+ )
266
+ statuses: list[str] = []
267
+ server.status.connect(statuses.append)
268
+ client = make_client(tmp_path)
269
+ lock_states: list[bool] = []
270
+ client.sessionLockChanged.connect(lock_states.append)
271
+ connected = []
272
+ client.connected.connect(connected.append)
273
+ client.connect_to("127.0.0.1", server.port)
274
+ try:
275
+ pump(qapp, lambda: connected)
276
+ client.send_input({"action": "move", "x": 0.5, "y": 0.25})
277
+ pump(qapp, lambda: injector.calls)
278
+ locked[0] = True
279
+ # The lock notice reaching the client proves the server's own state
280
+ # flipped before the input below arrives.
281
+ pump(qapp, lambda: lock_states == [True])
282
+ injector.calls.clear()
283
+ client.send_input({"action": "move", "x": 0.1, "y": 0.1})
284
+ client.send_input({"action": "key", "vk": 65, "pressed": True})
285
+ pump(qapp, lambda: any("Ignoring remote input" in s for s in statuses))
286
+ assert injector.calls == []
287
+ locked[0] = False
288
+ pump(qapp, lambda: lock_states == [True, False])
289
+ client.send_input({"action": "move", "x": 0.9, "y": 0.9})
290
+ pump(qapp, lambda: injector.calls) # unlocking resumes injection
291
+ finally:
292
+ client.close()
293
+ server.close()
294
+
295
+
296
+ def test_viewer_session_lock_overlay(qapp):
297
+ viewer = ViewerWidget()
298
+ viewer.resize(400, 400)
299
+ assert not viewer.session_locked
300
+ image = QImage(200, 100, QImage.Format.Format_RGB32)
301
+ image.fill(Qt.GlobalColor.red)
302
+ viewer.show_frame(image)
303
+ viewer.set_session_locked(True)
304
+ assert viewer.session_locked
305
+ assert not viewer.grab().isNull() # paints the dimmed-frame notice branch
306
+ viewer._frame = None
307
+ assert not viewer.grab().isNull() # and the no-frame notice branch
308
+ viewer.clear() # disconnecting clears the notice with the frame
309
+ assert not viewer.session_locked
310
+
311
+
256
312
  def test_viewer_paints_message_and_frame(qapp):
257
313
  viewer = ViewerWidget()
258
314
  viewer.resize(400, 400)
@@ -0,0 +1,18 @@
1
+ import sys
2
+
3
+ import pytest
4
+
5
+ from remotedesktop.session_lock import is_session_locked
6
+
7
+
8
+ @pytest.mark.skipif(sys.platform != "win32", reason="Windows desktop API")
9
+ def test_is_session_locked_reports_a_bool():
10
+ # The answer depends on the session running the tests (an interactive
11
+ # desktop says False; a service-like session may say True) — only the
12
+ # contract is asserted, not the state.
13
+ assert is_session_locked() in (True, False)
14
+
15
+
16
+ @pytest.mark.skipif(sys.platform == "win32", reason="non-Windows returns None")
17
+ def test_is_session_locked_is_none_off_windows():
18
+ assert is_session_locked() is None
@@ -30,6 +30,7 @@ def make_server(
30
30
  injector=None,
31
31
  clipboard=None,
32
32
  cursor_probe=None,
33
+ lock_probe=None,
33
34
  log_provider=None,
34
35
  ):
35
36
  # The server is a distinct "machine" from the client -> its own database.
@@ -40,6 +41,7 @@ def make_server(
40
41
  injector=injector,
41
42
  clipboard=clipboard,
42
43
  cursor_probe=cursor_probe,
44
+ lock_probe=lock_probe,
43
45
  log_provider=log_provider,
44
46
  )
45
47
  assert server.listen(0)
@@ -134,6 +136,36 @@ def test_cursor_shape_sent_on_admission_and_on_change_only(qapp, credentials, tm
134
136
  server.close()
135
137
 
136
138
 
139
+ def test_session_lock_sent_on_admission_and_on_change_only(qapp, credentials, tmp_path):
140
+ locked = [True]
141
+ server = make_server(
142
+ credentials, tmp_path, approve=lambda *_: True, lock_probe=lambda: locked[0]
143
+ )
144
+ client = make_client(tmp_path)
145
+ states: list[bool] = []
146
+ client.sessionLockChanged.connect(states.append)
147
+ statuses: list[str] = []
148
+ client.status.connect(statuses.append)
149
+ client.connect_to("127.0.0.1", server.port)
150
+ try:
151
+ # A viewer admitted while the session is already locked is told so
152
+ # immediately (its starting assumption is unlocked).
153
+ pump(qapp, lambda: states)
154
+ assert states == [True]
155
+ assert any("locked — sign in at the server machine" in s for s in statuses)
156
+ locked[0] = False
157
+ pump(qapp, lambda: len(states) >= 2)
158
+ # Give the broadcast timer a few more ticks: the probe is polled at
159
+ # the frame rate but must send only on change, never per tick.
160
+ deadline = time.monotonic() + 0.2
161
+ while time.monotonic() < deadline:
162
+ qapp.processEvents()
163
+ assert states == [True, False]
164
+ finally:
165
+ client.close()
166
+ server.close()
167
+
168
+
137
169
  def test_reconnect_uses_token_without_prompting(qapp, credentials, tmp_path):
138
170
  prompts = []
139
171
  # Keep one server (same port) so the client's stored "host:port" token matches.
File without changes
File without changes
File without changes
File without changes