remotedesktop 1.8.0__tar.gz → 1.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/CLAUDE.md +1 -0
  2. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/PKG-INFO +2 -1
  3. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/readme.md +1 -0
  4. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/app.py +117 -3
  6. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/clipboard.py +19 -0
  7. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/viewer.py +6 -0
  8. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_clipboard.py +24 -0
  9. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_main_window.py +118 -5
  10. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/.github/workflows/ci.yml +0 -0
  11. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/.github/workflows/release.yml +0 -0
  12. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/.gitignore +0 -0
  13. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/LICENSE +0 -0
  14. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/docs/media/client-demo.gif +0 -0
  15. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/docs/media/server-demo.gif +0 -0
  16. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/icon/remotedesktop.ico +0 -0
  17. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/pyproject.toml +0 -0
  18. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/run.bat +0 -0
  19. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/run_claude.bat +0 -0
  20. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/scripts/make_coverage_badge.py +0 -0
  21. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/scripts/make_installer.bat +0 -0
  22. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/scripts/make_venv.bat +0 -0
  23. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/__main__.py +0 -0
  24. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/about.py +0 -0
  25. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/autostart.py +0 -0
  26. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/client.py +0 -0
  27. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/compat.py +0 -0
  28. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/config.py +0 -0
  29. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/cursor_shape.py +0 -0
  30. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/db.py +0 -0
  31. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/discovery.py +0 -0
  32. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/dxgi.py +0 -0
  33. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/frames.py +0 -0
  34. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/icon.py +0 -0
  35. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/input_injection.py +0 -0
  36. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/inventory.py +0 -0
  37. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/logs.py +0 -0
  38. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/modal_loop.py +0 -0
  39. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/performance.py +0 -0
  40. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/preferences.py +0 -0
  41. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/protocol.py +0 -0
  42. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/server.py +0 -0
  43. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/session_lock.py +0 -0
  44. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/sharing.py +0 -0
  45. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/single_instance.py +0 -0
  46. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/tls.py +0 -0
  47. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/visual_effects.py +0 -0
  48. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/src/remotedesktop/window_state.py +0 -0
  49. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/conftest.py +0 -0
  50. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_about.py +0 -0
  51. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_autostart.py +0 -0
  52. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_compat.py +0 -0
  53. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_config.py +0 -0
  54. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_cursor_shape.py +0 -0
  55. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_discovery.py +0 -0
  56. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_dxgi.py +0 -0
  57. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_frames.py +0 -0
  58. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_icon.py +0 -0
  59. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_input.py +0 -0
  60. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_input_injection.py +0 -0
  61. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_inventory.py +0 -0
  62. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_logs.py +0 -0
  63. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_modal_loop.py +0 -0
  64. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_performance.py +0 -0
  65. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_preferences.py +0 -0
  66. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_protocol.py +0 -0
  67. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_session_lock.py +0 -0
  68. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_sharing.py +0 -0
  69. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_sharing_tab.py +0 -0
  70. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_single_instance.py +0 -0
  71. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_smoke.py +0 -0
  72. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_tls.py +0 -0
  73. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_visual_effects.py +0 -0
  74. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tests/test_window_state.py +0 -0
  75. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tools/make_demo_gifs.py +0 -0
  76. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/tools/make_icon.py +0 -0
  77. {remotedesktop-1.8.0 → remotedesktop-1.9.1}/uv.lock +0 -0
@@ -76,6 +76,7 @@ Managed with `uv` (hatchling build backend, src layout):
76
76
  - **Auto-reconnect** (`app.py`): a session that *was connected* and drops retries `client.connect_to` with exponential backoff (base `reconnect_base_seconds=2.0` kwarg — tests inject ~0.05 — doubling to a 30 s cap), driven by a per-session single-shot QTimer. `ShareClient.connectionFailed` exists because **Qt emits `disconnected` only for sockets that reached ConnectedState** — refused/unreachable attempts fire `errorOccurred` with the socket back in UnconnectedState. The active-timer guard dedupes the errorOccurred+disconnected double-fire. Denial cancels until a manual activation; tab close/forget/quit cancel; retries bypass `_connect_session` on purpose (no perf reset, no tab steal, no inventory attempt spam). **Open session tabs persist** (`open_sessions` JSON in Settings, written on connect/rename/close) and are restored + reconnected at startup with `auto_reconnect` pre-armed, so a not-yet-available server is retried on the backoff loop; deliberately closed tabs are not restored. While no session exists, a non-closable "Server" placeholder tab (instructions) sits at index 0 and swaps with the first session's tab.
