remotedesktop 1.7.0__tar.gz → 1.9.2__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.9.2}/CLAUDE.md +2 -0
  2. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/PKG-INFO +31 -2
  3. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/readme.md +30 -1
  4. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/app.py +118 -3
  6. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/clipboard.py +19 -0
  7. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/preferences.py +55 -11
  8. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/server.py +2 -0
  9. remotedesktop-1.9.2/src/remotedesktop/session_lock.py +46 -0
  10. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/sharing.py +60 -0
  11. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/viewer.py +62 -25
  12. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_clipboard.py +24 -0
  13. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_input.py +56 -0
  14. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_main_window.py +118 -5
  15. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_preferences.py +25 -0
  16. remotedesktop-1.9.2/tests/test_session_lock.py +18 -0
  17. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_sharing.py +32 -0
  18. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/.github/workflows/ci.yml +0 -0
  19. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/.github/workflows/release.yml +0 -0
  20. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/.gitignore +0 -0
  21. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/LICENSE +0 -0
  22. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/docs/media/client-demo.gif +0 -0
  23. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/docs/media/server-demo.gif +0 -0
  24. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/icon/remotedesktop.ico +0 -0
  25. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/pyproject.toml +0 -0
  26. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/run.bat +0 -0
  27. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/run_claude.bat +0 -0
  28. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/scripts/make_coverage_badge.py +0 -0
  29. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/scripts/make_installer.bat +0 -0
  30. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/scripts/make_venv.bat +0 -0
  31. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/__main__.py +0 -0
  32. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/about.py +0 -0
  33. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/autostart.py +0 -0
  34. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/client.py +0 -0
  35. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/compat.py +0 -0
  36. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/config.py +0 -0
  37. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/cursor_shape.py +0 -0
  38. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/db.py +0 -0
  39. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/discovery.py +0 -0
  40. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/dxgi.py +0 -0
  41. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/frames.py +0 -0
  42. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/icon.py +0 -0
  43. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/input_injection.py +0 -0
  44. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/inventory.py +0 -0
  45. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/logs.py +0 -0
  46. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/modal_loop.py +0 -0
  47. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/performance.py +0 -0
  48. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/protocol.py +0 -0
  49. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/single_instance.py +0 -0
  50. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/tls.py +0 -0
  51. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/visual_effects.py +0 -0
  52. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/window_state.py +0 -0
  53. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/conftest.py +0 -0
  54. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_about.py +0 -0
  55. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_autostart.py +0 -0
  56. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_compat.py +0 -0
  57. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_config.py +0 -0
  58. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_cursor_shape.py +0 -0
  59. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_discovery.py +0 -0
  60. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_dxgi.py +0 -0
  61. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_frames.py +0 -0
  62. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_icon.py +0 -0
  63. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_input_injection.py +0 -0
  64. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_inventory.py +0 -0
  65. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_logs.py +0 -0
  66. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_modal_loop.py +0 -0
  67. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_performance.py +0 -0
  68. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_protocol.py +0 -0
  69. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_sharing_tab.py +0 -0
  70. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_single_instance.py +0 -0
  71. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_smoke.py +0 -0
  72. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_tls.py +0 -0
  73. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_visual_effects.py +0 -0
  74. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_window_state.py +0 -0
  75. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tools/make_demo_gifs.py +0 -0
  76. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tools/make_icon.py +0 -0
  77. {remotedesktop-1.7.0 → remotedesktop-1.9.2}/uv.lock +0 -0
@@ -76,6 +76,7 @@ Managed with `uv` (hatchling build backend, src layout):
76
76
  - **Auto-reconnect** (`app.py`): a session that *was connected* and drops retries `client.connect_to` with exponential backoff (base `reconnect_base_seconds=2.0` kwarg — tests inject ~0.05 — doubling to a 30 s cap), driven by a per-session single-shot QTimer. `ShareClient.connectionFailed` exists because **Qt emits `disconnected` only for sockets that reached ConnectedState** — refused/unreachable attempts fire `errorOccurred` with the socket back in UnconnectedState. The active-timer guard dedupes the errorOccurred+disconnected double-fire. Denial cancels until a manual activation; tab close/forget/quit cancel; retries bypass `_connect_session` on purpose (no perf reset, no tab steal, no inventory attempt spam). **Open session tabs persist** (`open_sessions` JSON in Settings, written on connect/rename/close) and are restored + reconnected at startup with `auto_reconnect` pre-armed, so a not-yet-available server is retried on the backoff loop; deliberately closed tabs are not restored. While no session exists, a non-closable "Server" placeholder tab (instructions) sits at index 0 and swaps with the first session's tab.
