remotedesktop 1.6.0__tar.gz → 1.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/CLAUDE.md +3 -1
  2. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/PKG-INFO +24 -1
  3. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/pyproject.toml +1 -0
  4. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/readme.md +22 -0
  5. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/__init__.py +1 -1
  6. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/app.py +39 -0
  7. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/config.py +138 -122
  8. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/preferences.py +38 -4
  9. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/server.py +9 -7
  10. remotedesktop-1.8.0/src/remotedesktop/session_lock.py +46 -0
  11. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/sharing.py +60 -0
  12. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/viewer.py +56 -25
  13. remotedesktop-1.8.0/src/remotedesktop/visual_effects.py +182 -0
  14. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_config.py +79 -58
  15. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_input.py +56 -0
  16. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_main_window.py +55 -1
  17. remotedesktop-1.8.0/tests/test_session_lock.py +18 -0
  18. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_sharing.py +32 -0
  19. remotedesktop-1.8.0/tests/test_visual_effects.py +125 -0
  20. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/uv.lock +2 -0
  21. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/.github/workflows/ci.yml +0 -0
  22. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/.github/workflows/release.yml +0 -0
  23. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/.gitignore +0 -0
  24. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/LICENSE +0 -0
  25. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/docs/media/client-demo.gif +0 -0
  26. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/docs/media/server-demo.gif +0 -0
  27. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/icon/remotedesktop.ico +0 -0
  28. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/run.bat +0 -0
  29. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/run_claude.bat +0 -0
  30. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/scripts/make_coverage_badge.py +0 -0
  31. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/scripts/make_installer.bat +0 -0
  32. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/scripts/make_venv.bat +0 -0
  33. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/__main__.py +0 -0
  34. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/about.py +0 -0
  35. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/autostart.py +0 -0
  36. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/client.py +0 -0
  37. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/clipboard.py +0 -0
  38. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/compat.py +0 -0
  39. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/cursor_shape.py +0 -0
  40. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/db.py +0 -0
  41. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/discovery.py +0 -0
  42. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/dxgi.py +0 -0
  43. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/frames.py +0 -0
  44. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/icon.py +0 -0
  45. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/input_injection.py +0 -0
  46. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/inventory.py +0 -0
  47. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/logs.py +0 -0
  48. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/modal_loop.py +0 -0
  49. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/performance.py +0 -0
  50. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/protocol.py +0 -0
  51. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/single_instance.py +0 -0
  52. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/tls.py +0 -0
  53. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/src/remotedesktop/window_state.py +0 -0
  54. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/conftest.py +0 -0
  55. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_about.py +0 -0
  56. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_autostart.py +0 -0
  57. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_clipboard.py +0 -0
  58. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_compat.py +0 -0
  59. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_cursor_shape.py +0 -0
  60. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_discovery.py +0 -0
  61. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_dxgi.py +0 -0
  62. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_frames.py +0 -0
  63. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_icon.py +0 -0
  64. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_input_injection.py +0 -0
  65. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_inventory.py +0 -0
  66. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_logs.py +0 -0
  67. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_modal_loop.py +0 -0
  68. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_performance.py +0 -0
  69. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_preferences.py +0 -0
  70. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_protocol.py +0 -0
  71. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_sharing_tab.py +0 -0
  72. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_single_instance.py +0 -0
  73. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_smoke.py +0 -0
  74. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_tls.py +0 -0
  75. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tests/test_window_state.py +0 -0
  76. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tools/make_demo_gifs.py +0 -0
  77. {remotedesktop-1.6.0 → remotedesktop-1.8.0}/tools/make_icon.py +0 -0
@@ -76,12 +76,13 @@ 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
+ - **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
80
  - **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
81
  - **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
82
  - **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
83
  - 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
84
  - **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**.
85
+ - **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
86
  - **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
87
  - **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
88
  - **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 +90,7 @@ Managed with `uv` (hatchling build backend, src layout):
