remotedesktop 1.6.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.6.0 → remotedesktop-1.9.2}/CLAUDE.md +4 -1
  2. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/PKG-INFO +32 -2
  3. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/pyproject.toml +1 -0
  4. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/readme.md +30 -1
  5. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/__init__.py +1 -1
  6. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/app.py +156 -3
  7. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/clipboard.py +19 -0
  8. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/config.py +138 -122
  9. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/preferences.py +85 -7
  10. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/server.py +9 -7
  11. remotedesktop-1.9.2/src/remotedesktop/session_lock.py +46 -0
  12. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/sharing.py +60 -0
  13. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/viewer.py +62 -25
  14. remotedesktop-1.9.2/src/remotedesktop/visual_effects.py +182 -0
  15. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_clipboard.py +24 -0
  16. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_config.py +79 -58
  17. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_input.py +56 -0
  18. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_main_window.py +173 -6
  19. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_preferences.py +25 -0
  20. remotedesktop-1.9.2/tests/test_session_lock.py +18 -0
  21. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_sharing.py +32 -0
  22. remotedesktop-1.9.2/tests/test_visual_effects.py +125 -0
  23. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/uv.lock +2 -0
  24. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/.github/workflows/ci.yml +0 -0
  25. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/.github/workflows/release.yml +0 -0
  26. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/.gitignore +0 -0
  27. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/LICENSE +0 -0
  28. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/docs/media/client-demo.gif +0 -0
  29. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/docs/media/server-demo.gif +0 -0
  30. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/icon/remotedesktop.ico +0 -0
  31. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/run.bat +0 -0
  32. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/run_claude.bat +0 -0
  33. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/scripts/make_coverage_badge.py +0 -0
  34. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/scripts/make_installer.bat +0 -0
  35. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/scripts/make_venv.bat +0 -0
  36. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/__main__.py +0 -0
  37. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/about.py +0 -0
  38. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/autostart.py +0 -0
  39. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/client.py +0 -0
  40. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/compat.py +0 -0
  41. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/cursor_shape.py +0 -0
  42. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/db.py +0 -0
  43. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/discovery.py +0 -0
  44. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/dxgi.py +0 -0
  45. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/frames.py +0 -0
  46. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/icon.py +0 -0
  47. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/input_injection.py +0 -0
  48. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/inventory.py +0 -0
  49. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/logs.py +0 -0
  50. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/modal_loop.py +0 -0
  51. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/performance.py +0 -0
  52. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/protocol.py +0 -0
  53. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/single_instance.py +0 -0
  54. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/tls.py +0 -0
  55. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/window_state.py +0 -0
  56. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/conftest.py +0 -0
  57. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_about.py +0 -0
  58. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_autostart.py +0 -0
  59. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_compat.py +0 -0
  60. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_cursor_shape.py +0 -0
  61. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_discovery.py +0 -0
  62. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_dxgi.py +0 -0
  63. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_frames.py +0 -0
  64. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_icon.py +0 -0
  65. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_input_injection.py +0 -0
  66. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_inventory.py +0 -0
  67. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_logs.py +0 -0
  68. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_modal_loop.py +0 -0
  69. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_performance.py +0 -0
  70. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_protocol.py +0 -0
  71. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_sharing_tab.py +0 -0
  72. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_single_instance.py +0 -0
  73. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_smoke.py +0 -0
  74. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_tls.py +0 -0
  75. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_window_state.py +0 -0
  76. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tools/make_demo_gifs.py +0 -0
  77. {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tools/make_icon.py +0 -0
@@ -76,12 +76,14 @@ 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).
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).
79
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.
80
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.
81
83
  - **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}`; datagrams with the wrong magic/version/type are dropped. **Scan robustness**: probes are re-sent every `_PROBE_INTERVAL` (0.3 s) for the whole scan window (a lost broadcast only delays discovery), ICMP port-unreachable resets never abort the scan (Windows `SIO_UDP_CONNRESET` ioctl + `ConnectionResetError` → continue), and the panel passes `client._broadcast_hosts()` — 255.255.255.255 plus every up non-loopback interface's directed broadcast, because Windows routes the limited broadcast out only one interface (VPN/virtual adapters made scans come up empty). A sharing instance runs a `DiscoveryResponder` thread while sharing is enabled; the `DiscoveryPanel` (dock in `MainWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. With `auto_scan=True` (the app; tests default to False and never broadcast) it scans **once at startup only** — further scans happen only on manual Refresh/F5 (deliberately no periodic background scanning; the user asked for this), guarded against overlapping scans and preserving the selection across repopulation; it also has a Connect button, a right-click Connect menu, and an empty-state hint. The "(this computer)" entry is not connectable (Connect disabled, activation swallowed, plus a `MainWindow._on_server_activated` guard). `DEFAULT_CONNECT_PORT` is 48654. An instance that shares and scans discovers itself — `DiscoveryPanel`'s `is_self=` callable labels that entry "(this computer)" (never hidden).