77
77
  - **Menus & shortcuts contract** (`app.py` `_build_menus`, `viewer.py`): File/View/Help menu bar; shortcuts Ctrl+W (close session tab), Ctrl+Q (quit), F5 (rescan), F11 (fullscreen). While a viewer shows a frame it **accepts `ShortcutOverride`** so every key forwards to the remote machine — **F11 is the single reserved local key**. Shortcut actions are also `addAction`-ed on the window so they fire with the menu bar hidden (fullscreen). Keep every menu/action as an attribute — PySide6 gives Python ownership of `addMenu`/`addAction` (and `QAction.menu()`) wrappers, and a GC'd local deletes the C++ object. Fullscreen hides menu/status bars, dock, and tab bar and restores them (including pre-fullscreen dock visibility) on exit; `closeEvent` exits fullscreen before persisting layout. Window geometry AND dock layout persist (`window_state.save_state`/`restore_state`, key `main_window_state`; the dock needs its objectName).
78
78
  - **Privacy toggles**: Preferences' "Sync clipboard" checkbox (`clipboard_sync_enabled`, default on) drives `ClipboardSync.enabled` — while off, local copies aren't sent and peer payloads aren't applied. View-only sharing is the "view" mode of the Screen sharing radios (`ShareServer.set_input_allowed` — a server-side drop, no wire change, that releases held input when switched off). Quitting with ≥1 viewer connected asks for confirmation (declining resets `_quitting`); restart shuts sharing down first so it never double-prompts.
79
+ - **Screen capture** (`app.py`): the dock's "Screen capture" group (Copy / Save…, viewer-role UI hidden with the panel) and the matching File-menu actions (`copy_capture_action`/`save_capture_action`, same handlers) capture the current session tab's last frame at full server resolution (`ViewerWidget.frame_image()` — the lock overlay is display-only and never part of it). Copy goes through `ClipboardSync.copy_image`, which records the content signature *before* setting the clipboard so the capture is never echoed to peers, and works with sync disabled (the preference governs syncing, not local copies). Save is `QFileDialog.getSaveFileName` (tests monkeypatch it) defaulting to `Pictures\<server> <timestamp>.png`, PNG only. All four widgets enable only while the current tab shows a frame — `_update_capture_actions()`, called from tab change, `_on_frame` (unconditionally: auto-reconnect refills a cleared viewer without `_connect_session`), and every `viewer.clear` path (connect/denied/approval-pending/disconnect).
79
80
  - **Reduce visual effects while sharing** (`visual_effects.py`): animations/fades are a burst of changed frames for the lossless pipeline, so while the preference is on (default on, `reduce_effects_enabled`, `load_reduce_effects_enabled`) **and ≥1 viewer is connected**, `MainWindow` applies `VisualEffectsReducer` and restores it when the last viewer leaves (driven by `SharingTab.viewerCountChanged`; explicit safety-net restore in `closeEvent`). The reduced set: SPI_SETUIEFFECTS off, drop shadows off, client-area + min/max animations off, submenu delay lowered to 50 ms (never raised). All via `SystemParametersInfo` **without** SPIF_UPDATEINIFILE — session-scoped, never persisted, self-heals at logoff; knobs already at the reduced value are left untouched so the user's own choices are never "restored" over. The backend (`SpiBackend`) is injectable — **tests must always inject `FakeSpiBackend`** (the preference defaults on, so a real reducer in a test would change the host's actual Windows settings; `make_window` in `tests/test_main_window.py` injects it for every window test, and real-backend tests may only read).
80
81
  - **Theme preference** (`preferences.py`): a three-radio group in Preferences — follow the Windows light/dark setting (default) / Light / Dark — persisted as `THEME_KEY` (`"system"`/`"light"`/`"dark"`, `load_theme` falls back to system on junk). `apply_theme` drives `QStyleHints.setColorScheme` (`unsetColorScheme` for system, so OS-side switches keep applying); the radios apply live and `MainWindow.__init__` re-applies the persisted choice before building widgets. Tests that set a non-system theme must restore with `apply_theme(THEME_SYSTEM)` — the color scheme is process-global QApplication state.
