remotedesktop 1.9.3__tar.gz → 2.1.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 (81) hide show
  1. remotedesktop-2.1.0/.claude/scheduled_tasks.lock +1 -0
  2. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/CLAUDE.md +12 -7
  3. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/PKG-INFO +15 -7
  4. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/pyproject.toml +7 -0
  5. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/readme.md +13 -6
  6. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/__init__.py +1 -1
  7. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/about.py +34 -4
  8. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/app.py +50 -14
  9. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/autostart.py +46 -40
  10. remotedesktop-2.1.0/src/remotedesktop/clipboard.py +326 -0
  11. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/frames.py +2 -4
  12. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/performance.py +8 -11
  13. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/preferences.py +76 -19
  14. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/protocol.py +2 -2
  15. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/server.py +26 -24
  16. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/sharing.py +39 -36
  17. remotedesktop-2.1.0/tests/test_about.py +42 -0
  18. remotedesktop-2.1.0/tests/test_autostart.py +99 -0
  19. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_clipboard.py +153 -1
  20. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_main_window.py +48 -5
  21. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_performance.py +2 -2
  22. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_preferences.py +44 -14
  23. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_sharing.py +0 -20
  24. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_sharing_tab.py +8 -9
  25. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tools/make_demo_gifs.py +2 -2
  26. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/uv.lock +11 -0
  27. remotedesktop-1.9.3/src/remotedesktop/clipboard.py +0 -120
  28. remotedesktop-1.9.3/tests/test_about.py +0 -32
  29. remotedesktop-1.9.3/tests/test_autostart.py +0 -113
  30. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/.github/workflows/ci.yml +0 -0
  31. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/.github/workflows/release.yml +0 -0
  32. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/.gitignore +0 -0
  33. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/LICENSE +0 -0
  34. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/docs/media/client-demo.gif +0 -0
  35. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/docs/media/server-demo.gif +0 -0
  36. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/icon/remotedesktop.ico +0 -0
  37. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/run.bat +0 -0
  38. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/run_claude.bat +0 -0
  39. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/scripts/make_coverage_badge.py +0 -0
  40. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/scripts/make_installer.bat +0 -0
  41. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/scripts/make_venv.bat +0 -0
  42. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/__main__.py +0 -0
  43. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/client.py +0 -0
  44. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/compat.py +0 -0
  45. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/config.py +0 -0
  46. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/cursor_shape.py +0 -0
  47. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/db.py +0 -0
  48. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/discovery.py +0 -0
  49. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/dxgi.py +0 -0
  50. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/icon.py +0 -0
  51. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/input_injection.py +0 -0
  52. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/inventory.py +0 -0
  53. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/logs.py +0 -0
  54. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/modal_loop.py +0 -0
  55. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/session_lock.py +0 -0
  56. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/single_instance.py +0 -0
  57. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/tls.py +0 -0
  58. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/viewer.py +0 -0
  59. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/visual_effects.py +0 -0
  60. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/src/remotedesktop/window_state.py +0 -0
  61. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/conftest.py +0 -0
  62. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_compat.py +0 -0
  63. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_config.py +0 -0
  64. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_cursor_shape.py +0 -0
  65. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_discovery.py +0 -0
  66. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_dxgi.py +0 -0
  67. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_frames.py +0 -0
  68. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_icon.py +0 -0
  69. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_input.py +0 -0
  70. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_input_injection.py +0 -0
  71. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_inventory.py +0 -0
  72. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_logs.py +0 -0
  73. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_modal_loop.py +0 -0
  74. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_protocol.py +0 -0
  75. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_session_lock.py +0 -0
  76. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_single_instance.py +0 -0
  77. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_smoke.py +0 -0
  78. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_tls.py +0 -0
  79. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_visual_effects.py +0 -0
  80. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tests/test_window_state.py +0 -0
  81. {remotedesktop-1.9.3 → remotedesktop-2.1.0}/tools/make_icon.py +0 -0
@@ -0,0 +1 @@
1
+ {"sessionId":"06786f54-7244-453c-8fd6-c0ad270c6bfc","pid":25604,"procStart":"639204255108546380","acquiredAt":1784854861122}
@@ -49,6 +49,11 @@ running instance holds the CLIP's DLLs locked. Do not set
49
49
  `[tool.pyship] run_on_startup` or signing keys in `pyproject.toml` (see the
50
50
  comments there).
51
51
 
52
+ **No backwards compatibility is required** (per the user): upgrades just
53
+ must not crash. Don't add migration shims, legacy-key/value handling, or
54
+ old-peer fallbacks for new work — old data may be dropped or reset, as long
55
+ as failure is graceful.
56
+
52
57
  ## Commands
53
58
 
54
59
  Managed with `uv` (hatchling build backend, src layout):
@@ -56,7 +61,7 @@ Managed with `uv` (hatchling build backend, src layout):
56
61
  - `uv sync` — create/update the venv with the project and dev dependencies
57
62
  - `uv run pytest` — run all tests
58
63
  - `uv run pytest tests/test_smoke.py::test_version` — run a single test
59
- - `uv run remotedesktop` — launch the app. It's a `gui-script`, so it runs detached with no console output; use `uv run python -m remotedesktop` when you need stdout/tracebacks. `--minimized` starts hidden in the tray when sharing is on (what the autostart registration passes).
64
+ - `uv run remotedesktop` — launch the app. It's a `gui-script`, so it runs detached with no console output; use `uv run python -m remotedesktop` when you need stdout/tracebacks. `--minimized` starts hidden in the tray when sharing is on, otherwise minimized to the taskbar (the default autostart registration passes it); `--maximized` starts maximized.
60
65
  - `uv build` — build sdist and wheel into `dist/`
