remotedesktop 1.5.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.5.0 → remotedesktop-1.7.0}/CLAUDE.md +4 -1
  2. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/PKG-INFO +2 -1
  3. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/pyproject.toml +1 -0
  4. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/app.py +44 -0
  6. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/config.py +138 -122
  7. remotedesktop-1.7.0/src/remotedesktop/cursor_shape.py +89 -0
  8. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/preferences.py +103 -10
  9. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/server.py +9 -7
  10. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/sharing.py +36 -0
  11. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/viewer.py +44 -0
  12. remotedesktop-1.7.0/src/remotedesktop/visual_effects.py +182 -0
  13. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_config.py +79 -58
  14. remotedesktop-1.7.0/tests/test_cursor_shape.py +20 -0
  15. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_input.py +15 -0
  16. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_main_window.py +68 -1
  17. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_preferences.py +31 -0
  18. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_sharing.py +36 -1
  19. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_smoke.py +35 -0
  20. remotedesktop-1.7.0/tests/test_visual_effects.py +125 -0
  21. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/uv.lock +4 -2
  22. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/.github/workflows/ci.yml +0 -0
  23. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/.github/workflows/release.yml +0 -0
  24. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/.gitignore +0 -0
  25. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/LICENSE +0 -0
  26. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/docs/media/client-demo.gif +0 -0
  27. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/docs/media/server-demo.gif +0 -0
  28. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/icon/remotedesktop.ico +0 -0
  29. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/readme.md +0 -0
  30. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/run.bat +0 -0
  31. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/run_claude.bat +0 -0
  32. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/scripts/make_coverage_badge.py +0 -0
  33. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/scripts/make_installer.bat +0 -0
  34. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/scripts/make_venv.bat +0 -0
  35. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/__main__.py +0 -0
  36. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/about.py +0 -0
  37. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/autostart.py +0 -0
  38. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/client.py +0 -0
  39. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/clipboard.py +0 -0
  40. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/compat.py +0 -0
  41. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/db.py +0 -0
  42. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/discovery.py +0 -0
  43. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/dxgi.py +0 -0
  44. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/frames.py +0 -0
  45. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/icon.py +0 -0
  46. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/input_injection.py +0 -0
  47. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/inventory.py +0 -0
  48. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/logs.py +0 -0
  49. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/modal_loop.py +0 -0
  50. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/performance.py +0 -0
  51. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/protocol.py +0 -0
  52. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/single_instance.py +0 -0
  53. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/tls.py +0 -0
  54. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/window_state.py +0 -0
  55. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/conftest.py +0 -0
  56. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_about.py +0 -0
  57. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_autostart.py +0 -0
  58. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_clipboard.py +0 -0
  59. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_compat.py +0 -0
  60. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_discovery.py +0 -0
  61. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_dxgi.py +0 -0
  62. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_frames.py +0 -0
  63. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_icon.py +0 -0
  64. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_input_injection.py +0 -0
  65. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_inventory.py +0 -0
  66. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_logs.py +0 -0
  67. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_modal_loop.py +0 -0
  68. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_performance.py +0 -0
  69. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_protocol.py +0 -0
  70. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_sharing_tab.py +0 -0
  71. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_single_instance.py +0 -0
  72. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_tls.py +0 -0
  73. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_window_state.py +0 -0
  74. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tools/make_demo_gifs.py +0 -0
  75. {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tools/make_icon.py +0 -0
@@ -76,17 +76,20 @@ 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).
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.
79
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.
80
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).
81
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.
82
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).
83
- - **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**.
84
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.
85
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.
86
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.
87
89
  - **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; each row gets its own button labeled `action_label` (in the trailing stretch column) whose callback receives that row's peer key. "Client history" wires "Revoke" → `SharingTab.revoke_client(client_id)` (via `ShareServer.revoke_client` while sharing — removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` emits a "revoked" peer event; a `_revoked` set distinguishes it from a plain disconnect — or directly via `PairedClients.revoke` while not sharing). "Server history" wires "Forget" → `KnownServers.forget(key)` + disconnect + `ConnectionInventory.remove(key)`. **Both delete the peer's row from the table and the DB outright** (`MainWindow._record_server_peer` turns a "revoked" event into a `remove`); a rescan or new connection attempt records the peer afresh. After either, the next connection needs a fresh approval. The roles use *separate tables* (`server_peers` / `client_peers`, selected via the `table=` arg) so they don't commingle; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
88
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.
89
91
  - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
92
+ - **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.
90
93
  - **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.