81
82
  - **Two `PerformanceMonitor`s, never one**: `MainWindow.client_performance` (viewing sessions) and `server_performance` (the SharingTab's viewers). `ShareServer.close()` resets its monitor, so sharing a monitor across roles would wipe the other role's graphs. The Performance tab has "Client (viewer)"/"Server (sharing)" sub-tabs (`MainWindow.performance_pages`) — but only for roles that can produce data: the client sub-tab exists only with the viewer role, the server one only while actually serving (`_update_performance_tabs`, driven from the viewer toggle and `sharingChanged`; both off shows an enable-a-role hint). `PreferencesTab` takes the list of monitors and applies the history-window setting to all.
@@ -90,6 +91,7 @@ Managed with `uv` (hatchling build backend, src layout):
90
91
  - 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
92
  - **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
93
  - **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.
94
+ - **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
95
  - **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
96
  - **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
97
  - **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.9.2
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
@@ -74,10 +74,14 @@ one-click *Forget* / *Revoke*, and the live connection log.
74
74
  - 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
75
75
  - 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
76
76
  - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
77
+ - ⚡ **Fewer frames to send while sharing** — while at least one viewer is connected, Windows animations, menu fades, and shadows are turned off on the shared computer so the stream snaps instead of smearing; everything is restored the moment the last viewer leaves, and nothing is permanently changed (a recommended, default-on preference).
77
78
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
79
+ - 🖲️ **Cursor shape mirroring** — the pointer is never burned into the captured frame; instead your own cursor takes the remote cursor's shape (an I-beam over text, resize arrows on a window edge), so it is always crisp and lag-free.
78
80
  - 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
79
81
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
82
+ - 📸 **Screen captures** — grab the remote screen at its full resolution and copy it to the clipboard or save it as a PNG, from the *Screen capture* panel buttons or the File menu.
80
83
  - 🔒 **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.
84
+ - 🔐 **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)).
81
85
  - 📊 **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.
82
86
  - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
83
87
  - 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights), 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.
@@ -139,6 +143,28 @@ access can be revoked at any time from the *Connections* tab. There is
139
143
  no dependency
140
144
  on Windows RDP or any Microsoft-based authentication.
141
145
 
146
+ ## The Windows lock screen
147
+
148
+ **A locked computer cannot be viewed or unlocked remotely.** The lock
149
+ screen, the PIN/password prompt, and UAC elevation prompts run on a
150
+ separate *secure desktop* that Windows deliberately walls off from
151
+ ordinary applications: nothing running as the signed-in user may capture
152
+ it or type into it, so no software can read or fill in a credential
153
+ prompt. This app intentionally runs as the signed-in user — no admin
154
+ rights, no system service — so it sits on the application side of that
155
+ wall (remote-access products that can show the login screen install a
156
+ privileged Windows service to cross it).
157
+
158
+ What happens instead: when the shared computer locks, viewers stay
159
+ connected and see a clear *"The remote computer is locked"* notice over
160
+ the last frame, and any input they send is ignored until the computer is
161
+ unlocked. The working assumption is that you (or someone nearby) can get
162
+ to the shared computer to sign it in; once its desktop is unlocked,
163
+ streaming and control resume on their own. If that round trip is a
164
+ nuisance, keep the shared computer signed in while you work on it
165
+ remotely — for example by lengthening its lock/sleep timeout in Windows
166
+ settings.
167
+
142
168
  ## How it works
143
169
 
144
170
  All of this is pure Python — the GUI is PySide6 (Qt), and the two places
@@ -155,7 +181,10 @@ an idle screen costs essentially nothing. When duplication is unavailable
155
181
  or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
156
182
  display-mode change — the server transparently falls back to Qt's