82
84
  - Discovery tests run over loopback with ephemeral ports (`bind_host`/`discovery_port`/`broadcast_hosts` parameters exist for this), so they never touch the real LAN or fixed ports.
83
85
  - **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
84
- - **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
86
+ - **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. **Boolean settings go through `Settings.get_bool`/`set_bool`** (the `tobool` package's `to_bool_strict`; on-disk form stays the historical `"1"`/`"0"`, junk falls back to the caller's default) — don't hand-roll `!= "0"` comparisons. This applies to binary values stored as strings/ints; ctypes Win32 `BOOL`s keep plain `bool()` (TRUE is "any nonzero", which strict parsing correctly refuses). `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
85
87
  - **Trust model plumbing** (`config.py`): the client has a stable UUID identity (`load_client_identity`, in the `settings` table) and the server maps each approved client-id to a token (`PairedClients`). `ShareServer` takes an `approve_client(client_id, name) -> bool` callback; `SharingTab` implements it as a modal stay-on-top QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
86
88
  - **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, `SharingTab`, and `DiscoveryPanel` emit human-readable `status`/`statusMessage` signals for every connection phase, shown in the window's timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the pane shows is also written to a rotating debug log file (`logs.py`; `logs.init_logging("remotedesktop")` is called only by `app.main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\remotedesktop.log` on Windows; pre-1.0 `client.log`/`server.log` files just age out). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
87
89
  - **Connection inventory** (`inventory.py`): the Connections tab has two grouped tables ("Client history" for the sharing role, "Server history" for the viewing role), each backed by its own `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The sharing side is driven by `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases), forwarded through `SharingTab.peerEvent`. The viewing side is driven by `MainWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
@@ -89,6 +91,7 @@ Managed with `uv` (hatchling build backend, src layout):
89
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.
90
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.
91
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.
92
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.
93
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`.
94
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.6.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
@@ -19,6 +19,7 @@ Requires-Dist: cryptography>=49.0.0
19
19
  Requires-Dist: platformdirs>=4.10.0
20
20
  Requires-Dist: pyside6>=6.11.1
21
21
  Requires-Dist: semver>=3.0
22
+ Requires-Dist: tobool>=0.4.0
22
23
  Description-Content-Type: text/markdown
23
24
 
24
25
  # Remote Desktop
@@ -73,10 +74,14 @@ one-click *Forget* / *Revoke*, and the live connection log.
73
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).
74
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.
75
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).
76
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.
77
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.
78
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.
79
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)).
80
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.
81
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.
82
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.
@@ -138,6 +143,28 @@ access can be revoked at any time from the *Connections* tab. There is
138
143
  no dependency
139
144
  on Windows RDP or any Microsoft-based authentication.
140
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
+
141
168
  ## How it works
142
169
 
143
170
  All of this is pure Python — the GUI is PySide6 (Qt), and the two places
@@ -154,7 +181,10 @@ an idle screen costs essentially nothing. When duplication is unavailable
154
181
  or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
155
182
  display-mode change — the server transparently falls back to Qt's
