remotedesktop 1.5.0__tar.gz → 1.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/CLAUDE.md +2 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/PKG-INFO +1 -1
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/app.py +6 -0
- remotedesktop-1.6.0/src/remotedesktop/cursor_shape.py +89 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/preferences.py +65 -6
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/server.py +2 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/sharing.py +36 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/viewer.py +44 -0
- remotedesktop-1.6.0/tests/test_cursor_shape.py +20 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_input.py +15 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_main_window.py +13 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_preferences.py +31 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_sharing.py +36 -1
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_smoke.py +35 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/uv.lock +2 -2
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/.github/workflows/release.yml +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/.gitignore +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/LICENSE +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/icon/remotedesktop.ico +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/pyproject.toml +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/readme.md +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/run.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/run_claude.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/scripts/make_installer.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/conftest.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_about.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_autostart.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_compat.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_config.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_discovery.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_frames.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_icon.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_inventory.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_logs.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_performance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_protocol.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_single_instance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_tls.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_window_state.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tools/make_icon.py +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
|
+
- **Theme preference** (`preferences.py`): a three-radio group in Preferences — follow the Windows light/dark setting (default) / Light / Dark — persisted as `THEME_KEY` (`"system"`/`"light"`/`"dark"`, `load_theme` falls back to system on junk). `apply_theme` drives `QStyleHints.setColorScheme` (`unsetColorScheme` for system, so OS-side switches keep applying); the radios apply live and `MainWindow.__init__` re-applies the persisted choice before building widgets. Tests that set a non-system theme must restore with `apply_theme(THEME_SYSTEM)` — the color scheme is process-global QApplication state.
|
|
79
80
|
- **Two `PerformanceMonitor`s, never one**: `MainWindow.client_performance` (viewing sessions) and `server_performance` (the SharingTab's viewers). `ShareServer.close()` resets its monitor, so sharing a monitor across roles would wipe the other role's graphs. The Performance tab has "Client (viewer)"/"Server (sharing)" sub-tabs (`MainWindow.performance_pages`) — but only for roles that can produce data: the client sub-tab exists only with the viewer role, the server one only while actually serving (`_update_performance_tabs`, driven from the viewer toggle and `sharingChanged`; both off shows an enable-a-role hint). `PreferencesTab` takes the list of monitors and applies the history-window setting to all.
|
|
80
81
|
- **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}`; datagrams with the wrong magic/version/type are dropped. **Scan robustness**: probes are re-sent every `_PROBE_INTERVAL` (0.3 s) for the whole scan window (a lost broadcast only delays discovery), ICMP port-unreachable resets never abort the scan (Windows `SIO_UDP_CONNRESET` ioctl + `ConnectionResetError` → continue), and the panel passes `client._broadcast_hosts()` — 255.255.255.255 plus every up non-loopback interface's directed broadcast, because Windows routes the limited broadcast out only one interface (VPN/virtual adapters made scans come up empty). A sharing instance runs a `DiscoveryResponder` thread while sharing is enabled; the `DiscoveryPanel` (dock in `MainWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. With `auto_scan=True` (the app; tests default to False and never broadcast) it scans **once at startup only** — further scans happen only on manual Refresh/F5 (deliberately no periodic background scanning; the user asked for this), guarded against overlapping scans and preserving the selection across repopulation; it also has a Connect button, a right-click Connect menu, and an empty-state hint. The "(this computer)" entry is not connectable (Connect disabled, activation swallowed, plus a `MainWindow._on_server_activated` guard). `DEFAULT_CONNECT_PORT` is 48654. An instance that shares and scans discovers itself — `DiscoveryPanel`'s `is_self=` callable labels that entry "(this computer)" (never hidden).
|
|
81
82
|
- Discovery tests run over loopback with ephemeral ports (`bind_host`/`discovery_port`/`broadcast_hosts` parameters exist for this), so they never touch the real LAN or fixed ports.
|
|
@@ -87,6 +88,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
87
88
|
- **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; each row gets its own button labeled `action_label` (in the trailing stretch column) whose callback receives that row's peer key. "Client history" wires "Revoke" → `SharingTab.revoke_client(client_id)` (via `ShareServer.revoke_client` while sharing — removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` emits a "revoked" peer event; a `_revoked` set distinguishes it from a plain disconnect — or directly via `PairedClients.revoke` while not sharing). "Server history" wires "Forget" → `KnownServers.forget(key)` + disconnect + `ConnectionInventory.remove(key)`. **Both delete the peer's row from the table and the DB outright** (`MainWindow._record_server_peer` turns a "revoked" event into a `remove`); a rescan or new connection attempt records the peer afresh. After either, the next connection needs a fresh approval. The roles use *separate tables* (`server_peers` / `client_peers`, selected via the `table=` arg) so they don't commingle; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
|
|
88
89
|
- Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
|
|
89
90
|
- **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
|
|
91
|
+
- **Remote cursor shape** (`cursor_shape.py`): the captured frame never contains the mouse pointer (DXGI duplication and BitBlt both exclude it), so the client mirrors the server's cursor on its *local* cursor instead. `ShareServer` takes an opt-in `cursor_probe=` callable (the `clipboard=` pattern — only the GUI passes one, `SharingTab` wires `current_cursor_shape`: GetCursorInfo compared against the shared standard IDC_* handles; custom cursors → "arrow", not showing → "hidden"). The probe is polled each broadcast tick and `{"type": "cursor", "shape": ...}` is sent on change only, plus once to each just-admitted stream. `ShareClient.cursorShapeChanged` → `ViewerWidget.set_remote_cursor` maps names to Qt cursors (unknown names → arrow); `clear()` unsets the cursor. Peers without the feature never send / silently ignore the message.
|
|
90
92
|
- **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for a sharing instance this deadlocked when a *remote* click grabbed the window's own title bar (the mouse-up sat unread on the socket). `MainWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). The caption buttons (min/max/close) are the exception — their tracking loop dispatches only mouse messages, so no timer can pump inside it; the pump instead performs those via its `caption_action` callback on press and consumes the message (nativeEvent must return True when `handle_native_event` does), so DefWindowProc's button tracking never starts. Tests inject a fake `timers` backend — never let them create real Win32 timers.
|
|
91
93
|
- **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the app passes `logs.read_log_tail("remotedesktop")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. The Connection log group on the Connections tab has both buttons — "Get server log" asks the server of the current session tab, "Get client log" asks the most recently admitted viewer; the reply opens a `logs.PeerLogDialog`.
|
|
92
94
|
- **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.6.0
|
|
4
4
|
Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
|
|
6
6
|
Project-URL: Repository, https://github.com/jamesabel/remotedesktop
|
|
@@ -52,8 +52,10 @@ from remotedesktop.modal_loop import HTCLOSE, HTMAXBUTTON, HTMINBUTTON, ModalLoo
|
|
|
52
52
|
from remotedesktop.performance import PerformanceMonitor, PerformanceTab
|
|
53
53
|
from remotedesktop.preferences import (
|
|
54
54
|
PreferencesTab,
|
|
55
|
+
apply_theme,
|
|
55
56
|
load_clipboard_sync_enabled,
|
|
56
57
|
load_performance_window_seconds,
|
|
58
|
+
load_theme,
|
|
57
59
|
load_viewer_enabled,
|
|
58
60
|
)
|
|
59
61
|
from remotedesktop.server import SharingTab, ViewersTable
|
|
@@ -136,6 +138,9 @@ class MainWindow(QMainWindow):
|
|
|
136
138
|
# The viewer (client) role is opt-out: a dedicated server turns it
|
|
137
139
|
# off and loses the client-side UI (Server tab, discovery, history).
|
|
138
140
|
self._viewer_enabled = load_viewer_enabled(self._settings)
|
|
141
|
+
# Restore the persisted theme before any widgets are built, so the
|
|
142
|
+
# window never flashes in the wrong scheme.
|
|
143
|
+
apply_theme(load_theme(self._settings))
|
|
139
144
|
# One monitor per role: ShareServer.close() resets its monitor, so
|
|
140
145
|
# toggling sharing must not share a monitor with the viewing sessions
|
|
141
146
|
# (and vice versa for _connect_session's reset).
|
|
@@ -996,6 +1001,7 @@ class MainWindow(QMainWindow):
|
|
|
996
1001
|
client.denied.connect(lambda reason, s=session: self._on_denied(s, reason))
|
|
997
1002
|
client.disconnected.connect(lambda s=session: self._on_disconnected(s))
|
|
998
1003
|
client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
|
|
1004
|
+
client.cursorShapeChanged.connect(lambda shape, s=session: s.viewer.set_remote_cursor(shape))
|
|
999
1005
|
client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
|
|
1000
1006
|
client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
|
|
1001
1007
|
self._sessions.append(session)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Identify the shape of the mouse cursor currently shown (server side).
|
|
2
|
+
|
|
3
|
+
The captured frame deliberately contains no mouse pointer (both DXGI desktop
|
|
4
|
+
duplication and GDI BitBlt exclude it), so what the viewer's user sees over
|
|
5
|
+
the remote screen is their own local cursor. This module lets the server
|
|
6
|
+
report what shape that cursor should be — an I-beam over text, the resize
|
|
7
|
+
arrows over a window edge, and so on: GetCursorInfo returns the current
|
|
8
|
+
cursor handle, and the standard cursors are shared handles identical to what
|
|
9
|
+
LoadCursor(NULL, IDC_*) returns, so a plain handle comparison identifies
|
|
10
|
+
them. Custom application cursors fall back to "arrow"; a cursor that is not
|
|
11
|
+
showing at all reports "hidden".
|
|
12
|
+
|
|
13
|
+
On non-Windows platforms `current_cursor_shape()` returns None (the app
|
|
14
|
+
targets Windows).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import ctypes
|
|
18
|
+
import sys
|
|
19
|
+
from ctypes import wintypes
|
|
20
|
+
|
|
21
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
22
|
+
|
|
23
|
+
_CURSOR_SHOWING = 0x1
|
|
24
|
+
|
|
25
|
+
# Standard cursor resource ids (winuser.h IDC_*) -> wire shape names. These
|
|
26
|
+
# names are the protocol vocabulary: ViewerWidget maps them to Qt cursors.
|
|
27
|
+
_IDC_SHAPES = {
|
|
28
|
+
32512: "arrow", # IDC_ARROW
|
|
29
|
+
32513: "ibeam", # IDC_IBEAM
|
|
30
|
+
32514: "wait", # IDC_WAIT
|
|
31
|
+
32515: "cross", # IDC_CROSS
|
|
32
|
+
32516: "uparrow", # IDC_UPARROW
|
|
33
|
+
32642: "size_nwse", # IDC_SIZENWSE (diagonal resize ↘)
|
|
34
|
+
32643: "size_nesw", # IDC_SIZENESW (diagonal resize ↗)
|
|
35
|
+
32644: "size_we", # IDC_SIZEWE (horizontal resize)
|
|
36
|
+
32645: "size_ns", # IDC_SIZENS (vertical resize)
|
|
37
|
+
32646: "size_all", # IDC_SIZEALL
|
|
38
|
+
32648: "no", # IDC_NO
|
|
39
|
+
32649: "hand", # IDC_HAND
|
|
40
|
+
32650: "appstarting", # IDC_APPSTARTING
|
|
41
|
+
32651: "help", # IDC_HELP
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
SHAPE_NAMES = frozenset(_IDC_SHAPES.values()) | {"hidden"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if _IS_WINDOWS:
|
|
48
|
+
|
|
49
|
+
class _CURSORINFO(ctypes.Structure):
|
|
50
|
+
_fields_ = [
|
|
51
|
+
("cbSize", wintypes.DWORD),
|
|
52
|
+
("flags", wintypes.DWORD),
|
|
53
|
+
("hCursor", ctypes.c_void_p),
|
|
54
|
+
("ptScreenPos", wintypes.POINT),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_handle_shapes: dict[int, str] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _standard_handles() -> dict[int, str]:
|
|
62
|
+
"""HCURSOR -> shape name for every standard cursor (loaded once; the
|
|
63
|
+
handles are shared and stable for the lifetime of the process)."""
|
|
64
|
+
global _handle_shapes
|
|
65
|
+
if _handle_shapes is None:
|
|
66
|
+
user32 = ctypes.windll.user32
|
|
67
|
+
user32.LoadCursorW.restype = ctypes.c_void_p
|
|
68
|
+
user32.LoadCursorW.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
|
|
69
|
+
_handle_shapes = {}
|
|
70
|
+
for resource, name in _IDC_SHAPES.items():
|
|
71
|
+
# MAKEINTRESOURCE: the id passed as the pointer-sized name arg.
|
|
72
|
+
handle = user32.LoadCursorW(None, ctypes.c_void_p(resource))
|
|
73
|
+
if handle:
|
|
74
|
+
_handle_shapes[handle] = name
|
|
75
|
+
return _handle_shapes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def current_cursor_shape() -> str | None:
|
|
79
|
+
"""The current cursor's shape name, "hidden" while no cursor is showing,
|
|
80
|
+
or None when it cannot be determined (non-Windows, API failure)."""
|
|
81
|
+
if not _IS_WINDOWS:
|
|
82
|
+
return None
|
|
83
|
+
info = _CURSORINFO()
|
|
84
|
+
info.cbSize = ctypes.sizeof(_CURSORINFO)
|
|
85
|
+
if not ctypes.windll.user32.GetCursorInfo(ctypes.byref(info)):
|
|
86
|
+
return None
|
|
87
|
+
if not info.flags & _CURSOR_SHOWING:
|
|
88
|
+
return "hidden"
|
|
89
|
+
return _standard_handles().get(info.hCursor or 0, "arrow")
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
"""The Preferences tab: user-adjustable settings.
|
|
2
2
|
|
|
3
|
-
Performance-history length
|
|
4
|
-
`Settings` (the shared SQLite settings table, injectable in tests
|
|
5
|
-
other store); the start-at-login option reads and writes the
|
|
6
|
-
registry key via `Autostart`. The clipboard toggle applies live
|
|
7
|
-
shared `ClipboardSync` (the `clipboard=` opt-in collaborator
|
|
3
|
+
Performance-history length, the clipboard-sync opt-out, and the theme choice
|
|
4
|
+
persist in `Settings` (the shared SQLite settings table, injectable in tests
|
|
5
|
+
like every other store); the start-at-login option reads and writes the
|
|
6
|
+
Windows Run registry key via `Autostart`. The clipboard toggle applies live
|
|
7
|
+
to the shared `ClipboardSync` (the `clipboard=` opt-in collaborator
|
|
8
|
+
pattern). The theme radios (follow-OS / light / dark) apply live via
|
|
9
|
+
`apply_theme`; `MainWindow` re-applies the persisted choice at startup.
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
from collections.abc import Sequence
|
|
11
13
|
|
|
12
|
-
from PySide6.QtCore import Signal
|
|
14
|
+
from PySide6.QtCore import Qt, Signal
|
|
15
|
+
from PySide6.QtGui import QGuiApplication
|
|
13
16
|
from PySide6.QtWidgets import (
|
|
14
17
|
QCheckBox,
|
|
15
18
|
QFormLayout,
|
|
@@ -34,12 +37,37 @@ PERFORMANCE_WINDOW_KEY = "performance_window_seconds"
|
|
|
34
37
|
DEFAULT_PERFORMANCE_WINDOW_SECONDS = 120
|
|
35
38
|
CLIPBOARD_SYNC_KEY = "clipboard_sync_enabled"
|
|
36
39
|
VIEWER_KEY = "viewer_enabled"
|
|
40
|
+
THEME_KEY = "theme"
|
|
41
|
+
THEME_SYSTEM = "system" # follow the OS light/dark setting
|
|
42
|
+
THEME_LIGHT = "light"
|
|
43
|
+
THEME_DARK = "dark"
|
|
44
|
+
_THEMES = (THEME_SYSTEM, THEME_LIGHT, THEME_DARK)
|
|
37
45
|
|
|
38
46
|
|
|
39
47
|
def load_clipboard_sync_enabled(settings: Settings) -> bool:
|
|
40
48
|
return settings.get(CLIPBOARD_SYNC_KEY, "1") != "0"
|
|
41
49
|
|
|
42
50
|
|
|
51
|
+
def load_theme(settings: Settings) -> str:
|
|
52
|
+
value = settings.get(THEME_KEY, THEME_SYSTEM)
|
|
53
|
+
return value if value in _THEMES else THEME_SYSTEM
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def apply_theme(theme: str) -> None:
|
|
57
|
+
"""Apply a theme app-wide; Qt's windows11 style restyles live.
|
|
58
|
+
|
|
59
|
+
THEME_SYSTEM unsets the override so the app follows the OS light/dark
|
|
60
|
+
setting again (including future OS-side switches).
|
|
61
|
+
"""
|
|
62
|
+
hints = QGuiApplication.styleHints()
|
|
63
|
+
if theme == THEME_LIGHT:
|
|
64
|
+
hints.setColorScheme(Qt.ColorScheme.Light)
|
|
65
|
+
elif theme == THEME_DARK:
|
|
66
|
+
hints.setColorScheme(Qt.ColorScheme.Dark)
|
|
67
|
+
else:
|
|
68
|
+
hints.unsetColorScheme()
|
|
69
|
+
|
|
70
|
+
|
|
43
71
|
def load_viewer_enabled(settings: Settings) -> bool:
|
|
44
72
|
return settings.get(VIEWER_KEY, "1") != "0"
|
|
45
73
|
|
|
@@ -120,6 +148,25 @@ class PreferencesTab(QWidget):
|
|
|
120
148
|
sharing_layout.setContentsMargins(0, 0, 0, 0)
|
|
121
149
|
for radio in self._mode_radios.values():
|
|
122
150
|
sharing_layout.addWidget(radio)
|
|
151
|
+
# Theme: an explicit light/dark override, or follow the OS setting.
|
|
152
|
+
self.theme_system_radio = QRadioButton("Follow the Windows light/dark setting")
|
|
153
|
+
self.theme_light_radio = QRadioButton("Light")
|
|
154
|
+
self.theme_dark_radio = QRadioButton("Dark")
|
|
155
|
+
self._theme_radios = {
|
|
156
|
+
THEME_SYSTEM: self.theme_system_radio,
|
|
157
|
+
THEME_LIGHT: self.theme_light_radio,
|
|
158
|
+
THEME_DARK: self.theme_dark_radio,
|
|
159
|
+
}
|
|
160
|
+
self._theme_radios[load_theme(settings)].setChecked(True)
|
|
161
|
+
for theme, radio in self._theme_radios.items():
|
|
162
|
+
radio.toggled.connect(
|
|
163
|
+
lambda checked, t=theme: checked and self._on_theme_changed(t)
|
|
164
|
+
)
|
|
165
|
+
theme_box = QWidget()
|
|
166
|
+
theme_layout = QVBoxLayout(theme_box)
|
|
167
|
+
theme_layout.setContentsMargins(0, 0, 0, 0)
|
|
168
|
+
for radio in self._theme_radios.values():
|
|
169
|
+
theme_layout.addWidget(radio)
|
|
123
170
|
self.restart_button = QPushButton("Restart app")
|
|
124
171
|
self.restart_button.setToolTip(
|
|
125
172
|
"Relaunch this app (e.g. after updating the software). It can be "
|
|
@@ -135,6 +182,7 @@ class PreferencesTab(QWidget):
|
|
|
135
182
|
layout = QFormLayout(self)
|
|
136
183
|
layout.addRow("Client (viewer)", self.viewer_checkbox)
|
|
137
184
|
layout.addRow("Server (sharing)", sharing_box)
|
|
185
|
+
layout.addRow("Theme", theme_box)
|
|
138
186
|
layout.addRow("Performance history", self.history_minutes)
|
|
139
187
|
layout.addRow(self.clipboard_checkbox)
|
|
140
188
|
layout.addRow(self.autostart_checkbox)
|
|
@@ -163,6 +211,17 @@ class PreferencesTab(QWidget):
|
|
|
163
211
|
)
|
|
164
212
|
self.viewerModeChanged.emit(checked)
|
|
165
213
|
|
|
214
|
+
def _on_theme_changed(self, theme: str) -> None:
|
|
215
|
+
self._settings.set(THEME_KEY, theme)
|
|
216
|
+
apply_theme(theme)
|
|
217
|
+
self.statusMessage.emit(
|
|
218
|
+
{
|
|
219
|
+
THEME_SYSTEM: "Theme follows the Windows light/dark setting",
|
|
220
|
+
THEME_LIGHT: "Theme set to light",
|
|
221
|
+
THEME_DARK: "Theme set to dark",
|
|
222
|
+
}[theme]
|
|
223
|
+
)
|
|
224
|
+
|
|
166
225
|
def _on_clipboard_toggled(self, checked: bool) -> None:
|
|
167
226
|
self._settings.set(CLIPBOARD_SYNC_KEY, "1" if checked else "0")
|
|
168
227
|
if self._clipboard is not None:
|
|
@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
|
|
|
28
28
|
|
|
29
29
|
from remotedesktop import __version__, compat, tls
|
|
30
30
|
from remotedesktop.config import PairedClients, Settings, default_config_dir
|
|
31
|
+
from remotedesktop.cursor_shape import current_cursor_shape
|
|
31
32
|
from remotedesktop.discovery import (
|
|
32
33
|
DEFAULT_CONNECT_PORT,
|
|
33
34
|
DISCOVERY_PORT,
|
|
@@ -274,6 +275,7 @@ class SharingTab(QWidget):
|
|
|
274
275
|
credentials=self._credentials,
|
|
275
276
|
paired=self._paired,
|
|
276
277
|
clipboard=self._clipboard,
|
|
278
|
+
cursor_probe=current_cursor_shape,
|
|
277
279
|
performance=self._performance,
|
|
278
280
|
log_provider=lambda: read_log_tail("remotedesktop"),
|
|
279
281
|
input_allowed=self._settings.get(ALLOW_INPUT_KEY, "1") != "0",
|
|
@@ -116,6 +116,7 @@ class ShareServer(QObject):
|
|
|
116
116
|
fps: int = DEFAULT_FPS,
|
|
117
117
|
injector: InputInjector | None = None,
|
|
118
118
|
clipboard=None,
|
|
119
|
+
cursor_probe: Callable[[], str | None] | None = None,
|
|
119
120
|
performance: PerformanceMonitor | None = None,
|
|
120
121
|
log_provider: Callable[[], str] | None = None,
|
|
121
122
|
input_allowed: bool = True,
|
|
@@ -132,6 +133,10 @@ class ShareServer(QObject):
|
|
|
132
133
|
self._clipboard = clipboard
|
|
133
134
|
if clipboard is not None:
|
|
134
135
|
clipboard.changed.connect(self._broadcast_clipboard)
|
|
136
|
+
# Opt-in like clipboard: only the GUI passes a probe, so plain
|
|
137
|
+
# sharing tests never poll (or depend on) the host's real cursor.
|
|
138
|
+
self._cursor_probe = cursor_probe
|
|
139
|
+
self._cursor_shape: str | None = None # last shape sent to viewers
|
|
135
140
|
self._streams: list[MessageStream] = []
|
|
136
141
|
self._all_streams: set[MessageStream] = set()
|
|
137
142
|
self._controllers: set[MessageStream] = set()
|
|
@@ -237,6 +242,7 @@ class ShareServer(QObject):
|
|
|
237
242
|
self._needs_keyframe.clear()
|
|
238
243
|
self._viewer_info.clear()
|
|
239
244
|
self._previous_frame = None
|
|
245
|
+
self._cursor_shape = None
|
|
240
246
|
if self._dxgi is not None:
|
|
241
247
|
self._dxgi.close()
|
|
242
248
|
self._dxgi = None
|
|
@@ -468,6 +474,7 @@ class ShareServer(QObject):
|
|
|
468
474
|
stream.send_json(welcome)
|
|
469
475
|
self._needs_keyframe.add(stream) # its first frame must be a full one
|
|
470
476
|
self._streams.append(stream)
|
|
477
|
+
self._broadcast_cursor(new_stream=stream) # start with the right cursor
|
|
471
478
|
if self._performance is not None:
|
|
472
479
|
self._performance.add_stream(stream)
|
|
473
480
|
self.clientCountChanged.emit(len(self._streams))
|
|
@@ -637,9 +644,30 @@ class ShareServer(QObject):
|
|
|
637
644
|
_log.info("DXGI capture unavailable — using grabWindow until it recovers")
|
|
638
645
|
return image
|
|
639
646
|
|
|
647
|
+
def _broadcast_cursor(self, new_stream: MessageStream | None = None) -> None:
|
|
648
|
+
"""Tell viewers what shape their local cursor should mirror: everyone
|
|
649
|
+
when the server's cursor changed shape, plus a just-admitted stream
|
|
650
|
+
that still needs the current shape.
|
|
651
|
+
|
|
652
|
+
The message is tiny and shape changes are rare next to frames, so it
|
|
653
|
+
is sent even to backlogged streams (like clipboard updates).
|
|
654
|
+
"""
|
|
655
|
+
if self._cursor_probe is None:
|
|
656
|
+
return
|
|
657
|
+
shape = self._cursor_probe()
|
|
658
|
+
if shape is None:
|
|
659
|
+
return
|
|
660
|
+
if shape != self._cursor_shape:
|
|
661
|
+
self._cursor_shape = shape
|
|
662
|
+
for stream in self._streams:
|
|
663
|
+
stream.send_json({"type": "cursor", "shape": shape})
|
|
664
|
+
elif new_stream is not None:
|
|
665
|
+
new_stream.send_json({"type": "cursor", "shape": shape})
|
|
666
|
+
|
|
640
667
|
def _broadcast_frame(self) -> None:
|
|
641
668
|
if not self._streams:
|
|
642
669
|
return
|
|
670
|
+
self._broadcast_cursor() # polled at the frame rate, sent on change
|
|
643
671
|
image = self._capture()
|
|
644
672
|
if image is None:
|
|
645
673
|
self.status.emit("Screen capture failed (null image)")
|
|
@@ -711,6 +739,10 @@ class ShareClient(QObject):
|
|
|
711
739
|
# emits it only for sockets that actually reached ConnectedState.
|
|
712
740
|
connectionFailed = Signal(str)
|
|
713
741
|
frameReceived = Signal(QImage)
|
|
742
|
+
# The server's cursor changed shape (a cursor_shape.py name, e.g.
|
|
743
|
+
# "size_we"); the viewer mirrors it on the local cursor. Servers without
|
|
744
|
+
# the feature (pre-1.6) simply never emit it.
|
|
745
|
+
cursorShapeChanged = Signal(str)
|
|
714
746
|
status = Signal(str)
|
|
715
747
|
logReceived = Signal(str) # server log text answering request_log
|
|
716
748
|
|
|
@@ -907,6 +939,10 @@ class ShareClient(QObject):
|
|
|
907
939
|
if self._clipboard is not None:
|
|
908
940
|
self.status.emit("Clipboard update received from server")
|
|
909
941
|
self._clipboard.apply(message)
|
|
942
|
+
case "cursor":
|
|
943
|
+
# Too frequent for the status log; unknown names are the
|
|
944
|
+
# viewer's problem (it falls back to the arrow).
|
|
945
|
+
self.cursorShapeChanged.emit(str(message.get("shape", "")) or "arrow")
|
|
910
946
|
case "ping" | "pong":
|
|
911
947
|
if self._performance is not None:
|
|
912
948
|
self._performance.handle_message(self._stream, message)
|
|
@@ -9,23 +9,52 @@ forwarded.
|
|
|
9
9
|
|
|
10
10
|
from PySide6.QtCore import QEvent, QPointF, QRectF, QSize, Qt, Signal
|
|
11
11
|
from PySide6.QtGui import (
|
|
12
|
+
QColor,
|
|
13
|
+
QCursor,
|
|
12
14
|
QFocusEvent,
|
|
13
15
|
QImage,
|
|
14
16
|
QKeyEvent,
|
|
15
17
|
QMouseEvent,
|
|
16
18
|
QPaintEvent,
|
|
17
19
|
QPainter,
|
|
20
|
+
QPalette,
|
|
18
21
|
QPixmap,
|
|
19
22
|
QWheelEvent,
|
|
20
23
|
)
|
|
21
24
|
from PySide6.QtWidgets import QWidget
|
|
22
25
|
|
|
26
|
+
# Frame-outline shades: light gray on a dark theme, dark gray on a light one,
|
|
27
|
+
# so the border contrasts with the app background on either side of the edge.
|
|
28
|
+
_BORDER_ON_DARK = QColor(170, 170, 170)
|
|
29
|
+
_BORDER_ON_LIGHT = QColor(110, 110, 110)
|
|
30
|
+
|
|
23
31
|
_BUTTON_NAMES = {
|
|
24
32
|
Qt.MouseButton.LeftButton: "left",
|
|
25
33
|
Qt.MouseButton.RightButton: "right",
|
|
26
34
|
Qt.MouseButton.MiddleButton: "middle",
|
|
27
35
|
}
|
|
28
36
|
|
|
37
|
+
# Wire cursor-shape names (cursor_shape.py on the server side) -> the local
|
|
38
|
+
# cursor mirroring the remote one. Names this map doesn't know (a newer
|
|
39
|
+
# server's vocabulary) fall back to the arrow.
|
|
40
|
+
_CURSOR_SHAPES = {
|
|
41
|
+
"arrow": Qt.CursorShape.ArrowCursor,
|
|
42
|
+
"ibeam": Qt.CursorShape.IBeamCursor,
|
|
43
|
+
"wait": Qt.CursorShape.WaitCursor,
|
|
44
|
+
"cross": Qt.CursorShape.CrossCursor,
|
|
45
|
+
"uparrow": Qt.CursorShape.UpArrowCursor,
|
|
46
|
+
"size_nwse": Qt.CursorShape.SizeFDiagCursor,
|
|
47
|
+
"size_nesw": Qt.CursorShape.SizeBDiagCursor,
|
|
48
|
+
"size_we": Qt.CursorShape.SizeHorCursor,
|
|
49
|
+
"size_ns": Qt.CursorShape.SizeVerCursor,
|
|
50
|
+
"size_all": Qt.CursorShape.SizeAllCursor,
|
|
51
|
+
"no": Qt.CursorShape.ForbiddenCursor,
|
|
52
|
+
"hand": Qt.CursorShape.PointingHandCursor,
|
|
53
|
+
"appstarting": Qt.CursorShape.BusyCursor,
|
|
54
|
+
"help": Qt.CursorShape.WhatsThisCursor,
|
|
55
|
+
"hidden": Qt.CursorShape.BlankCursor,
|
|
56
|
+
}
|
|
57
|
+
|
|
29
58
|
|
|
30
59
|
class ViewerWidget(QWidget):
|
|
31
60
|
"""Displays the remote desktop screen scaled to fit, and forwards mouse and
|
|
@@ -101,8 +130,14 @@ class ViewerWidget(QWidget):
|
|
|
101
130
|
self._message = message
|
|
102
131
|
self._pressed_buttons.clear()
|
|
103
132
|
self._pressed_keys.clear()
|
|
133
|
+
self.unsetCursor() # a disconnected viewer shows the normal cursor
|
|
104
134
|
self.update()
|
|
105
135
|
|
|
136
|
+
def set_remote_cursor(self, shape: str) -> None:
|
|
137
|
+
"""Mirror the server's cursor shape on the local cursor (the frame
|
|
138
|
+
itself never contains the pointer — screen capture excludes it)."""
|
|
139
|
+
self.setCursor(QCursor(_CURSOR_SHAPES.get(shape, Qt.CursorShape.ArrowCursor)))
|
|
140
|
+
|
|
106
141
|
def _display_rect(self) -> QRectF:
|
|
107
142
|
"""Rectangle the frame occupies, centered and aspect-preserved."""
|
|
108
143
|
assert self._frame is not None
|
|
@@ -257,3 +292,12 @@ class ViewerWidget(QWidget):
|
|
|
257
292
|
)
|
|
258
293
|
self._scaled.setDevicePixelRatio(dpr)
|
|
259
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))
|
|
299
|
+
|
|
300
|
+
def _border_color(self) -> QColor:
|
|
301
|
+
"""Outline shade for the current theme (follows the window background)."""
|
|
302
|
+
window = self.palette().color(QPalette.ColorRole.Window)
|
|
303
|
+
return _BORDER_ON_DARK if window.lightness() < 128 else _BORDER_ON_LIGHT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from remotedesktop.cursor_shape import SHAPE_NAMES, current_cursor_shape
|
|
6
|
+
from remotedesktop.viewer import _CURSOR_SHAPES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_every_wire_name_has_a_client_side_cursor():
|
|
10
|
+
# The server's vocabulary and the viewer's map must stay in sync, or a
|
|
11
|
+
# shape would silently degrade to the arrow.
|
|
12
|
+
assert SHAPE_NAMES <= set(_CURSOR_SHAPES)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows cursor API")
|
|
16
|
+
def test_current_cursor_shape_reports_a_known_name():
|
|
17
|
+
shape = current_cursor_shape()
|
|
18
|
+
# None only when GetCursorInfo fails (e.g. no interactive desktop).
|
|
19
|
+
if shape is not None:
|
|
20
|
+
assert shape in SHAPE_NAMES
|
|
@@ -238,6 +238,21 @@ def test_release_when_frame_vanished_mid_drag(qapp):
|
|
|
238
238
|
assert [e["pressed"] for e in events] == [True]
|
|
239
239
|
|
|
240
240
|
|
|
241
|
+
def test_viewer_mirrors_remote_cursor_shape(qapp):
|
|
242
|
+
viewer = ViewerWidget()
|
|
243
|
+
viewer.set_remote_cursor("size_we")
|
|
244
|
+
assert viewer.cursor().shape() == Qt.CursorShape.SizeHorCursor
|
|
245
|
+
viewer.set_remote_cursor("hidden")
|
|
246
|
+
assert viewer.cursor().shape() == Qt.CursorShape.BlankCursor
|
|
247
|
+
# A name from a newer server's vocabulary falls back to the arrow.
|
|
248
|
+
viewer.set_remote_cursor("lasso")
|
|
249
|
+
assert viewer.cursor().shape() == Qt.CursorShape.ArrowCursor
|
|
250
|
+
# Disconnecting restores the normal local cursor.
|
|
251
|
+
viewer.set_remote_cursor("size_ns")
|
|
252
|
+
viewer.clear()
|
|
253
|
+
assert viewer.cursor().shape() == Qt.CursorShape.ArrowCursor
|
|
254
|
+
|
|
255
|
+
|
|
241
256
|
def test_viewer_paints_message_and_frame(qapp):
|
|
242
257
|
viewer = ViewerWidget()
|
|
243
258
|
viewer.resize(400, 400)
|
|
@@ -60,6 +60,19 @@ def make_share_server(credentials, tmp_path, *, approve=lambda *_: True, db_name
|
|
|
60
60
|
return server
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def test_persisted_theme_is_applied_at_startup(qapp, tmp_path):
|
|
64
|
+
from remotedesktop.preferences import THEME_KEY, THEME_SYSTEM, apply_theme
|
|
65
|
+
|
|
66
|
+
Settings(db.connect(tmp_path / "app.db")).set(THEME_KEY, "dark")
|
|
67
|
+
window = make_window(tmp_path)
|
|
68
|
+
try:
|
|
69
|
+
assert qapp.styleHints().colorScheme() == Qt.ColorScheme.Dark
|
|
70
|
+
assert window.preferences_tab.theme_dark_radio.isChecked()
|
|
71
|
+
finally:
|
|
72
|
+
window.close()
|
|
73
|
+
apply_theme(THEME_SYSTEM) # don't leak a dark palette into other tests
|
|
74
|
+
|
|
75
|
+
|
|
63
76
|
def test_get_server_log_button_needs_a_connection(qapp, tmp_path):
|
|
64
77
|
window = make_window(tmp_path)
|
|
65
78
|
try:
|
|
@@ -92,6 +92,37 @@ def test_clipboard_toggle_persists_and_applies_live(qapp, tmp_path):
|
|
|
92
92
|
assert not load_clipboard_sync_enabled(settings)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def test_theme_defaults_and_invalid_fall_back(tmp_path):
|
|
96
|
+
from remotedesktop.preferences import THEME_KEY, THEME_SYSTEM, load_theme
|
|
97
|
+
|
|
98
|
+
settings = Settings(db.connect(tmp_path / "prefs.db"))
|
|
99
|
+
assert load_theme(settings) == THEME_SYSTEM
|
|
100
|
+
settings.set(THEME_KEY, "sepia")
|
|
101
|
+
assert load_theme(settings) == THEME_SYSTEM
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_theme_radio_persists_and_applies_live(qapp, tmp_path):
|
|
105
|
+
from PySide6.QtCore import Qt
|
|
106
|
+
|
|
107
|
+
from remotedesktop.preferences import THEME_KEY, THEME_SYSTEM, apply_theme
|
|
108
|
+
|
|
109
|
+
settings = Settings(db.connect(tmp_path / "prefs.db"))
|
|
110
|
+
tab = PreferencesTab(settings, PerformanceMonitor(), autostart=make_autostart())
|
|
111
|
+
assert tab.theme_system_radio.isChecked() # default: follow the OS
|
|
112
|
+
messages = []
|
|
113
|
+
tab.statusMessage.connect(messages.append)
|
|
114
|
+
try:
|
|
115
|
+
tab.theme_dark_radio.setChecked(True)
|
|
116
|
+
assert settings.get(THEME_KEY) == "dark"
|
|
117
|
+
assert qapp.styleHints().colorScheme() == Qt.ColorScheme.Dark
|
|
118
|
+
assert any("dark" in m for m in messages)
|
|
119
|
+
# A fresh tab (restart) reads the persisted choice.
|
|
120
|
+
tab2 = PreferencesTab(settings, PerformanceMonitor(), autostart=make_autostart())
|
|
121
|
+
assert tab2.theme_dark_radio.isChecked()
|
|
122
|
+
finally:
|
|
123
|
+
apply_theme(THEME_SYSTEM) # don't leak a dark palette into other tests
|
|
124
|
+
|
|
125
|
+
|
|
95
126
|
def test_sharing_mode_radios_reflect_persisted_state_and_emit(qapp, tmp_path):
|
|
96
127
|
settings = Settings(db.connect(tmp_path / "prefs.db"))
|
|
97
128
|
settings.set("server_enabled", "1")
|
|
@@ -23,7 +23,14 @@ def pump(qapp, condition, timeout=10.0):
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def make_server(
|
|
26
|
-
credentials,
|
|
26
|
+
credentials,
|
|
27
|
+
tmp_path,
|
|
28
|
+
*,
|
|
29
|
+
approve,
|
|
30
|
+
injector=None,
|
|
31
|
+
clipboard=None,
|
|
32
|
+
cursor_probe=None,
|
|
33
|
+
log_provider=None,
|
|
27
34
|
):
|
|
28
35
|
# The server is a distinct "machine" from the client -> its own database.
|
|
29
36
|
server = ShareServer(
|
|
@@ -32,6 +39,7 @@ def make_server(
|
|
|
32
39
|
paired=PairedClients(db.connect(tmp_path / "server.db")),
|
|
33
40
|
injector=injector,
|
|
34
41
|
clipboard=clipboard,
|
|
42
|
+
cursor_probe=cursor_probe,
|
|
35
43
|
log_provider=log_provider,
|
|
36
44
|
)
|
|
37
45
|
assert server.listen(0)
|
|
@@ -99,6 +107,33 @@ def test_first_connection_prompts_pairs_and_streams(qapp, credentials, tmp_path)
|
|
|
99
107
|
server.close()
|
|
100
108
|
|
|
101
109
|
|
|
110
|
+
def test_cursor_shape_sent_on_admission_and_on_change_only(qapp, credentials, tmp_path):
|
|
111
|
+
shape = ["size_we"]
|
|
112
|
+
server = make_server(
|
|
113
|
+
credentials, tmp_path, approve=lambda *_: True, cursor_probe=lambda: shape[0]
|
|
114
|
+
)
|
|
115
|
+
client = make_client(tmp_path)
|
|
116
|
+
shapes: list[str] = []
|
|
117
|
+
client.cursorShapeChanged.connect(shapes.append)
|
|
118
|
+
client.connect_to("127.0.0.1", server.port)
|
|
119
|
+
try:
|
|
120
|
+
# A just-admitted viewer gets the current shape without waiting for
|
|
121
|
+
# a change.
|
|
122
|
+
pump(qapp, lambda: shapes)
|
|
123
|
+
assert shapes == ["size_we"]
|
|
124
|
+
shape[0] = "ibeam"
|
|
125
|
+
pump(qapp, lambda: len(shapes) >= 2)
|
|
126
|
+
# Give the broadcast timer a few more ticks: the probe is polled at
|
|
127
|
+
# the frame rate but must send only on change, never per tick.
|
|
128
|
+
deadline = time.monotonic() + 0.2
|
|
129
|
+
while time.monotonic() < deadline:
|
|
130
|
+
qapp.processEvents()
|
|
131
|
+
assert shapes == ["size_we", "ibeam"]
|
|
132
|
+
finally:
|
|
133
|
+
client.close()
|
|
134
|
+
server.close()
|
|
135
|
+
|
|
136
|
+
|
|
102
137
|
def test_reconnect_uses_token_without_prompting(qapp, credentials, tmp_path):
|
|
103
138
|
prompts = []
|
|
104
139
|
# Keep one server (same port) so the client's stored "host:port" token matches.
|
|
@@ -69,3 +69,38 @@ def test_viewer_scales_frames_in_device_pixels(qapp) -> None:
|
|
|
69
69
|
assert viewer._scaled.devicePixelRatio() == dpr
|
|
70
70
|
assert viewer._scaled.size().width() == round(320 * dpr)
|
|
71
71
|
assert viewer._scaled.size().height() == round(240 * dpr)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_viewer_draws_border_around_frame(qapp) -> None:
|
|
75
|
+
from PySide6.QtGui import QImage
|
|
76
|
+
from PySide6.QtCore import Qt
|
|
77
|
+
|
|
78
|
+
viewer = ViewerWidget()
|
|
79
|
+
viewer.resize(320, 240)
|
|
80
|
+
frame = QImage(640, 480, QImage.Format.Format_RGB32) # same 4:3 aspect: fills the widget
|
|
81
|
+
frame.fill(Qt.GlobalColor.white)
|
|
82
|
+
viewer.show_frame(frame)
|
|
83
|
+
grabbed = viewer.grab().toImage()
|
|
84
|
+
# The frame edge is outlined so it stands out from the app background;
|
|
85
|
+
# the interior stays untouched remote content.
|
|
86
|
+
assert grabbed.pixelColor(0, 0) != Qt.GlobalColor.white
|
|
87
|
+
assert (
|
|
88
|
+
grabbed.pixelColor(grabbed.width() // 2, grabbed.height() // 2)
|
|
89
|
+
== Qt.GlobalColor.white
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_viewer_border_color_follows_theme(qapp) -> None:
|
|
94
|
+
from PySide6.QtGui import QColor, QPalette
|
|
95
|
+
|
|
96
|
+
viewer = ViewerWidget()
|
|
97
|
+
palette = viewer.palette()
|
|
98
|
+
palette.setColor(QPalette.ColorRole.Window, QColor(30, 30, 30))
|
|
99
|
+
viewer.setPalette(palette)
|
|
100
|
+
dark_theme_border = viewer._border_color()
|
|
101
|
+
palette.setColor(QPalette.ColorRole.Window, QColor(240, 240, 240))
|
|
102
|
+
viewer.setPalette(palette)
|
|
103
|
+
light_theme_border = viewer._border_color()
|
|
104
|
+
# Light outline on a dark theme, dark outline on a light theme.
|
|
105
|
+
assert dark_theme_border.lightness() > 128
|
|
106
|
+
assert light_theme_border.lightness() < 128
|
|
@@ -557,7 +557,7 @@ wheels = [
|
|
|
557
557
|
|
|
558
558
|
[[package]]
|
|
559
559
|
name = "pyship"
|
|
560
|
-
version = "0.7.
|
|
560
|
+
version = "0.7.5"
|
|
561
561
|
source = { registry = "https://pypi.org/simple" }
|
|
562
562
|
dependencies = [
|
|
563
563
|
{ name = "attrs" },
|
|
@@ -577,7 +577,7 @@ dependencies = [
|
|
|
577
577
|
{ name = "wheel-inspect" },
|
|
578
578
|
]
|
|
579
579
|
wheels = [
|
|
580
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
580
|
+
{ url = "https://files.pythonhosted.org/packages/ec/4d/195e7e4a44134e7cad54f35e995ad8cf9f2dd75ca0d221e0bcb2705cd73c/pyship-0.7.5-py3-none-any.whl", hash = "sha256:539db5c9c6aa2cb83dd935f98b7eacd883cad6519a50a4066a872f76318add65", size = 66792, upload-time = "2026-07-19T22:30:31.336Z" },
|
|
581
581
|
]
|
|
582
582
|
|
|
583
583
|
[[package]]
|
|
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
|