157
183
  `QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
158
- the background.
184
+ the background. The captured frame never contains the mouse pointer;
185
+ instead the server reports the current cursor's *shape* whenever it
186
+ changes, and each client mirrors it on its own local cursor — which is why
187
+ the pointer you see is always sharp and moves with zero latency.
159
188
 
160
189
  **Screen transfer.** Frames are captured at up to 30 fps and compared with
161
190
  the previous capture in 64-row bands; only the bands that changed are
@@ -50,10 +50,14 @@ one-click *Forget* / *Revoke*, and the live connection log.
50
50
  - 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
51
51
  - 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
52
52
  - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
53
+ - ⚡ **Fewer frames to send while sharing** — while at least one viewer is connected, Windows animations, menu fades, and shadows are turned off on the shared computer so the stream snaps instead of smearing; everything is restored the moment the last viewer leaves, and nothing is permanently changed (a recommended, default-on preference).
53
54
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
55
+ - 🖲️ **Cursor shape mirroring** — the pointer is never burned into the captured frame; instead your own cursor takes the remote cursor's shape (an I-beam over text, resize arrows on a window edge), so it is always crisp and lag-free.
54
56
  - 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
55
57
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
58
+ - 📸 **Screen captures** — grab the remote screen at its full resolution and copy it to the clipboard or save it as a PNG, from the *Screen capture* panel buttons or the File menu.
56
59
  - 🔒 **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.
60
+ - 🔐 **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)).
57
61
  - 📊 **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.
58
62
  - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
59
63
  - 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights), 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.
@@ -115,6 +119,28 @@ access can be revoked at any time from the *Connections* tab. There is
115
119
  no dependency
116
120
  on Windows RDP or any Microsoft-based authentication.
117
121
 
122
+ ## The Windows lock screen
123
+
124
+ **A locked computer cannot be viewed or unlocked remotely.** The lock
125
+ screen, the PIN/password prompt, and UAC elevation prompts run on a
126
+ separate *secure desktop* that Windows deliberately walls off from
127
+ ordinary applications: nothing running as the signed-in user may capture
128
+ it or type into it, so no software can read or fill in a credential
129
+ prompt. This app intentionally runs as the signed-in user — no admin
130
+ rights, no system service — so it sits on the application side of that
131
+ wall (remote-access products that can show the login screen install a
132
+ privileged Windows service to cross it).
133
+
134
+ What happens instead: when the shared computer locks, viewers stay
135
+ connected and see a clear *"The remote computer is locked"* notice over
136
+ the last frame, and any input they send is ignored until the computer is
137
+ unlocked. The working assumption is that you (or someone nearby) can get
138
+ to the shared computer to sign it in; once its desktop is unlocked,
139
+ streaming and control resume on their own. If that round trip is a
140
+ nuisance, keep the shared computer signed in while you work on it
141
+ remotely — for example by lengthening its lock/sleep timeout in Windows
142
+ settings.
143
+
118
144
  ## How it works
119
145
 
120
146
  All of this is pure Python — the GUI is PySide6 (Qt), and the two places
@@ -131,7 +157,10 @@ an idle screen costs essentially nothing. When duplication is unavailable
131
157
  or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
132
158
  display-mode change — the server transparently falls back to Qt's
133
159
  `QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
134
- the background.
160
+ the background. The captured frame never contains the mouse pointer;
161
+ instead the server reports the current cursor's *shape* whenever it
162
+ changes, and each client mirrors it on its own local cursor — which is why
163
+ the pointer you see is always sharp and moves with zero latency.
135
164
 
136
165
  **Screen transfer.** Frames are captured at up to 30 fps and compared with
137
166
  the previous capture in 64-row bands; only the bands that changed are