77
77
  - **Menus & shortcuts contract** (`app.py` `_build_menus`, `viewer.py`): File/View/Help menu bar; shortcuts Ctrl+W (close session tab), Ctrl+Q (quit), F5 (rescan), F11 (fullscreen). While a viewer shows a frame it **accepts `ShortcutOverride`** so every key forwards to the remote machine — **F11 is the single reserved local key**. Shortcut actions are also `addAction`-ed on the window so they fire with the menu bar hidden (fullscreen). Keep every menu/action as an attribute — PySide6 gives Python ownership of `addMenu`/`addAction` (and `QAction.menu()`) wrappers, and a GC'd local deletes the C++ object. Fullscreen hides menu/status bars, dock, and tab bar and restores them (including pre-fullscreen dock visibility) on exit; `closeEvent` exits fullscreen before persisting layout. Window geometry AND dock layout persist (`window_state.save_state`/`restore_state`, key `main_window_state`; the dock needs its objectName).
78
78
  - **Privacy toggles**: Preferences' "Sync clipboard" checkbox (`clipboard_sync_enabled`, default on) drives `ClipboardSync.enabled` — while off, local copies aren't sent and peer payloads aren't applied. View-only sharing is the "view" mode of the Screen sharing radios (`ShareServer.set_input_allowed` — a server-side drop, no wire change, that releases held input when switched off). Quitting with ≥1 viewer connected asks for confirmation (declining resets `_quitting`); restart shuts sharing down first so it never double-prompts.
79
+ - **Screen capture** (`app.py`): the dock's "Screen capture" group (Copy / Save…, viewer-role UI hidden with the panel) and the matching File-menu actions (`copy_capture_action`/`save_capture_action`, same handlers) capture the current session tab's last frame at full server resolution (`ViewerWidget.frame_image()` — the lock overlay is display-only and never part of it). Copy goes through `ClipboardSync.copy_image`, which records the content signature *before* setting the clipboard so the capture is never echoed to peers, and works with sync disabled (the preference governs syncing, not local copies). Save is `QFileDialog.getSaveFileName` (tests monkeypatch it) defaulting to `Pictures\<server> <timestamp>.png`, PNG only. All four widgets enable only while the current tab shows a frame — `_update_capture_actions()`, called from tab change, `_on_frame` (unconditionally: auto-reconnect refills a cleared viewer without `_connect_session`), and every `viewer.clear` path (connect/denied/approval-pending/disconnect).
79
80
  - **Reduce visual effects while sharing** (`visual_effects.py`): animations/fades are a burst of changed frames for the lossless pipeline, so while the preference is on (default on, `reduce_effects_enabled`, `load_reduce_effects_enabled`) **and ≥1 viewer is connected**, `MainWindow` applies `VisualEffectsReducer` and restores it when the last viewer leaves (driven by `SharingTab.viewerCountChanged`; explicit safety-net restore in `closeEvent`). The reduced set: SPI_SETUIEFFECTS off, drop shadows off, client-area + min/max animations off, submenu delay lowered to 50 ms (never raised). All via `SystemParametersInfo` **without** SPIF_UPDATEINIFILE — session-scoped, never persisted, self-heals at logoff; knobs already at the reduced value are left untouched so the user's own choices are never "restored" over. The backend (`SpiBackend`) is injectable — **tests must always inject `FakeSpiBackend`** (the preference defaults on, so a real reducer in a test would change the host's actual Windows settings; `make_window` in `tests/test_main_window.py` injects it for every window test, and real-backend tests may only read).