156
183
  `QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
157
- 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.
158
188
 
159
189
  **Screen transfer.** Frames are captured at up to 30 fps and compared with
160
190
  the previous capture in 64-row bands; only the bands that changed are
@@ -19,6 +19,7 @@ dependencies = [
19
19
  "platformdirs>=4.10.0",
20
20
  "pyside6>=6.11.1",
21
21
  "semver>=3.0",
22
+ "tobool>=0.4.0",
22
23
  ]
23
24
 
24
25
  [project.urls]
@@ -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.6.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,
@@ -55,6 +58,7 @@ from remotedesktop.preferences import (
55
58
  apply_theme,
56
59
  load_clipboard_sync_enabled,
57
60
  load_performance_window_seconds,
61
+ load_reduce_effects_enabled,
58
62
  load_theme,
59
63
  load_viewer_enabled,
60
64
  )
@@ -62,6 +66,7 @@ from remotedesktop.server import SharingTab, ViewersTable
62
66
  from remotedesktop.sharing import ShareClient
63
67
  from remotedesktop.single_instance import SingleInstance
64
68
  from remotedesktop.viewer import ViewerWidget
69
+ from remotedesktop.visual_effects import VisualEffectsReducer
65
70
 
66
71
  _log = logging.getLogger("remotedesktop.app")
67
72
 
@@ -127,6 +132,7 @@ class MainWindow(QMainWindow):
127
132
  connect_port: int = DEFAULT_CONNECT_PORT,
128
133
  tray_available: bool | None = None,
129
134
  reconnect_base_seconds: float = 2.0,
135
+ effects_reducer: VisualEffectsReducer | None = None,
130
136
  ) -> None:
131
137
  super().__init__()
132
138
  self._reconnect_base = reconnect_base_seconds
@@ -164,6 +170,15 @@ class MainWindow(QMainWindow):
164
170
  self._tray: QSystemTrayIcon | None = None
165
171
  self._tray_notified = False
166
172
  self._quitting = False
173
+ # Windows visual effects are reduced only while someone is actually
174
+ # watching (fewer animation frames to encode and ship) and restored
175
+ # when the last viewer leaves. The default reducer changes real OS
176
+ # settings — tests always inject one with a fake backend.
177
+ self._effects_reducer = (
178
+ effects_reducer if effects_reducer is not None else VisualEffectsReducer()
179
+ )
180
+ self._reduce_effects = load_reduce_effects_enabled(self._settings)
181
+ self._sharing_viewer_count = 0
167
182
  self._fullscreen_state: dict | None = None
168
183
  self._fullscreen_hint: QLabel | None = None
169
184
 
@@ -180,6 +195,7 @@ class MainWindow(QMainWindow):
180
195
  self.sharing_tab.peerEvent.connect(self._record_server_peer)
181
196
  self.sharing_tab.sharingChanged.connect(self._on_sharing_changed)
182
197
  self.sharing_tab.viewerCountChanged.connect(self._update_sharing_indicator)
198
+ self.sharing_tab.viewerCountChanged.connect(self._on_viewer_count_changed)
183
199
 
184
200
  # One tab per server connection (inserted at the front, closable),
185
201
  # followed by the fixed tabs, which never get a close button. While
@@ -301,11 +317,35 @@ class MainWindow(QMainWindow):
301
317
  # through, and the roles are changed only in Preferences.
302
318
  self.client_role_button = self._make_role_button()
303
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)
304
343
  dock_body = QWidget()
305
344
  self._dock_layout = QVBoxLayout(dock_body)
306
345
  self._dock_layout.addWidget(self.client_role_button)
307
346
  self._dock_layout.addWidget(self.server_role_button)
308
347
  self._dock_layout.addWidget(self.discovery_panel)
348
+ self._dock_layout.addWidget(self._capture_group)
309
349
  # A permanent trailing spacer: it takes the leftover height while
310
350
  # the discovery panel is hidden (keeping the indicators pinned to
311
351
  # the top), and collapses to nothing while the panel is visible
@@ -313,6 +353,7 @@ class MainWindow(QMainWindow):
313
353
  self._dock_layout.addStretch(0)
314
354
  if not self._viewer_enabled:
315
355
  self.discovery_panel.hide()
356
+ self._capture_group.hide()
316
357
  self._update_dock_layout()
317
358
  # No title bar at all: the contents are self-explanatory, and the
318
359
  # View menu ("Panel") is the way to show or hide it — a blank header
@@ -338,6 +379,7 @@ class MainWindow(QMainWindow):
338
379
  # three-state choice) and the viewer role's UI.
339
380
  self.preferences_tab.sharingModeChanged.connect(self.sharing_tab.set_mode)
340
381
  self.preferences_tab.viewerModeChanged.connect(self._set_viewer_enabled)
382
+ self.preferences_tab.reduceEffectsChanged.connect(self._on_reduce_effects_changed)
341
383
  self.preferences_tab.restart_button.clicked.connect(self._restart_app)
342
384
  self._tabs.addTab(self.preferences_tab, "Preferences")
343
385
  self._about_tab = AboutTab()
@@ -403,6 +445,15 @@ class MainWindow(QMainWindow):
403
445
  self.close_tab_action.triggered.connect(self._close_current_session_tab)
404
446
  self.close_tab_action.setEnabled(False) # startup tab is a fixed tab
405
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()
406
457
  self.quit_action = self._file_menu.addAction("&Quit")
407
458
  self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
408
459
  self.quit_action.triggered.connect(self._quit)
@@ -523,7 +574,7 @@ class MainWindow(QMainWindow):
523
574
  """Give the dock's leftover height to the panel or the spacer."""
524
575
  panel_visible = self._viewer_enabled
525
576
  self._dock_layout.setStretch(2, 1 if panel_visible else 0) # panel
526
- 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
527
578
 
528
579
  def _update_role_indicators(self) -> None:
529
580
  self.client_role_button.setChecked(self._viewer_enabled)
@@ -546,8 +597,10 @@ class MainWindow(QMainWindow):
546
597
  if placeholder_index != -1:
547
598
  self._tabs.removeTab(placeholder_index)
548
599
  self.discovery_panel.hide()
600
+ self._capture_group.hide()
549
601
  else:
550
602
  self.discovery_panel.show()
603
+ self._capture_group.show()
551
604
  self._ensure_placeholder()
552
605
  self._tabs.setCurrentIndex(0)
553
606
  if self._auto_scan:
@@ -567,6 +620,74 @@ class MainWindow(QMainWindow):
567
620
  self.fullscreen_action.setEnabled(session is not None)
568
621
  self.actual_size_action.setEnabled(session is not None)
569
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}")
570
691
 
571
692
  def _close_current_session_tab(self) -> None:
572
693
  session = self._session_for_page(self._tabs.currentWidget())
@@ -713,6 +834,27 @@ class MainWindow(QMainWindow):
713
834
  self._update_performance_tabs()
714
835
  self._update_connections_groups()
715
836
 
837
+ def _on_viewer_count_changed(self, count: int) -> None:
838
+ self._sharing_viewer_count = count
839
+ self._update_effects_reduction()
840
+
841
+ def _on_reduce_effects_changed(self, enabled: bool) -> None:
842
+ self._reduce_effects = enabled
843
+ self._update_effects_reduction() # applies/restores live mid-session
844
+
845
+ def _update_effects_reduction(self) -> None:
846
+ """Reduce Windows visual effects while the preference is on AND a
847
+ viewer is connected; restore the user's values otherwise. Stop,
848
+ quit, and restart all reach here via their viewerCountChanged(0)."""
849
+ if self._reduce_effects and self._sharing_viewer_count > 0:
850
+ if self._effects_reducer.apply():
851
+ self.log(
852
+ "Windows visual effects reduced while viewers are connected "
853
+ "(restored when the last one disconnects)"
854
+ )
855
+ elif self._effects_reducer.restore():
856
+ self.log("Windows visual effects restored")
857
+
716
858
  def _quit(self) -> None:
717
859
  self._quitting = True
718
860
  self.close()
@@ -758,6 +900,9 @@ class MainWindow(QMainWindow):
758
900
  self._cancel_reconnect(session)
759
901
  session.client.close()
760
902
  self.sharing_tab.shutdown()
903
+ # shutdown's viewerCountChanged(0) already restored the effects;
904
+ # this is the safety net for any exit path that skipped the signal.
905
+ self._effects_reducer.restore()
761
906
  super().closeEvent(event)
762
907
  QApplication.quit() # main() disables quit-on-last-window-closed
763
908
 
@@ -1002,6 +1147,7 @@ class MainWindow(QMainWindow):
1002
1147
  client.disconnected.connect(lambda s=session: self._on_disconnected(s))
1003
1148
  client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
1004
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))
1005
1151
  client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
1006
1152
  client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
1007
1153
  self._sessions.append(session)
@@ -1031,6 +1177,7 @@ class MainWindow(QMainWindow):
1031
1177
  if not any(s.connected for s in self._sessions if s is not session):
1032
1178
  self.client_performance.reset()
1033
1179
  session.viewer.clear(f"Connecting to {session.name} …")
1180
+ self._update_capture_actions() # cleared viewer; tab may already be current
1034
1181
  self._tabs.setCurrentWidget(session.page)
1035
1182
  self._set_session_status(
1036
1183
  session, f"Connecting to {session.name} ({session.key}) …"
@@ -1072,6 +1219,7 @@ class MainWindow(QMainWindow):
1072
1219
  f"Waiting for approval — someone at {session.name} "
1073
1220
  "must allow this connection"
1074
1221
  )
1222
+ self._update_capture_actions()
1075
1223
  self._set_session_status(
1076
1224
  session,
1077
1225
  f"Waiting for the user on {session.name} to approve this computer …",
@@ -1139,6 +1287,7 @@ class MainWindow(QMainWindow):
1139
1287
  session.auto_reconnect = False
1140
1288
  self.client_inventory.record(session.key, "denied", name=session.name)
1141
1289
  session.viewer.clear(f"Connection denied: {reason}")
1290
+ self._update_capture_actions()
1142
1291
  self._set_session_status(session, f"Denied by {session.name}: {reason}")
1143
1292
  self._update_window_title()
1144
1293
 
@@ -1152,6 +1301,7 @@ class MainWindow(QMainWindow):
1152
1301
  self.client_inventory.record(session.key, "disconnected", name=session.name)
1153
1302
  session.viewer.clear("Disconnected")
1154
1303
  self._set_session_status(session, f"Disconnected from {session.name}")
1304
+ self._update_capture_actions() # a cleared viewer has nothing to capture
1155
1305
  self._update_window_title()
1156
1306
  self._sessions_source.notify()
1157
1307
  if session.auto_reconnect and not session.denied and not self._quitting:
@@ -1234,6 +1384,9 @@ class MainWindow(QMainWindow):
1234
1384
  return
1235
1385
  session.frame_count += 1
1236
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()
1237
1390
  self._set_session_status(
1238
1391
  session,
1239
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