@@ -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.9.2"
@@ -12,16 +12,19 @@ tray menu. Only one instance runs per user session (`single_instance`)."""
12
12
 
13
13
  import json
14
14
  import logging
15
+ import re
15
16
  import sqlite3
16
17
  import sys
17
18
  import time
19
+ from pathlib import Path
18
20
 
19
- from PySide6.QtCore import QObject, QProcess, Qt, QTimer, Signal
20
- from PySide6.QtGui import QCloseEvent, QKeySequence
21
+ from PySide6.QtCore import QObject, QProcess, QStandardPaths, Qt, QTimer, Signal
22
+ from PySide6.QtGui import QCloseEvent, QImage, QKeySequence
21
23
  from PySide6.QtNetwork import QNetworkInterface
22
24
  from PySide6.QtWidgets import (
23
25
  QApplication,
24
26
  QDockWidget,
27
+ QFileDialog,
25
28
  QFrame,
26
29
  QGroupBox,
27
30
  QHBoxLayout,
@@ -314,11 +317,35 @@ class MainWindow(QMainWindow):
314
317
  # through, and the roles are changed only in Preferences.
315
318
  self.client_role_button = self._make_role_button()
316
319
  self.server_role_button = self._make_role_button()
320
+ # Screen capture of the current session tab (the last received frame
321
+ # at the server's full resolution). Buttons live in the dock; the
322
+ # same handlers back the File-menu actions. Enabled only while the
323
+ # current tab shows a frame (_update_capture_actions).
324
+ self.copy_capture_button = QPushButton("Copy")
325
+ self.copy_capture_button.setToolTip(
326
+ "Copy a screen capture of the current session to the clipboard"
327
+ )
328
+ self.copy_capture_button.clicked.connect(self._copy_screen_capture)
329
+ self.save_capture_button = QPushButton("Save…")
330
+ self.save_capture_button.setToolTip(
331
+ "Save a screen capture of the current session to a file"
332
+ )
333
+ self.save_capture_button.clicked.connect(self._save_screen_capture)
334
+ # Nothing to capture until a session tab shows a frame.
335
+ self.copy_capture_button.setEnabled(False)
336
+ self.save_capture_button.setEnabled(False)
337
+ self._capture_group = QGroupBox("Screen capture")
338
+ # Stacked, not side by side: the dock is narrow and its width is
339
+ # what the panel/server list gets — height is the cheaper dimension.
340
+ capture_layout = QVBoxLayout(self._capture_group)
341
+ capture_layout.addWidget(self.copy_capture_button)
342
+ capture_layout.addWidget(self.save_capture_button)
317
343
  dock_body = QWidget()
318
344
  self._dock_layout = QVBoxLayout(dock_body)
319
345
  self._dock_layout.addWidget(self.client_role_button)
320
346
  self._dock_layout.addWidget(self.server_role_button)
321
347
  self._dock_layout.addWidget(self.discovery_panel)
348
+ self._dock_layout.addWidget(self._capture_group)
322
349
  # A permanent trailing spacer: it takes the leftover height while
323
350
  # the discovery panel is hidden (keeping the indicators pinned to
324
351
  # the top), and collapses to nothing while the panel is visible
@@ -326,6 +353,7 @@ class MainWindow(QMainWindow):
326
353
  self._dock_layout.addStretch(0)
327
354
  if not self._viewer_enabled:
328
355
  self.discovery_panel.hide()
356
+ self._capture_group.hide()
329
357
  self._update_dock_layout()
330
358
  # No title bar at all: the contents are self-explanatory, and the
331
359
  # View menu ("Panel") is the way to show or hide it — a blank header
@@ -417,6 +445,15 @@ class MainWindow(QMainWindow):
417
445
  self.close_tab_action.triggered.connect(self._close_current_session_tab)
418
446
  self.close_tab_action.setEnabled(False) # startup tab is a fixed tab
419
447
  self._file_menu.addSeparator()
448
+ # Same handlers as the dock's Screen capture buttons; enabled state
449
+ # is kept in sync with them (_update_capture_actions).
450
+ self.copy_capture_action = self._file_menu.addAction("Copy screen &capture")
451
+ self.copy_capture_action.triggered.connect(self._copy_screen_capture)
452
+ self.copy_capture_action.setEnabled(False)
453
+ self.save_capture_action = self._file_menu.addAction("&Save screen capture…")
454
+ self.save_capture_action.triggered.connect(self._save_screen_capture)
455
+ self.save_capture_action.setEnabled(False)
456
+ self._file_menu.addSeparator()
420
457
  self.quit_action = self._file_menu.addAction("&Quit")
421
458
  self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
422
459
  self.quit_action.triggered.connect(self._quit)
@@ -537,7 +574,7 @@ class MainWindow(QMainWindow):
537
574
  """Give the dock's leftover height to the panel or the spacer."""
538
575
  panel_visible = self._viewer_enabled
539
576
  self._dock_layout.setStretch(2, 1 if panel_visible else 0) # panel
540
- self._dock_layout.setStretch(3, 0 if panel_visible else 1) # spacer
577
+ self._dock_layout.setStretch(4, 0 if panel_visible else 1) # spacer
541
578
 
542
579
  def _update_role_indicators(self) -> None:
543
580
  self.client_role_button.setChecked(self._viewer_enabled)
@@ -560,8 +597,10 @@ class MainWindow(QMainWindow):
560
597
  if placeholder_index != -1:
561
598
  self._tabs.removeTab(placeholder_index)
562
599
  self.discovery_panel.hide()
600
+ self._capture_group.hide()
563
601
  else:
564
602
  self.discovery_panel.show()
603
+ self._capture_group.show()
565
604
  self._ensure_placeholder()
566
605
  self._tabs.setCurrentIndex(0)
567
606
  if self._auto_scan:
@@ -581,6 +620,74 @@ class MainWindow(QMainWindow):
581
620
  self.fullscreen_action.setEnabled(session is not None)
582
621
  self.actual_size_action.setEnabled(session is not None)
583
622
  self.actual_size_action.setChecked(session.actual_size if session else False)