91
94
  - **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`.
92
95
  - **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.5.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.5.0"
3
+ __version__ = "1.7.0"
@@ -52,14 +52,18 @@ from remotedesktop.modal_loop import HTCLOSE, HTMAXBUTTON, HTMINBUTTON, ModalLoo
52
52
  from remotedesktop.performance import PerformanceMonitor, PerformanceTab
53
53
  from remotedesktop.preferences import (
54
54
  PreferencesTab,
55
+ apply_theme,
55
56
  load_clipboard_sync_enabled,
56
57
  load_performance_window_seconds,
58
+ load_reduce_effects_enabled,
59
+ load_theme,
57
60
  load_viewer_enabled,
58
61
  )
59
62
  from remotedesktop.server import SharingTab, ViewersTable
60
63
  from remotedesktop.sharing import ShareClient
61
64
  from remotedesktop.single_instance import SingleInstance
62
65
  from remotedesktop.viewer import ViewerWidget
66
+ from remotedesktop.visual_effects import VisualEffectsReducer
63
67
 
64
68
  _log = logging.getLogger("remotedesktop.app")
65
69
 
@@ -125,6 +129,7 @@ class MainWindow(QMainWindow):
125
129
  connect_port: int = DEFAULT_CONNECT_PORT,
126
130
  tray_available: bool | None = None,
127
131
  reconnect_base_seconds: float = 2.0,
132
+ effects_reducer: VisualEffectsReducer | None = None,
128
133
  ) -> None:
129
134
  super().__init__()
130
135
  self._reconnect_base = reconnect_base_seconds
@@ -136,6 +141,9 @@ class MainWindow(QMainWindow):
136
141
  # The viewer (client) role is opt-out: a dedicated server turns it
137
142
  # off and loses the client-side UI (Server tab, discovery, history).
138
143
  self._viewer_enabled = load_viewer_enabled(self._settings)
144
+ # Restore the persisted theme before any widgets are built, so the
145
+ # window never flashes in the wrong scheme.
146
+ apply_theme(load_theme(self._settings))
139
147
  # One monitor per role: ShareServer.close() resets its monitor, so
140
148
  # toggling sharing must not share a monitor with the viewing sessions
141
149
  # (and vice versa for _connect_session's reset).
@@ -159,6 +167,15 @@ class MainWindow(QMainWindow):
159
167
  self._tray: QSystemTrayIcon | None = None
160
168
  self._tray_notified = False
161
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
162
179
  self._fullscreen_state: dict | None = None
163
180
  self._fullscreen_hint: QLabel | None = None
164
181
 
@@ -175,6 +192,7 @@ class MainWindow(QMainWindow):
175
192
  self.sharing_tab.peerEvent.connect(self._record_server_peer)
176
193
  self.sharing_tab.sharingChanged.connect(self._on_sharing_changed)
177
194
  self.sharing_tab.viewerCountChanged.connect(self._update_sharing_indicator)
195
+ self.sharing_tab.viewerCountChanged.connect(self._on_viewer_count_changed)
178
196
 
179
197
  # One tab per server connection (inserted at the front, closable),
180
198
  # followed by the fixed tabs, which never get a close button. While
@@ -333,6 +351,7 @@ class MainWindow(QMainWindow):
333
351
  # three-state choice) and the viewer role's UI.
334
352
  self.preferences_tab.sharingModeChanged.connect(self.sharing_tab.set_mode)
335
353
  self.preferences_tab.viewerModeChanged.connect(self._set_viewer_enabled)
354
+ self.preferences_tab.reduceEffectsChanged.connect(self._on_reduce_effects_changed)
336
355
  self.preferences_tab.restart_button.clicked.connect(self._restart_app)
337
356
  self._tabs.addTab(self.preferences_tab, "Preferences")
338
357
  self._about_tab = AboutTab()
@@ -708,6 +727,27 @@ class MainWindow(QMainWindow):
708
727
  self._update_performance_tabs()
709
728
  self._update_connections_groups()
710
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
+
711
751
  def _quit(self) -> None:
712
752
  self._quitting = True
713
753
  self.close()
@@ -753,6 +793,9 @@ class MainWindow(QMainWindow):
753
793
  self._cancel_reconnect(session)
754
794
  session.client.close()
755
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()
756
799
  super().closeEvent(event)
757
800
  QApplication.quit() # main() disables quit-on-last-window-closed
758
801
 
@@ -996,6 +1039,7 @@ class MainWindow(QMainWindow):
996
1039
  client.denied.connect(lambda reason, s=session: self._on_denied(s, reason))
997
1040
  client.disconnected.connect(lambda s=session: self._on_disconnected(s))
998
1041
  client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
1042
+ client.cursorShapeChanged.connect(lambda shape, s=session: s.viewer.set_remote_cursor(shape))
999
1043
  client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
1000
1044
  client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
1001
1045
  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()
@@ -0,0 +1,89 @@
1
+ """Identify the shape of the mouse cursor currently shown (server side).
2
+
3
+ The captured frame deliberately contains no mouse pointer (both DXGI desktop
4
+ duplication and GDI BitBlt exclude it), so what the viewer's user sees over
5
+ the remote screen is their own local cursor. This module lets the server
6
+ report what shape that cursor should be — an I-beam over text, the resize
7
+ arrows over a window edge, and so on: GetCursorInfo returns the current
8
+ cursor handle, and the standard cursors are shared handles identical to what
9
+ LoadCursor(NULL, IDC_*) returns, so a plain handle comparison identifies
10
+ them. Custom application cursors fall back to "arrow"; a cursor that is not
11
+ showing at all reports "hidden".
12
+
13
+ On non-Windows platforms `current_cursor_shape()` returns None (the app
14
+ targets Windows).
15
+ """
16
+
17
+ import ctypes
18
+ import sys
19
+ from ctypes import wintypes
20
+
21
+ _IS_WINDOWS = sys.platform == "win32"
22
+
23
+ _CURSOR_SHOWING = 0x1
24
+
25
+ # Standard cursor resource ids (winuser.h IDC_*) -> wire shape names. These
26
+ # names are the protocol vocabulary: ViewerWidget maps them to Qt cursors.
27
+ _IDC_SHAPES = {
28
+ 32512: "arrow", # IDC_ARROW
29
+ 32513: "ibeam", # IDC_IBEAM
30
+ 32514: "wait", # IDC_WAIT
31
+ 32515: "cross", # IDC_CROSS
32
+ 32516: "uparrow", # IDC_UPARROW
33
+ 32642: "size_nwse", # IDC_SIZENWSE (diagonal resize ↘)
34
+ 32643: "size_nesw", # IDC_SIZENESW (diagonal resize ↗)
35
+ 32644: "size_we", # IDC_SIZEWE (horizontal resize)
36
+ 32645: "size_ns", # IDC_SIZENS (vertical resize)
37
+ 32646: "size_all", # IDC_SIZEALL
38
+ 32648: "no", # IDC_NO
39
+ 32649: "hand", # IDC_HAND
40
+ 32650: "appstarting", # IDC_APPSTARTING
41
+ 32651: "help", # IDC_HELP
42
+ }
43
+
44
+ SHAPE_NAMES = frozenset(_IDC_SHAPES.values()) | {"hidden"}
45
+
46
+
47
+ if _IS_WINDOWS:
48
+
49
+ class _CURSORINFO(ctypes.Structure):
50
+ _fields_ = [
51
+ ("cbSize", wintypes.DWORD),
52
+ ("flags", wintypes.DWORD),
53
+ ("hCursor", ctypes.c_void_p),
54
+ ("ptScreenPos", wintypes.POINT),
55
+ ]
56
+
57
+
58
+ _handle_shapes: dict[int, str] | None = None
59
+
60
+
61
+ def _standard_handles() -> dict[int, str]:
62
+ """HCURSOR -> shape name for every standard cursor (loaded once; the
63
+ handles are shared and stable for the lifetime of the process)."""
64
+ global _handle_shapes
65
+ if _handle_shapes is None:
66
+ user32 = ctypes.windll.user32
67
+ user32.LoadCursorW.restype = ctypes.c_void_p
68
+ user32.LoadCursorW.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
69
+ _handle_shapes = {}
70
+ for resource, name in _IDC_SHAPES.items():
71
+ # MAKEINTRESOURCE: the id passed as the pointer-sized name arg.
72
+ handle = user32.LoadCursorW(None, ctypes.c_void_p(resource))
73
+ if handle:
74
+ _handle_shapes[handle] = name
75
+ return _handle_shapes
76
+
77
+
78
+ def current_cursor_shape() -> str | None:
79
+ """The current cursor's shape name, "hidden" while no cursor is showing,
80
+ or None when it cannot be determined (non-Windows, API failure)."""
81
+ if not _IS_WINDOWS:
82
+ return None
83
+ info = _CURSORINFO()
84
+ info.cbSize = ctypes.sizeof(_CURSORINFO)
85
+ if not ctypes.windll.user32.GetCursorInfo(ctypes.byref(info)):
86
+ return None
87
+ if not info.flags & _CURSOR_SHOWING:
88
+ return "hidden"
89
+ return _standard_handles().get(info.hCursor or 0, "arrow")