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.
Files changed (73) hide show
  1. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/CLAUDE.md +2 -0
  2. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/PKG-INFO +1 -1
  3. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/__init__.py +1 -1
  4. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/app.py +6 -0
  5. remotedesktop-1.6.0/src/remotedesktop/cursor_shape.py +89 -0
  6. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/preferences.py +65 -6
  7. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/server.py +2 -0
  8. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/sharing.py +36 -0
  9. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/viewer.py +44 -0
  10. remotedesktop-1.6.0/tests/test_cursor_shape.py +20 -0
  11. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_input.py +15 -0
  12. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_main_window.py +13 -0
  13. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_preferences.py +31 -0
  14. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_sharing.py +36 -1
  15. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_smoke.py +35 -0
  16. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/uv.lock +2 -2
  17. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/.github/workflows/ci.yml +0 -0
  18. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/.github/workflows/release.yml +0 -0
  19. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/.gitignore +0 -0
  20. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/LICENSE +0 -0
  21. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/docs/media/client-demo.gif +0 -0
  22. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/docs/media/server-demo.gif +0 -0
  23. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/icon/remotedesktop.ico +0 -0
  24. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/pyproject.toml +0 -0
  25. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/readme.md +0 -0
  26. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/run.bat +0 -0
  27. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/run_claude.bat +0 -0
  28. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/scripts/make_coverage_badge.py +0 -0
  29. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/scripts/make_installer.bat +0 -0
  30. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/scripts/make_venv.bat +0 -0
  31. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/__main__.py +0 -0
  32. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/about.py +0 -0
  33. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/autostart.py +0 -0
  34. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/client.py +0 -0
  35. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/clipboard.py +0 -0
  36. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/compat.py +0 -0
  37. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/config.py +0 -0
  38. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/db.py +0 -0
  39. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/discovery.py +0 -0
  40. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/dxgi.py +0 -0
  41. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/frames.py +0 -0
  42. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/icon.py +0 -0
  43. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/input_injection.py +0 -0
  44. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/inventory.py +0 -0
  45. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/logs.py +0 -0
  46. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/modal_loop.py +0 -0
  47. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/performance.py +0 -0
  48. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/protocol.py +0 -0
  49. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/single_instance.py +0 -0
  50. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/tls.py +0 -0
  51. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/src/remotedesktop/window_state.py +0 -0
  52. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/conftest.py +0 -0
  53. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_about.py +0 -0
  54. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_autostart.py +0 -0
  55. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_clipboard.py +0 -0
  56. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_compat.py +0 -0
  57. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_config.py +0 -0
  58. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_discovery.py +0 -0
  59. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_dxgi.py +0 -0
  60. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_frames.py +0 -0
  61. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_icon.py +0 -0
  62. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_input_injection.py +0 -0
  63. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_inventory.py +0 -0
  64. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_logs.py +0 -0
  65. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_modal_loop.py +0 -0
  66. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_performance.py +0 -0
  67. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_protocol.py +0 -0
  68. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_sharing_tab.py +0 -0
  69. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_single_instance.py +0 -0
  70. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_tls.py +0 -0
  71. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tests/test_window_state.py +0 -0
  72. {remotedesktop-1.5.0 → remotedesktop-1.6.0}/tools/make_demo_gifs.py +0 -0
  73. {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.5.0
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
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "1.5.0"
3
+ __version__ = "1.6.0"
@@ -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 and the clipboard-sync opt-out persist in
4
- `Settings` (the shared SQLite settings table, injectable in tests like every
5
- other store); the start-at-login option reads and writes the Windows Run
6
- registry key via `Autostart`. The clipboard toggle applies live to the
7
- shared `ClipboardSync` (the `clipboard=` opt-in collaborator pattern).
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, tmp_path, *, approve, injector=None, clipboard=None, log_provider=None
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.4"
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/b7/23/c5e21fb5a2f3c5071f2adfdb2b9f69ebc55fc1227cfbc6338b09f443b4e8/pyship-0.7.4-py3-none-any.whl", hash = "sha256:b479d17bc87223adbee3624619f44843d34ae55e3c2dbaf285d67e8a92a81124", size = 66282, upload-time = "2026-07-19T17:49:36.674Z" },
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