623
+ self._update_capture_actions()
624
+
625
+ # ------------------------------------------------------ screen capture
626
+
627
+ def _update_capture_actions(self) -> None:
628
+ """Capture is possible while the current tab shows a frame; the dock
629
+ buttons and the File-menu actions enable and disable together."""
630
+ session = self._session_for_page(self._tabs.currentWidget())
631
+ available = session is not None and session.viewer.has_frame
632
+ for action in (
633
+ self.copy_capture_button,
634
+ self.save_capture_button,
635
+ self.copy_capture_action,
636
+ self.save_capture_action,
637
+ ):
638
+ action.setEnabled(available)
639
+
640
+ def _current_capture(self) -> tuple[ServerSession, QImage] | None:
641
+ """The current tab's session and its full-resolution frame, or None
642
+ (the handlers can fire with no frame via QAction.trigger())."""
643
+ session = self._session_for_page(self._tabs.currentWidget())
644
+ if session is None:
645
+ return None
646
+ image = session.viewer.frame_image()
647
+ if image is None:
648
+ return None
649
+ return session, image
650
+
651
+ def _copy_screen_capture(self) -> None:
652
+ capture = self._current_capture()
653
+ if capture is None:
654
+ return
655
+ session, image = capture
656
+ # copy_image does not echo to peers: sending the server a capture of
657
+ # its own screen (or spamming viewers) would be pure waste.
658
+ self._clipboard.copy_image(image)
659
+ self.log(
660
+ f"Screen capture of {session.name} "
661
+ f"({image.width()}x{image.height()}) copied to the clipboard"
662
+ )
663
+
664
+ def _save_screen_capture(self) -> None:
665
+ capture = self._current_capture()
666
+ if capture is None:
667
+ return
668
+ session, image = capture
669
+ # Default: Pictures\<server> 2026-07-20 143059.png (the server name
670
+ # is a hostname, but sanitize for safety — it came off the wire).
671
+ name = re.sub(r'[<>:"/\\|?*]', "_", session.name).strip() or "server"
672
+ stamp = time.strftime("%Y-%m-%d %H%M%S")
673
+ directory = QStandardPaths.writableLocation(
674
+ QStandardPaths.StandardLocation.PicturesLocation
675
+ )
676
+ default = str(Path(directory) / f"{name} {stamp}.png") if directory else f"{name} {stamp}.png"
677
+ path, _filter = QFileDialog.getSaveFileName(
678
+ self, "Save screen capture", default, "PNG image (*.png)"
679
+ )
680
+ if not path:
681
+ return # cancelled
682
+ if not path.lower().endswith(".png"):
683
+ path += ".png"
684
+ if image.save(path, "PNG"): # ty: ignore[no-matching-overload]
685
+ self.log(
686
+ f"Screen capture of {session.name} "
687
+ f"({image.width()}x{image.height()}) saved to {path}"
688
+ )
689
+ else:
690
+ self.log(f"Could not save the screen capture to {path}")
584
691
 
585
692
  def _close_current_session_tab(self) -> None:
586
693
  session = self._session_for_page(self._tabs.currentWidget())
@@ -1040,6 +1147,7 @@ class MainWindow(QMainWindow):
1040
1147
  client.disconnected.connect(lambda s=session: self._on_disconnected(s))
1041
1148
  client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
1042
1149
  client.cursorShapeChanged.connect(lambda shape, s=session: s.viewer.set_remote_cursor(shape))
1150
+ client.sessionLockChanged.connect(lambda locked, s=session: s.viewer.set_session_locked(locked))
1043
1151
  client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
1044
1152
  client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
1045
1153
  self._sessions.append(session)
@@ -1069,6 +1177,7 @@ class MainWindow(QMainWindow):
1069
1177
  if not any(s.connected for s in self._sessions if s is not session):
1070
1178
  self.client_performance.reset()
1071
1179
  session.viewer.clear(f"Connecting to {session.name} …")
1180
+ self._update_capture_actions() # cleared viewer; tab may already be current
1072
1181
  self._tabs.setCurrentWidget(session.page)
1073
1182
  self._set_session_status(
1074
1183
  session, f"Connecting to {session.name} ({session.key}) …"
@@ -1110,6 +1219,7 @@ class MainWindow(QMainWindow):
1110
1219
  f"Waiting for approval — someone at {session.name} "
1111
1220
  "must allow this connection"
1112
1221
  )