80
81
  - **Theme preference** (`preferences.py`): a three-radio group in Preferences — follow the Windows light/dark setting (default) / Light / Dark — persisted as `THEME_KEY` (`"system"`/`"light"`/`"dark"`, `load_theme` falls back to system on junk). `apply_theme` drives `QStyleHints.setColorScheme` (`unsetColorScheme` for system, so OS-side switches keep applying); the radios apply live and `MainWindow.__init__` re-applies the persisted choice before building widgets. Tests that set a non-system theme must restore with `apply_theme(THEME_SYSTEM)` — the color scheme is process-global QApplication state.
81
82
  - **Two `PerformanceMonitor`s, never one**: `MainWindow.client_performance` (viewing sessions) and `server_performance` (the SharingTab's viewers). `ShareServer.close()` resets its monitor, so sharing a monitor across roles would wipe the other role's graphs. The Performance tab has "Client (viewer)"/"Server (sharing)" sub-tabs (`MainWindow.performance_pages`) — but only for roles that can produce data: the client sub-tab exists only with the viewer role, the server one only while actually serving (`_update_performance_tabs`, driven from the viewer toggle and `sharingChanged`; both off shows an enable-a-role hint). `PreferencesTab` takes the list of monitors and applies the history-window setting to all.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 1.8.0
3
+ Version: 1.9.1
4
4
  Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
5
5
  Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
6
6
  Project-URL: Repository, https://github.com/jamesabel/remotedesktop
@@ -77,6 +77,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
77
77
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
78
78
  - 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
79
79
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
80
+ - 📸 **Screen captures** — grab the remote screen at its full resolution and copy it to the clipboard or save it as a PNG, from the *Screen capture* panel buttons or the File menu.
80
81
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
81
82
  - 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
82
83
  - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
@@ -53,6 +53,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
53
53
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
54
54
  - 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
55
55
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
56
+ - 📸 **Screen captures** — grab the remote screen at its full resolution and copy it to the clipboard or save it as a PNG, from the *Screen capture* panel buttons or the File menu.
56
57
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
57
58
  - 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
58
59
  - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "1.8.0"
3
+ __version__ = "1.9.1"
@@ -12,16 +12,19 @@ tray menu. Only one instance runs per user session (`single_instance`)."""
12
12
 
13
13
  import json
14
14
  import logging
15
+ import re
15
16
  import sqlite3
16
17
  import sys
17
18
  import time
19
+ from pathlib import Path
18
20
 
19
- from PySide6.QtCore import QObject, QProcess, Qt, QTimer, Signal
20
- from PySide6.QtGui import QCloseEvent, QKeySequence
21
+ from PySide6.QtCore import QObject, QProcess, QStandardPaths, Qt, QTimer, Signal
22
+ from PySide6.QtGui import QCloseEvent, QImage, QKeySequence
21
23
  from PySide6.QtNetwork import QNetworkInterface
22
24
  from PySide6.QtWidgets import (
23
25
  QApplication,
24
26
  QDockWidget,
27
+ QFileDialog,
25
28
  QFrame,
26
29
  QGroupBox,
27
30
  QHBoxLayout,
@@ -314,11 +317,35 @@ class MainWindow(QMainWindow):
314
317
  # through, and the roles are changed only in Preferences.
315
318
  self.client_role_button = self._make_role_button()
316
319
  self.server_role_button = self._make_role_button()
320
+ # Screen capture of the current session tab (the last received frame
321
+ # at the server's full resolution). Buttons live in the dock; the
322
+ # same handlers back the File-menu actions. Enabled only while the
323
+ # current tab shows a frame (_update_capture_actions).
324
+ self.copy_capture_button = QPushButton("Copy")
325
+ self.copy_capture_button.setToolTip(
326
+ "Copy a screen capture of the current session to the clipboard"
327
+ )
328
+ self.copy_capture_button.clicked.connect(self._copy_screen_capture)
329
+ self.save_capture_button = QPushButton("Save…")
330
+ self.save_capture_button.setToolTip(
331
+ "Save a screen capture of the current session to a file"
332
+ )
333
+ self.save_capture_button.clicked.connect(self._save_screen_capture)
334
+ # Nothing to capture until a session tab shows a frame.
335
+ self.copy_capture_button.setEnabled(False)
336
+ self.save_capture_button.setEnabled(False)
337
+ self._capture_group = QGroupBox("Screen capture")
338
+ # Stacked, not side by side: the dock is narrow and its width is
339
+ # what the panel/server list gets — height is the cheaper dimension.
340
+ capture_layout = QVBoxLayout(self._capture_group)
341
+ capture_layout.addWidget(self.copy_capture_button)
342
+ capture_layout.addWidget(self.save_capture_button)
317
343
  dock_body = QWidget()
318
344
  self._dock_layout = QVBoxLayout(dock_body)
319
345
  self._dock_layout.addWidget(self.client_role_button)
320
346
  self._dock_layout.addWidget(self.server_role_button)
321
347
  self._dock_layout.addWidget(self.discovery_panel)
348
+ self._dock_layout.addWidget(self._capture_group)
322
349
  # A permanent trailing spacer: it takes the leftover height while
323
350
  # the discovery panel is hidden (keeping the indicators pinned to
324
351
  # the top), and collapses to nothing while the panel is visible
@@ -326,6 +353,7 @@ class MainWindow(QMainWindow):
326
353
  self._dock_layout.addStretch(0)
327
354
  if not self._viewer_enabled:
328
355
  self.discovery_panel.hide()
356
+ self._capture_group.hide()
329
357
  self._update_dock_layout()
330
358
  # No title bar at all: the contents are self-explanatory, and the
331
359
  # View menu ("Panel") is the way to show or hide it — a blank header
@@ -417,6 +445,15 @@ class MainWindow(QMainWindow):
417
445
  self.close_tab_action.triggered.connect(self._close_current_session_tab)
418
446
  self.close_tab_action.setEnabled(False) # startup tab is a fixed tab
419
447
  self._file_menu.addSeparator()
448
+ # Same handlers as the dock's Screen capture buttons; enabled state
449
+ # is kept in sync with them (_update_capture_actions).
450
+ self.copy_capture_action = self._file_menu.addAction("Copy screen &capture")
451
+ self.copy_capture_action.triggered.connect(self._copy_screen_capture)
452
+ self.copy_capture_action.setEnabled(False)
453
+ self.save_capture_action = self._file_menu.addAction("&Save screen capture…")
454
+ self.save_capture_action.triggered.connect(self._save_screen_capture)
455
+ self.save_capture_action.setEnabled(False)
456
+ self._file_menu.addSeparator()
420
457
  self.quit_action = self._file_menu.addAction("&Quit")
421
458
  self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
422
459
  self.quit_action.triggered.connect(self._quit)
@@ -537,7 +574,7 @@ class MainWindow(QMainWindow):
537
574
  """Give the dock's leftover height to the panel or the spacer."""
538
575
  panel_visible = self._viewer_enabled
539
576
  self._dock_layout.setStretch(2, 1 if panel_visible else 0) # panel
540
- self._dock_layout.setStretch(3, 0 if panel_visible else 1) # spacer
577
+ self._dock_layout.setStretch(4, 0 if panel_visible else 1) # spacer
541
578
 
542
579
  def _update_role_indicators(self) -> None:
543
580
  self.client_role_button.setChecked(self._viewer_enabled)
@@ -560,8 +597,10 @@ class MainWindow(QMainWindow):
560
597
  if placeholder_index != -1:
561
598
  self._tabs.removeTab(placeholder_index)
562
599
  self.discovery_panel.hide()
600
+ self._capture_group.hide()
563
601
  else:
564
602
  self.discovery_panel.show()
603
+ self._capture_group.show()
565
604
  self._ensure_placeholder()
566
605
  self._tabs.setCurrentIndex(0)
567
606
  if self._auto_scan:
@@ -581,6 +620,74 @@ class MainWindow(QMainWindow):
581
620
  self.fullscreen_action.setEnabled(session is not None)
582
621
  self.actual_size_action.setEnabled(session is not None)
583
622
  self.actual_size_action.setChecked(session.actual_size if session else False)
623
+ self._update_capture_actions()
624
+
625
+ # ------------------------------------------------------ screen capture
626
+
627
+ def _update_capture_actions(self) -> None:
628
+ """Capture is possible while the current tab shows a frame; the dock
629
+ buttons and the File-menu actions enable and disable together."""
630
+ session = self._session_for_page(self._tabs.currentWidget())
631
+ available = session is not None and session.viewer.has_frame
632
+ for action in (
633
+ self.copy_capture_button,
634
+ self.save_capture_button,
635
+ self.copy_capture_action,
636
+ self.save_capture_action,
637
+ ):
638
+ action.setEnabled(available)
639
+
640
+ def _current_capture(self) -> tuple[ServerSession, QImage] | None:
641
+ """The current tab's session and its full-resolution frame, or None
642
+ (the handlers can fire with no frame via QAction.trigger())."""
643
+ session = self._session_for_page(self._tabs.currentWidget())
644
+ if session is None:
645
+ return None
646
+ image = session.viewer.frame_image()
647
+ if image is None:
648
+ return None
649
+ return session, image
650
+
651
+ def _copy_screen_capture(self) -> None:
652
+ capture = self._current_capture()
653
+ if capture is None:
654
+ return
655
+ session, image = capture
656
+ # copy_image does not echo to peers: sending the server a capture of
657
+ # its own screen (or spamming viewers) would be pure waste.
658
+ self._clipboard.copy_image(image)
659
+ self.log(
660
+ f"Screen capture of {session.name} "
661
+ f"({image.width()}x{image.height()}) copied to the clipboard"
662
+ )
663
+
664
+ def _save_screen_capture(self) -> None:
665
+ capture = self._current_capture()
666
+ if capture is None:
667
+ return
668
+ session, image = capture
669
+ # Default: Pictures\<server> 2026-07-20 143059.png (the server name
670
+ # is a hostname, but sanitize for safety — it came off the wire).
671
+ name = re.sub(r'[<>:"/\\|?*]', "_", session.name).strip() or "server"
672
+ stamp = time.strftime("%Y-%m-%d %H%M%S")
673
+ directory = QStandardPaths.writableLocation(
674
+ QStandardPaths.StandardLocation.PicturesLocation
675
+ )
676
+ default = str(Path(directory) / f"{name} {stamp}.png") if directory else f"{name} {stamp}.png"
677
+ path, _filter = QFileDialog.getSaveFileName(
678
+ self, "Save screen capture", default, "PNG image (*.png)"
679
+ )
680
+ if not path:
681
+ return # cancelled
682
+ if not path.lower().endswith(".png"):
683
+ path += ".png"
684
+ if image.save(path, "PNG"): # ty: ignore[no-matching-overload]
685
+ self.log(
686
+ f"Screen capture of {session.name} "
687
+ f"({image.width()}x{image.height()}) saved to {path}"
688
+ )
689
+ else:
690
+ self.log(f"Could not save the screen capture to {path}")
584
691
 
585
692
  def _close_current_session_tab(self) -> None:
586
693
  session = self._session_for_page(self._tabs.currentWidget())
@@ -1070,6 +1177,7 @@ class MainWindow(QMainWindow):
1070
1177
  if not any(s.connected for s in self._sessions if s is not session):
1071
1178
  self.client_performance.reset()
1072
1179
  session.viewer.clear(f"Connecting to {session.name} …")
1180
+ self._update_capture_actions() # cleared viewer; tab may already be current
1073
1181
  self._tabs.setCurrentWidget(session.page)
1074
1182
  self._set_session_status(
1075
1183
  session, f"Connecting to {session.name} ({session.key}) …"
@@ -1111,6 +1219,7 @@ class MainWindow(QMainWindow):
1111
1219
  f"Waiting for approval — someone at {session.name} "
1112
1220
  "must allow this connection"
1113
1221
  )
1222
+ self._update_capture_actions()
1114
1223
  self._set_session_status(
1115
1224
  session,
1116
1225
  f"Waiting for the user on {session.name} to approve this computer …",
@@ -1178,6 +1287,7 @@ class MainWindow(QMainWindow):
1178
1287
  session.auto_reconnect = False
1179
1288
  self.client_inventory.record(session.key, "denied", name=session.name)
1180
1289
  session.viewer.clear(f"Connection denied: {reason}")
1290
+ self._update_capture_actions()
1181
1291
  self._set_session_status(session, f"Denied by {session.name}: {reason}")
1182
1292
  self._update_window_title()
1183
1293
 
@@ -1191,6 +1301,7 @@ class MainWindow(QMainWindow):
1191
1301
  self.client_inventory.record(session.key, "disconnected", name=session.name)
1192
1302
  session.viewer.clear("Disconnected")
1193
1303
  self._set_session_status(session, f"Disconnected from {session.name}")
1304
+ self._update_capture_actions() # a cleared viewer has nothing to capture
1194
1305
  self._update_window_title()
1195
1306
  self._sessions_source.notify()
1196
1307
  if session.auto_reconnect and not session.denied and not self._quitting:
@@ -1273,6 +1384,9 @@ class MainWindow(QMainWindow):
1273
1384
  return
1274
1385
  session.frame_count += 1
1275
1386
  session.viewer.show_frame(image)
1387
+ # Unconditional (not just the first frame): auto-reconnect refills a
1388
+ # cleared viewer without passing through _connect_session.
1389
+ self._update_capture_actions()
1276
1390
  self._set_session_status(
1277
1391
  session,
1278
1392
  f"Viewing {self._server_label(session)} — {image.width()}x{image.height()} — "
@@ -69,6 +69,25 @@ class ClipboardSync(QObject):
69
69
  ).decode()
70
70
  self.changed.emit(payload)
71
71
 
72
+ def copy_image(self, image: QImage) -> None:
73
+ """Place an app-generated image (a screen capture) on the local
74
+ clipboard WITHOUT echoing it to peers: its signature is recorded
75
+ first, so the asynchronous dataChanged this set fires is ignored —
76
+ the same mechanism apply() uses. Sending a capture of the server's
77
+ own screen back to it would be pure waste.
78
+
79
+ Deliberately not gated on `enabled`: that preference governs
80
+ syncing, and this is a local copy.
81
+ """
82
+ if image.isNull():
83
+ return
84
+ self._last_signature = (None, _image_hash(image))
85
+ self._applying = True
86
+ try:
87
+ self._clipboard.setImage(image)
88
+ finally:
89
+ self._applying = False
90
+
72
91
  def apply(self, payload: dict) -> None:
73
92
  if not self.enabled:
74
93
  return # peers may still send payloads; they are dropped locally
@@ -104,6 +104,12 @@ class ViewerWidget(QWidget):
104
104
  def has_frame(self) -> bool:
105
105
  return self._frame is not None
106
106
 
107
+ def frame_image(self) -> QImage | None:
108
+ """The most recent frame at the server's full resolution (what a
109
+ screen capture saves/copies), or None while nothing is shown. The
110
+ display-only lock overlay is never part of it."""
111
+ return self._frame.toImage() if self._frame is not None else None
112
+
107
113
  def set_actual_size(self, enabled: bool) -> None:
108
114
  """Toggle 1:1 device-pixel display (host provides the scrolling)."""
109
115
  if self._actual_size == enabled:
@@ -157,6 +157,30 @@ def test_apply_image_payload_sets_clipboard_image(qapp):
157
157
  sync.apply(payload)
158
158
 
159
159
 
160
+ def test_copy_image_sets_clipboard_without_echoing_to_peers(qapp):
161
+ clip = qapp.clipboard()
162
+ sync = ClipboardSync(clip)
163
+ emitted: list[dict] = []
164
+ sync.changed.connect(emitted.append)
165
+ sync.copy_image(_red_image())
166
+ for _ in range(10):
167
+ qapp.processEvents()
168
+ assert clip.image().pixelColor(0, 0).name() == "#ff0000"
169
+ # An app-generated copy (a screen capture) is never synced to peers.
170
+ assert emitted == []
171
+
172
+
173
+ def test_copy_image_works_with_sync_disabled(qapp):
174
+ # The Preferences toggle governs syncing; a local capture copy is not sync.
175
+ clip = qapp.clipboard()
176
+ sync = ClipboardSync(clip)
177
+ sync.enabled = False
178
+ blue = QImage(4, 4, QImage.Format.Format_RGB32)
179
+ blue.fill(Qt.GlobalColor.blue)
180
+ sync.copy_image(blue)
181
+ assert clip.image().pixelColor(0, 0).name() == "#0000ff"
182
+
183
+
160
184
  def test_apply_ignores_garbage_payloads(qapp):
161
185
  clip = qapp.clipboard()
162
186
  clip.setText("before")
@@ -4,6 +4,7 @@ import socket
4
4
  import sys
5
5
 
6
6
  from PySide6.QtCore import QProcess, Qt
7
+ from PySide6.QtGui import QImage
7
8
  from PySide6.QtWidgets import QApplication, QListWidgetItem, QMessageBox
8
9
 
9
10
  from remotedesktop import client as client_module
@@ -360,6 +361,117 @@ def test_forget_server_disconnects_and_forgets(qapp, credentials, tmp_path, monk
360
361
  server.close()
361
362
 
362
363
 
364
+ def _capture_widgets(window):
365
+ return (
366
+ window.copy_capture_button,
367
+ window.save_capture_button,
368
+ window.copy_capture_action,
369
+ window.save_capture_action,
370
+ )
371
+
372
+
373
+ def test_capture_buttons_and_actions_follow_frame_availability(qapp, credentials, tmp_path):
374
+ server = make_share_server(credentials, tmp_path)
375
+ window = make_window(tmp_path)
376
+ try:
377
+ assert not any(w.isEnabled() for w in _capture_widgets(window))
378
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
379
+ session = window._sessions[0]
380
+ pump(qapp, lambda: session.viewer.has_frame)
381
+ assert all(w.isEnabled() for w in _capture_widgets(window))
382
+ server.close() # dropping the connection clears the viewer again
383
+ pump(qapp, lambda: not session.viewer.has_frame)
384
+ assert not any(w.isEnabled() for w in _capture_widgets(window))
385
+ finally:
386
+ window.close()
387
+ server.close()
388
+
389
+
390
+ def test_capture_group_hidden_without_the_viewer_role(qapp, credentials, tmp_path):
391
+ window = make_window(tmp_path, credentials, serving=True, viewer=False)
392
+ try:
393
+ assert window._capture_group.isHidden()
394
+ finally:
395
+ window.close()
396
+
397
+
398
+ def test_copy_screen_capture_goes_through_clipboard_sync(qapp, credentials, tmp_path):
399
+ class RecordingSync:
400
+ def __init__(self):
401
+ self.images = []
402
+
403
+ def copy_image(self, image):
404
+ self.images.append(image)
405
+
406
+ server = make_share_server(credentials, tmp_path)
407
+ window = make_window(tmp_path)
408
+ try:
409
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
410
+ session = window._sessions[0]
411
+ pump(qapp, lambda: session.viewer.has_frame)
412
+ # A recording stand-in, so the test never touches the OS clipboard.
413
+ recorder = RecordingSync()
414
+ window._clipboard = recorder
415
+ window.copy_capture_button.click()
416
+ assert len(recorder.images) == 1
417
+ assert recorder.images[0].size() == session.viewer.frame_image().size()
418
+ assert "copied to the clipboard" in window.connection_log.toPlainText()
419
+ finally:
420
+ window.close()
421
+ server.close()
422
+
423
+
424
+ def test_save_screen_capture_writes_png(qapp, credentials, tmp_path, monkeypatch):
425
+ from PySide6.QtWidgets import QFileDialog
426
+
427
+ target = tmp_path / "capture.png"
428
+ dialogs = []
429
+ monkeypatch.setattr(
430
+ QFileDialog,
431
+ "getSaveFileName",
432
+ staticmethod(
433
+ lambda parent, title, default, filter: dialogs.append(default)
434
+ or (str(target), filter)
435
+ ),
436
+ )
437
+ server = make_share_server(credentials, tmp_path)
438
+ window = make_window(tmp_path)
439
+ try:
440
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
441
+ session = window._sessions[0]
442
+ pump(qapp, lambda: session.viewer.has_frame)
443
+ window.save_capture_action.trigger() # the menu path this time
444
+ assert target.exists()
445
+ saved = QImage(str(target))
446
+ assert saved.size() == session.viewer.frame_image().size()
447
+ # The server's reported name (its hostname, after the on-connect
448
+ # rename) seeds the default filename.
449
+ assert dialogs and session.name in dialogs[0]
450
+ assert f"saved to {target}" in window.connection_log.toPlainText()
451
+ finally:
452
+ window.close()
453
+ server.close()
454
+
455
+
456
+ def test_save_screen_capture_cancelled_dialog_saves_nothing(qapp, credentials, tmp_path, monkeypatch):
457
+ from PySide6.QtWidgets import QFileDialog
458
+
459
+ monkeypatch.setattr(
460
+ QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: ("", ""))
461
+ )
462
+ server = make_share_server(credentials, tmp_path)
463
+ window = make_window(tmp_path)
464
+ try:
465
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
466
+ session = window._sessions[0]
467
+ pump(qapp, lambda: session.viewer.has_frame)
468
+ window.save_capture_button.click()
469
+ assert "saved to" not in window.connection_log.toPlainText()
470
+ finally:
471
+ window.close()
472
+ server.close()
473
+
474
+
363
475
  def test_version_mismatch_warns_but_still_connects(qapp, credentials, tmp_path, monkeypatch):
364
476
  shown = []
365
477
  monkeypatch.setattr(QMessageBox, "show", lambda self: shown.append(self.windowTitle()))
@@ -1195,11 +1307,12 @@ def test_role_indicators_are_inert_buttons(qapp, credentials, tmp_path):
1195
1307
 
1196
1308
 
1197
1309
  def test_dock_keeps_indicators_at_the_top_in_both_roles(qapp, credentials, tmp_path):
1198
- # Layout indexes: 0/1 = indicator buttons, 2 = discovery panel, 3 = spacer.
1310
+ # Layout indexes: 0/1 = indicator buttons, 2 = discovery panel,
1311
+ # 3 = screen-capture group, 4 = spacer.
1199
1312
  window = make_window(tmp_path, credentials, serving=True, viewer=False)
1200
1313
  try:
1201
1314
  assert window._dock_layout.stretch(2) == 0 # hidden panel
1202
- assert window._dock_layout.stretch(3) == 1 # spacer pins buttons up
1315
+ assert window._dock_layout.stretch(4) == 1 # spacer pins buttons up
1203
1316
  window.show()
1204
1317
  qapp.processEvents()
1205
1318
  # The two indicators sit adjacent at the top, not spread apart.
@@ -1211,14 +1324,14 @@ def test_dock_keeps_indicators_at_the_top_in_both_roles(qapp, credentials, tmp_p
1211
1324
  window = make_window(tmp_path, credentials, db_name="viewer.db")
1212
1325
  try:
1213
1326
  assert window._dock_layout.stretch(2) == 1 # panel takes the height
1214
- assert window._dock_layout.stretch(3) == 0
1327
+ assert window._dock_layout.stretch(4) == 0
1215
1328
  # Live toggle off: the spacer takes over immediately (this was the
1216
1329
  # spaced-out bug — the spacer only existed for non-viewer startup).
1217
1330
  window.preferences_tab.viewer_checkbox.setChecked(False)
1218
- assert window._dock_layout.stretch(3) == 1
1331
+ assert window._dock_layout.stretch(4) == 1
1219
1332
  window.preferences_tab.viewer_checkbox.setChecked(True)
1220
1333
  assert window._dock_layout.stretch(2) == 1
1221
- assert window._dock_layout.stretch(3) == 0
1334
+ assert window._dock_layout.stretch(4) == 0
1222
1335
  finally:
1223
1336
  window.close()
1224
1337
 
File without changes
File without changes
File without changes
File without changes