remotedesktop 1.7.0__tar.gz → 1.9.1__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.7.0 → remotedesktop-1.9.1}/CLAUDE.md +2 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/PKG-INFO +24 -1
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/readme.md +23 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/app.py +118 -3
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/clipboard.py +19 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/server.py +2 -0
- remotedesktop-1.9.1/src/remotedesktop/session_lock.py +46 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/sharing.py +60 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/viewer.py +62 -25
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_clipboard.py +24 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_input.py +56 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_main_window.py +118 -5
- remotedesktop-1.9.1/tests/test_session_lock.py +18 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_sharing.py +32 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/.github/workflows/ci.yml +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/.github/workflows/release.yml +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/.gitignore +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/LICENSE +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/docs/media/client-demo.gif +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/docs/media/server-demo.gif +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/icon/remotedesktop.ico +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/pyproject.toml +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/run.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/run_claude.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/scripts/make_installer.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/scripts/make_venv.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/about.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/client.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/config.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/db.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/visual_effects.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/conftest.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_about.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_autostart.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_compat.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_config.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_discovery.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_dxgi.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_frames.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_icon.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_input_injection.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_inventory.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_logs.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_modal_loop.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_performance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_preferences.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_protocol.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_single_instance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_smoke.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_tls.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_visual_effects.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tests/test_window_state.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/tools/make_icon.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.1}/uv.lock +0 -0
|
@@ -76,6 +76,7 @@ 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).
|
|
79
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).
|
|
80
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.
|
|
81
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.
|
|
@@ -90,6 +91,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
90
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.
|
|
91
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.
|
|
92
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.
|
|
93
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.
|
|
94
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`.
|
|
95
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.1
|
|
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
|
|
@@ -77,6 +77,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
77
77
|
- ⌨️🖱️ **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.
|
|
78
78
|
- 🖼️ **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.
|
|
79
79
|
- 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
|
|
80
|
+
- 📸 **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.
|
|
80
81
|
- 🔒 **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.
|
|
81
82
|
- 📊 **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.
|
|
82
83
|
- 🔁 **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.
|
|
@@ -139,6 +140,28 @@ access can be revoked at any time from the *Connections* tab. There is
|
|
|
139
140
|
no dependency
|
|
140
141
|
on Windows RDP or any Microsoft-based authentication.
|
|
141
142
|
|
|
143
|
+
## The Windows lock screen
|
|
144
|
+
|
|
145
|
+
**A locked computer cannot be viewed or unlocked remotely.** The lock
|
|
146
|
+
screen, the PIN/password prompt, and UAC elevation prompts run on a
|
|
147
|
+
separate *secure desktop* that Windows deliberately walls off from
|
|
148
|
+
ordinary applications: nothing running as the signed-in user may capture
|
|
149
|
+
it or type into it, so no software can read or fill in a credential
|
|
150
|
+
prompt. This app intentionally runs as the signed-in user — no admin
|
|
151
|
+
rights, no system service — so it sits on the application side of that
|
|
152
|
+
wall (remote-access products that can show the login screen install a
|
|
153
|
+
privileged Windows service to cross it).
|
|
154
|
+
|
|
155
|
+
What happens instead: when the shared computer locks, viewers stay
|
|
156
|
+
connected and see a clear *"The remote computer is locked"* notice over
|
|
157
|
+
the last frame, and any input they send is ignored until the computer is
|
|
158
|
+
unlocked. The working assumption is that you (or someone nearby) can get
|
|
159
|
+
to the shared computer to sign it in; once its desktop is unlocked,
|
|
160
|
+
streaming and control resume on their own. If that round trip is a
|
|
161
|
+
nuisance, keep the shared computer signed in while you work on it
|
|
162
|
+
remotely — for example by lengthening its lock/sleep timeout in Windows
|
|
163
|
+
settings.
|
|
164
|
+
|
|
142
165
|
## How it works
|
|
143
166
|
|
|
144
167
|
All of this is pure Python — the GUI is PySide6 (Qt), and the two places
|
|
@@ -53,6 +53,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
53
53
|
- ⌨️🖱️ **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.
|
|
54
54
|
- 🖼️ **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
55
|
- 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
|
|
56
|
+
- 📸 **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
57
|
- 🔒 **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.
|
|
57
58
|
- 📊 **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
59
|
- 🔁 **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.
|
|
@@ -115,6 +116,28 @@ access can be revoked at any time from the *Connections* tab. There is
|
|
|
115
116
|
no dependency
|
|
116
117
|
on Windows RDP or any Microsoft-based authentication.
|
|
117
118
|
|
|
119
|
+
## The Windows lock screen
|
|
120
|
+
|
|
121
|
+
**A locked computer cannot be viewed or unlocked remotely.** The lock
|
|
122
|
+
screen, the PIN/password prompt, and UAC elevation prompts run on a
|
|
123
|
+
separate *secure desktop* that Windows deliberately walls off from
|
|
124
|
+
ordinary applications: nothing running as the signed-in user may capture
|
|
125
|
+
it or type into it, so no software can read or fill in a credential
|
|
126
|
+
prompt. This app intentionally runs as the signed-in user — no admin
|
|
127
|
+
rights, no system service — so it sits on the application side of that
|
|
128
|
+
wall (remote-access products that can show the login screen install a
|
|
129
|
+
privileged Windows service to cross it).
|
|
130
|
+
|
|
131
|
+
What happens instead: when the shared computer locks, viewers stay
|
|
132
|
+
connected and see a clear *"The remote computer is locked"* notice over
|
|
133
|
+
the last frame, and any input they send is ignored until the computer is
|
|
134
|
+
unlocked. The working assumption is that you (or someone nearby) can get
|
|
135
|
+
to the shared computer to sign it in; once its desktop is unlocked,
|
|
136
|
+
streaming and control resume on their own. If that round trip is a
|
|
137
|
+
nuisance, keep the shared computer signed in while you work on it
|
|
138
|
+
remotely — for example by lengthening its lock/sleep timeout in Windows
|
|
139
|
+
settings.
|
|
140
|
+
|
|
118
141
|
## How it works
|
|
119
142
|
|
|
120
143
|
All of this is pure Python — the GUI is PySide6 (Qt), and the two places
|
|
@@ -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,
|
|
@@ -314,11 +317,35 @@ class MainWindow(QMainWindow):
|
|
|
314
317
|
# through, and the roles are changed only in Preferences.
|
|
315
318
|
self.client_role_button = self._make_role_button()
|
|
316
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)
|
|
317
343
|
dock_body = QWidget()
|
|
318
344
|
self._dock_layout = QVBoxLayout(dock_body)
|
|
319
345
|
self._dock_layout.addWidget(self.client_role_button)
|
|
320
346
|
self._dock_layout.addWidget(self.server_role_button)
|
|
321
347
|
self._dock_layout.addWidget(self.discovery_panel)
|
|
348
|
+
self._dock_layout.addWidget(self._capture_group)
|
|
322
349
|
# A permanent trailing spacer: it takes the leftover height while
|
|
323
350
|
# the discovery panel is hidden (keeping the indicators pinned to
|
|
324
351
|
# the top), and collapses to nothing while the panel is visible
|
|
@@ -326,6 +353,7 @@ class MainWindow(QMainWindow):
|
|
|
326
353
|
self._dock_layout.addStretch(0)
|
|
327
354
|
if not self._viewer_enabled:
|
|
328
355
|
self.discovery_panel.hide()
|
|
356
|
+
self._capture_group.hide()
|
|
329
357
|
self._update_dock_layout()
|
|
330
358
|
# No title bar at all: the contents are self-explanatory, and the
|
|
331
359
|
# View menu ("Panel") is the way to show or hide it — a blank header
|
|
@@ -417,6 +445,15 @@ class MainWindow(QMainWindow):
|
|
|
417
445
|
self.close_tab_action.triggered.connect(self._close_current_session_tab)
|
|
418
446
|
self.close_tab_action.setEnabled(False) # startup tab is a fixed tab
|
|
419
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()
|
|
420
457
|
self.quit_action = self._file_menu.addAction("&Quit")
|
|
421
458
|
self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
|
|
422
459
|
self.quit_action.triggered.connect(self._quit)
|
|
@@ -537,7 +574,7 @@ class MainWindow(QMainWindow):
|
|
|
537
574
|
"""Give the dock's leftover height to the panel or the spacer."""
|
|
538
575
|
panel_visible = self._viewer_enabled
|
|
539
576
|
self._dock_layout.setStretch(2, 1 if panel_visible else 0) # panel
|
|
540
|
-
self._dock_layout.setStretch(
|
|
577
|
+
self._dock_layout.setStretch(4, 0 if panel_visible else 1) # spacer
|
|
541
578
|
|
|
542
579
|
def _update_role_indicators(self) -> None:
|
|
543
580
|
self.client_role_button.setChecked(self._viewer_enabled)
|
|
@@ -560,8 +597,10 @@ class MainWindow(QMainWindow):
|
|
|
560
597
|
if placeholder_index != -1:
|
|
561
598
|
self._tabs.removeTab(placeholder_index)
|
|
562
599
|
self.discovery_panel.hide()
|
|
600
|
+
self._capture_group.hide()
|
|
563
601
|
else:
|
|
564
602
|
self.discovery_panel.show()
|
|
603
|
+
self._capture_group.show()
|
|
565
604
|
self._ensure_placeholder()
|
|
566
605
|
self._tabs.setCurrentIndex(0)
|
|
567
606
|
if self._auto_scan:
|
|
@@ -581,6 +620,74 @@ class MainWindow(QMainWindow):
|
|
|
581
620
|
self.fullscreen_action.setEnabled(session is not None)
|
|
582
621
|
self.actual_size_action.setEnabled(session is not None)
|
|
583
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}")
|
|
584
691
|
|
|
585
692
|
def _close_current_session_tab(self) -> None:
|
|
586
693
|
session = self._session_for_page(self._tabs.currentWidget())
|
|
@@ -1040,6 +1147,7 @@ class MainWindow(QMainWindow):
|
|
|
1040
1147
|
client.disconnected.connect(lambda s=session: self._on_disconnected(s))
|
|
1041
1148
|
client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
|
|
1042
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))
|
|
1043
1151
|
client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
|
|
1044
1152
|
client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
|
|
1045
1153
|
self._sessions.append(session)
|
|
@@ -1069,6 +1177,7 @@ class MainWindow(QMainWindow):
|
|
|
1069
1177
|
if not any(s.connected for s in self._sessions if s is not session):
|
|
1070
1178
|
self.client_performance.reset()
|
|
1071
1179
|
session.viewer.clear(f"Connecting to {session.name} …")
|
|
1180
|
+
self._update_capture_actions() # cleared viewer; tab may already be current
|
|
1072
1181
|
self._tabs.setCurrentWidget(session.page)
|
|
1073
1182
|
self._set_session_status(
|
|
1074
1183
|
session, f"Connecting to {session.name} ({session.key}) …"
|
|
@@ -1110,6 +1219,7 @@ class MainWindow(QMainWindow):
|
|
|
1110
1219
|
f"Waiting for approval — someone at {session.name} "
|
|
1111
1220
|
"must allow this connection"
|
|
1112
1221
|
)
|
|
1222
|
+
self._update_capture_actions()
|
|
1113
1223
|
self._set_session_status(
|
|
1114
1224
|
session,
|
|
1115
1225
|
f"Waiting for the user on {session.name} to approve this computer …",
|
|
@@ -1177,6 +1287,7 @@ class MainWindow(QMainWindow):
|
|
|
1177
1287
|
session.auto_reconnect = False
|
|
1178
1288
|
self.client_inventory.record(session.key, "denied", name=session.name)
|
|
1179
1289
|
session.viewer.clear(f"Connection denied: {reason}")
|
|
1290
|
+
self._update_capture_actions()
|
|
1180
1291
|
self._set_session_status(session, f"Denied by {session.name}: {reason}")
|
|
1181
1292
|
self._update_window_title()
|
|
1182
1293
|
|
|
@@ -1190,6 +1301,7 @@ class MainWindow(QMainWindow):
|
|
|
1190
1301
|
self.client_inventory.record(session.key, "disconnected", name=session.name)
|
|
1191
1302
|
session.viewer.clear("Disconnected")
|
|
1192
1303
|
self._set_session_status(session, f"Disconnected from {session.name}")
|
|
1304
|
+
self._update_capture_actions() # a cleared viewer has nothing to capture
|
|
1193
1305
|
self._update_window_title()
|
|
1194
1306
|
self._sessions_source.notify()
|
|
1195
1307
|
if session.auto_reconnect and not session.denied and not self._quitting:
|
|
@@ -1272,6 +1384,9 @@ class MainWindow(QMainWindow):
|
|
|
1272
1384
|
return
|
|
1273
1385
|
session.frame_count += 1
|
|
1274
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()
|
|
1275
1390
|
self._set_session_status(
|
|
1276
1391
|
session,
|
|
1277
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
|
|
@@ -29,6 +29,7 @@ from PySide6.QtWidgets import (
|
|
|
29
29
|
from remotedesktop import __version__, compat, tls
|
|
30
30
|
from remotedesktop.config import PairedClients, Settings, default_config_dir
|
|
31
31
|
from remotedesktop.cursor_shape import current_cursor_shape
|
|
32
|
+
from remotedesktop.session_lock import is_session_locked
|
|
32
33
|
from remotedesktop.discovery import (
|
|
33
34
|
DEFAULT_CONNECT_PORT,
|
|
34
35
|
DISCOVERY_PORT,
|
|
@@ -276,6 +277,7 @@ class SharingTab(QWidget):
|
|
|
276
277
|
paired=self._paired,
|
|
277
278
|
clipboard=self._clipboard,
|
|
278
279
|
cursor_probe=current_cursor_shape,
|
|
280
|
+
lock_probe=is_session_locked,
|
|
279
281
|
performance=self._performance,
|
|
280
282
|
log_provider=lambda: read_log_tail("remotedesktop"),
|
|
281
283
|
input_allowed=self._settings.get_bool(ALLOW_INPUT_KEY, True),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Detect whether the Windows secure desktop is in control (server side).
|
|
2
|
+
|
|
3
|
+
While the workstation is locked — and during UAC prompts and the
|
|
4
|
+
Ctrl+Alt+Del screen — Windows switches to a separate "secure desktop" that
|
|
5
|
+
processes running as the signed-in user may neither capture nor inject
|
|
6
|
+
input into. That is a deliberate security boundary (nothing in the user's
|
|
7
|
+
session can read or type into a credential prompt), so remote unlock is
|
|
8
|
+
impossible for this app by design: screen capture yields a stale frame and
|
|
9
|
+
SendInput is silently discarded. The server uses this probe to tell
|
|
10
|
+
viewers what is going on instead of leaving them a frozen screen.
|
|
11
|
+
|
|
12
|
+
The check: `OpenInputDesktop` fails for an ordinary user process while the
|
|
13
|
+
input desktop is the secure desktop, and succeeds while the user's own
|
|
14
|
+
desktop has input.
|
|
15
|
+
|
|
16
|
+
On non-Windows platforms `is_session_locked()` returns None (the app
|
|
17
|
+
targets Windows).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import ctypes
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
24
|
+
|
|
25
|
+
# winuser.h desktop access right — the weakest thing OpenInputDesktop can be
|
|
26
|
+
# asked for, enough to answer "may this process touch the input desktop?".
|
|
27
|
+
_DESKTOP_SWITCHDESKTOP = 0x0100
|
|
28
|
+
|
|
29
|
+
if _IS_WINDOWS:
|
|
30
|
+
_user32 = ctypes.windll.user32
|
|
31
|
+
_user32.OpenInputDesktop.restype = ctypes.c_void_p
|
|
32
|
+
_user32.OpenInputDesktop.argtypes = (ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32)
|
|
33
|
+
_user32.CloseDesktop.argtypes = (ctypes.c_void_p,)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_session_locked() -> bool | None:
|
|
37
|
+
"""True while the secure desktop (lock screen, UAC prompt) has the
|
|
38
|
+
input, False while the user's own desktop does, or None when it cannot
|
|
39
|
+
be determined (non-Windows)."""
|
|
40
|
+
if not _IS_WINDOWS:
|
|
41
|
+
return None
|
|
42
|
+
handle = _user32.OpenInputDesktop(0, False, _DESKTOP_SWITCHDESKTOP)
|
|
43
|
+
if not handle:
|
|
44
|
+
return True
|
|
45
|
+
_user32.CloseDesktop(handle)
|
|
46
|
+
return False
|
|
@@ -117,6 +117,7 @@ class ShareServer(QObject):
|
|
|
117
117
|
injector: InputInjector | None = None,
|
|
118
118
|
clipboard=None,
|
|
119
119
|
cursor_probe: Callable[[], str | None] | None = None,
|
|
120
|
+
lock_probe: Callable[[], bool | None] | None = None,
|
|
120
121
|
performance: PerformanceMonitor | None = None,
|
|
121
122
|
log_provider: Callable[[], str] | None = None,
|
|
122
123
|
input_allowed: bool = True,
|
|
@@ -137,6 +138,13 @@ class ShareServer(QObject):
|
|
|
137
138
|
# sharing tests never poll (or depend on) the host's real cursor.
|
|
138
139
|
self._cursor_probe = cursor_probe
|
|
139
140
|
self._cursor_shape: str | None = None # last shape sent to viewers
|
|
141
|
+
# Opt-in for the same reason: only the GUI probes the real desktop.
|
|
142
|
+
# While the session is locked (secure desktop — see session_lock.py)
|
|
143
|
+
# the screen cannot be captured and injected input is discarded, so
|
|
144
|
+
# viewers are told and remote input is dropped here.
|
|
145
|
+
self._lock_probe = lock_probe
|
|
146
|
+
self._session_locked = False # last state sent to viewers
|
|
147
|
+
self._lock_input_reported = False # one status line per lock episode
|
|
140
148
|
self._streams: list[MessageStream] = []
|
|
141
149
|
self._all_streams: set[MessageStream] = set()
|
|
142
150
|
self._controllers: set[MessageStream] = set()
|
|
@@ -243,6 +251,8 @@ class ShareServer(QObject):
|
|
|
243
251
|
self._viewer_info.clear()
|
|
244
252
|
self._previous_frame = None
|
|
245
253
|
self._cursor_shape = None
|
|
254
|
+
self._session_locked = False
|
|
255
|
+
self._lock_input_reported = False
|
|
246
256
|
if self._dxgi is not None:
|
|
247
257
|
self._dxgi.close()
|
|
248
258
|
self._dxgi = None
|
|
@@ -475,6 +485,7 @@ class ShareServer(QObject):
|
|
|
475
485
|
self._needs_keyframe.add(stream) # its first frame must be a full one
|
|
476
486
|
self._streams.append(stream)
|
|
477
487
|
self._broadcast_cursor(new_stream=stream) # start with the right cursor
|
|
488
|
+
self._broadcast_lock(new_stream=stream) # tell it if the session is locked
|
|
478
489
|
if self._performance is not None:
|
|
479
490
|
self._performance.add_stream(stream)
|
|
480
491
|
self.clientCountChanged.emit(len(self._streams))
|
|
@@ -490,6 +501,13 @@ class ShareServer(QObject):
|
|
|
490
501
|
# View-only: the toggle already produced a status line; per-event
|
|
491
502
|
# noise goes nowhere (not even the debug log — it's every move).
|
|
492
503
|
return
|
|
504
|
+
if self._session_locked:
|
|
505
|
+
# The secure desktop discards injected input anyway; drop it here
|
|
506
|
+
# and say so once per lock episode (not per event — it's every move).
|
|
507
|
+
if not self._lock_input_reported:
|
|
508
|
+
self._lock_input_reported = True
|
|
509
|
+
self.status.emit("Ignoring remote input while the session is locked")
|
|
510
|
+
return
|
|
493
511
|
action = message.get("action")
|
|
494
512
|
x, y = message.get("x"), message.get("y")
|
|
495
513
|
if stream not in self._controllers:
|
|
@@ -664,10 +682,40 @@ class ShareServer(QObject):
|
|
|
664
682
|
elif new_stream is not None:
|
|
665
683
|
new_stream.send_json({"type": "cursor", "shape": shape})
|
|
666
684
|
|
|
685
|
+
def _broadcast_lock(self, new_stream: MessageStream | None = None) -> None:
|
|
686
|
+
"""Tell viewers when the session locks or unlocks (secure desktop —
|
|
687
|
+
see session_lock.py): the screen can no longer be captured and input
|
|
688
|
+
cannot be injected, so the client shows a notice instead of leaving
|
|
689
|
+
its user a silently frozen frame.
|
|
690
|
+
|
|
691
|
+
Sent on change to everyone, plus to a just-admitted stream while
|
|
692
|
+
locked (unlocked is every stream's starting assumption).
|
|
693
|
+
"""
|
|
694
|
+
if self._lock_probe is None:
|
|
695
|
+
return
|
|
696
|
+
locked = self._lock_probe()
|
|
697
|
+
if locked is None:
|
|
698
|
+
return
|
|
699
|
+
if locked != self._session_locked:
|
|
700
|
+
self._session_locked = locked
|
|
701
|
+
if locked:
|
|
702
|
+
self.status.emit(
|
|
703
|
+
"Session locked — the lock screen cannot be captured or "
|
|
704
|
+
"controlled remotely; viewers are notified"
|
|
705
|
+
)
|
|
706
|
+
else:
|
|
707
|
+
self._lock_input_reported = False
|
|
708
|
+
self.status.emit("Session unlocked — resuming normal streaming")
|
|
709
|
+
for stream in self._streams:
|
|
710
|
+
stream.send_json({"type": "session_lock", "locked": locked})
|
|
711
|
+
elif new_stream is not None and locked:
|
|
712
|
+
new_stream.send_json({"type": "session_lock", "locked": True})
|
|
713
|
+
|
|
667
714
|
def _broadcast_frame(self) -> None:
|
|
668
715
|
if not self._streams:
|
|
669
716
|
return
|
|
670
717
|
self._broadcast_cursor() # polled at the frame rate, sent on change
|
|
718
|
+
self._broadcast_lock() # likewise
|
|
671
719
|
image = self._capture()
|
|
672
720
|
if image is None:
|
|
673
721
|
self.status.emit("Screen capture failed (null image)")
|
|
@@ -743,6 +791,10 @@ class ShareClient(QObject):
|
|
|
743
791
|
# "size_we"); the viewer mirrors it on the local cursor. Servers without
|
|
744
792
|
# the feature (pre-1.6) simply never emit it.
|
|
745
793
|
cursorShapeChanged = Signal(str)
|
|
794
|
+
# The server's session locked (True) or unlocked (False) — the secure
|
|
795
|
+
# desktop cannot be captured or controlled, so the viewer shows a notice.
|
|
796
|
+
# Servers without the feature (pre-1.8) simply never emit it.
|
|
797
|
+
sessionLockChanged = Signal(bool)
|
|
746
798
|
status = Signal(str)
|
|
747
799
|
logReceived = Signal(str) # server log text answering request_log
|
|
748
800
|
|
|
@@ -943,6 +995,14 @@ class ShareClient(QObject):
|
|
|
943
995
|
# Too frequent for the status log; unknown names are the
|
|
944
996
|
# viewer's problem (it falls back to the arrow).
|
|
945
997
|
self.cursorShapeChanged.emit(str(message.get("shape", "")) or "arrow")
|
|
998
|
+
case "session_lock":
|
|
999
|
+
locked = bool(message.get("locked"))
|
|
1000
|
+
self.status.emit(
|
|
1001
|
+
"Server session is locked — sign in at the server machine"
|
|
1002
|
+
if locked
|
|
1003
|
+
else "Server session unlocked"
|
|
1004
|
+
)
|
|
1005
|
+
self.sessionLockChanged.emit(locked)
|
|
946
1006
|
case "ping" | "pong":
|
|
947
1007
|
if self._performance is not None:
|
|
948
1008
|
self._performance.handle_message(self._stream, message)
|
|
@@ -34,6 +34,16 @@ _BUTTON_NAMES = {
|
|
|
34
34
|
Qt.MouseButton.MiddleButton: "middle",
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
# Shown (over a dimmed last frame) while the server's session is locked: the
|
|
38
|
+
# secure desktop cannot be captured or controlled remotely, so signing in
|
|
39
|
+
# has to happen at the server machine itself.
|
|
40
|
+
LOCKED_MESSAGE = (
|
|
41
|
+
"The remote computer is locked.\n"
|
|
42
|
+
"Its sign-in screen cannot be viewed or controlled remotely —\n"
|
|
43
|
+
"sign in at that computer to continue."
|
|
44
|
+
)
|
|
45
|
+
_LOCK_DIM = QColor(0, 0, 0, 160)
|
|
46
|
+
|
|
37
47
|
# Wire cursor-shape names (cursor_shape.py on the server side) -> the local
|
|
38
48
|
# cursor mirroring the remote one. Names this map doesn't know (a newer
|
|
39
49
|
# server's vocabulary) fall back to the arrow.
|
|
@@ -78,6 +88,9 @@ class ViewerWidget(QWidget):
|
|
|
78
88
|
# display-scaling factor (125-150% on most monitors), blurring text.
|
|
79
89
|
self._scaled: QPixmap | None = None
|
|
80
90
|
self._message = "Not connected"
|
|
91
|
+
# The server's session is locked (secure desktop): the frame is
|
|
92
|
+
# stale, so it is dimmed under a LOCKED_MESSAGE notice.
|
|
93
|
+
self._session_locked = False
|
|
81
94
|
# Buttons/keys currently held, so releases can be forwarded even when
|
|
82
95
|
# they happen outside the frame or when the widget loses focus —
|
|
83
96
|
# otherwise the server would keep them pressed forever.
|
|
@@ -91,6 +104,12 @@ class ViewerWidget(QWidget):
|
|
|
91
104
|
def has_frame(self) -> bool:
|
|
92
105
|
return self._frame is not None
|
|
93
106
|
|
|
107
|
+
def frame_image(self) -> QImage | None:
|
|
108
|
+
"""The most recent frame at the server's full resolution (what a
|
|
109
|
+
screen capture saves/copies), or None while nothing is shown. The
|
|
110
|
+
display-only lock overlay is never part of it."""
|
|
111
|
+
return self._frame.toImage() if self._frame is not None else None
|
|
112
|
+
|
|
94
113
|
def set_actual_size(self, enabled: bool) -> None:
|
|
95
114
|
"""Toggle 1:1 device-pixel display (host provides the scrolling)."""
|
|
96
115
|
if self._actual_size == enabled:
|
|
@@ -128,11 +147,24 @@ class ViewerWidget(QWidget):
|
|
|
128
147
|
self._frame = None
|
|
129
148
|
self._scaled = None
|
|
130
149
|
self._message = message
|
|
150
|
+
self._session_locked = False
|
|
131
151
|
self._pressed_buttons.clear()
|
|
132
152
|
self._pressed_keys.clear()
|
|
133
153
|
self.unsetCursor() # a disconnected viewer shows the normal cursor
|
|
134
154
|
self.update()
|
|
135
155
|
|
|
156
|
+
@property
|
|
157
|
+
def session_locked(self) -> bool:
|
|
158
|
+
return self._session_locked
|
|
159
|
+
|
|
160
|
+
def set_session_locked(self, locked: bool) -> None:
|
|
161
|
+
"""Dim the view under a lock notice while the server's session is
|
|
162
|
+
locked (the secure desktop is neither captured nor controllable)."""
|
|
163
|
+
if self._session_locked == locked:
|
|
164
|
+
return
|
|
165
|
+
self._session_locked = locked
|
|
166
|
+
self.update()
|
|
167
|
+
|
|
136
168
|
def set_remote_cursor(self, shape: str) -> None:
|
|
137
169
|
"""Mirror the server's cursor shape on the local cursor (the frame
|
|
138
170
|
itself never contains the pointer — screen capture excludes it)."""
|
|
@@ -270,32 +302,37 @@ class ViewerWidget(QWidget):
|
|
|
270
302
|
painter = QPainter(self)
|
|
271
303
|
painter.fillRect(self.rect(), Qt.GlobalColor.black)
|
|
272
304
|
if self._frame is None:
|
|
305
|
+
if not self._session_locked: # the lock notice below says it all
|
|
306
|
+
painter.setPen(Qt.GlobalColor.white)
|
|
307
|
+
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._message)
|
|
308
|
+
else:
|
|
309
|
+
rect = self._display_rect().toRect()
|
|
310
|
+
dpr = self.devicePixelRatioF()
|
|
311
|
+
target = QSize(round(rect.width() * dpr), round(rect.height() * dpr))
|
|
312
|
+
if (
|
|
313
|
+
self._scaled is None
|
|
314
|
+
or self._scaled.size() != target
|
|
315
|
+
or self._scaled.devicePixelRatio() != dpr
|
|
316
|
+
):
|
|
317
|
+
if self._frame.size() == target:
|
|
318
|
+
# Displayed at exactly 1:1 device pixels — no resample at all.
|
|
319
|
+
self._scaled = QPixmap(self._frame)
|
|
320
|
+
else:
|
|
321
|
+
self._scaled = self._frame.scaled(
|
|
322
|
+
target,
|
|
323
|
+
Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
|
|
324
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
325
|
+
)
|
|
326
|
+
self._scaled.setDevicePixelRatio(dpr)
|
|
327
|
+
painter.drawPixmap(rect.topLeft(), self._scaled)
|
|
328
|
+
# Thin outline marking where the remote screen ends and the app
|
|
329
|
+
# background begins (they can otherwise blend together).
|
|
330
|
+
painter.setPen(self._border_color())
|
|
331
|
+
painter.drawRect(rect.adjusted(0, 0, -1, -1))
|
|
332
|
+
if self._session_locked:
|
|
333
|
+
painter.fillRect(self.rect(), _LOCK_DIM)
|
|
273
334
|
painter.setPen(Qt.GlobalColor.white)
|
|
274
|
-
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter,
|
|
275
|
-
return
|
|
276
|
-
rect = self._display_rect().toRect()
|
|
277
|
-
dpr = self.devicePixelRatioF()
|
|
278
|
-
target = QSize(round(rect.width() * dpr), round(rect.height() * dpr))
|
|
279
|
-
if (
|
|
280
|
-
self._scaled is None
|
|
281
|
-
or self._scaled.size() != target
|
|
282
|
-
or self._scaled.devicePixelRatio() != dpr
|
|
283
|
-
):
|
|
284
|
-
if self._frame.size() == target:
|
|
285
|
-
# Displayed at exactly 1:1 device pixels — no resample at all.
|
|
286
|
-
self._scaled = QPixmap(self._frame)
|
|
287
|
-
else:
|
|
288
|
-
self._scaled = self._frame.scaled(
|
|
289
|
-
target,
|
|
290
|
-
Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
|
|
291
|
-
Qt.TransformationMode.SmoothTransformation,
|
|
292
|
-
)
|
|
293
|
-
self._scaled.setDevicePixelRatio(dpr)
|
|
294
|
-
painter.drawPixmap(rect.topLeft(), self._scaled)
|
|
295
|
-
# Thin outline marking where the remote screen ends and the app
|
|
296
|
-
# background begins (they can otherwise blend together).
|
|
297
|
-
painter.setPen(self._border_color())
|
|
298
|
-
painter.drawRect(rect.adjusted(0, 0, -1, -1))
|
|
335
|
+
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, LOCKED_MESSAGE)
|
|
299
336
|
|
|
300
337
|
def _border_color(self) -> QColor:
|
|
301
338
|
"""Outline shade for the current theme (follows the window background)."""
|
|
@@ -157,6 +157,30 @@ def test_apply_image_payload_sets_clipboard_image(qapp):
|
|
|
157
157
|
sync.apply(payload)
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def test_copy_image_sets_clipboard_without_echoing_to_peers(qapp):
|
|
161
|
+
clip = qapp.clipboard()
|
|
162
|
+
sync = ClipboardSync(clip)
|
|
163
|
+
emitted: list[dict] = []
|
|
164
|
+
sync.changed.connect(emitted.append)
|
|
165
|
+
sync.copy_image(_red_image())
|
|
166
|
+
for _ in range(10):
|
|
167
|
+
qapp.processEvents()
|
|
168
|
+
assert clip.image().pixelColor(0, 0).name() == "#ff0000"
|
|
169
|
+
# An app-generated copy (a screen capture) is never synced to peers.
|
|
170
|
+
assert emitted == []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_copy_image_works_with_sync_disabled(qapp):
|
|
174
|
+
# The Preferences toggle governs syncing; a local capture copy is not sync.
|
|
175
|
+
clip = qapp.clipboard()
|
|
176
|
+
sync = ClipboardSync(clip)
|
|
177
|
+
sync.enabled = False
|
|
178
|
+
blue = QImage(4, 4, QImage.Format.Format_RGB32)
|
|
179
|
+
blue.fill(Qt.GlobalColor.blue)
|
|
180
|
+
sync.copy_image(blue)
|
|
181
|
+
assert clip.image().pixelColor(0, 0).name() == "#0000ff"
|
|
182
|
+
|
|
183
|
+
|
|
160
184
|
def test_apply_ignores_garbage_payloads(qapp):
|
|
161
185
|
clip = qapp.clipboard()
|
|
162
186
|
clip.setText("before")
|
|
@@ -253,6 +253,62 @@ def test_viewer_mirrors_remote_cursor_shape(qapp):
|
|
|
253
253
|
assert viewer.cursor().shape() == Qt.CursorShape.ArrowCursor
|
|
254
254
|
|
|
255
255
|
|
|
256
|
+
def test_input_dropped_while_session_locked(qapp, credentials, tmp_path):
|
|
257
|
+
injector = RecordingInjector()
|
|
258
|
+
locked = [False]
|
|
259
|
+
server = make_server(
|
|
260
|
+
credentials,
|
|
261
|
+
tmp_path,
|
|
262
|
+
approve=lambda *_: True,
|
|
263
|
+
injector=injector,
|
|
264
|
+
lock_probe=lambda: locked[0],
|
|
265
|
+
)
|
|
266
|
+
statuses: list[str] = []
|
|
267
|
+
server.status.connect(statuses.append)
|
|
268
|
+
client = make_client(tmp_path)
|
|
269
|
+
lock_states: list[bool] = []
|
|
270
|
+
client.sessionLockChanged.connect(lock_states.append)
|
|
271
|
+
connected = []
|
|
272
|
+
client.connected.connect(connected.append)
|
|
273
|
+
client.connect_to("127.0.0.1", server.port)
|
|
274
|
+
try:
|
|
275
|
+
pump(qapp, lambda: connected)
|
|
276
|
+
client.send_input({"action": "move", "x": 0.5, "y": 0.25})
|
|
277
|
+
pump(qapp, lambda: injector.calls)
|
|
278
|
+
locked[0] = True
|
|
279
|
+
# The lock notice reaching the client proves the server's own state
|
|
280
|
+
# flipped before the input below arrives.
|
|
281
|
+
pump(qapp, lambda: lock_states == [True])
|
|
282
|
+
injector.calls.clear()
|
|
283
|
+
client.send_input({"action": "move", "x": 0.1, "y": 0.1})
|
|
284
|
+
client.send_input({"action": "key", "vk": 65, "pressed": True})
|
|
285
|
+
pump(qapp, lambda: any("Ignoring remote input" in s for s in statuses))
|
|
286
|
+
assert injector.calls == []
|
|
287
|
+
locked[0] = False
|
|
288
|
+
pump(qapp, lambda: lock_states == [True, False])
|
|
289
|
+
client.send_input({"action": "move", "x": 0.9, "y": 0.9})
|
|
290
|
+
pump(qapp, lambda: injector.calls) # unlocking resumes injection
|
|
291
|
+
finally:
|
|
292
|
+
client.close()
|
|
293
|
+
server.close()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_viewer_session_lock_overlay(qapp):
|
|
297
|
+
viewer = ViewerWidget()
|
|
298
|
+
viewer.resize(400, 400)
|
|
299
|
+
assert not viewer.session_locked
|
|
300
|
+
image = QImage(200, 100, QImage.Format.Format_RGB32)
|
|
301
|
+
image.fill(Qt.GlobalColor.red)
|
|
302
|
+
viewer.show_frame(image)
|
|
303
|
+
viewer.set_session_locked(True)
|
|
304
|
+
assert viewer.session_locked
|
|
305
|
+
assert not viewer.grab().isNull() # paints the dimmed-frame notice branch
|
|
306
|
+
viewer._frame = None
|
|
307
|
+
assert not viewer.grab().isNull() # and the no-frame notice branch
|
|
308
|
+
viewer.clear() # disconnecting clears the notice with the frame
|
|
309
|
+
assert not viewer.session_locked
|
|
310
|
+
|
|
311
|
+
|
|
256
312
|
def test_viewer_paints_message_and_frame(qapp):
|
|
257
313
|
viewer = ViewerWidget()
|
|
258
314
|
viewer.resize(400, 400)
|
|
@@ -4,6 +4,7 @@ import socket
|
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
6
|
from PySide6.QtCore import QProcess, Qt
|
|
7
|
+
from PySide6.QtGui import QImage
|
|
7
8
|
from PySide6.QtWidgets import QApplication, QListWidgetItem, QMessageBox
|
|
8
9
|
|
|
9
10
|
from remotedesktop import client as client_module
|
|
@@ -360,6 +361,117 @@ def test_forget_server_disconnects_and_forgets(qapp, credentials, tmp_path, monk
|
|
|
360
361
|
server.close()
|
|
361
362
|
|
|
362
363
|
|
|
364
|
+
def _capture_widgets(window):
|
|
365
|
+
return (
|
|
366
|
+
window.copy_capture_button,
|
|
367
|
+
window.save_capture_button,
|
|
368
|
+
window.copy_capture_action,
|
|
369
|
+
window.save_capture_action,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_capture_buttons_and_actions_follow_frame_availability(qapp, credentials, tmp_path):
|
|
374
|
+
server = make_share_server(credentials, tmp_path)
|
|
375
|
+
window = make_window(tmp_path)
|
|
376
|
+
try:
|
|
377
|
+
assert not any(w.isEnabled() for w in _capture_widgets(window))
|
|
378
|
+
window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
|
|
379
|
+
session = window._sessions[0]
|
|
380
|
+
pump(qapp, lambda: session.viewer.has_frame)
|
|
381
|
+
assert all(w.isEnabled() for w in _capture_widgets(window))
|
|
382
|
+
server.close() # dropping the connection clears the viewer again
|
|
383
|
+
pump(qapp, lambda: not session.viewer.has_frame)
|
|
384
|
+
assert not any(w.isEnabled() for w in _capture_widgets(window))
|
|
385
|
+
finally:
|
|
386
|
+
window.close()
|
|
387
|
+
server.close()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_capture_group_hidden_without_the_viewer_role(qapp, credentials, tmp_path):
|
|
391
|
+
window = make_window(tmp_path, credentials, serving=True, viewer=False)
|
|
392
|
+
try:
|
|
393
|
+
assert window._capture_group.isHidden()
|
|
394
|
+
finally:
|
|
395
|
+
window.close()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_copy_screen_capture_goes_through_clipboard_sync(qapp, credentials, tmp_path):
|
|
399
|
+
class RecordingSync:
|
|
400
|
+
def __init__(self):
|
|
401
|
+
self.images = []
|
|
402
|
+
|
|
403
|
+
def copy_image(self, image):
|
|
404
|
+
self.images.append(image)
|
|
405
|
+
|
|
406
|
+
server = make_share_server(credentials, tmp_path)
|
|
407
|
+
window = make_window(tmp_path)
|
|
408
|
+
try:
|
|
409
|
+
window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
|
|
410
|
+
session = window._sessions[0]
|
|
411
|
+
pump(qapp, lambda: session.viewer.has_frame)
|
|
412
|
+
# A recording stand-in, so the test never touches the OS clipboard.
|
|
413
|
+
recorder = RecordingSync()
|
|
414
|
+
window._clipboard = recorder
|
|
415
|
+
window.copy_capture_button.click()
|
|
416
|
+
assert len(recorder.images) == 1
|
|
417
|
+
assert recorder.images[0].size() == session.viewer.frame_image().size()
|
|
418
|
+
assert "copied to the clipboard" in window.connection_log.toPlainText()
|
|
419
|
+
finally:
|
|
420
|
+
window.close()
|
|
421
|
+
server.close()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def test_save_screen_capture_writes_png(qapp, credentials, tmp_path, monkeypatch):
|
|
425
|
+
from PySide6.QtWidgets import QFileDialog
|
|
426
|
+
|
|
427
|
+
target = tmp_path / "capture.png"
|
|
428
|
+
dialogs = []
|
|
429
|
+
monkeypatch.setattr(
|
|
430
|
+
QFileDialog,
|
|
431
|
+
"getSaveFileName",
|
|
432
|
+
staticmethod(
|
|
433
|
+
lambda parent, title, default, filter: dialogs.append(default)
|
|
434
|
+
or (str(target), filter)
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
server = make_share_server(credentials, tmp_path)
|
|
438
|
+
window = make_window(tmp_path)
|
|
439
|
+
try:
|
|
440
|
+
window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
|
|
441
|
+
session = window._sessions[0]
|
|
442
|
+
pump(qapp, lambda: session.viewer.has_frame)
|
|
443
|
+
window.save_capture_action.trigger() # the menu path this time
|
|
444
|
+
assert target.exists()
|
|
445
|
+
saved = QImage(str(target))
|
|
446
|
+
assert saved.size() == session.viewer.frame_image().size()
|
|
447
|
+
# The server's reported name (its hostname, after the on-connect
|
|
448
|
+
# rename) seeds the default filename.
|
|
449
|
+
assert dialogs and session.name in dialogs[0]
|
|
450
|
+
assert f"saved to {target}" in window.connection_log.toPlainText()
|
|
451
|
+
finally:
|
|
452
|
+
window.close()
|
|
453
|
+
server.close()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_save_screen_capture_cancelled_dialog_saves_nothing(qapp, credentials, tmp_path, monkeypatch):
|
|
457
|
+
from PySide6.QtWidgets import QFileDialog
|
|
458
|
+
|
|
459
|
+
monkeypatch.setattr(
|
|
460
|
+
QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: ("", ""))
|
|
461
|
+
)
|
|
462
|
+
server = make_share_server(credentials, tmp_path)
|
|
463
|
+
window = make_window(tmp_path)
|
|
464
|
+
try:
|
|
465
|
+
window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
|
|
466
|
+
session = window._sessions[0]
|
|
467
|
+
pump(qapp, lambda: session.viewer.has_frame)
|
|
468
|
+
window.save_capture_button.click()
|
|
469
|
+
assert "saved to" not in window.connection_log.toPlainText()
|
|
470
|
+
finally:
|
|
471
|
+
window.close()
|
|
472
|
+
server.close()
|
|
473
|
+
|
|
474
|
+
|
|
363
475
|
def test_version_mismatch_warns_but_still_connects(qapp, credentials, tmp_path, monkeypatch):
|
|
364
476
|
shown = []
|
|
365
477
|
monkeypatch.setattr(QMessageBox, "show", lambda self: shown.append(self.windowTitle()))
|
|
@@ -1195,11 +1307,12 @@ def test_role_indicators_are_inert_buttons(qapp, credentials, tmp_path):
|
|
|
1195
1307
|
|
|
1196
1308
|
|
|
1197
1309
|
def test_dock_keeps_indicators_at_the_top_in_both_roles(qapp, credentials, tmp_path):
|
|
1198
|
-
# Layout indexes: 0/1 = indicator buttons, 2 = discovery panel,
|
|
1310
|
+
# Layout indexes: 0/1 = indicator buttons, 2 = discovery panel,
|
|
1311
|
+
# 3 = screen-capture group, 4 = spacer.
|
|
1199
1312
|
window = make_window(tmp_path, credentials, serving=True, viewer=False)
|
|
1200
1313
|
try:
|
|
1201
1314
|
assert window._dock_layout.stretch(2) == 0 # hidden panel
|
|
1202
|
-
assert window._dock_layout.stretch(
|
|
1315
|
+
assert window._dock_layout.stretch(4) == 1 # spacer pins buttons up
|
|
1203
1316
|
window.show()
|
|
1204
1317
|
qapp.processEvents()
|
|
1205
1318
|
# The two indicators sit adjacent at the top, not spread apart.
|
|
@@ -1211,14 +1324,14 @@ def test_dock_keeps_indicators_at_the_top_in_both_roles(qapp, credentials, tmp_p
|
|
|
1211
1324
|
window = make_window(tmp_path, credentials, db_name="viewer.db")
|
|
1212
1325
|
try:
|
|
1213
1326
|
assert window._dock_layout.stretch(2) == 1 # panel takes the height
|
|
1214
|
-
assert window._dock_layout.stretch(
|
|
1327
|
+
assert window._dock_layout.stretch(4) == 0
|
|
1215
1328
|
# Live toggle off: the spacer takes over immediately (this was the
|
|
1216
1329
|
# spaced-out bug — the spacer only existed for non-viewer startup).
|
|
1217
1330
|
window.preferences_tab.viewer_checkbox.setChecked(False)
|
|
1218
|
-
assert window._dock_layout.stretch(
|
|
1331
|
+
assert window._dock_layout.stretch(4) == 1
|
|
1219
1332
|
window.preferences_tab.viewer_checkbox.setChecked(True)
|
|
1220
1333
|
assert window._dock_layout.stretch(2) == 1
|
|
1221
|
-
assert window._dock_layout.stretch(
|
|
1334
|
+
assert window._dock_layout.stretch(4) == 0
|
|
1222
1335
|
finally:
|
|
1223
1336
|
window.close()
|
|
1224
1337
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from remotedesktop.session_lock import is_session_locked
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows desktop API")
|
|
9
|
+
def test_is_session_locked_reports_a_bool():
|
|
10
|
+
# The answer depends on the session running the tests (an interactive
|
|
11
|
+
# desktop says False; a service-like session may say True) — only the
|
|
12
|
+
# contract is asserted, not the state.
|
|
13
|
+
assert is_session_locked() in (True, False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="non-Windows returns None")
|
|
17
|
+
def test_is_session_locked_is_none_off_windows():
|
|
18
|
+
assert is_session_locked() is None
|
|
@@ -30,6 +30,7 @@ def make_server(
|
|
|
30
30
|
injector=None,
|
|
31
31
|
clipboard=None,
|
|
32
32
|
cursor_probe=None,
|
|
33
|
+
lock_probe=None,
|
|
33
34
|
log_provider=None,
|
|
34
35
|
):
|
|
35
36
|
# The server is a distinct "machine" from the client -> its own database.
|
|
@@ -40,6 +41,7 @@ def make_server(
|
|
|
40
41
|
injector=injector,
|
|
41
42
|
clipboard=clipboard,
|
|
42
43
|
cursor_probe=cursor_probe,
|
|
44
|
+
lock_probe=lock_probe,
|
|
43
45
|
log_provider=log_provider,
|
|
44
46
|
)
|
|
45
47
|
assert server.listen(0)
|
|
@@ -134,6 +136,36 @@ def test_cursor_shape_sent_on_admission_and_on_change_only(qapp, credentials, tm
|
|
|
134
136
|
server.close()
|
|
135
137
|
|
|
136
138
|
|
|
139
|
+
def test_session_lock_sent_on_admission_and_on_change_only(qapp, credentials, tmp_path):
|
|
140
|
+
locked = [True]
|
|
141
|
+
server = make_server(
|
|
142
|
+
credentials, tmp_path, approve=lambda *_: True, lock_probe=lambda: locked[0]
|
|
143
|
+
)
|
|
144
|
+
client = make_client(tmp_path)
|
|
145
|
+
states: list[bool] = []
|
|
146
|
+
client.sessionLockChanged.connect(states.append)
|
|
147
|
+
statuses: list[str] = []
|
|
148
|
+
client.status.connect(statuses.append)
|
|
149
|
+
client.connect_to("127.0.0.1", server.port)
|
|
150
|
+
try:
|
|
151
|
+
# A viewer admitted while the session is already locked is told so
|
|
152
|
+
# immediately (its starting assumption is unlocked).
|
|
153
|
+
pump(qapp, lambda: states)
|
|
154
|
+
assert states == [True]
|
|
155
|
+
assert any("locked — sign in at the server machine" in s for s in statuses)
|
|
156
|
+
locked[0] = False
|
|
157
|
+
pump(qapp, lambda: len(states) >= 2)
|
|
158
|
+
# Give the broadcast timer a few more ticks: the probe is polled at
|
|
159
|
+
# the frame rate but must send only on change, never per tick.
|
|
160
|
+
deadline = time.monotonic() + 0.2
|
|
161
|
+
while time.monotonic() < deadline:
|
|
162
|
+
qapp.processEvents()
|
|
163
|
+
assert states == [True, False]
|
|
164
|
+
finally:
|
|
165
|
+
client.close()
|
|
166
|
+
server.close()
|
|
167
|
+
|
|
168
|
+
|
|
137
169
|
def test_reconnect_uses_token_without_prompting(qapp, credentials, tmp_path):
|
|
138
170
|
prompts = []
|
|
139
171
|
# Keep one server (same port) so the client's stored "host:port" token matches.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|