1222
+ self._update_capture_actions()
1113
1223
  self._set_session_status(
1114
1224
  session,
1115
1225
  f"Waiting for the user on {session.name} to approve this computer …",
@@ -1177,6 +1287,7 @@ class MainWindow(QMainWindow):
1177
1287
  session.auto_reconnect = False
1178
1288
  self.client_inventory.record(session.key, "denied", name=session.name)
1179
1289
  session.viewer.clear(f"Connection denied: {reason}")
1290
+ self._update_capture_actions()
1180
1291
  self._set_session_status(session, f"Denied by {session.name}: {reason}")
1181
1292
  self._update_window_title()
1182
1293
 
@@ -1190,6 +1301,7 @@ class MainWindow(QMainWindow):
1190
1301
  self.client_inventory.record(session.key, "disconnected", name=session.name)
1191
1302
  session.viewer.clear("Disconnected")
1192
1303
  self._set_session_status(session, f"Disconnected from {session.name}")
1304
+ self._update_capture_actions() # a cleared viewer has nothing to capture
1193
1305
  self._update_window_title()
1194
1306
  self._sessions_source.notify()
1195
1307
  if session.auto_reconnect and not session.denied and not self._quitting:
@@ -1272,6 +1384,9 @@ class MainWindow(QMainWindow):
1272
1384
  return
1273
1385
  session.frame_count += 1
1274
1386
  session.viewer.show_frame(image)
1387
+ # Unconditional (not just the first frame): auto-reconnect refills a
1388
+ # cleared viewer without passing through _connect_session.
1389
+ self._update_capture_actions()
1275
1390
  self._set_session_status(
1276
1391
  session,
1277
1392
  f"Viewing {self._server_label(session)} — {image.width()}x{image.height()} — "
@@ -69,6 +69,25 @@ class ClipboardSync(QObject):
69
69
  ).decode()
70
70
  self.changed.emit(payload)
71
71
 
72
+ def copy_image(self, image: QImage) -> None:
73
+ """Place an app-generated image (a screen capture) on the local
74
+ clipboard WITHOUT echoing it to peers: its signature is recorded
75
+ first, so the asynchronous dataChanged this set fires is ignored —
76
+ the same mechanism apply() uses. Sending a capture of the server's
77
+ own screen back to it would be pure waste.
78
+
79
+ Deliberately not gated on `enabled`: that preference governs
80
+ syncing, and this is a local copy.
81
+ """
82
+ if image.isNull():
83
+ return
84
+ self._last_signature = (None, _image_hash(image))
85
+ self._applying = True
86
+ try:
87
+ self._clipboard.setImage(image)
88
+ finally:
89
+ self._applying = False
90
+
72
91
  def apply(self, payload: dict) -> None:
73
92
  if not self.enabled:
74
93
  return # peers may still send payloads; they are dropped locally
@@ -118,6 +118,10 @@ class PreferencesTab(QWidget):
118
118
  )
119
119
  self._autostart = autostart if autostart is not None else Autostart()
120
120
  self.history_minutes = QSpinBox()
121
+ self.history_minutes.setToolTip(
122
+ "How much recent history the Performance tab's bandwidth and\n"
123
+ "round-trip-time graphs (and their statistics) cover."
124
+ )
121
125
  self.history_minutes.setRange(1, 30)
122
126
  self.history_minutes.setSuffix(" min")
123
127
  self.history_minutes.setValue(
@@ -125,23 +129,35 @@ class PreferencesTab(QWidget):
125
129
  )
126
130
  self.history_minutes.valueChanged.connect(self._on_history_changed)
127
131
  self.autostart_checkbox = QCheckBox("Start Remote Desktop when I log in to Windows")
132
+ self.autostart_checkbox.setToolTip(
133
+ "Registers this app in your Windows startup (per-user, no admin\n"
134
+ "rights) so it launches at login — minimized to the tray while\n"
135
+ "sharing is on, so sharing resumes after a reboot without\n"
136
+ "anyone having to launch the app."
137
+ )
128
138
  self.autostart_checkbox.setChecked(self._autostart.is_enabled())
129
139
  self.autostart_checkbox.setEnabled(self._autostart.available)
130
140
  self.autostart_checkbox.toggled.connect(self._on_autostart_toggled)
131
141
  self.clipboard_checkbox = QCheckBox("Sync clipboard with connected computers")
142
+ self.clipboard_checkbox.setToolTip(
143
+ "Text and images copied on this computer appear on connected\n"
144
+ "computers, and theirs appear here.\n"
145
+ "Turn off to keep this computer's clipboard private — nothing\n"
146
+ "is sent or applied in either direction."
147
+ )
132
148
  self.clipboard_checkbox.setChecked(load_clipboard_sync_enabled(settings))
133
149
  self.clipboard_checkbox.toggled.connect(self._on_clipboard_toggled)
134
150
  self.reduce_effects_checkbox = QCheckBox(
135
- "Reduce Windows visual effects while sharing this screen"
151
+ "Reduce Windows visual effects while sharing this screen (recommended)"
136
152
  )