61
66
  - `uv publish` — publish to PyPI
62
67
  - `scripts\make_installer.bat` — build the **signed Windows installer** with pyship
@@ -70,11 +75,11 @@ Managed with `uv` (hatchling build backend, src layout):
70
75
  ## Architecture
71
76
 
72
77
  - One PySide6 GUI app: `src/remotedesktop/app.py` (`MainWindow` + `main()`, wired to the `remotedesktop` GUI script in `pyproject.toml`; `__main__.py` makes `python -m remotedesktop` work — the in-app Restart relaunches that). `client.py` holds the viewing-role widgets (`DiscoveryPanel`, `ServerSession`), `server.py` the sharing-role widgets (`SharingTab`, `ViewersTable`) — neither has a window or entry point of its own anymore.
73
- - **The sharing lifecycle** (`server.py` `SharingTab`) lives on the "Connections" tab, arranged as one outer QGroupBox per role, side by side as widgets with "Connection log" full-width below: "Client (viewer)" (left; nests "Connected servers" — `app._SessionsTable`, the client-side twin of the viewers table fed by `_ConnectedServersSource` from the live sessions and `client_performance` — over "Server history", client-role inventory, Forget) and "Server (sharing)" (right; the `SharingTab` summary + viewers table over "Client history", server-role inventory, Revoke). **Each role box is visible only while its role is on** (`_update_connections_groups`); boxes are widgets (not column layouts) so a hidden role frees its space and the other starts at the left edge. **The mode itself is chosen in Preferences**: a three-state radio group (`preferences.py`, `sharingModeChanged` signal) wired by `MainWindow` to `SharingTab.set_mode("off"/"view"/"control")` — `server.py` exposes `SHARING_MODE_*` constants and `load_sharing_mode(settings)`, persisted as the two legacy keys `server_enabled` + `allow_remote_input` so old installs migrate for free. View ↔ control switches apply live (`ShareServer.set_input_allowed`) without dropping viewers; off/on tears down / creates the `ShareServer` + `DiscoveryResponder` (a fresh `ShareServer` per enable; `close()` leaves teardown state), so a viewing-only instance binds no ports and never creates TLS credentials (loaded lazily on first enable). The host window calls `restore_sharing()` after wiring `statusMessage`/`peerEvent`/`sharingChanged`, so no startup status message is emitted before anyone listens. `serving` is mode-on AND actually listening. `revoke_client` works even while not sharing (direct `PairedClients.revoke`). The Restart app button also lives in Preferences (`preferences_tab.restart_button` → `MainWindow._restart_app`).
74
- - **Tray + lifecycle** (`app.py`): while sharing, closing the window hides to a `QSystemTrayIcon` (menu: Show / Restart / Quit) and sharing continues; not sharing → close quits. `main()` sets `setQuitOnLastWindowClosed(False)`, so **every exit path must reach `QApplication.quit()`** (plain close, tray Quit, restart). `tray_available=` is injectable — tests always pass an explicit value and never call `isSystemTrayAvailable()`. `--minimized` (used by the autostart registration) starts hidden in the tray when sharing is on. `single_instance.py` (`SingleInstance`, QLocalServer-based) enforces one instance per user session; a second launch tells the first to `bring_to_front()` and exits. `autostart.py` registers `remotedesktop.exe --minimized` under HKCU Run value `"remotedesktop"` and migrates the pre-1.0 `"remotedesktop-server"` value at startup (`migrate_legacy`).
78
+ - **The sharing lifecycle** (`server.py` `SharingTab`) lives on the "Connections" tab, arranged as one outer QGroupBox per role, side by side as widgets with "Connection log" full-width below: "Client (viewer)" (left; nests "Connected servers" — `app._SessionsTable`, the client-side twin of the viewers table fed by `_ConnectedServersSource` from the live sessions and `client_performance` — over "Server history", client-role inventory, Forget) and "Server (sharing)" (right; the `SharingTab` summary + viewers table over "Client history", server-role inventory, Revoke). **Each role box is visible only while its role is on** (`_update_connections_groups`); boxes are widgets (not column layouts) so a hidden role frees its space and the other starts at the left edge. **The mode itself is chosen in Preferences**: a three-state radio group (`preferences.py`, `sharingModeChanged` signal) wired by `MainWindow` to `SharingTab.set_mode("off"/"view"/"control")` — `server.py` exposes `SHARING_MODE_*` constants and `load_sharing_mode(settings)`, persisted as the single `sharing_mode` key. View ↔ control switches apply live (`ShareServer.set_input_allowed`) without dropping viewers; off/on tears down / creates the `ShareServer` + `DiscoveryResponder` (a fresh `ShareServer` per enable; `close()` leaves teardown state), so a viewing-only instance binds no ports and never creates TLS credentials (loaded lazily on first enable). The host window calls `restore_sharing()` after wiring `statusMessage`/`peerEvent`/`sharingChanged`, so no startup status message is emitted before anyone listens. `serving` is mode-on AND actually listening. `revoke_client` works even while not sharing (direct `PairedClients.revoke`). The Restart app button also lives in Preferences (`preferences_tab.restart_button` → `MainWindow._restart_app`).
79
+ - **Tray + lifecycle** (`app.py`): while sharing, closing the window hides to a `QSystemTrayIcon` (menu: Show / Restart / Quit) and sharing continues; not sharing → close quits. `main()` sets `setQuitOnLastWindowClosed(False)`, so **every exit path must reach `QApplication.quit()`** (plain close, tray Quit, restart). `tray_available=` is injectable — tests always pass an explicit value and never call `isSystemTrayAvailable()`. `--minimized` starts hidden in the tray when sharing is on, otherwise minimized; `--maximized` starts maximized. `single_instance.py` (`SingleInstance`, QLocalServer-based) enforces one instance per user session; a second launch tells the first to `bring_to_front()` and exits. **Start at login is a four-way start mode** (`autostart.py` `START_MINIMIZED/NORMAL/MAXIMIZED/OFF`): Preferences radios persist it as `autostart_mode` (`load_autostart_mode`, default **minimized** — the recommended setting) and `PreferencesTab` mirrors it into the HKCU Run value `"remotedesktop"` at every construction, so the start-minimized default takes effect on a fresh install and the registered path follows the current installation. The mode is encoded in the registered command's flag (`--minimized` / none / `--maximized`; no value = off, `Autostart.mode()` reads it back). Tests always inject `Autostart(key_path=...)` pointed at an isolated test key — a real `Autostart` in a test would rewrite the developer's actual Run key at tab construction.
75
80
  - The remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window. **Multiple simultaneous server connections**: each is a `ServerSession` in `client.py` (its own `ShareClient` + `ViewerWidget`, hosted in a `QScrollArea` tab page — `session.page` is the tab widget, `session.viewer` the viewer), shown as a closable tab at the front of `MainWindow`'s central `QTabWidget`, titled with the server's reported hostname (fixed tabs get their close buttons stripped). Fit mode is `widgetResizable(True)`; **View → Actual size** flips the page to non-resizable and the viewer sizes itself to `frame / devicePixelRatio` (1:1 device pixels, the no-resample paint branch; input mapping unchanged because the frame fills the widget). Sessions are keyed by `host:port`; activating an already-known server reconnects in its existing tab, activating a connected one just focuses it. The window title lists the connected server names and a "— sharing" suffix while serving (`_update_window_title`); the status bar's message area follows the currently selected session tab (`status_text` per session) and a permanent right-side label shows "Sharing — N viewer(s)" whenever sharing is on (driven by `SharingTab.viewerCountChanged`). The `ClipboardSync`, inventories, and Connection log are shared across sessions; `client_performance.reset()` is only called when no *other* session is connected (resetting detaches every stream). Per-session status log lines are prefixed `[name]`.