89
90
  - Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
90
91
  - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
91
92
  - **Remote cursor shape** (`cursor_shape.py`): the captured frame never contains the mouse pointer (DXGI duplication and BitBlt both exclude it), so the client mirrors the server's cursor on its *local* cursor instead. `ShareServer` takes an opt-in `cursor_probe=` callable (the `clipboard=` pattern — only the GUI passes one, `SharingTab` wires `current_cursor_shape`: GetCursorInfo compared against the shared standard IDC_* handles; custom cursors → "arrow", not showing → "hidden"). The probe is polled each broadcast tick and `{"type": "cursor", "shape": ...}` is sent on change only, plus once to each just-admitted stream. `ShareClient.cursorShapeChanged` → `ViewerWidget.set_remote_cursor` maps names to Qt cursors (unknown names → arrow); `clear()` unsets the cursor. Peers without the feature never send / silently ignore the message.
93
+ - **Lock-screen notice** (`session_lock.py`): the Windows lock screen / secure desktop (also UAC, Ctrl+Alt+Del) can be neither captured nor injected into by a user-mode process, so remote unlock is impossible by design (documented in the readme; the assumption is the server machine is physically reachable to sign in). `ShareServer` takes an opt-in `lock_probe=` callable (the `cursor_probe=` pattern — only the GUI wires `is_session_locked`, which reads OpenInputDesktop failure as "secure desktop has input"). Polled each broadcast tick; `{"type": "session_lock", "locked": bool}` is sent on change, plus to just-admitted streams while locked (unlocked is the assumed start state, so it's never sent on admission). While locked the server drops remote input with a once-per-lock-episode status line ("Ignoring remote input while the session is locked"). `ShareClient.sessionLockChanged` → `ViewerWidget.set_session_locked`: the viewer dims whatever it shows under `viewer.LOCKED_MESSAGE`; `clear()` resets it (so reconnects start unlocked). Peers without the feature never send / silently ignore the message. Sharing tests inject a fake `lock_probe` — the default None means plain tests never poll the host's real desktop.
92
94
  - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for a sharing instance this deadlocked when a *remote* click grabbed the window's own title bar (the mouse-up sat unread on the socket). `MainWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). The caption buttons (min/max/close) are the exception — their tracking loop dispatches only mouse messages, so no timer can pump inside it; the pump instead performs those via its `caption_action` callback on press and consumes the message (nativeEvent must return True when `handle_native_event` does), so DefWindowProc's button tracking never starts. Tests inject a fake `timers` backend — never let them create real Win32 timers.
93
95
  - **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the app passes `logs.read_log_tail("remotedesktop")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. The Connection log group on the Connections tab has both buttons — "Get server log" asks the server of the current session tab, "Get client log" asks the most recently admitted viewer; the reply opens a `logs.PeerLogDialog`.
94
96
  - **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 1.6.0
3
+ Version: 1.8.0
4
4
  Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
5
5
  Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
6
6
  Project-URL: Repository, https://github.com/jamesabel/remotedesktop
@@ -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
@@ -138,6 +139,28 @@ access can be revoked at any time from the *Connections* tab. There is
138
139
  no dependency
139
140
  on Windows RDP or any Microsoft-based authentication.
140
141
 
142
+ ## The Windows lock screen
143
+
144
+ **A locked computer cannot be viewed or unlocked remotely.** The lock
145
+ screen, the PIN/password prompt, and UAC elevation prompts run on a
146
+ separate *secure desktop* that Windows deliberately walls off from
147
+ ordinary applications: nothing running as the signed-in user may capture
148
+ it or type into it, so no software can read or fill in a credential
149
+ prompt. This app intentionally runs as the signed-in user — no admin
150
+ rights, no system service — so it sits on the application side of that
151
+ wall (remote-access products that can show the login screen install a
152
+ privileged Windows service to cross it).
153
+
154
+ What happens instead: when the shared computer locks, viewers stay
155
+ connected and see a clear *"The remote computer is locked"* notice over
156
+ the last frame, and any input they send is ignored until the computer is
157
+ unlocked. The working assumption is that you (or someone nearby) can get
158
+ to the shared computer to sign it in; once its desktop is unlocked,
159
+ streaming and control resume on their own. If that round trip is a
160
+ nuisance, keep the shared computer signed in while you work on it
161
+ remotely — for example by lengthening its lock/sleep timeout in Windows
162
+ settings.
163
+
141
164
  ## How it works
142
165
 
143
166
  All of this is pure Python — the GUI is PySide6 (Qt), and the two places
@@ -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]
@@ -115,6 +115,28 @@ access can be revoked at any time from the *Connections* tab. There is
115
115
  no dependency
116
116
  on Windows RDP or any Microsoft-based authentication.
117
117
 
118
+ ## The Windows lock screen
119
+
120
+ **A locked computer cannot be viewed or unlocked remotely.** The lock
121
+ screen, the PIN/password prompt, and UAC elevation prompts run on a
122
+ separate *secure desktop* that Windows deliberately walls off from
123
+ ordinary applications: nothing running as the signed-in user may capture
124
+ it or type into it, so no software can read or fill in a credential
125
+ prompt. This app intentionally runs as the signed-in user — no admin
126
+ rights, no system service — so it sits on the application side of that
127
+ wall (remote-access products that can show the login screen install a
128
+ privileged Windows service to cross it).
129
+
130
+ What happens instead: when the shared computer locks, viewers stay
131
+ connected and see a clear *"The remote computer is locked"* notice over
132
+ the last frame, and any input they send is ignored until the computer is
133
+ unlocked. The working assumption is that you (or someone nearby) can get
134
+ to the shared computer to sign it in; once its desktop is unlocked,
135
+ streaming and control resume on their own. If that round trip is a
136
+ nuisance, keep the shared computer signed in while you work on it
137
+ remotely — for example by lengthening its lock/sleep timeout in Windows
138
+ settings.
139
+
118
140
  ## How it works
119
141
 
120
142
  All of this is pure Python — the GUI is PySide6 (Qt), and the two places
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "1.6.0"
3
+ __version__ = "1.8.0"
@@ -55,6 +55,7 @@ from remotedesktop.preferences import (
55
55
  apply_theme,
56
56
  load_clipboard_sync_enabled,
57
57
  load_performance_window_seconds,
58
+ load_reduce_effects_enabled,
58
59
  load_theme,
59
60
  load_viewer_enabled,
60
61
  )
@@ -62,6 +63,7 @@ from remotedesktop.server import SharingTab, ViewersTable
62
63
  from remotedesktop.sharing import ShareClient
63
64
  from remotedesktop.single_instance import SingleInstance
64
65
  from remotedesktop.viewer import ViewerWidget
66
+ from remotedesktop.visual_effects import VisualEffectsReducer
65
67
 
66
68
  _log = logging.getLogger("remotedesktop.app")
67
69
 
@@ -127,6 +129,7 @@ class MainWindow(QMainWindow):
127
129
  connect_port: int = DEFAULT_CONNECT_PORT,
128
130
  tray_available: bool | None = None,
129
131
  reconnect_base_seconds: float = 2.0,
132
+ effects_reducer: VisualEffectsReducer | None = None,
130
133
  ) -> None:
131
134
  super().__init__()
132
135
  self._reconnect_base = reconnect_base_seconds
@@ -164,6 +167,15 @@ class MainWindow(QMainWindow):
164
167
  self._tray: QSystemTrayIcon | None = None
165
168
  self._tray_notified = False
166
169
  self._quitting = False
170
+ # Windows visual effects are reduced only while someone is actually
171
+ # watching (fewer animation frames to encode and ship) and restored
172
+ # when the last viewer leaves. The default reducer changes real OS
173
+ # settings — tests always inject one with a fake backend.
174
+ self._effects_reducer = (
175
+ effects_reducer if effects_reducer is not None else VisualEffectsReducer()
176
+ )
177
+ self._reduce_effects = load_reduce_effects_enabled(self._settings)
178
+ self._sharing_viewer_count = 0
167
179
  self._fullscreen_state: dict | None = None
168
180
  self._fullscreen_hint: QLabel | None = None
169
181
 
@@ -180,6 +192,7 @@ class MainWindow(QMainWindow):
180
192
  self.sharing_tab.peerEvent.connect(self._record_server_peer)
181
193
  self.sharing_tab.sharingChanged.connect(self._on_sharing_changed)
182
194
  self.sharing_tab.viewerCountChanged.connect(self._update_sharing_indicator)
195
+ self.sharing_tab.viewerCountChanged.connect(self._on_viewer_count_changed)
183
196
 
184
197
  # One tab per server connection (inserted at the front, closable),
185
198
  # followed by the fixed tabs, which never get a close button. While
@@ -338,6 +351,7 @@ class MainWindow(QMainWindow):
338
351
  # three-state choice) and the viewer role's UI.
339
352
  self.preferences_tab.sharingModeChanged.connect(self.sharing_tab.set_mode)
340
353
  self.preferences_tab.viewerModeChanged.connect(self._set_viewer_enabled)
354
+ self.preferences_tab.reduceEffectsChanged.connect(self._on_reduce_effects_changed)
341
355
  self.preferences_tab.restart_button.clicked.connect(self._restart_app)
342
356
  self._tabs.addTab(self.preferences_tab, "Preferences")
343
357
  self._about_tab = AboutTab()
@@ -713,6 +727,27 @@ class MainWindow(QMainWindow):
713
727
  self._update_performance_tabs()
714
728
  self._update_connections_groups()
715
729
 
730
+ def _on_viewer_count_changed(self, count: int) -> None:
731
+ self._sharing_viewer_count = count
732
+ self._update_effects_reduction()
733
+
734
+ def _on_reduce_effects_changed(self, enabled: bool) -> None:
735
+ self._reduce_effects = enabled
736
+ self._update_effects_reduction() # applies/restores live mid-session
737
+
738
+ def _update_effects_reduction(self) -> None:
739
+ """Reduce Windows visual effects while the preference is on AND a
740
+ viewer is connected; restore the user's values otherwise. Stop,
741
+ quit, and restart all reach here via their viewerCountChanged(0)."""
742
+ if self._reduce_effects and self._sharing_viewer_count > 0:
743
+ if self._effects_reducer.apply():
744
+ self.log(
745
+ "Windows visual effects reduced while viewers are connected "
746
+ "(restored when the last one disconnects)"
747
+ )
748
+ elif self._effects_reducer.restore():
749
+ self.log("Windows visual effects restored")
750
+
716
751
  def _quit(self) -> None:
717
752
  self._quitting = True
718
753
  self.close()
@@ -758,6 +793,9 @@ class MainWindow(QMainWindow):
758
793
  self._cancel_reconnect(session)
759
794
  session.client.close()
760
795
  self.sharing_tab.shutdown()
796
+ # shutdown's viewerCountChanged(0) already restored the effects;
797
+ # this is the safety net for any exit path that skipped the signal.
798
+ self._effects_reducer.restore()
761
799
  super().closeEvent(event)
762
800
  QApplication.quit() # main() disables quit-on-last-window-closed
763
801
 
@@ -1002,6 +1040,7 @@ class MainWindow(QMainWindow):
1002
1040
  client.disconnected.connect(lambda s=session: self._on_disconnected(s))
1003
1041
  client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
1004
1042
  client.cursorShapeChanged.connect(lambda shape, s=session: s.viewer.set_remote_cursor(shape))
1043
+ client.sessionLockChanged.connect(lambda locked, s=session: s.viewer.set_session_locked(locked))
1005
1044
  client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
1006
1045
  client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
1007
1046
  self._sessions.append(session)
@@ -1,122 +1,138 @@
1
- """Persistent per-machine state, all stored in the shared SQLite database.
2
-
3
- `Settings` is a generic key/value store (and holds this client's stable id and
4
- name). `PairedClients` maps each approved client id to the token issued at
5
- approval (server side). `KnownServers` records the servers this machine has
6
- paired with — pinned fingerprint and token — keyed by "host:port" (client
7
- side). All three operate on a `sqlite3.Connection` from `db.connect`.
8
- """
9
-
10
- import socket
11
- import sqlite3
12
- import uuid
13
- from pathlib import Path
14
-
15
- import platformdirs
16
-
17
-
18
- def default_config_dir() -> Path:
19
- return Path(platformdirs.user_data_dir("remotedesktop", appauthor=False))
20
-
21
-
22
- def default_db_path() -> Path:
23
- return default_config_dir() / "remotedesktop.db"
24
-
25
-
26
- class Settings:
27
- """A key/value store over the `settings` table."""
28
-
29
- def __init__(self, connection: sqlite3.Connection) -> None:
30
- self._db = connection
31
-
32
- def get(self, key: str, default: str | None = None) -> str | None:
33
- row = self._db.execute(
34
- "SELECT value FROM settings WHERE key = ?", (key,)
35
- ).fetchone()
36
- return row[0] if row is not None else default
37
-
38
- def set(self, key: str, value: str) -> None:
39
- self._db.execute(
40
- "INSERT INTO settings (key, value) VALUES (?, ?) "
41
- "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
42
- (key, value),
43
- )
44
- self._db.commit()
45
-
46
-
47
- def load_client_identity(connection: sqlite3.Connection) -> tuple[str, str]:
48
- """Return this client's (stable id, display name), creating it on first use."""
49
- settings = Settings(connection)
50
- client_id = settings.get("client_id")
51
- name = settings.get("client_name")
52
- if client_id and name:
53
- return client_id, name
54
- client_id = str(uuid.uuid4())
55
- name = socket.gethostname()
56
- settings.set("client_id", client_id)
57
- settings.set("client_name", name)
58
- return client_id, name
59
-
60
-
61
- class PairedClients:
62
- """Maps each approved client id to its shared token (server side).
63
-
64
- A client is "approved" exactly when it has a token here.
65
- """
66
-
67
- def __init__(self, connection: sqlite3.Connection) -> None:
68
- self._db = connection
69
-
70
- def __contains__(self, client_id: str) -> bool:
71
- return self.token_for(client_id) is not None
72
-
73
- def token_for(self, client_id: str) -> str | None:
74
- row = self._db.execute(
75
- "SELECT token FROM paired_clients WHERE client_id = ?", (client_id,)
76
- ).fetchone()
77
- return row[0] if row is not None else None
78
-
79
- def pair(self, client_id: str) -> str:
80
- """Issue and persist a new token for a client, returning it."""
81
- import secrets
82
-
83
- token = secrets.token_hex(32)
84
- self._db.execute(
85
- "INSERT INTO paired_clients (client_id, token) VALUES (?, ?) "
86
- "ON CONFLICT(client_id) DO UPDATE SET token = excluded.token",
87
- (client_id, token),
88
- )
89
- self._db.commit()
90
- return token
91
-
92
- def revoke(self, client_id: str) -> None:
93
- """Remove a client's token so it must be approved again to reconnect."""
94
- self._db.execute("DELETE FROM paired_clients WHERE client_id = ?", (client_id,))
95
- self._db.commit()
96
-
97
-
98
- class KnownServers:
99
- """Client-side record of paired servers, keyed by "host:port"."""
100
-
101
- def __init__(self, connection: sqlite3.Connection) -> None:
102
- self._db = connection
103
-
104
- def get(self, key: str) -> dict | None:
105
- row = self._db.execute(
106
- "SELECT fingerprint, token FROM known_servers WHERE key = ?", (key,)
107
- ).fetchone()
108
- return {"fingerprint": row[0], "token": row[1]} if row is not None else None
109
-
110
- def remember(self, key: str, fingerprint: str, token: str) -> None:
111
- self._db.execute(
112
- "INSERT INTO known_servers (key, fingerprint, token) VALUES (?, ?, ?) "
113
- "ON CONFLICT(key) DO UPDATE SET fingerprint = excluded.fingerprint, "
114
- "token = excluded.token",
115
- (key, fingerprint, token),
116
- )
117
- self._db.commit()
118
-
119
- def forget(self, key: str) -> None:
120
- """Drop a server's stored token and pin, so the next connection re-pairs."""
121
- self._db.execute("DELETE FROM known_servers WHERE key = ?", (key,))
122
- self._db.commit()
1
+ """Persistent per-machine state, all stored in the shared SQLite database.
2
+
3
+ `Settings` is a generic key/value store (and holds this client's stable id and
4
+ name). `PairedClients` maps each approved client id to the token issued at
5
+ approval (server side). `KnownServers` records the servers this machine has
6
+ paired with — pinned fingerprint and token — keyed by "host:port" (client
7
+ side). All three operate on a `sqlite3.Connection` from `db.connect`.
8
+ """
9
+
10
+ import socket
11
+ import sqlite3
12
+ import uuid
13
+ from pathlib import Path
14
+
15
+ import platformdirs
16
+ from tobool import to_bool_strict
17
+
18
+
19
+ def default_config_dir() -> Path:
20
+ return Path(platformdirs.user_data_dir("remotedesktop", appauthor=False))
21
+
22
+
23
+ def default_db_path() -> Path:
24
+ return default_config_dir() / "remotedesktop.db"
25
+
26
+
27
+ class Settings:
28
+ """A key/value store over the `settings` table."""
29
+
30
+ def __init__(self, connection: sqlite3.Connection) -> None:
31
+ self._db = connection
32
+
33
+ def get(self, key: str, default: str | None = None) -> str | None:
34
+ row = self._db.execute(
35
+ "SELECT value FROM settings WHERE key = ?", (key,)
36
+ ).fetchone()
37
+ return row[0] if row is not None else default
38
+
39
+ def set(self, key: str, value: str) -> None:
40
+ self._db.execute(
41
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
42
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
43
+ (key, value),
44
+ )
45
+ self._db.commit()
46
+
47
+ def get_bool(self, key: str, default: bool = False) -> bool:
48
+ """A boolean setting, stored as "1"/"0" (the historical on-disk
49
+ form). Anything unparseable falls back to the default — settings
50
+ are data, and junk must never take the app down."""
51
+ value = self.get(key)
52
+ if value is None:
53
+ return default
54
+ try:
55
+ return to_bool_strict(value)
56
+ except ValueError:
57
+ return default
58
+
59
+ def set_bool(self, key: str, value: bool) -> None:
60
+ self.set(key, "1" if value else "0")
61
+
62
+
63
+ def load_client_identity(connection: sqlite3.Connection) -> tuple[str, str]:
64
+ """Return this client's (stable id, display name), creating it on first use."""
65
+ settings = Settings(connection)
66
+ client_id = settings.get("client_id")
67
+ name = settings.get("client_name")
68
+ if client_id and name:
69
+ return client_id, name
70
+ client_id = str(uuid.uuid4())
71
+ name = socket.gethostname()
72
+ settings.set("client_id", client_id)
73
+ settings.set("client_name", name)
74
+ return client_id, name
75
+
76
+
77
+ class PairedClients:
78
+ """Maps each approved client id to its shared token (server side).
79
+
80
+ A client is "approved" exactly when it has a token here.
81
+ """
82
+
83
+ def __init__(self, connection: sqlite3.Connection) -> None:
84
+ self._db = connection
85
+
86
+ def __contains__(self, client_id: str) -> bool:
87
+ return self.token_for(client_id) is not None
88
+
89
+ def token_for(self, client_id: str) -> str | None:
90
+ row = self._db.execute(
91
+ "SELECT token FROM paired_clients WHERE client_id = ?", (client_id,)
92
+ ).fetchone()
93
+ return row[0] if row is not None else None
94
+
95
+ def pair(self, client_id: str) -> str:
96
+ """Issue and persist a new token for a client, returning it."""
97
+ import secrets
98
+
99
+ token = secrets.token_hex(32)
100
+ self._db.execute(
101
+ "INSERT INTO paired_clients (client_id, token) VALUES (?, ?) "
102
+ "ON CONFLICT(client_id) DO UPDATE SET token = excluded.token",
103
+ (client_id, token),
104
+ )
105
+ self._db.commit()
106
+ return token
107
+
108
+ def revoke(self, client_id: str) -> None:
109
+ """Remove a client's token so it must be approved again to reconnect."""
110
+ self._db.execute("DELETE FROM paired_clients WHERE client_id = ?", (client_id,))
111
+ self._db.commit()
112
+
113
+
114
+ class KnownServers:
115
+ """Client-side record of paired servers, keyed by "host:port"."""
116
+
117
+ def __init__(self, connection: sqlite3.Connection) -> None:
118
+ self._db = connection
119
+
120
+ def get(self, key: str) -> dict | None:
121
+ row = self._db.execute(
122
+ "SELECT fingerprint, token FROM known_servers WHERE key = ?", (key,)
123
+ ).fetchone()
124
+ return {"fingerprint": row[0], "token": row[1]} if row is not None else None
125
+
126
+ def remember(self, key: str, fingerprint: str, token: str) -> None:
127
+ self._db.execute(
128
+ "INSERT INTO known_servers (key, fingerprint, token) VALUES (?, ?, ?) "
129
+ "ON CONFLICT(key) DO UPDATE SET fingerprint = excluded.fingerprint, "
130
+ "token = excluded.token",
131
+ (key, fingerprint, token),
132
+ )
133
+ self._db.commit()
134
+
135
+ def forget(self, key: str) -> None:
136
+ """Drop a server's stored token and pin, so the next connection re-pairs."""
137
+ self._db.execute("DELETE FROM known_servers WHERE key = ?", (key,))
138
+ self._db.commit()
@@ -36,6 +36,7 @@ from remotedesktop.server import (
36
36
  PERFORMANCE_WINDOW_KEY = "performance_window_seconds"
37
37
  DEFAULT_PERFORMANCE_WINDOW_SECONDS = 120
38
38
  CLIPBOARD_SYNC_KEY = "clipboard_sync_enabled"
39
+ REDUCE_EFFECTS_KEY = "reduce_effects_enabled"
39
40
  VIEWER_KEY = "viewer_enabled"
40
41
  THEME_KEY = "theme"
41
42
  THEME_SYSTEM = "system" # follow the OS light/dark setting
@@ -45,7 +46,13 @@ _THEMES = (THEME_SYSTEM, THEME_LIGHT, THEME_DARK)
45
46
 
46
47
 
47
48
  def load_clipboard_sync_enabled(settings: Settings) -> bool:
48
- return settings.get(CLIPBOARD_SYNC_KEY, "1") != "0"
49
+ return settings.get_bool(CLIPBOARD_SYNC_KEY, True)
50
+
51
+
52
+ def load_reduce_effects_enabled(settings: Settings) -> bool:
53
+ # Default on (per the user's request): the effects come back the moment
54
+ # the last viewer disconnects, and nothing is persisted OS-side.
55
+ return settings.get_bool(REDUCE_EFFECTS_KEY, True)
49
56
 
50
57
 
51
58
  def load_theme(settings: Settings) -> str:
@@ -69,7 +76,7 @@ def apply_theme(theme: str) -> None:
69
76
 
70
77
 
71
78
  def load_viewer_enabled(settings: Settings) -> bool:
72
- return settings.get(VIEWER_KEY, "1") != "0"
79
+ return settings.get_bool(VIEWER_KEY, True)
73
80
 
74
81
 
75
82
  def load_performance_window_seconds(settings: Settings) -> int:
@@ -89,6 +96,9 @@ class PreferencesTab(QWidget):
89
96
  # The viewer (client) role: whether this instance connects to other
90
97
  # computers at all. The window shows/hides the client UI accordingly.
91
98
  viewerModeChanged = Signal(bool)
99
+ # Reduce Windows visual effects while viewers are connected; the window
100
+ # owns the VisualEffectsReducer and applies/restores on this.
101
+ reduceEffectsChanged = Signal(bool)
92
102
 
93
103
  def __init__(
94
104
  self,
@@ -121,6 +131,20 @@ class PreferencesTab(QWidget):
121
131
  self.clipboard_checkbox = QCheckBox("Sync clipboard with connected computers")
122
132
  self.clipboard_checkbox.setChecked(load_clipboard_sync_enabled(settings))
123
133
  self.clipboard_checkbox.toggled.connect(self._on_clipboard_toggled)
134
+ self.reduce_effects_checkbox = QCheckBox(
135
+ "Reduce Windows visual effects while sharing this screen"
136
+ )
137
+ 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."
145
+ )
146
+ self.reduce_effects_checkbox.setChecked(load_reduce_effects_enabled(settings))
147
+ self.reduce_effects_checkbox.toggled.connect(self._on_reduce_effects_toggled)
124
148
  # The three-state sharing choice: off, view-only, or full control.
125
149
  self.sharing_off_radio = QRadioButton(
126
150
  "Not shared — no one can see this computer's screen"
@@ -185,6 +209,7 @@ class PreferencesTab(QWidget):
185
209
  layout.addRow("Theme", theme_box)
186
210
  layout.addRow("Performance history", self.history_minutes)
187
211
  layout.addRow(self.clipboard_checkbox)
212
+ layout.addRow(self.reduce_effects_checkbox)
188
213
  layout.addRow(self.autostart_checkbox)
189
214
  layout.addRow(self.restart_button)
190
215
 
@@ -203,7 +228,7 @@ class PreferencesTab(QWidget):
203
228
  )
204
229
 
205
230
  def _on_viewer_toggled(self, checked: bool) -> None:
206
- self._settings.set(VIEWER_KEY, "1" if checked else "0")
231
+ self._settings.set_bool(VIEWER_KEY, checked)
207
232
  self.statusMessage.emit(
208
233
  "Client (viewer) role enabled — this computer can connect to servers"
209
234
  if checked
@@ -222,8 +247,17 @@ class PreferencesTab(QWidget):
222
247
  }[theme]
223
248
  )
224
249
 
250
+ def _on_reduce_effects_toggled(self, checked: bool) -> None:
251
+ self._settings.set_bool(REDUCE_EFFECTS_KEY, checked)
252
+ self.statusMessage.emit(
253
+ "Windows visual effects will be reduced while viewers are connected"
254
+ if checked
255
+ else "Windows visual effects will be left unchanged while sharing"
256
+ )
257
+ self.reduceEffectsChanged.emit(checked)
258
+
225
259
  def _on_clipboard_toggled(self, checked: bool) -> None:
226
- self._settings.set(CLIPBOARD_SYNC_KEY, "1" if checked else "0")
260
+ self._settings.set_bool(CLIPBOARD_SYNC_KEY, checked)
227
261
  if self._clipboard is not None:
228
262
  self._clipboard.enabled = checked
229
263
  self.statusMessage.emit(