137
153
  self.reduce_effects_checkbox.setToolTip(
138
- "Makes the remote view feel more responsive: window animations, "
139
- "menu fades, and shadows each stream as a burst of screen "
140
- "updates, so turning them off while viewers are connected lets "
141
- "the remote screen snap instead of smearing — and saves "
142
- "bandwidth. Submenus also open faster. Your Windows settings "
143
- "come back when the last viewer disconnects; nothing is "
144
- "permanently changed."
154
+ "Makes the remote view feel more responsive: window animations,\n"
155
+ "menu fades, and shadows each stream as a burst of screen\n"
156
+ "updates, so turning them off while viewers are connected lets\n"
157
+ "the remote screen snap instead of smearing — and saves\n"
158
+ "bandwidth. Submenus also open faster.\n"
159
+ "Your Windows settings come back when the last viewer\n"
160
+ "disconnects; nothing is permanently changed."
145
161
  )
146
162
  self.reduce_effects_checkbox.setChecked(load_reduce_effects_enabled(settings))
147
163
  self.reduce_effects_checkbox.toggled.connect(self._on_reduce_effects_toggled)
@@ -149,14 +165,30 @@ class PreferencesTab(QWidget):
149
165
  self.sharing_off_radio = QRadioButton(
150
166
  "Not shared — no one can see this computer's screen"
151
167
  )
168
+ self.sharing_off_radio.setToolTip(
169
+ "This computer neither listens for connections nor announces\n"
170
+ "itself on the LAN. Existing viewers are disconnected."
171
+ )
152
172
  self.sharing_view_radio = QRadioButton(
153
173
  "Shared, view only — clients can watch this computer's screen "
154
174
  "but not control it"
155
175
  )
176
+ self.sharing_view_radio.setToolTip(
177
+ "Approved clients see this screen live, but their keyboard and\n"
178
+ "mouse input is ignored.\n"
179
+ "Switching between view only and full control applies instantly\n"
180
+ "without disconnecting viewers."
181
+ )
156
182
  self.sharing_control_radio = QRadioButton(
157
183
  "Shared, full control — clients can watch this computer's screen "
158
184
  "and control it with their keyboard and mouse"
159
185
  )
186
+ self.sharing_control_radio.setToolTip(
187
+ "Approved clients see this screen live and can type and click\n"
188
+ "as if sitting at this computer.\n"
189
+ "Each new client needs a one-time approval on this computer\n"
190
+ "before it can connect."
191
+ )
160
192
  self._mode_radios = {
161
193
  SHARING_MODE_OFF: self.sharing_off_radio,
162
194
  SHARING_MODE_VIEW: self.sharing_view_radio,
@@ -174,8 +206,14 @@ class PreferencesTab(QWidget):
174
206
  sharing_layout.addWidget(radio)
175
207
  # Theme: an explicit light/dark override, or follow the OS setting.
176
208
  self.theme_system_radio = QRadioButton("Follow the Windows light/dark setting")
209
+ self.theme_system_radio.setToolTip(
210
+ "Match Windows: the app switches automatically when the\n"
211
+ "Windows light/dark mode changes."
212
+ )
177
213
  self.theme_light_radio = QRadioButton("Light")
214
+ self.theme_light_radio.setToolTip("Always use the light theme.")
178
215
  self.theme_dark_radio = QRadioButton("Dark")
216
+ self.theme_dark_radio.setToolTip("Always use the dark theme.")
179
217
  self._theme_radios = {
180
218
  THEME_SYSTEM: self.theme_system_radio,
181
219
  THEME_LIGHT: self.theme_light_radio,
@@ -193,14 +231,20 @@ class PreferencesTab(QWidget):
193
231
  theme_layout.addWidget(radio)
194
232
  self.restart_button = QPushButton("Restart app")
195
233
  self.restart_button.setToolTip(
196
- "Relaunch this app (e.g. after updating the software). It can be "
197
- "clicked from a remote desktop session, so an update doesn't "
198
- "require visiting this computer."
234
+ "Relaunch this app (e.g. after updating the software).\n"
235
+ "It can be clicked from a remote desktop session, so an update\n"
236
+ "doesn't require visiting this computer."
199
237
  )
200
238
  self.viewer_checkbox = QCheckBox(
201
239
  "Act as a client — discover servers on this LAN and view or "
202
240
  "control their screens"
203
241
  )
242
+ self.viewer_checkbox.setToolTip(
243
+ "Shows the client-side UI: the server panel, session tabs, and\n"
244
+ "server history.\n"
245
+ "Turn off on a computer that only shares its screen — it will\n"
246
+ "not connect to (or scan for) other computers at all."
247
+ )
204
248
  self.viewer_checkbox.setChecked(load_viewer_enabled(settings))
205
249
  self.viewer_checkbox.toggled.connect(self._on_viewer_toggled)
206
250
  layout = QFormLayout(self)
@@ -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