76
81
  - **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
- - **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).
82
+ - **Menus & shortcuts contract** (`app.py` `_build_menus`, `viewer.py`): File/View/Help menu bar; shortcuts Ctrl+W (close the current session tab or the Preferences tab), Ctrl+, (Preferences), Ctrl+Q (quit), F5 (rescan), F11 (fullscreen). **Preferences and About are not pinned tabs**: File ▸ Preferences… shows `preferences_tab` as an on-demand *closable* tab (the widget is built once at startup — its constructor and signal wiring drive the roles — and `removeTab` on close keeps it alive for the next open); Help ▸ About shows the single modeless `about.AboutDialog` instance (`_about_dialog`, wrapping `AboutPage`). 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
83
  - **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
84
  - **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).
80
85
  - **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).
@@ -82,10 +87,10 @@ Managed with `uv` (hatchling build backend, src layout):
82
87
  - **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.
83
88
  - **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).
84
89
  - 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.
85
- - **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
90
+ - **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). Every client is assumed delta-capable (no negotiation). Each variant (keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
86
91
  - **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. **Boolean settings go through `Settings.get_bool`/`set_bool`** (the `tobool` package's `to_bool_strict`; on-disk form stays the historical `"1"`/`"0"`, junk falls back to the caller's default) — don't hand-roll `!= "0"` comparisons. This applies to binary values stored as strings/ints; ctypes Win32 `BOOL`s keep plain `bool()` (TRUE is "any nonzero", which strict parsing correctly refuses). `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
87
92
  - **Trust model plumbing** (`config.py`): the client has a stable UUID identity (`load_client_identity`, in the `settings` table) and the server maps each approved client-id to a token (`PairedClients`). `ShareServer` takes an `approve_client(client_id, name) -> bool` callback; `SharingTab` implements it as a modal stay-on-top QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
88
- - **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, `SharingTab`, and `DiscoveryPanel` emit human-readable `status`/`statusMessage` signals for every connection phase, shown in the window's timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the pane shows is also written to a rotating debug log file (`logs.py`; `logs.init_logging("remotedesktop")` is called only by `app.main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\remotedesktop.log` on Windows; pre-1.0 `client.log`/`server.log` files just age out). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
93
+ - **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, `SharingTab`, and `DiscoveryPanel` emit human-readable `status`/`statusMessage` signals for every connection phase, shown in the window's timestamped "Connection log" pane. **Byte counts in any user-facing string go through `humanize.naturalsize(n, binary=True)`** ("2.0 KiB", "1.3 MiB") — never hand-rolled `// 1024` math (the `humanize` package is a dependency; `performance.format_rate` wraps it for rates). When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the pane shows is also written to a rotating debug log file (`logs.py`; `logs.init_logging("remotedesktop")` is called only by `app.main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\remotedesktop.log` on Windows; pre-1.0 `client.log`/`server.log` files just age out). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
89
94
  - **Connection inventory** (`inventory.py`): the Connections tab has two grouped tables ("Client history" for the sharing role, "Server history" for the viewing role), each backed by its own `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The sharing side is driven by `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases), forwarded through `SharingTab.peerEvent`. The viewing side is driven by `MainWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
90
95
  - **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).
91
96
  - 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.
@@ -97,7 +102,7 @@ Managed with `uv` (hatchling build backend, src layout):
97
102
  - **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.
98
103
  - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
99
104
  - **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings **every** attached stream (each ping id maps to its stream; a pong is honored only on the stream it was sent to). The graphs' RTT series follow only the *active* (most recently admitted) stream; per-stream send/recv/RTT snapshots come from `metrics_for(stream)` and feed the Server tab's `ViewersTable` (`server.py`; it takes the monitor and follows the current ShareServer via `set_share_server`, empty when None), which joins them with `ShareServer.viewers()` — identity fields the client hello reports (`_client_details()`; `user`/`os` are displayed — `host` is still on the wire but not shown, since the display name defaults to the hostname; missing on old clients, shown as dashes). The ping is: `{"type": "ping", "id", "rtt"}` / `{"type": "pong", "id"}` — each side measures its own RTT and piggybacks it on its next ping so both ends graph both directions; peers without the feature ignore pings harmlessly (RTT graphs show "no data"). Ping/pong is admitted-streams-only on the server, like input/clipboard. The "Performance" tab (`PerformanceTab`, custom QPainter graphs, one per role sub-tab) schedules **no paint work while hidden** (gated on `isVisible()`, repaint on `showEvent`); the "Preferences" tab's history-window setting persists via the `Settings` store (`performance_window_seconds`, default 120). The monitor's timer runs only while streams are attached; all timing is `time.monotonic()` with an injectable `clock=` for tests — never monkeypatch `time.monotonic` (the test `pump()` helper depends on it).
100
- - **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy → server and server copy → all clients, as `{"type": "clipboard", "text"/"image_png"}` messages. It's opt-in — `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUI (and clipboard tests) enables it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** The app shares ONE `ClipboardSync` across both roles (one OS clipboard): a real local copy fans out to connected servers and own viewers; a peer-received payload records its signature before applying, so it never re-emits `changed` and cannot loop between the roles. Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
105
+ - **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy → server and server copy → all clients, as `{"type": "clipboard", "text"/"image_png"/"files"}` messages. **Files sync by content**: a local file copy (Qt local-file URLs) ships `files: [{name, data-b64}, ...]` capped at `FILES_CAP_BYTES` (32 MB — the batch is one JSON message under the 64 MB protocol cap, sharing the stream's socket) with folders skipped; `apply` sanitizes names to their final component, writes a fresh batch dir under `default_files_dir()` (`%LOCALAPPDATA%\remotedesktop\clipboard`, injectable `files_dir=` for tests), purges older batches best-effort, and puts the written paths on the clipboard as URLs (CF_HDROP → Explorer paste works). When files are present they travel alone — text/image on the same clipboard entry is ignored on both ends. `ClipboardSync.status` feeds the Connection log with **problems only** (skipped folders, over-cap, disk errors); routine traffic is described by the transport's own status lines via `clipboard.describe_payload` (kinds + sizes, never content — e.g. "Sending clipboard to 1 viewer(s): 2 file(s) (1.3 MiB)"), so one copy produces one log line per direction. It's opt-in — `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUI (and clipboard tests) enables it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** The app shares ONE `ClipboardSync` across both roles (one OS clipboard): a real local copy fans out to connected servers and own viewers; a peer-received payload records its signature before applying, so it never re-emits `changed` and cannot loop between the roles. Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop, and file signatures hash names + contents (never paths) so the receiving side's temp copies don't loop either. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
101
106
  - The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place. **Versioning is strict semver** (`compat.py`, using the `semver` package): major = client/server compatibility contract, minor = backward-compatible features, patch = bug fixes — bump the major when the wire protocol or behavior breaks older peers. Both sides compare majors on every connection (hello/welcome carry `app_version`) and show a strong warning on mismatch (server status log + ⚠ in the viewers table's Version column; client log + non-modal dialog + status-bar marker) but never block the connection. Peers too old to report a version produce no warning.
102
107
  - Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
103
108
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 1.9.3
3
+ Version: 2.1.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
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Topic :: System :: Networking
17
17
  Requires-Python: >=3.14
18
18
  Requires-Dist: cryptography>=49.0.0
19
+ Requires-Dist: humanize>=4.0
19
20
  Requires-Dist: platformdirs>=4.10.0
20
21
  Requires-Dist: pyside6>=6.11.1
21
22
  Requires-Dist: semver>=3.0
@@ -78,13 +79,13 @@ one-click *Forget* / *Revoke*, and the live connection log.
78
79
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
79
80
  - 🖲️ **Cursor shape mirroring** — the pointer is never burned into the captured frame; instead your own cursor takes the remote cursor's shape (an I-beam over text, resize arrows on a window edge), so it is always crisp and lag-free.
80
81
  - 🖼️ **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.
81
- - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
82
+ - 📋 **Two-way clipboard** — text, images, and files copied on either machine appear on the other (files up to 32 MB per copy, pasted as real local copies; folders aren't synced); a Preferences toggle turns syncing off entirely.
82
83
  - 📸 **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.
83
84
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
84
85
  - 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
85
86
  - 📊 **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.
86
87
  - 🔁 **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.
87
- - 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights), close-to-tray while sharing (the screen stays available with the window closed), and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
88
+ - 🚀 **Hands-off operation** — start-at-login (per-user, no admin rights) with a choice of minimized (the default and recommended — sharing resumes after a reboot with no clicks), normal, maximized, or not starting at all; close-to-tray while sharing (the screen stays available with the window closed); and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
88
89
  - 🗃️ **Persistent peer inventory** — a SQLite-backed history of every peer seen on the LAN, with one-click *Revoke* / *Forget*.
89
90
  - 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts, a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
90
91
 
@@ -209,10 +210,17 @@ still held down (a dragged button, a modifier key) is released
209
210
  automatically if the viewer disconnects or loses focus.
210
211
 
211
212
  **Clipboard.** Both sides watch their local clipboard via Qt and forward
212
- copies (text, or images as PNG) over the same connection. Echo loops are
213
- prevented by content signaturean image is hashed by its canonical
214
- pixels, so a PNG that makes a round trip through the OS clipboard and
215
- comes back re-encoded is still recognized and not sent again.
213
+ copies (text, images as PNG, or files) over the same connection. A file
214
+ copy ships the file *contents* a path would be meaningless on another
215
+ machine capped at 32 MB per copy so a transfer never chokes the screen
216
+ stream; the receiving side materializes the files in a scratch folder and
217
+ puts them on its clipboard, so pasting in Explorer produces real local
218
+ copies (only the newest received batch is kept on disk; folders are
219
+ skipped). Echo loops are prevented by content signature — an image is
220
+ hashed by its canonical pixels and files by their names and contents (not
221
+ their paths), so content that makes a round trip through the OS clipboard
222
+ and comes back re-encoded or re-homed is still recognized and not sent
223
+ again.
216
224
 
217
225
  **Transport.** Each client talks to the server over a single TCP
218
226
  connection: TLS via the Windows schannel backend with a self-signed
@@ -16,6 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "cryptography>=49.0.0",
19
+ "humanize>=4.0",
19
20
  "platformdirs>=4.10.0",
20
21
  "pyside6>=6.11.1",
21
22
  "semver>=3.0",
@@ -60,6 +61,12 @@ upload = false
60
61
  # run_on_startup deliberately NOT set: it would write an HKLM Run value named
61
62
  # "remotedesktop", colliding with the app's own HKCU autostart preference.
62
63
 
64
+ [tool.ruff.lint]
65
+ # CI runs the latest ruff (uvx). Ruff 0.16 expanded its default rule set
66
+ # (I, UP, RUF, S, BLE, ...), which the codebase was never linted against —
67
+ # pin the selection to the historical defaults so unrelated PRs stay green.
68
+ select = ["E4", "E7", "E9", "F"]
69
+
63
70
  [tool.ty.environment]
64
71
  # The app targets Windows (SendInput via ctypes.windll); type-check as such
65
72
  # even when ty runs on a Linux CI runner.
@@ -54,13 +54,13 @@ one-click *Forget* / *Revoke*, and the live connection log.
54
54
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
55
55
  - 🖲️ **Cursor shape mirroring** — the pointer is never burned into the captured frame; instead your own cursor takes the remote cursor's shape (an I-beam over text, resize arrows on a window edge), so it is always crisp and lag-free.
56
56
  - 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
57
- - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
57
+ - 📋 **Two-way clipboard** — text, images, and files copied on either machine appear on the other (files up to 32 MB per copy, pasted as real local copies; folders aren't synced); a Preferences toggle turns syncing off entirely.
58
58
  - 📸 **Screen captures** — grab the remote screen at its full resolution and copy it to the clipboard or save it as a PNG, from the *Screen capture* panel buttons or the File menu.
59
59
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
60
60
  - 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
61
61
  - 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
62
62
  - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
63
- - 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights), close-to-tray while sharing (the screen stays available with the window closed), and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
63
+ - 🚀 **Hands-off operation** — start-at-login (per-user, no admin rights) with a choice of minimized (the default and recommended — sharing resumes after a reboot with no clicks), normal, maximized, or not starting at all; close-to-tray while sharing (the screen stays available with the window closed); and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
64
64
  - 🗃️ **Persistent peer inventory** — a SQLite-backed history of every peer seen on the LAN, with one-click *Revoke* / *Forget*.
65
65
  - 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts, a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
66
66
 
@@ -185,10 +185,17 @@ still held down (a dragged button, a modifier key) is released
185
185
  automatically if the viewer disconnects or loses focus.
186
186
 
187
187
  **Clipboard.** Both sides watch their local clipboard via Qt and forward
188
- copies (text, or images as PNG) over the same connection. Echo loops are
189
- prevented by content signaturean image is hashed by its canonical
190
- pixels, so a PNG that makes a round trip through the OS clipboard and
191
- comes back re-encoded is still recognized and not sent again.
188
+ copies (text, images as PNG, or files) over the same connection. A file
189
+ copy ships the file *contents* a path would be meaningless on another
190
+ machine capped at 32 MB per copy so a transfer never chokes the screen
191
+ stream; the receiving side materializes the files in a scratch folder and
192
+ puts them on its clipboard, so pasting in Explorer produces real local
193
+ copies (only the newest received batch is kept on disk; folders are
194
+ skipped). Echo loops are prevented by content signature — an image is
195
+ hashed by its canonical pixels and files by their names and contents (not
196
+ their paths), so content that makes a round trip through the OS clipboard
197
+ and comes back re-encoded or re-homed is still recognized and not sent
198
+ again.
192
199
 
193
200
  **Transport.** Each client talks to the server over a single TCP
194
201
  connection: TLS via the Windows schannel backend with a self-signed
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "1.9.3"
3
+ __version__ = "2.1.0"
@@ -1,15 +1,21 @@
1
- """The "About" tab of the app window.
1
+ """The "About" dialog (Help About).
2
2
 
3
3
  Shows the same metadata the PyPI page shows — summary, author, license,
4
4
  Python requirement, links — read from the installed package's metadata via
5
- importlib.metadata, with static fallbacks so the tab still renders in odd
5
+ importlib.metadata, with static fallbacks so the page still renders in odd
6
6
  environments (the version always comes from `remotedesktop.__version__`,
7
7
  the single source hatchling packages)."""
8
8
 
9
9
  from importlib import metadata
10
10
 
11
11
  from PySide6.QtCore import Qt
12
- from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
12
+ from PySide6.QtWidgets import (
13
+ QDialog,
14
+ QDialogButtonBox,
15
+ QLabel,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
13
19
 
14
20
  from remotedesktop import __version__, icon
15
21
 
@@ -36,7 +42,7 @@ def _field(name: str) -> str:
36
42
  return str(value) if value else _FALLBACKS.get(name, "")
37
43
 
38
44
 
39
- class AboutTab(QWidget):
45
+ class AboutPage(QWidget):
40
46
  """Package metadata as a rich-text page with clickable links."""
41
47
 
42
48
  def __init__(self, parent: QWidget | None = None) -> None:
@@ -70,3 +76,27 @@ class AboutTab(QWidget):
70
76
  layout.addWidget(logo)
71
77
  layout.addWidget(body)
72
78
  layout.addStretch(1)
79
+
80
+
81
+ class AboutDialog(QDialog):
82
+ """Help ▸ About: the AboutPage in a modeless dialog with a Close button.
83
+
84
+ Modeless on purpose — like the rest of the app it stays usable from a
85
+ remote session, and it must never block streaming or approval prompts.
86
+ The window keeps one instance and re-shows it (`open_`).
87
+ """
88
+
89
+ def __init__(self, parent: QWidget | None = None) -> None:
90
+ super().__init__(parent)
91
+ self.setWindowTitle("About Remote Desktop")
92
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
93
+ buttons.rejected.connect(self.close)
94
+ layout = QVBoxLayout(self)
95
+ layout.addWidget(AboutPage(self))
96
+ layout.addWidget(buttons)
97
+
98
+ def open_(self) -> None:
99
+ """Show (or re-raise) the dialog, modeless."""
100
+ self.show()
101
+ self.raise_()
102
+ self.activateWindow()
@@ -43,7 +43,7 @@ from PySide6.QtWidgets import (
43
43
  )
44
44
 
45
45
  from remotedesktop import __version__, compat, db, icon, logs, window_state
46
- from remotedesktop.about import AboutTab
46
+ from remotedesktop.about import AboutDialog
47
47
  from remotedesktop.autostart import Autostart, installed_launcher
48
48
  from remotedesktop.client import DiscoveryPanel, ServerSession
49
49
  from remotedesktop.clipboard import ClipboardSync
@@ -162,6 +162,7 @@ class MainWindow(QMainWindow):
162
162
  # persisted Preferences opt-out applies from the first moment.
163
163
  self._clipboard = ClipboardSync(parent=self)
164
164
  self._clipboard.enabled = load_clipboard_sync_enabled(self._settings)
165
+ self._clipboard.status.connect(self.log)
165
166
  self._known_servers = KnownServers(self._db)
166
167
  self._identity = load_client_identity(self._db)
167
168
  self._tray_available = (
@@ -381,11 +382,14 @@ class MainWindow(QMainWindow):
381
382
  self.preferences_tab.viewerModeChanged.connect(self._set_viewer_enabled)
382
383
  self.preferences_tab.reduceEffectsChanged.connect(self._on_reduce_effects_changed)
383
384
  self.preferences_tab.restart_button.clicked.connect(self._restart_app)
384
- self._tabs.addTab(self.preferences_tab, "Preferences")
385
- self._about_tab = AboutTab()
386
- self._tabs.addTab(self._about_tab, "About")
387
- # Only session tabs are closable; strip the buttons the fixed tabs
388
- # got from setTabsClosable (styles place them on either side).
385
+ # Preferences is an on-demand closable tab (File Preferences):
386
+ # the widget exists from startup — its constructor and signal wiring
387
+ # drive the roles — but it only occupies the tab bar while open.
388
+ # About is a Help-menu dialog; one instance, re-shown.
389
+ self._about_dialog = AboutDialog(self)
390
+ # Only session tabs (and the on-demand Preferences tab) are closable;
391
+ # strip the buttons the fixed tabs got from setTabsClosable (styles
392
+ # place them on either side).
389
393
  bar = self._tabs.tabBar()
390
394
  for index in range(self._tabs.count()):
391
395
  for side in (QTabBar.ButtonPosition.LeftSide, QTabBar.ButtonPosition.RightSide):
@@ -454,6 +458,10 @@ class MainWindow(QMainWindow):
454
458
  self.save_capture_action.triggered.connect(self._save_screen_capture)
455
459
  self.save_capture_action.setEnabled(False)
456
460
  self._file_menu.addSeparator()
461
+ self.preferences_action = self._file_menu.addAction("&Preferences…")
462
+ self.preferences_action.setShortcut(QKeySequence("Ctrl+,"))
463
+ self.preferences_action.triggered.connect(self._open_preferences)
464
+ self._file_menu.addSeparator()
457
465
  self.quit_action = self._file_menu.addAction("&Quit")
458
466
  self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
459
467
  self.quit_action.triggered.connect(self._quit)
@@ -480,9 +488,7 @@ class MainWindow(QMainWindow):
480
488
  self.fullscreen_action.triggered.connect(self._toggle_fullscreen)
481
489
  self._help_menu = bar.addMenu("&Help")
482
490
  self.about_action = self._help_menu.addAction("&About")
483
- self.about_action.triggered.connect(
484
- lambda: self._tabs.setCurrentWidget(self._about_tab)
485
- )
491
+ self.about_action.triggered.connect(self._about_dialog.open_)
486
492
  # Register shortcut actions on the window itself so they keep firing
487
493
  # when the menu bar is hidden (fullscreen). While a viewer has a
488
494
  # frame and focus, its ShortcutOverride handling forwards these keys
@@ -492,6 +498,7 @@ class MainWindow(QMainWindow):
492
498
  self.quit_action,
493
499
  self.refresh_action,
494
500
  self.fullscreen_action,
501
+ self.preferences_action,
495
502
  ):
496
503
  self.addAction(action)
497
504
 
@@ -506,7 +513,8 @@ class MainWindow(QMainWindow):
506
513
  "<p>Double-click one — or select it and click <b>Connect</b> — "
507
514
  "and this tab becomes your view of that computer.</p>"
508
515
  "<p>To make a computer appear in the list, enable "
509
- "<b>Server (sharing)</b> in the Preferences tab on that computer.</p>"
516
+ "<b>Server (sharing)</b> under File&nbsp;▸&nbsp;Preferences on "
517
+ "that computer.</p>"
510
518
  )
511
519
  label.setAlignment(Qt.AlignmentFlag.AlignCenter)
512
520
  label.setWordWrap(True)
@@ -616,7 +624,9 @@ class MainWindow(QMainWindow):
616
624
  if getattr(self, "close_tab_action", None) is None:
617
625
  return # menus are built after the tabs
618
626
  session = self._session_for_page(self._tabs.currentWidget())
619
- self.close_tab_action.setEnabled(session is not None)
627
+ self.close_tab_action.setEnabled(
628
+ session is not None or self._tabs.currentWidget() is self.preferences_tab
629
+ )
620
630
  self.fullscreen_action.setEnabled(session is not None)
621
631
  self.actual_size_action.setEnabled(session is not None)
622
632
  self.actual_size_action.setChecked(session.actual_size if session else False)
@@ -689,8 +699,25 @@ class MainWindow(QMainWindow):
689
699
  else:
690
700
  self.log(f"Could not save the screen capture to {path}")
691
701
 
702
+ def _open_preferences(self) -> None:
703
+ """File ▸ Preferences…: show the on-demand closable Preferences tab."""
704
+ if self._tabs.indexOf(self.preferences_tab) == -1:
705
+ self._tabs.addTab(self.preferences_tab, "Preferences")
706
+ self._tabs.setCurrentWidget(self.preferences_tab)
707
+
708
+ def _close_preferences_tab(self) -> None:
709
+ # removeTab never deletes the widget; the window keeps the reference
710
+ # (and all its signal wiring) for the next _open_preferences.
711
+ index = self._tabs.indexOf(self.preferences_tab)
712
+ if index != -1:
713
+ self._tabs.removeTab(index)
714
+
692
715
  def _close_current_session_tab(self) -> None:
693
- session = self._session_for_page(self._tabs.currentWidget())
716
+ current = self._tabs.currentWidget()
717
+ if current is self.preferences_tab:
718
+ self._close_preferences_tab()
719
+ return
720
+ session = self._session_for_page(current)
694
721
  if session is not None:
695
722
  self._close_session(session)
696
723
 
@@ -1209,7 +1236,9 @@ class MainWindow(QMainWindow):
1209
1236
  self._sessions_source.notify()
1210
1237
 
1211
1238
  def _on_tab_close_requested(self, index: int) -> None:
1212
- if index < len(self._sessions): # fixed tabs carry no close button
1239
+ if self._tabs.widget(index) is self.preferences_tab:
1240
+ self._close_preferences_tab()
1241
+ elif index < len(self._sessions): # fixed tabs carry no close button
1213
1242
  self._close_session(self._sessions[index])
1214
1243
 
1215
1244
  def _on_approval_pending(self, session: ServerSession) -> None:
@@ -1395,7 +1424,10 @@ class MainWindow(QMainWindow):
1395
1424
 
1396
1425
 
1397
1426
  def main() -> None: # pragma: no cover - runs the Qt event loop
1427
+ # The autostart registration passes one of these per the start mode
1428
+ # chosen in Preferences (no flag means a normal window).
1398
1429
  minimized = "--minimized" in sys.argv[1:]
1430
+ maximized = "--maximized" in sys.argv[1:]
1399
1431
  log_path = logs.init_logging("remotedesktop")
1400
1432
  icon.set_windows_app_id("remotedesktop")
1401
1433
  app = QApplication(sys.argv)
@@ -1407,13 +1439,17 @@ def main() -> None: # pragma: no cover - runs the Qt event loop
1407
1439
  if not guard.acquire():
1408
1440
  # The running instance was asked to show itself; nothing to do here.
1409
1441
  raise SystemExit(0)
1410
- Autostart().migrate_legacy() # pre-1.0 server-only registration
1411
1442
  window = MainWindow()
1412
1443
  guard.activateRequested.connect(window.bring_to_front)
1413
1444
  window.log(f"Detailed log: {log_path}")
1414
1445
  if minimized and window.sharing_tab.serving and window._tray is not None:
1415
1446
  # Login-started while sharing: live in the tray until summoned.
1416
1447
  window._tray_notified = True # no balloon for a start nobody clicked
1448
+ elif minimized:
1449
+ # Not sharing (or no tray): a minimized window on the taskbar.
1450
+ window.showMinimized()
1451
+ elif maximized:
1452
+ window.showMaximized()
1417
1453
  else:
1418
1454
  window.show()
1419
1455
  raise SystemExit(app.exec())
@@ -2,13 +2,13 @@
2
2
 
3
3
  Uses the per-user Run registry key (HKCU), so no administrator rights are
4
4
  needed and the app starts in the interactive session — which it needs,
5
- because approving a new client is a GUI prompt. Login-started instances get
6
- `--minimized`, so an instance that is sharing goes straight to the tray.
7
- On non-Windows platforms this is an inert stub, like input injection.
8
-
9
- Installations upgraded from the separate server app may still carry the old
10
- "remotedesktop-server" Run value; `migrate_legacy()` (called once at app
11
- startup) moves that registration to the new value name and command.
5
+ because approving a new client is a GUI prompt. The registration is one of
6
+ four *start modes*: minimized (the default and recommended a sharing
7
+ instance goes straight to the tray), a normal window, maximized, or off
8
+ (no registration). The mode is encoded in the registered command line
9
+ (`--minimized`, no flag, `--maximized`), so the Run value alone says what
10
+ happens at login. On non-Windows platforms this is an inert stub, like
11
+ input injection.
12
12
  """
13
13
 
14
14
  import re
@@ -21,7 +21,20 @@ if _IS_WINDOWS:
21
21
 
22
22
  _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
23
23
  _VALUE_NAME = "remotedesktop"
24
- _LEGACY_VALUE_NAME = "remotedesktop-server" # the pre-1.0 server-only app
24
+
25
+ # How (and whether) the app starts at login.
26
+ START_MINIMIZED = "minimized" # the default and recommended mode
27
+ START_NORMAL = "normal"
28
+ START_MAXIMIZED = "maximized"
29
+ START_OFF = "off"
30
+ START_MODES = (START_MINIMIZED, START_NORMAL, START_MAXIMIZED, START_OFF)
31
+
32
+ # The command-line suffix that makes a login-started instance open that way.
33
+ _MODE_FLAGS = {
34
+ START_MINIMIZED: " --minimized",
35
+ START_NORMAL: "",
36
+ START_MAXIMIZED: " --maximized",
37
+ }
25
38
 
26
39
  # A pyship CLIP directory: <install dir>\remotedesktop_<version>\pythonw.exe.
27
40
  _CLIP_DIR_RE = re.compile(r"remotedesktop_\d+(\.\d+)*", re.IGNORECASE)
@@ -42,16 +55,17 @@ def installed_launcher() -> Path | None:
42
55
  return None
43
56
 
44
57
 
45
- def app_command() -> str:
58
+ def app_command(mode: str = START_MINIMIZED) -> str:
46
59
  """The command line that launches this installation at login."""
60
+ flag = _MODE_FLAGS[mode]
47
61
  launcher = installed_launcher()
48
62
  if launcher is not None:
49
- return f'"{launcher}" --minimized'
63
+ return f'"{launcher}"{flag}'
50
64
  exe = Path(sys.executable).with_name("remotedesktop.exe") # venv Scripts dir
51
65
  if exe.exists():
52
- return f'"{exe}" --minimized'
66
+ return f'"{exe}"{flag}'
53
67
  # Fallback (e.g. running from source without the entry-point exe).
54
- return f'"{sys.executable}" -m remotedesktop --minimized'
68
+ return f'"{sys.executable}" -m remotedesktop{flag}'
55
69
 
56
70
 
57
71
  class Autostart:
@@ -65,20 +79,18 @@ class Autostart:
65
79
  *,
66
80
  key_path: str = _RUN_KEY,
67
81
  value_name: str = _VALUE_NAME,
68
- legacy_value_name: str = _LEGACY_VALUE_NAME,
69
82
  ) -> None:
70
83
  self.available = _IS_WINDOWS
71
84
  self._key_path = key_path
72
85
  self._value_name = value_name
73
- self._legacy_value_name = legacy_value_name
74
86
 
75
- def _has_value(self, value_name: str) -> bool:
87
+ def _read_value(self, value_name: str) -> str | None:
76
88
  try:
77
89
  with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self._key_path) as key:
78
- winreg.QueryValueEx(key, value_name)
79
- return True
90
+ value, _kind = winreg.QueryValueEx(key, value_name)
91
+ return str(value)
80
92
  except OSError:
81
- return False
93
+ return None
82
94
 
83
95
  def _delete_value(self, value_name: str) -> None:
84
96
  try:
@@ -89,30 +101,24 @@ class Autostart:
89
101
  except OSError:
90
102
  pass # already not registered
91
103
 
92
- def is_enabled(self) -> bool:
104
+ def mode(self) -> str:
105
+ """The registered start mode, read back from the Run value's flag."""
93
106
  if not self.available:
94
- return False
95
- return self._has_value(self._value_name)
96
-
97
- def set_enabled(self, enabled: bool) -> None:
107
+ return START_OFF
108
+ command = self._read_value(self._value_name)
109
+ if command is None:
110
+ return START_OFF
111
+ if command.endswith("--minimized"):
112
+ return START_MINIMIZED
113
+ if command.endswith("--maximized"):
114
+ return START_MAXIMIZED
115
+ return START_NORMAL
116
+
117
+ def set_mode(self, mode: str) -> None:
98
118
  if not self.available:
99
119
  return
100
- if enabled:
101
- with winreg.CreateKey(winreg.HKEY_CURRENT_USER, self._key_path) as key:
102
- winreg.SetValueEx(key, self._value_name, 0, winreg.REG_SZ, app_command())
103
- self._delete_value(self._legacy_value_name) # never leave both
104
- else:
120
+ if mode == START_OFF:
105
121
  self._delete_value(self._value_name)
106
-
107
- def migrate_legacy(self) -> None:
108
- """Move a pre-1.0 "remotedesktop-server" registration to this app.
109
-
110
- Best-effort: a registry failure must never block startup.
111
- """
112
- if not self.available:
113
122
  return
114
- try:
115
- if self._has_value(self._legacy_value_name):
116
- self.set_enabled(True)
117
- except OSError:
118
- pass
123
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, self._key_path) as key:
124
+ winreg.SetValueEx(key, self._value_name, 0, winreg.REG_SZ, app_command(mode))