remotedesktop 1.6.0__tar.gz → 1.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/CLAUDE.md +4 -1
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/PKG-INFO +32 -2
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/pyproject.toml +1 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/readme.md +30 -1
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/app.py +156 -3
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/clipboard.py +19 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/config.py +138 -122
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/preferences.py +85 -7
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/server.py +9 -7
- remotedesktop-1.9.2/src/remotedesktop/session_lock.py +46 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/sharing.py +60 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/viewer.py +62 -25
- remotedesktop-1.9.2/src/remotedesktop/visual_effects.py +182 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_clipboard.py +24 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_config.py +79 -58
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_input.py +56 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_main_window.py +173 -6
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_preferences.py +25 -0
- remotedesktop-1.9.2/tests/test_session_lock.py +18 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_sharing.py +32 -0
- remotedesktop-1.9.2/tests/test_visual_effects.py +125 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/uv.lock +2 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/.github/workflows/ci.yml +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/.github/workflows/release.yml +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/.gitignore +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/LICENSE +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/docs/media/client-demo.gif +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/docs/media/server-demo.gif +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/icon/remotedesktop.ico +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/run.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/run_claude.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/scripts/make_installer.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/scripts/make_venv.bat +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/about.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/client.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/db.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/conftest.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_about.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_autostart.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_compat.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_discovery.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_dxgi.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_frames.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_icon.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_input_injection.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_inventory.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_logs.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_modal_loop.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_performance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_protocol.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_single_instance.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_smoke.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_tls.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tests/test_window_state.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-1.6.0 → remotedesktop-1.9.2}/tools/make_icon.py +0 -0
|
@@ -76,12 +76,14 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
76
76
|
- **Auto-reconnect** (`app.py`): a session that *was connected* and drops retries `client.connect_to` with exponential backoff (base `reconnect_base_seconds=2.0` kwarg — tests inject ~0.05 — doubling to a 30 s cap), driven by a per-session single-shot QTimer. `ShareClient.connectionFailed` exists because **Qt emits `disconnected` only for sockets that reached ConnectedState** — refused/unreachable attempts fire `errorOccurred` with the socket back in UnconnectedState. The active-timer guard dedupes the errorOccurred+disconnected double-fire. Denial cancels until a manual activation; tab close/forget/quit cancel; retries bypass `_connect_session` on purpose (no perf reset, no tab steal, no inventory attempt spam). **Open session tabs persist** (`open_sessions` JSON in Settings, written on connect/rename/close) and are restored + reconnected at startup with `auto_reconnect` pre-armed, so a not-yet-available server is retried on the backoff loop; deliberately closed tabs are not restored. While no session exists, a non-closable "Server" placeholder tab (instructions) sits at index 0 and swaps with the first session's tab.
|
|
77
77
|
- **Menus & shortcuts contract** (`app.py` `_build_menus`, `viewer.py`): File/View/Help menu bar; shortcuts Ctrl+W (close session tab), Ctrl+Q (quit), F5 (rescan), F11 (fullscreen). While a viewer shows a frame it **accepts `ShortcutOverride`** so every key forwards to the remote machine — **F11 is the single reserved local key**. Shortcut actions are also `addAction`-ed on the window so they fire with the menu bar hidden (fullscreen). Keep every menu/action as an attribute — PySide6 gives Python ownership of `addMenu`/`addAction` (and `QAction.menu()`) wrappers, and a GC'd local deletes the C++ object. Fullscreen hides menu/status bars, dock, and tab bar and restores them (including pre-fullscreen dock visibility) on exit; `closeEvent` exits fullscreen before persisting layout. Window geometry AND dock layout persist (`window_state.save_state`/`restore_state`, key `main_window_state`; the dock needs its objectName).
|
|
78
78
|
- **Privacy toggles**: Preferences' "Sync clipboard" checkbox (`clipboard_sync_enabled`, default on) drives `ClipboardSync.enabled` — while off, local copies aren't sent and peer payloads aren't applied. View-only sharing is the "view" mode of the Screen sharing radios (`ShareServer.set_input_allowed` — a server-side drop, no wire change, that releases held input when switched off). Quitting with ≥1 viewer connected asks for confirmation (declining resets `_quitting`); restart shuts sharing down first so it never double-prompts.
|
|
79
|
+
- **Screen capture** (`app.py`): the dock's "Screen capture" group (Copy / Save…, viewer-role UI hidden with the panel) and the matching File-menu actions (`copy_capture_action`/`save_capture_action`, same handlers) capture the current session tab's last frame at full server resolution (`ViewerWidget.frame_image()` — the lock overlay is display-only and never part of it). Copy goes through `ClipboardSync.copy_image`, which records the content signature *before* setting the clipboard so the capture is never echoed to peers, and works with sync disabled (the preference governs syncing, not local copies). Save is `QFileDialog.getSaveFileName` (tests monkeypatch it) defaulting to `Pictures\<server> <timestamp>.png`, PNG only. All four widgets enable only while the current tab shows a frame — `_update_capture_actions()`, called from tab change, `_on_frame` (unconditionally: auto-reconnect refills a cleared viewer without `_connect_session`), and every `viewer.clear` path (connect/denied/approval-pending/disconnect).
|
|
80
|
+
- **Reduce visual effects while sharing** (`visual_effects.py`): animations/fades are a burst of changed frames for the lossless pipeline, so while the preference is on (default on, `reduce_effects_enabled`, `load_reduce_effects_enabled`) **and ≥1 viewer is connected**, `MainWindow` applies `VisualEffectsReducer` and restores it when the last viewer leaves (driven by `SharingTab.viewerCountChanged`; explicit safety-net restore in `closeEvent`). The reduced set: SPI_SETUIEFFECTS off, drop shadows off, client-area + min/max animations off, submenu delay lowered to 50 ms (never raised). All via `SystemParametersInfo` **without** SPIF_UPDATEINIFILE — session-scoped, never persisted, self-heals at logoff; knobs already at the reduced value are left untouched so the user's own choices are never "restored" over. The backend (`SpiBackend`) is injectable — **tests must always inject `FakeSpiBackend`** (the preference defaults on, so a real reducer in a test would change the host's actual Windows settings; `make_window` in `tests/test_main_window.py` injects it for every window test, and real-backend tests may only read).
|
|
79
81
|
- **Theme preference** (`preferences.py`): a three-radio group in Preferences — follow the Windows light/dark setting (default) / Light / Dark — persisted as `THEME_KEY` (`"system"`/`"light"`/`"dark"`, `load_theme` falls back to system on junk). `apply_theme` drives `QStyleHints.setColorScheme` (`unsetColorScheme` for system, so OS-side switches keep applying); the radios apply live and `MainWindow.__init__` re-applies the persisted choice before building widgets. Tests that set a non-system theme must restore with `apply_theme(THEME_SYSTEM)` — the color scheme is process-global QApplication state.
|
|
80
82
|
- **Two `PerformanceMonitor`s, never one**: `MainWindow.client_performance` (viewing sessions) and `server_performance` (the SharingTab's viewers). `ShareServer.close()` resets its monitor, so sharing a monitor across roles would wipe the other role's graphs. The Performance tab has "Client (viewer)"/"Server (sharing)" sub-tabs (`MainWindow.performance_pages`) — but only for roles that can produce data: the client sub-tab exists only with the viewer role, the server one only while actually serving (`_update_performance_tabs`, driven from the viewer toggle and `sharingChanged`; both off shows an enable-a-role hint). `PreferencesTab` takes the list of monitors and applies the history-window setting to all.
|
|
81
83
|
- **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}`; datagrams with the wrong magic/version/type are dropped. **Scan robustness**: probes are re-sent every `_PROBE_INTERVAL` (0.3 s) for the whole scan window (a lost broadcast only delays discovery), ICMP port-unreachable resets never abort the scan (Windows `SIO_UDP_CONNRESET` ioctl + `ConnectionResetError` → continue), and the panel passes `client._broadcast_hosts()` — 255.255.255.255 plus every up non-loopback interface's directed broadcast, because Windows routes the limited broadcast out only one interface (VPN/virtual adapters made scans come up empty). A sharing instance runs a `DiscoveryResponder` thread while sharing is enabled; the `DiscoveryPanel` (dock in `MainWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. With `auto_scan=True` (the app; tests default to False and never broadcast) it scans **once at startup only** — further scans happen only on manual Refresh/F5 (deliberately no periodic background scanning; the user asked for this), guarded against overlapping scans and preserving the selection across repopulation; it also has a Connect button, a right-click Connect menu, and an empty-state hint. The "(this computer)" entry is not connectable (Connect disabled, activation swallowed, plus a `MainWindow._on_server_activated` guard). `DEFAULT_CONNECT_PORT` is 48654. An instance that shares and scans discovers itself — `DiscoveryPanel`'s `is_self=` callable labels that entry "(this computer)" (never hidden).
|
|
82
84
|
- Discovery tests run over loopback with ephemeral ports (`bind_host`/`discovery_port`/`broadcast_hosts` parameters exist for this), so they never touch the real LAN or fixed ports.
|
|
83
85
|
- **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
|
|
84
|
-
- **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
|
|
86
|
+
- **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. **Boolean settings go through `Settings.get_bool`/`set_bool`** (the `tobool` package's `to_bool_strict`; on-disk form stays the historical `"1"`/`"0"`, junk falls back to the caller's default) — don't hand-roll `!= "0"` comparisons. This applies to binary values stored as strings/ints; ctypes Win32 `BOOL`s keep plain `bool()` (TRUE is "any nonzero", which strict parsing correctly refuses). `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
|
|
85
87
|
- **Trust model plumbing** (`config.py`): the client has a stable UUID identity (`load_client_identity`, in the `settings` table) and the server maps each approved client-id to a token (`PairedClients`). `ShareServer` takes an `approve_client(client_id, name) -> bool` callback; `SharingTab` implements it as a modal stay-on-top QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
|
|
86
88
|
- **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, `SharingTab`, and `DiscoveryPanel` emit human-readable `status`/`statusMessage` signals for every connection phase, shown in the window's timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the pane shows is also written to a rotating debug log file (`logs.py`; `logs.init_logging("remotedesktop")` is called only by `app.main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\remotedesktop.log` on Windows; pre-1.0 `client.log`/`server.log` files just age out). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
|
|
87
89
|
- **Connection inventory** (`inventory.py`): the Connections tab has two grouped tables ("Client history" for the sharing role, "Server history" for the viewing role), each backed by its own `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The sharing side is driven by `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases), forwarded through `SharingTab.peerEvent`. The viewing side is driven by `MainWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
|
|
@@ -89,6 +91,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
89
91
|
- Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
|
|
90
92
|
- **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
|
|
91
93
|
- **Remote cursor shape** (`cursor_shape.py`): the captured frame never contains the mouse pointer (DXGI duplication and BitBlt both exclude it), so the client mirrors the server's cursor on its *local* cursor instead. `ShareServer` takes an opt-in `cursor_probe=` callable (the `clipboard=` pattern — only the GUI passes one, `SharingTab` wires `current_cursor_shape`: GetCursorInfo compared against the shared standard IDC_* handles; custom cursors → "arrow", not showing → "hidden"). The probe is polled each broadcast tick and `{"type": "cursor", "shape": ...}` is sent on change only, plus once to each just-admitted stream. `ShareClient.cursorShapeChanged` → `ViewerWidget.set_remote_cursor` maps names to Qt cursors (unknown names → arrow); `clear()` unsets the cursor. Peers without the feature never send / silently ignore the message.
|
|
94
|
+
- **Lock-screen notice** (`session_lock.py`): the Windows lock screen / secure desktop (also UAC, Ctrl+Alt+Del) can be neither captured nor injected into by a user-mode process, so remote unlock is impossible by design (documented in the readme; the assumption is the server machine is physically reachable to sign in). `ShareServer` takes an opt-in `lock_probe=` callable (the `cursor_probe=` pattern — only the GUI wires `is_session_locked`, which reads OpenInputDesktop failure as "secure desktop has input"). Polled each broadcast tick; `{"type": "session_lock", "locked": bool}` is sent on change, plus to just-admitted streams while locked (unlocked is the assumed start state, so it's never sent on admission). While locked the server drops remote input with a once-per-lock-episode status line ("Ignoring remote input while the session is locked"). `ShareClient.sessionLockChanged` → `ViewerWidget.set_session_locked`: the viewer dims whatever it shows under `viewer.LOCKED_MESSAGE`; `clear()` resets it (so reconnects start unlocked). Peers without the feature never send / silently ignore the message. Sharing tests inject a fake `lock_probe` — the default None means plain tests never poll the host's real desktop.
|
|
92
95
|
- **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for a sharing instance this deadlocked when a *remote* click grabbed the window's own title bar (the mouse-up sat unread on the socket). `MainWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). The caption buttons (min/max/close) are the exception — their tracking loop dispatches only mouse messages, so no timer can pump inside it; the pump instead performs those via its `caption_action` callback on press and consumes the message (nativeEvent must return True when `handle_native_event` does), so DefWindowProc's button tracking never starts. Tests inject a fake `timers` backend — never let them create real Win32 timers.
|
|
93
96
|
- **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the app passes `logs.read_log_tail("remotedesktop")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. The Connection log group on the Connections tab has both buttons — "Get server log" asks the server of the current session tab, "Get client log" asks the most recently admitted viewer; the reply opens a `logs.PeerLogDialog`.
|
|
94
97
|
- **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.2
|
|
4
4
|
Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
|
|
6
6
|
Project-URL: Repository, https://github.com/jamesabel/remotedesktop
|
|
@@ -19,6 +19,7 @@ Requires-Dist: cryptography>=49.0.0
|
|
|
19
19
|
Requires-Dist: platformdirs>=4.10.0
|
|
20
20
|
Requires-Dist: pyside6>=6.11.1
|
|
21
21
|
Requires-Dist: semver>=3.0
|
|
22
|
+
Requires-Dist: tobool>=0.4.0
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
|
|
24
25
|
# Remote Desktop
|
|
@@ -73,10 +74,14 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
73
74
|
- 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
|
|
74
75
|
- 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
|
|
75
76
|
- 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
|
|
77
|
+
- ⚡ **Fewer frames to send while sharing** — while at least one viewer is connected, Windows animations, menu fades, and shadows are turned off on the shared computer so the stream snaps instead of smearing; everything is restored the moment the last viewer leaves, and nothing is permanently changed (a recommended, default-on preference).
|
|
76
78
|
- ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
|
|
79
|
+
- 🖲️ **Cursor shape mirroring** — the pointer is never burned into the captured frame; instead your own cursor takes the remote cursor's shape (an I-beam over text, resize arrows on a window edge), so it is always crisp and lag-free.
|
|
77
80
|
- 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
|
|
78
81
|
- 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
|
|
82
|
+
- 📸 **Screen captures** — grab the remote screen at its full resolution and copy it to the clipboard or save it as a PNG, from the *Screen capture* panel buttons or the File menu.
|
|
79
83
|
- 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
|
|
84
|
+
- 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
|
|
80
85
|
- 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
|
|
81
86
|
- 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
|
|
82
87
|
- 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights), close-to-tray while sharing (the screen stays available with the window closed), and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
|
|
@@ -138,6 +143,28 @@ access can be revoked at any time from the *Connections* tab. There is
|
|
|
138
143
|
no dependency
|
|
139
144
|
on Windows RDP or any Microsoft-based authentication.
|
|
140
145
|
|
|
146
|
+
## The Windows lock screen
|
|
147
|
+
|
|
148
|
+
**A locked computer cannot be viewed or unlocked remotely.** The lock
|
|
149
|
+
screen, the PIN/password prompt, and UAC elevation prompts run on a
|
|
150
|
+
separate *secure desktop* that Windows deliberately walls off from
|
|
151
|
+
ordinary applications: nothing running as the signed-in user may capture
|
|
152
|
+
it or type into it, so no software can read or fill in a credential
|
|
153
|
+
prompt. This app intentionally runs as the signed-in user — no admin
|
|
154
|
+
rights, no system service — so it sits on the application side of that
|
|
155
|
+
wall (remote-access products that can show the login screen install a
|
|
156
|
+
privileged Windows service to cross it).
|
|
157
|
+
|
|
158
|
+
What happens instead: when the shared computer locks, viewers stay
|
|
159
|
+
connected and see a clear *"The remote computer is locked"* notice over
|
|
160
|
+
the last frame, and any input they send is ignored until the computer is
|
|
161
|
+
unlocked. The working assumption is that you (or someone nearby) can get
|
|
162
|
+
to the shared computer to sign it in; once its desktop is unlocked,
|
|
163
|
+
streaming and control resume on their own. If that round trip is a
|
|
164
|
+
nuisance, keep the shared computer signed in while you work on it
|
|
165
|
+
remotely — for example by lengthening its lock/sleep timeout in Windows
|
|
166
|
+
settings.
|
|
167
|
+
|
|
141
168
|
## How it works
|
|
142
169
|
|
|
143
170
|
All of this is pure Python — the GUI is PySide6 (Qt), and the two places
|
|
@@ -154,7 +181,10 @@ an idle screen costs essentially nothing. When duplication is unavailable
|
|
|
154
181
|
or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
|
|
155
182
|
display-mode change — the server transparently falls back to Qt's
|
|
156
183
|
`QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
|
|
157
|
-
the background.
|
|
184
|
+
the background. The captured frame never contains the mouse pointer;
|
|
185
|
+
instead the server reports the current cursor's *shape* whenever it
|
|
186
|
+
changes, and each client mirrors it on its own local cursor — which is why
|
|
187
|
+
the pointer you see is always sharp and moves with zero latency.
|
|
158
188
|
|
|
159
189
|
**Screen transfer.** Frames are captured at up to 30 fps and compared with
|
|
160
190
|
the previous capture in 64-row bands; only the bands that changed are
|
|
@@ -50,10 +50,14 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
50
50
|
- 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
|
|
51
51
|
- 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
|
|
52
52
|
- 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
|
|
53
|
+
- ⚡ **Fewer frames to send while sharing** — while at least one viewer is connected, Windows animations, menu fades, and shadows are turned off on the shared computer so the stream snaps instead of smearing; everything is restored the moment the last viewer leaves, and nothing is permanently changed (a recommended, default-on preference).
|
|
53
54
|
- ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
|
|
55
|
+
- 🖲️ **Cursor shape mirroring** — the pointer is never burned into the captured frame; instead your own cursor takes the remote cursor's shape (an I-beam over text, resize arrows on a window edge), so it is always crisp and lag-free.
|
|
54
56
|
- 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
|
|
55
57
|
- 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
|
|
58
|
+
- 📸 **Screen captures** — grab the remote screen at its full resolution and copy it to the clipboard or save it as a PNG, from the *Screen capture* panel buttons or the File menu.
|
|
56
59
|
- 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
|
|
60
|
+
- 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
|
|
57
61
|
- 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
|
|
58
62
|
- 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
|
|
59
63
|
- 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights), close-to-tray while sharing (the screen stays available with the window closed), and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
|
|
@@ -115,6 +119,28 @@ access can be revoked at any time from the *Connections* tab. There is
|
|
|
115
119
|
no dependency
|
|
116
120
|
on Windows RDP or any Microsoft-based authentication.
|
|
117
121
|
|
|
122
|
+
## The Windows lock screen
|
|
123
|
+
|
|
124
|
+
**A locked computer cannot be viewed or unlocked remotely.** The lock
|
|
125
|
+
screen, the PIN/password prompt, and UAC elevation prompts run on a
|
|
126
|
+
separate *secure desktop* that Windows deliberately walls off from
|
|
127
|
+
ordinary applications: nothing running as the signed-in user may capture
|
|
128
|
+
it or type into it, so no software can read or fill in a credential
|
|
129
|
+
prompt. This app intentionally runs as the signed-in user — no admin
|
|
130
|
+
rights, no system service — so it sits on the application side of that
|
|
131
|
+
wall (remote-access products that can show the login screen install a
|
|
132
|
+
privileged Windows service to cross it).
|
|
133
|
+
|
|
134
|
+
What happens instead: when the shared computer locks, viewers stay
|
|
135
|
+
connected and see a clear *"The remote computer is locked"* notice over
|
|
136
|
+
the last frame, and any input they send is ignored until the computer is
|
|
137
|
+
unlocked. The working assumption is that you (or someone nearby) can get
|
|
138
|
+
to the shared computer to sign it in; once its desktop is unlocked,
|
|
139
|
+
streaming and control resume on their own. If that round trip is a
|
|
140
|
+
nuisance, keep the shared computer signed in while you work on it
|
|
141
|
+
remotely — for example by lengthening its lock/sleep timeout in Windows
|
|
142
|
+
settings.
|
|
143
|
+
|
|
118
144
|
## How it works
|
|
119
145
|
|
|
120
146
|
All of this is pure Python — the GUI is PySide6 (Qt), and the two places
|
|
@@ -131,7 +157,10 @@ an idle screen costs essentially nothing. When duplication is unavailable
|
|
|
131
157
|
or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
|
|
132
158
|
display-mode change — the server transparently falls back to Qt's
|
|
133
159
|
`QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
|
|
134
|
-
the background.
|
|
160
|
+
the background. The captured frame never contains the mouse pointer;
|
|
161
|
+
instead the server reports the current cursor's *shape* whenever it
|
|
162
|
+
changes, and each client mirrors it on its own local cursor — which is why
|
|
163
|
+
the pointer you see is always sharp and moves with zero latency.
|
|
135
164
|
|
|
136
165
|
**Screen transfer.** Frames are captured at up to 30 fps and compared with
|
|
137
166
|
the previous capture in 64-row bands; only the bands that changed are
|
|
@@ -12,16 +12,19 @@ tray menu. Only one instance runs per user session (`single_instance`)."""
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
|
+
import re
|
|
15
16
|
import sqlite3
|
|
16
17
|
import sys
|
|
17
18
|
import time
|
|
19
|
+
from pathlib import Path
|
|
18
20
|
|
|
19
|
-
from PySide6.QtCore import QObject, QProcess, Qt, QTimer, Signal
|
|
20
|
-
from PySide6.QtGui import QCloseEvent, QKeySequence
|
|
21
|
+
from PySide6.QtCore import QObject, QProcess, QStandardPaths, Qt, QTimer, Signal
|
|
22
|
+
from PySide6.QtGui import QCloseEvent, QImage, QKeySequence
|
|
21
23
|
from PySide6.QtNetwork import QNetworkInterface
|
|
22
24
|
from PySide6.QtWidgets import (
|
|
23
25
|
QApplication,
|
|
24
26
|
QDockWidget,
|
|
27
|
+
QFileDialog,
|
|
25
28
|
QFrame,
|
|
26
29
|
QGroupBox,
|
|
27
30
|
QHBoxLayout,
|
|
@@ -55,6 +58,7 @@ from remotedesktop.preferences import (
|
|
|
55
58
|
apply_theme,
|
|
56
59
|
load_clipboard_sync_enabled,
|
|
57
60
|
load_performance_window_seconds,
|
|
61
|
+
load_reduce_effects_enabled,
|
|
58
62
|
load_theme,
|
|
59
63
|
load_viewer_enabled,
|
|
60
64
|
)
|
|
@@ -62,6 +66,7 @@ from remotedesktop.server import SharingTab, ViewersTable
|
|
|
62
66
|
from remotedesktop.sharing import ShareClient
|
|
63
67
|
from remotedesktop.single_instance import SingleInstance
|
|
64
68
|
from remotedesktop.viewer import ViewerWidget
|
|
69
|
+
from remotedesktop.visual_effects import VisualEffectsReducer
|
|
65
70
|
|
|
66
71
|
_log = logging.getLogger("remotedesktop.app")
|
|
67
72
|
|
|
@@ -127,6 +132,7 @@ class MainWindow(QMainWindow):
|
|
|
127
132
|
connect_port: int = DEFAULT_CONNECT_PORT,
|
|
128
133
|
tray_available: bool | None = None,
|
|
129
134
|
reconnect_base_seconds: float = 2.0,
|
|
135
|
+
effects_reducer: VisualEffectsReducer | None = None,
|
|
130
136
|
) -> None:
|
|
131
137
|
super().__init__()
|
|
132
138
|
self._reconnect_base = reconnect_base_seconds
|
|
@@ -164,6 +170,15 @@ class MainWindow(QMainWindow):
|
|
|
164
170
|
self._tray: QSystemTrayIcon | None = None
|
|
165
171
|
self._tray_notified = False
|
|
166
172
|
self._quitting = False
|
|
173
|
+
# Windows visual effects are reduced only while someone is actually
|
|
174
|
+
# watching (fewer animation frames to encode and ship) and restored
|
|
175
|
+
# when the last viewer leaves. The default reducer changes real OS
|
|
176
|
+
# settings — tests always inject one with a fake backend.
|
|
177
|
+
self._effects_reducer = (
|
|
178
|
+
effects_reducer if effects_reducer is not None else VisualEffectsReducer()
|
|
179
|
+
)
|
|
180
|
+
self._reduce_effects = load_reduce_effects_enabled(self._settings)
|
|
181
|
+
self._sharing_viewer_count = 0
|
|
167
182
|
self._fullscreen_state: dict | None = None
|
|
168
183
|
self._fullscreen_hint: QLabel | None = None
|
|
169
184
|
|
|
@@ -180,6 +195,7 @@ class MainWindow(QMainWindow):
|
|
|
180
195
|
self.sharing_tab.peerEvent.connect(self._record_server_peer)
|
|
181
196
|
self.sharing_tab.sharingChanged.connect(self._on_sharing_changed)
|
|
182
197
|
self.sharing_tab.viewerCountChanged.connect(self._update_sharing_indicator)
|
|
198
|
+
self.sharing_tab.viewerCountChanged.connect(self._on_viewer_count_changed)
|
|
183
199
|
|
|
184
200
|
# One tab per server connection (inserted at the front, closable),
|
|
185
201
|
# followed by the fixed tabs, which never get a close button. While
|
|
@@ -301,11 +317,35 @@ class MainWindow(QMainWindow):
|
|
|
301
317
|
# through, and the roles are changed only in Preferences.
|
|
302
318
|
self.client_role_button = self._make_role_button()
|
|
303
319
|
self.server_role_button = self._make_role_button()
|
|
320
|
+
# Screen capture of the current session tab (the last received frame
|
|
321
|
+
# at the server's full resolution). Buttons live in the dock; the
|
|
322
|
+
# same handlers back the File-menu actions. Enabled only while the
|
|
323
|
+
# current tab shows a frame (_update_capture_actions).
|
|
324
|
+
self.copy_capture_button = QPushButton("Copy")
|
|
325
|
+
self.copy_capture_button.setToolTip(
|
|
326
|
+
"Copy a screen capture of the current session to the clipboard"
|
|
327
|
+
)
|
|
328
|
+
self.copy_capture_button.clicked.connect(self._copy_screen_capture)
|
|
329
|
+
self.save_capture_button = QPushButton("Save…")
|
|
330
|
+
self.save_capture_button.setToolTip(
|
|
331
|
+
"Save a screen capture of the current session to a file"
|
|
332
|
+
)
|
|
333
|
+
self.save_capture_button.clicked.connect(self._save_screen_capture)
|
|
334
|
+
# Nothing to capture until a session tab shows a frame.
|
|
335
|
+
self.copy_capture_button.setEnabled(False)
|
|
336
|
+
self.save_capture_button.setEnabled(False)
|
|
337
|
+
self._capture_group = QGroupBox("Screen capture")
|
|
338
|
+
# Stacked, not side by side: the dock is narrow and its width is
|
|
339
|
+
# what the panel/server list gets — height is the cheaper dimension.
|
|
340
|
+
capture_layout = QVBoxLayout(self._capture_group)
|
|
341
|
+
capture_layout.addWidget(self.copy_capture_button)
|
|
342
|
+
capture_layout.addWidget(self.save_capture_button)
|
|
304
343
|
dock_body = QWidget()
|
|
305
344
|
self._dock_layout = QVBoxLayout(dock_body)
|
|
306
345
|
self._dock_layout.addWidget(self.client_role_button)
|
|
307
346
|
self._dock_layout.addWidget(self.server_role_button)
|
|
308
347
|
self._dock_layout.addWidget(self.discovery_panel)
|
|
348
|
+
self._dock_layout.addWidget(self._capture_group)
|
|
309
349
|
# A permanent trailing spacer: it takes the leftover height while
|
|
310
350
|
# the discovery panel is hidden (keeping the indicators pinned to
|
|
311
351
|
# the top), and collapses to nothing while the panel is visible
|
|
@@ -313,6 +353,7 @@ class MainWindow(QMainWindow):
|
|
|
313
353
|
self._dock_layout.addStretch(0)
|
|
314
354
|
if not self._viewer_enabled:
|
|
315
355
|
self.discovery_panel.hide()
|
|
356
|
+
self._capture_group.hide()
|
|
316
357
|
self._update_dock_layout()
|
|
317
358
|
# No title bar at all: the contents are self-explanatory, and the
|
|
318
359
|
# View menu ("Panel") is the way to show or hide it — a blank header
|
|
@@ -338,6 +379,7 @@ class MainWindow(QMainWindow):
|
|
|
338
379
|
# three-state choice) and the viewer role's UI.
|
|
339
380
|
self.preferences_tab.sharingModeChanged.connect(self.sharing_tab.set_mode)
|
|
340
381
|
self.preferences_tab.viewerModeChanged.connect(self._set_viewer_enabled)
|
|
382
|
+
self.preferences_tab.reduceEffectsChanged.connect(self._on_reduce_effects_changed)
|
|
341
383
|
self.preferences_tab.restart_button.clicked.connect(self._restart_app)
|
|
342
384
|
self._tabs.addTab(self.preferences_tab, "Preferences")
|
|
343
385
|
self._about_tab = AboutTab()
|
|
@@ -403,6 +445,15 @@ class MainWindow(QMainWindow):
|
|
|
403
445
|
self.close_tab_action.triggered.connect(self._close_current_session_tab)
|
|
404
446
|
self.close_tab_action.setEnabled(False) # startup tab is a fixed tab
|
|
405
447
|
self._file_menu.addSeparator()
|
|
448
|
+
# Same handlers as the dock's Screen capture buttons; enabled state
|
|
449
|
+
# is kept in sync with them (_update_capture_actions).
|
|
450
|
+
self.copy_capture_action = self._file_menu.addAction("Copy screen &capture")
|
|
451
|
+
self.copy_capture_action.triggered.connect(self._copy_screen_capture)
|
|
452
|
+
self.copy_capture_action.setEnabled(False)
|
|
453
|
+
self.save_capture_action = self._file_menu.addAction("&Save screen capture…")
|
|
454
|
+
self.save_capture_action.triggered.connect(self._save_screen_capture)
|
|
455
|
+
self.save_capture_action.setEnabled(False)
|
|
456
|
+
self._file_menu.addSeparator()
|
|
406
457
|
self.quit_action = self._file_menu.addAction("&Quit")
|
|
407
458
|
self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
|
|
408
459
|
self.quit_action.triggered.connect(self._quit)
|
|
@@ -523,7 +574,7 @@ class MainWindow(QMainWindow):
|
|
|
523
574
|
"""Give the dock's leftover height to the panel or the spacer."""
|
|
524
575
|
panel_visible = self._viewer_enabled
|
|
525
576
|
self._dock_layout.setStretch(2, 1 if panel_visible else 0) # panel
|
|
526
|
-
self._dock_layout.setStretch(
|
|
577
|
+
self._dock_layout.setStretch(4, 0 if panel_visible else 1) # spacer
|
|
527
578
|
|
|
528
579
|
def _update_role_indicators(self) -> None:
|
|
529
580
|
self.client_role_button.setChecked(self._viewer_enabled)
|
|
@@ -546,8 +597,10 @@ class MainWindow(QMainWindow):
|
|
|
546
597
|
if placeholder_index != -1:
|
|
547
598
|
self._tabs.removeTab(placeholder_index)
|
|
548
599
|
self.discovery_panel.hide()
|
|
600
|
+
self._capture_group.hide()
|
|
549
601
|
else:
|
|
550
602
|
self.discovery_panel.show()
|
|
603
|
+
self._capture_group.show()
|
|
551
604
|
self._ensure_placeholder()
|
|
552
605
|
self._tabs.setCurrentIndex(0)
|
|
553
606
|
if self._auto_scan:
|
|
@@ -567,6 +620,74 @@ class MainWindow(QMainWindow):
|
|
|
567
620
|
self.fullscreen_action.setEnabled(session is not None)
|
|
568
621
|
self.actual_size_action.setEnabled(session is not None)
|
|
569
622
|
self.actual_size_action.setChecked(session.actual_size if session else False)
|
|
623
|
+
self._update_capture_actions()
|
|
624
|
+
|
|
625
|
+
# ------------------------------------------------------ screen capture
|
|
626
|
+
|
|
627
|
+
def _update_capture_actions(self) -> None:
|
|
628
|
+
"""Capture is possible while the current tab shows a frame; the dock
|
|
629
|
+
buttons and the File-menu actions enable and disable together."""
|
|
630
|
+
session = self._session_for_page(self._tabs.currentWidget())
|
|
631
|
+
available = session is not None and session.viewer.has_frame
|
|
632
|
+
for action in (
|
|
633
|
+
self.copy_capture_button,
|
|
634
|
+
self.save_capture_button,
|
|
635
|
+
self.copy_capture_action,
|
|
636
|
+
self.save_capture_action,
|
|
637
|
+
):
|
|
638
|
+
action.setEnabled(available)
|
|
639
|
+
|
|
640
|
+
def _current_capture(self) -> tuple[ServerSession, QImage] | None:
|
|
641
|
+
"""The current tab's session and its full-resolution frame, or None
|
|
642
|
+
(the handlers can fire with no frame via QAction.trigger())."""
|
|
643
|
+
session = self._session_for_page(self._tabs.currentWidget())
|
|
644
|
+
if session is None:
|
|
645
|
+
return None
|
|
646
|
+
image = session.viewer.frame_image()
|
|
647
|
+
if image is None:
|
|
648
|
+
return None
|
|
649
|
+
return session, image
|
|
650
|
+
|
|
651
|
+
def _copy_screen_capture(self) -> None:
|
|
652
|
+
capture = self._current_capture()
|
|
653
|
+
if capture is None:
|
|
654
|
+
return
|
|
655
|
+
session, image = capture
|
|
656
|
+
# copy_image does not echo to peers: sending the server a capture of
|
|
657
|
+
# its own screen (or spamming viewers) would be pure waste.
|
|
658
|
+
self._clipboard.copy_image(image)
|
|
659
|
+
self.log(
|
|
660
|
+
f"Screen capture of {session.name} "
|
|
661
|
+
f"({image.width()}x{image.height()}) copied to the clipboard"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def _save_screen_capture(self) -> None:
|
|
665
|
+
capture = self._current_capture()
|
|
666
|
+
if capture is None:
|
|
667
|
+
return
|
|
668
|
+
session, image = capture
|
|
669
|
+
# Default: Pictures\<server> 2026-07-20 143059.png (the server name
|
|
670
|
+
# is a hostname, but sanitize for safety — it came off the wire).
|
|
671
|
+
name = re.sub(r'[<>:"/\\|?*]', "_", session.name).strip() or "server"
|
|
672
|
+
stamp = time.strftime("%Y-%m-%d %H%M%S")
|
|
673
|
+
directory = QStandardPaths.writableLocation(
|
|
674
|
+
QStandardPaths.StandardLocation.PicturesLocation
|
|
675
|
+
)
|
|
676
|
+
default = str(Path(directory) / f"{name} {stamp}.png") if directory else f"{name} {stamp}.png"
|
|
677
|
+
path, _filter = QFileDialog.getSaveFileName(
|
|
678
|
+
self, "Save screen capture", default, "PNG image (*.png)"
|
|
679
|
+
)
|
|
680
|
+
if not path:
|
|
681
|
+
return # cancelled
|
|
682
|
+
if not path.lower().endswith(".png"):
|
|
683
|
+
path += ".png"
|
|
684
|
+
if image.save(path, "PNG"): # ty: ignore[no-matching-overload]
|
|
685
|
+
self.log(
|
|
686
|
+
f"Screen capture of {session.name} "
|
|
687
|
+
f"({image.width()}x{image.height()}) saved to {path}"
|
|
688
|
+
)
|
|
689
|
+
else:
|
|
690
|
+
self.log(f"Could not save the screen capture to {path}")
|
|
570
691
|
|
|
571
692
|
def _close_current_session_tab(self) -> None:
|
|
572
693
|
session = self._session_for_page(self._tabs.currentWidget())
|
|
@@ -713,6 +834,27 @@ class MainWindow(QMainWindow):
|
|
|
713
834
|
self._update_performance_tabs()
|
|
714
835
|
self._update_connections_groups()
|
|
715
836
|
|
|
837
|
+
def _on_viewer_count_changed(self, count: int) -> None:
|
|
838
|
+
self._sharing_viewer_count = count
|
|
839
|
+
self._update_effects_reduction()
|
|
840
|
+
|
|
841
|
+
def _on_reduce_effects_changed(self, enabled: bool) -> None:
|
|
842
|
+
self._reduce_effects = enabled
|
|
843
|
+
self._update_effects_reduction() # applies/restores live mid-session
|
|
844
|
+
|
|
845
|
+
def _update_effects_reduction(self) -> None:
|
|
846
|
+
"""Reduce Windows visual effects while the preference is on AND a
|
|
847
|
+
viewer is connected; restore the user's values otherwise. Stop,
|
|
848
|
+
quit, and restart all reach here via their viewerCountChanged(0)."""
|
|
849
|
+
if self._reduce_effects and self._sharing_viewer_count > 0:
|
|
850
|
+
if self._effects_reducer.apply():
|
|
851
|
+
self.log(
|
|
852
|
+
"Windows visual effects reduced while viewers are connected "
|
|
853
|
+
"(restored when the last one disconnects)"
|
|
854
|
+
)
|
|
855
|
+
elif self._effects_reducer.restore():
|
|
856
|
+
self.log("Windows visual effects restored")
|
|
857
|
+
|
|
716
858
|
def _quit(self) -> None:
|
|
717
859
|
self._quitting = True
|
|
718
860
|
self.close()
|
|
@@ -758,6 +900,9 @@ class MainWindow(QMainWindow):
|
|
|
758
900
|
self._cancel_reconnect(session)
|
|
759
901
|
session.client.close()
|
|
760
902
|
self.sharing_tab.shutdown()
|
|
903
|
+
# shutdown's viewerCountChanged(0) already restored the effects;
|
|
904
|
+
# this is the safety net for any exit path that skipped the signal.
|
|
905
|
+
self._effects_reducer.restore()
|
|
761
906
|
super().closeEvent(event)
|
|
762
907
|
QApplication.quit() # main() disables quit-on-last-window-closed
|
|
763
908
|
|
|
@@ -1002,6 +1147,7 @@ class MainWindow(QMainWindow):
|
|
|
1002
1147
|
client.disconnected.connect(lambda s=session: self._on_disconnected(s))
|
|
1003
1148
|
client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
|
|
1004
1149
|
client.cursorShapeChanged.connect(lambda shape, s=session: s.viewer.set_remote_cursor(shape))
|
|
1150
|
+
client.sessionLockChanged.connect(lambda locked, s=session: s.viewer.set_session_locked(locked))
|
|
1005
1151
|
client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
|
|
1006
1152
|
client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
|
|
1007
1153
|
self._sessions.append(session)
|
|
@@ -1031,6 +1177,7 @@ class MainWindow(QMainWindow):
|
|
|
1031
1177
|
if not any(s.connected for s in self._sessions if s is not session):
|
|
1032
1178
|
self.client_performance.reset()
|
|
1033
1179
|
session.viewer.clear(f"Connecting to {session.name} …")
|
|
1180
|
+
self._update_capture_actions() # cleared viewer; tab may already be current
|
|
1034
1181
|
self._tabs.setCurrentWidget(session.page)
|
|
1035
1182
|
self._set_session_status(
|
|
1036
1183
|
session, f"Connecting to {session.name} ({session.key}) …"
|
|
@@ -1072,6 +1219,7 @@ class MainWindow(QMainWindow):
|
|
|
1072
1219
|
f"Waiting for approval — someone at {session.name} "
|
|
1073
1220
|
"must allow this connection"
|
|
1074
1221
|
)
|
|
1222
|
+
self._update_capture_actions()
|
|
1075
1223
|
self._set_session_status(
|
|
1076
1224
|
session,
|
|
1077
1225
|
f"Waiting for the user on {session.name} to approve this computer …",
|
|
@@ -1139,6 +1287,7 @@ class MainWindow(QMainWindow):
|
|
|
1139
1287
|
session.auto_reconnect = False
|
|
1140
1288
|
self.client_inventory.record(session.key, "denied", name=session.name)
|
|
1141
1289
|
session.viewer.clear(f"Connection denied: {reason}")
|
|
1290
|
+
self._update_capture_actions()
|
|
1142
1291
|
self._set_session_status(session, f"Denied by {session.name}: {reason}")
|
|
1143
1292
|
self._update_window_title()
|
|
1144
1293
|
|
|
@@ -1152,6 +1301,7 @@ class MainWindow(QMainWindow):
|
|
|
1152
1301
|
self.client_inventory.record(session.key, "disconnected", name=session.name)
|
|
1153
1302
|
session.viewer.clear("Disconnected")
|
|
1154
1303
|
self._set_session_status(session, f"Disconnected from {session.name}")
|
|
1304
|
+
self._update_capture_actions() # a cleared viewer has nothing to capture
|
|
1155
1305
|
self._update_window_title()
|
|
1156
1306
|
self._sessions_source.notify()
|
|
1157
1307
|
if session.auto_reconnect and not session.denied and not self._quitting:
|
|
@@ -1234,6 +1384,9 @@ class MainWindow(QMainWindow):
|
|
|
1234
1384
|
return
|
|
1235
1385
|
session.frame_count += 1
|
|
1236
1386
|
session.viewer.show_frame(image)
|
|
1387
|
+
# Unconditional (not just the first frame): auto-reconnect refills a
|
|
1388
|
+
# cleared viewer without passing through _connect_session.
|
|
1389
|
+
self._update_capture_actions()
|
|
1237
1390
|
self._set_session_status(
|
|
1238
1391
|
session,
|
|
1239
1392
|
f"Viewing {self._server_label(session)} — {image.width()}x{image.height()} — "
|
|
@@ -69,6 +69,25 @@ class ClipboardSync(QObject):
|
|
|
69
69
|
).decode()
|
|
70
70
|
self.changed.emit(payload)
|
|
71
71
|
|
|
72
|
+
def copy_image(self, image: QImage) -> None:
|
|
73
|
+
"""Place an app-generated image (a screen capture) on the local
|
|
74
|
+
clipboard WITHOUT echoing it to peers: its signature is recorded
|
|
75
|
+
first, so the asynchronous dataChanged this set fires is ignored —
|
|
76
|
+
the same mechanism apply() uses. Sending a capture of the server's
|
|
77
|
+
own screen back to it would be pure waste.
|
|
78
|
+
|
|
79
|
+
Deliberately not gated on `enabled`: that preference governs
|
|
80
|
+
syncing, and this is a local copy.
|
|
81
|
+
"""
|
|
82
|
+
if image.isNull():
|
|
83
|
+
return
|
|
84
|
+
self._last_signature = (None, _image_hash(image))
|
|
85
|
+
self._applying = True
|
|
86
|
+
try:
|
|
87
|
+
self._clipboard.setImage(image)
|
|
88
|
+
finally:
|
|
89
|
+
self._applying = False
|
|
90
|
+
|
|
72
91
|
def apply(self, payload: dict) -> None:
|
|
73
92
|
if not self.enabled:
|
|
74
93
|
return # peers may still send payloads; they are dropped locally
|