remotedesktop 1.6.0__tar.gz → 1.7.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 (75) hide show
  1. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/CLAUDE.md +2 -1
  2. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/PKG-INFO +2 -1
  3. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/pyproject.toml +1 -0
  4. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/app.py +38 -0
  6. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/config.py +138 -122
  7. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/preferences.py +38 -4
  8. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/server.py +7 -7
  9. remotedesktop-1.7.0/src/remotedesktop/visual_effects.py +182 -0
  10. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_config.py +79 -58
  11. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_main_window.py +55 -1
  12. remotedesktop-1.7.0/tests/test_visual_effects.py +125 -0
  13. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/uv.lock +2 -0
  14. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/.github/workflows/ci.yml +0 -0
  15. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/.github/workflows/release.yml +0 -0
  16. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/.gitignore +0 -0
  17. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/LICENSE +0 -0
  18. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/docs/media/client-demo.gif +0 -0
  19. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/docs/media/server-demo.gif +0 -0
  20. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/icon/remotedesktop.ico +0 -0
  21. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/readme.md +0 -0
  22. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/run.bat +0 -0
  23. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/run_claude.bat +0 -0
  24. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/scripts/make_coverage_badge.py +0 -0
  25. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/scripts/make_installer.bat +0 -0
  26. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/scripts/make_venv.bat +0 -0
  27. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/__main__.py +0 -0
  28. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/about.py +0 -0
  29. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/autostart.py +0 -0
  30. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/client.py +0 -0
  31. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/clipboard.py +0 -0
  32. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/compat.py +0 -0
  33. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/cursor_shape.py +0 -0
  34. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/db.py +0 -0
  35. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/discovery.py +0 -0
  36. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/dxgi.py +0 -0
  37. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/frames.py +0 -0
  38. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/icon.py +0 -0
  39. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/input_injection.py +0 -0
  40. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/inventory.py +0 -0
  41. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/logs.py +0 -0
  42. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/modal_loop.py +0 -0
  43. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/performance.py +0 -0
  44. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/protocol.py +0 -0
  45. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/sharing.py +0 -0
  46. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/single_instance.py +0 -0
  47. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/tls.py +0 -0
  48. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/viewer.py +0 -0
  49. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/window_state.py +0 -0
  50. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/conftest.py +0 -0
  51. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_about.py +0 -0
  52. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_autostart.py +0 -0
  53. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_clipboard.py +0 -0
  54. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_compat.py +0 -0
  55. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_cursor_shape.py +0 -0
  56. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_discovery.py +0 -0
  57. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_dxgi.py +0 -0
  58. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_frames.py +0 -0
  59. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_icon.py +0 -0
  60. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_input.py +0 -0
  61. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_input_injection.py +0 -0
  62. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_inventory.py +0 -0
  63. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_logs.py +0 -0
  64. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_modal_loop.py +0 -0
  65. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_performance.py +0 -0
  66. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_preferences.py +0 -0
  67. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_protocol.py +0 -0
  68. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_sharing.py +0 -0
  69. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_sharing_tab.py +0 -0
  70. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_single_instance.py +0 -0
  71. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_smoke.py +0 -0
  72. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_tls.py +0 -0
  73. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_window_state.py +0 -0
  74. {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tools/make_demo_gifs.py +0 -0
  75. {remotedesktop-1.6.0 → remotedesktop-1.7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 1.6.0
3
+ Version: 1.7.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
@@ -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]
@@ -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.7.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
 
@@ -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(
@@ -49,9 +49,9 @@ ALLOW_INPUT_KEY = "allow_remote_input"
49
49
 
50
50
 
51
51
  def load_sharing_mode(settings: Settings) -> str:
52
- if settings.get("server_enabled") != "1":
52
+ if not settings.get_bool("server_enabled", False):
53
53
  return SHARING_MODE_OFF
54
- if settings.get(ALLOW_INPUT_KEY, "1") != "0":
54
+ if settings.get_bool(ALLOW_INPUT_KEY, True):
55
55
  return SHARING_MODE_CONTROL
56
56
  return SHARING_MODE_VIEW
57
57
 
@@ -249,10 +249,10 @@ class SharingTab(QWidget):
249
249
  if self.share_server is not None:
250
250
  self.stop_sharing()
251
251
  else:
252
- self._settings.set("server_enabled", "0")
252
+ self._settings.set_bool("server_enabled", False)
253
253
  return
254
254
  allowed = mode == SHARING_MODE_CONTROL
255
- self._settings.set(ALLOW_INPUT_KEY, "1" if allowed else "0")
255
+ self._settings.set_bool(ALLOW_INPUT_KEY, allowed)
256
256
  if self.share_server is not None:
257
257
  self.share_server.set_input_allowed(allowed) # emits its status line
258
258
  return
@@ -278,7 +278,7 @@ class SharingTab(QWidget):
278
278
  cursor_probe=current_cursor_shape,
279
279
  performance=self._performance,
280
280
  log_provider=lambda: read_log_tail("remotedesktop"),
281
- input_allowed=self._settings.get(ALLOW_INPUT_KEY, "1") != "0",
281
+ input_allowed=self._settings.get_bool(ALLOW_INPUT_KEY, True),
282
282
  parent=self,
283
283
  )
284
284
  server.status.connect(self.statusMessage)
@@ -307,13 +307,13 @@ class SharingTab(QWidget):
307
307
  f'Discoverable as "{self._name}" '
308
308
  f"(UDP port {self._discovery_port}, TCP port {server.port})"
309
309
  )
310
- self._settings.set("server_enabled", "1")
310
+ self._settings.set_bool("server_enabled", True)
311
311
  self._update_summary(0)
312
312
  self.sharingChanged.emit(self.serving)
313
313
 
314
314
  def stop_sharing(self) -> None:
315
315
  self._teardown()
316
- self._settings.set("server_enabled", "0")
316
+ self._settings.set_bool("server_enabled", False)
317
317
  self._update_summary(0)
318
318
  self.sharingChanged.emit(False)
319
319