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.
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/CLAUDE.md +2 -1
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/PKG-INFO +2 -1
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/pyproject.toml +1 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/app.py +38 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/config.py +138 -122
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/preferences.py +38 -4
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/server.py +7 -7
- remotedesktop-1.7.0/src/remotedesktop/visual_effects.py +182 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_config.py +79 -58
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_main_window.py +55 -1
- remotedesktop-1.7.0/tests/test_visual_effects.py +125 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/uv.lock +2 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/.github/workflows/release.yml +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/.gitignore +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/LICENSE +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/icon/remotedesktop.ico +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/readme.md +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/run.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/run_claude.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/scripts/make_installer.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/sharing.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/conftest.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_about.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_autostart.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_compat.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_discovery.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_frames.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_icon.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_input.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_inventory.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_logs.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_performance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_preferences.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_protocol.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_sharing.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_single_instance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_smoke.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_tls.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tests/test_window_state.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.7.0}/tools/make_demo_gifs.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
(key
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
self._db
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
self._db.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
self._db.execute(
|
|
122
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
52
|
+
if not settings.get_bool("server_enabled", False):
|
|
53
53
|
return SHARING_MODE_OFF
|
|
54
|
-
if settings.
|
|
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.
|
|
252
|
+
self._settings.set_bool("server_enabled", False)
|
|
253
253
|
return
|
|
254
254
|
allowed = mode == SHARING_MODE_CONTROL
|
|
255
|
-
self._settings.
|
|
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.
|
|
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.
|
|
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.
|
|
316
|
+
self._settings.set_bool("server_enabled", False)
|
|
317
317
|
self._update_summary(0)
|
|
318
318
|
self.sharingChanged.emit(False)
|
|
319
319
|
|