remotedesktop 1.7.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.7.0 → remotedesktop-1.9.2}/CLAUDE.md +2 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/PKG-INFO +31 -2
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/readme.md +30 -1
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/app.py +118 -3
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/clipboard.py +19 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/preferences.py +55 -11
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/server.py +2 -0
- remotedesktop-1.9.2/src/remotedesktop/session_lock.py +46 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/sharing.py +60 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/viewer.py +62 -25
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_clipboard.py +24 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_input.py +56 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_main_window.py +118 -5
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_preferences.py +25 -0
- remotedesktop-1.9.2/tests/test_session_lock.py +18 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_sharing.py +32 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/.github/workflows/ci.yml +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/.github/workflows/release.yml +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/.gitignore +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/LICENSE +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/docs/media/client-demo.gif +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/docs/media/server-demo.gif +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/icon/remotedesktop.ico +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/pyproject.toml +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/run.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/run_claude.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/scripts/make_installer.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/scripts/make_venv.bat +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/about.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/client.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/config.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/db.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/visual_effects.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/conftest.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_about.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_autostart.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_compat.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_config.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_discovery.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_dxgi.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_frames.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_icon.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_input_injection.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_inventory.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_logs.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_modal_loop.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_performance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_protocol.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_single_instance.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_smoke.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_tls.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_visual_effects.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tests/test_window_state.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/tools/make_icon.py +0 -0
- {remotedesktop-1.7.0 → remotedesktop-1.9.2}/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.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
|
|
@@ -74,10 +74,14 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
74
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).
|
|
75
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.
|
|
76
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).
|
|
77
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.
|
|
78
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.
|
|
79
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.
|
|
80
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)).
|
|
81
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.
|
|
82
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.
|
|
83
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.
|
|
@@ -139,6 +143,28 @@ access can be revoked at any time from the *Connections* tab. There is
|
|
|
139
143
|
no dependency
|
|
140
144
|
on Windows RDP or any Microsoft-based authentication.
|
|
141
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
|
+
|
|
142
168
|
## How it works
|
|
143
169
|
|
|
144
170
|
All of this is pure Python — the GUI is PySide6 (Qt), and the two places
|
|
@@ -155,7 +181,10 @@ an idle screen costs essentially nothing. When duplication is unavailable
|
|
|
155
181
|
or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
|
|
156
182
|
display-mode change — the server transparently falls back to Qt's
|
|
157
183
|
`QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
|
|
158
|
-
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.
|
|
159
188
|
|
|
160
189
|
**Screen transfer.** Frames are captured at up to 30 fps and compared with
|
|
161
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,
|
|
@@ -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
|
|
@@ -118,6 +118,10 @@ class PreferencesTab(QWidget):
|
|
|
118
118
|
)
|
|
119
119
|
self._autostart = autostart if autostart is not None else Autostart()
|
|
120
120
|
self.history_minutes = QSpinBox()
|
|
121
|
+
self.history_minutes.setToolTip(
|
|
122
|
+
"How much recent history the Performance tab's bandwidth and\n"
|
|
123
|
+
"round-trip-time graphs (and their statistics) cover."
|
|
124
|
+
)
|
|
121
125
|
self.history_minutes.setRange(1, 30)
|
|
122
126
|
self.history_minutes.setSuffix(" min")
|
|
123
127
|
self.history_minutes.setValue(
|
|
@@ -125,23 +129,35 @@ class PreferencesTab(QWidget):
|
|
|
125
129
|
)
|
|
126
130
|
self.history_minutes.valueChanged.connect(self._on_history_changed)
|
|
127
131
|
self.autostart_checkbox = QCheckBox("Start Remote Desktop when I log in to Windows")
|
|
132
|
+
self.autostart_checkbox.setToolTip(
|
|
133
|
+
"Registers this app in your Windows startup (per-user, no admin\n"
|
|
134
|
+
"rights) so it launches at login — minimized to the tray while\n"
|
|
135
|
+
"sharing is on, so sharing resumes after a reboot without\n"
|
|
136
|
+
"anyone having to launch the app."
|
|
137
|
+
)
|
|
128
138
|
self.autostart_checkbox.setChecked(self._autostart.is_enabled())
|
|
129
139
|
self.autostart_checkbox.setEnabled(self._autostart.available)
|
|
130
140
|
self.autostart_checkbox.toggled.connect(self._on_autostart_toggled)
|
|
131
141
|
self.clipboard_checkbox = QCheckBox("Sync clipboard with connected computers")
|
|
142
|
+
self.clipboard_checkbox.setToolTip(
|
|
143
|
+
"Text and images copied on this computer appear on connected\n"
|
|
144
|
+
"computers, and theirs appear here.\n"
|
|
145
|
+
"Turn off to keep this computer's clipboard private — nothing\n"
|
|
146
|
+
"is sent or applied in either direction."
|
|
147
|
+
)
|
|
132
148
|
self.clipboard_checkbox.setChecked(load_clipboard_sync_enabled(settings))
|
|
133
149
|
self.clipboard_checkbox.toggled.connect(self._on_clipboard_toggled)
|
|
134
150
|
self.reduce_effects_checkbox = QCheckBox(
|
|
135
|
-
"Reduce Windows visual effects while sharing this screen"
|
|
151
|
+
"Reduce Windows visual effects while sharing this screen (recommended)"
|
|
136
152
|
)
|
|
137
153
|
self.reduce_effects_checkbox.setToolTip(
|
|
138
|
-
"Makes the remote view feel more responsive: window animations
|
|
139
|
-
"menu fades, and shadows each stream as a burst of screen
|
|
140
|
-
"updates, so turning them off while viewers are connected lets
|
|
141
|
-
"the remote screen snap instead of smearing — and saves
|
|
142
|
-
"bandwidth. Submenus also open faster
|
|
143
|
-
"come back when the last viewer
|
|
144
|
-
"permanently changed."
|
|
154
|
+
"Makes the remote view feel more responsive: window animations,\n"
|
|
155
|
+
"menu fades, and shadows each stream as a burst of screen\n"
|
|
156
|
+
"updates, so turning them off while viewers are connected lets\n"
|
|
157
|
+
"the remote screen snap instead of smearing — and saves\n"
|
|
158
|
+
"bandwidth. Submenus also open faster.\n"
|
|
159
|
+
"Your Windows settings come back when the last viewer\n"
|
|
160
|
+
"disconnects; nothing is permanently changed."
|
|
145
161
|
)
|
|
146
162
|
self.reduce_effects_checkbox.setChecked(load_reduce_effects_enabled(settings))
|
|
147
163
|
self.reduce_effects_checkbox.toggled.connect(self._on_reduce_effects_toggled)
|
|
@@ -149,14 +165,30 @@ class PreferencesTab(QWidget):
|
|
|
149
165
|
self.sharing_off_radio = QRadioButton(
|
|
150
166
|
"Not shared — no one can see this computer's screen"
|
|
151
167
|
)
|
|
168
|
+
self.sharing_off_radio.setToolTip(
|
|
169
|
+
"This computer neither listens for connections nor announces\n"
|
|
170
|
+
"itself on the LAN. Existing viewers are disconnected."
|
|
171
|
+
)
|
|
152
172
|
self.sharing_view_radio = QRadioButton(
|
|
153
173
|
"Shared, view only — clients can watch this computer's screen "
|
|
154
174
|
"but not control it"
|
|
155
175
|
)
|
|
176
|
+
self.sharing_view_radio.setToolTip(
|
|
177
|
+
"Approved clients see this screen live, but their keyboard and\n"
|
|
178
|
+
"mouse input is ignored.\n"
|
|
179
|
+
"Switching between view only and full control applies instantly\n"
|
|
180
|
+
"without disconnecting viewers."
|
|
181
|
+
)
|
|
156
182
|
self.sharing_control_radio = QRadioButton(
|
|
157
183
|
"Shared, full control — clients can watch this computer's screen "
|
|
158
184
|
"and control it with their keyboard and mouse"
|
|
159
185
|
)
|
|
186
|
+
self.sharing_control_radio.setToolTip(
|
|
187
|
+
"Approved clients see this screen live and can type and click\n"
|
|
188
|
+
"as if sitting at this computer.\n"
|
|
189
|
+
"Each new client needs a one-time approval on this computer\n"
|
|
190
|
+
"before it can connect."
|
|
191
|
+
)
|
|
160
192
|
self._mode_radios = {
|
|
161
193
|
SHARING_MODE_OFF: self.sharing_off_radio,
|
|
162
194
|
SHARING_MODE_VIEW: self.sharing_view_radio,
|
|
@@ -174,8 +206,14 @@ class PreferencesTab(QWidget):
|
|
|
174
206
|
sharing_layout.addWidget(radio)
|
|
175
207
|
# Theme: an explicit light/dark override, or follow the OS setting.
|
|
176
208
|
self.theme_system_radio = QRadioButton("Follow the Windows light/dark setting")
|
|
209
|
+
self.theme_system_radio.setToolTip(
|
|
210
|
+
"Match Windows: the app switches automatically when the\n"
|
|
211
|
+
"Windows light/dark mode changes."
|
|
212
|
+
)
|
|
177
213
|
self.theme_light_radio = QRadioButton("Light")
|
|
214
|
+
self.theme_light_radio.setToolTip("Always use the light theme.")
|
|
178
215
|
self.theme_dark_radio = QRadioButton("Dark")
|
|
216
|
+
self.theme_dark_radio.setToolTip("Always use the dark theme.")
|
|
179
217
|
self._theme_radios = {
|
|
180
218
|
THEME_SYSTEM: self.theme_system_radio,
|
|
181
219
|
THEME_LIGHT: self.theme_light_radio,
|
|
@@ -193,14 +231,20 @@ class PreferencesTab(QWidget):
|
|
|
193
231
|
theme_layout.addWidget(radio)
|
|
194
232
|
self.restart_button = QPushButton("Restart app")
|
|
195
233
|
self.restart_button.setToolTip(
|
|
196
|
-
"Relaunch this app (e.g. after updating the software)
|
|
197
|
-
"clicked from a remote desktop session, so an update
|
|
198
|
-
"require visiting this computer."
|
|
234
|
+
"Relaunch this app (e.g. after updating the software).\n"
|
|
235
|
+
"It can be clicked from a remote desktop session, so an update\n"
|
|
236
|
+
"doesn't require visiting this computer."
|
|
199
237
|
)
|
|
200
238
|
self.viewer_checkbox = QCheckBox(
|
|
201
239
|
"Act as a client — discover servers on this LAN and view or "
|
|
202
240
|
"control their screens"
|
|
203
241
|
)
|
|
242
|
+
self.viewer_checkbox.setToolTip(
|
|
243
|
+
"Shows the client-side UI: the server panel, session tabs, and\n"
|
|
244
|
+
"server history.\n"
|
|
245
|
+
"Turn off on a computer that only shares its screen — it will\n"
|
|
246
|
+
"not connect to (or scan for) other computers at all."
|
|
247
|
+
)
|
|
204
248
|
self.viewer_checkbox.setChecked(load_viewer_enabled(settings))
|
|
205
249
|
self.viewer_checkbox.toggled.connect(self._on_viewer_toggled)
|
|
206
250
|
layout = QFormLayout(self)
|
|
@@ -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
|