remotedesktop 2.2.0__tar.gz → 2.4.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 (82) hide show
  1. remotedesktop-2.4.0/.github/dependabot.yml +22 -0
  2. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/.github/workflows/ci.yml +14 -8
  3. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/.github/workflows/release.yml +4 -4
  4. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/.gitignore +3 -0
  5. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/CLAUDE.md +9 -3
  6. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/PKG-INFO +34 -11
  7. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/pyproject.toml +2 -0
  8. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/readme.md +33 -10
  9. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/__init__.py +1 -1
  10. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/app.py +58 -10
  11. remotedesktop-2.4.0/src/remotedesktop/backlog.py +103 -0
  12. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/config.py +14 -0
  13. remotedesktop-2.4.0/src/remotedesktop/frames.py +324 -0
  14. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/sharing.py +192 -56
  15. remotedesktop-2.4.0/tests/conftest.py +87 -0
  16. remotedesktop-2.4.0/tests/test_backlog.py +95 -0
  17. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_config.py +10 -0
  18. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_frames.py +74 -0
  19. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_main_window.py +235 -2
  20. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_sharing.py +99 -8
  21. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_smoke.py +23 -3
  22. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/uv.lock +125 -71
  23. remotedesktop-2.2.0/src/remotedesktop/frames.py +0 -110
  24. remotedesktop-2.2.0/tests/conftest.py +0 -39
  25. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/LICENSE +0 -0
  26. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/docs/media/client-demo.gif +0 -0
  27. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/docs/media/server-demo.gif +0 -0
  28. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/icon/remotedesktop.ico +0 -0
  29. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/run.bat +0 -0
  30. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/run_claude.bat +0 -0
  31. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/scripts/make_coverage_badge.py +0 -0
  32. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/scripts/make_installer.bat +0 -0
  33. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/scripts/make_venv.bat +0 -0
  34. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/__main__.py +0 -0
  35. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/about.py +0 -0
  36. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/autostart.py +0 -0
  37. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/client.py +0 -0
  38. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/clipboard.py +0 -0
  39. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/compat.py +0 -0
  40. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/cursor_shape.py +0 -0
  41. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/db.py +0 -0
  42. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/discovery.py +0 -0
  43. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/dxgi.py +0 -0
  44. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/icon.py +0 -0
  45. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/input_injection.py +0 -0
  46. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/inventory.py +0 -0
  47. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/logs.py +0 -0
  48. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/modal_loop.py +0 -0
  49. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/performance.py +0 -0
  50. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/preferences.py +0 -0
  51. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/protocol.py +0 -0
  52. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/server.py +0 -0
  53. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/session_lock.py +0 -0
  54. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/single_instance.py +0 -0
  55. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/tls.py +0 -0
  56. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/viewer.py +0 -0
  57. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/visual_effects.py +0 -0
  58. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/window_state.py +0 -0
  59. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_about.py +0 -0
  60. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_autostart.py +0 -0
  61. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_clipboard.py +0 -0
  62. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_compat.py +0 -0
  63. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_cursor_shape.py +0 -0
  64. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_discovery.py +0 -0
  65. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_dxgi.py +0 -0
  66. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_icon.py +0 -0
  67. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_input.py +0 -0
  68. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_input_injection.py +0 -0
  69. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_inventory.py +0 -0
  70. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_logs.py +0 -0
  71. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_modal_loop.py +0 -0
  72. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_performance.py +0 -0
  73. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_preferences.py +0 -0
  74. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_protocol.py +0 -0
  75. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_session_lock.py +0 -0
  76. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_sharing_tab.py +0 -0
  77. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_single_instance.py +0 -0
  78. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_tls.py +0 -0
  79. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_visual_effects.py +0 -0
  80. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_window_state.py +0 -0
  81. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tools/make_demo_gifs.py +0 -0
  82. {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tools/make_icon.py +0 -0
@@ -0,0 +1,22 @@
1
+ # Keeps the pinned toolchain current. ruff and ty (pre-1.0, frequent
2
+ # releases that add rules) are locked in uv.lock so CI never floats to a
3
+ # new version by accident; these weekly PRs are the deliberate upgrade
4
+ # path, and CI validates each one before it can merge.
5
+ version: 2
6
+ updates:
7
+ - package-ecosystem: "uv"
8
+ directory: "/"
9
+ schedule:
10
+ interval: "weekly"
11
+ day: "monday"
12
+ groups:
13
+ # One PR for the whole toolchain rather than one per package.
14
+ dev-tools:
15
+ dependency-type: "development"
16
+ open-pull-requests-limit: 5
17
+
18
+ - package-ecosystem: "github-actions"
19
+ directory: "/"
20
+ schedule:
21
+ interval: "weekly"
22
+ day: "monday"
@@ -6,22 +6,28 @@ on:
6
6
  pull_request:
7
7
 
8
8
  jobs:
9
+ # ruff and ty are dev dependencies pinned by uv.lock (not floating `uvx`
10
+ # installs): a new ty release adding rules must not turn master red on
11
+ # its own. Dependabot (.github/dependabot.yml) opens the upgrade PRs, so
12
+ # any new findings get fixed in the PR that brings the new version.
9
13
  ruff:
10
14
  runs-on: ubuntu-latest
11
15
  steps:
12
- - uses: actions/checkout@v4
13
- - uses: astral-sh/setup-uv@v6
14
- - run: uvx ruff check .
16
+ - uses: actions/checkout@v7
17
+ - uses: astral-sh/setup-uv@v7
18
+ # Only the dev group: linting doesn't need PySide6 & co. installed.
19
+ - run: uv sync --only-group dev
20
+ - run: uv run --no-sync ruff check .
15
21
 
16
22
  ty:
17
23
  runs-on: ubuntu-latest
18
24
  steps:
19
- - uses: actions/checkout@v4
20
- - uses: astral-sh/setup-uv@v6
25
+ - uses: actions/checkout@v7
26
+ - uses: astral-sh/setup-uv@v7
21
27
  # ty resolves imports from the project venv; pyproject pins the
22
28
  # type-checking platform to win32 (the app's target).
23
29
  - run: uv sync
24
- - run: uvx ty check .
30
+ - run: uv run ty check .
25
31
 
26
32
  test:
27
33
  # The app targets Windows: input injection, schannel TLS, and the GUI
@@ -30,8 +36,8 @@ jobs:
30
36
  permissions:
31
37
  contents: write # to push the updated coverage badge
32
38
  steps:
33
- - uses: actions/checkout@v4
34
- - uses: astral-sh/setup-uv@v6
39
+ - uses: actions/checkout@v7
40
+ - uses: astral-sh/setup-uv@v7
35
41
  - run: uv sync
36
42
  - run: uv run pytest --cov=remotedesktop --cov-report=term
37
43
  - name: Publish coverage badge
@@ -23,8 +23,8 @@ jobs:
23
23
  outputs:
24
24
  version: ${{ steps.version.outputs.version }}
25
25
  steps:
26
- - uses: actions/checkout@v4
27
- - uses: astral-sh/setup-uv@v6
26
+ - uses: actions/checkout@v7
27
+ - uses: astral-sh/setup-uv@v7
28
28
  - name: Read version
29
29
  id: version
30
30
  # __init__.py is docstring + __version__ only; exec-ing it avoids
@@ -58,8 +58,8 @@ jobs:
58
58
  permissions:
59
59
  contents: write # upload the installer to the release
60
60
  steps:
61
- - uses: actions/checkout@v4
62
- - uses: astral-sh/setup-uv@v6
61
+ - uses: actions/checkout@v7
62
+ - uses: astral-sh/setup-uv@v7
63
63
  - name: Install NSIS
64
64
  # Not preinstalled on windows-latest; choco puts makensis.exe at
65
65
  # C:\Program Files (x86)\NSIS\makensis.exe, pyship's default path.
@@ -29,3 +29,6 @@ badges/
29
29
  # IDE
30
30
  .idea/
31
31
  .vscode/
32
+
33
+ # Local environment / secrets
34
+ .env
@@ -22,7 +22,13 @@ change goes through a pull request: branch → push → PR → CI green → merg
22
22
  The ruleset also **requires the `ruff`, `ty`, and `test` checks to pass**
23
23
  (GitHub Actions, non-strict), so a merge with a red or missing check is
24
24
  rejected by GitHub; if a CI job is added or renamed, update the ruleset's
25
- required checks to match. CI publishes the coverage badge to the
25
+ required checks to match. **ruff and ty are pinned dev dependencies**
26
+ (`uv.lock`, run as `uv run ruff check .` / `uv run ty check .`) — never
27
+ `uvx <tool>` unpinned in CI, because ty is pre-1.0 and a release adding a
28
+ rule turned master red with no code change (2026-09-10). Dependabot
29
+ (`.github/dependabot.yml`, weekly, `uv` + `github-actions` ecosystems)
30
+ opens the upgrade PRs; fix any new findings in that PR. ty's default exit
31
+ code is nonzero on *warnings*, deliberately kept so they never accumulate. CI publishes the coverage badge to the
26
32
  unprotected `badges` branch (readme references it by raw URL), so no
27
33
  workflow ever needs to write to `master`.
28
34
 
@@ -78,7 +84,7 @@ Managed with `uv` (hatchling build backend, src layout):
78
84
  - **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
85
  - **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.
80
86
  - 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]`.
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). **Re-discovery follows a moved server**: sessions, tokens, and pins are all keyed by `host:port`, so a DHCP address change across a server reboot would otherwise strand every retry — once `_REDISCOVER_AFTER_ATTEMPTS` (2) direct attempts have failed, each further failure also runs a discovery scan on a worker thread (`rediscover=` kwarg — tests inject a fake, `make_window` defaults it to a no-op so window tests never broadcast; result delivered via the queued `_rediscoveryFinished` signal). A scanned server matching the session's pinned cert fingerprint (discovery replies advertise it as `fp`; display-name match is the fallback for servers too old to send one) at a *different* address migrates the session: the `KnownServers` record is re-keyed (token preserved — no re-approval), the inventory row moves, sessions re-persist, and the connect is immediate with a fresh backoff. This is not background polling — it runs only while a session is failing to reconnect. **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.
87
+ - **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). **Re-discovery follows a moved server**: sessions, tokens, and pins are all keyed by `host:port`, so a DHCP address change across a server reboot would otherwise strand every retry — once `_REDISCOVER_AFTER_ATTEMPTS` (2) direct attempts have failed, each further failure also runs a discovery scan on a worker thread (`rediscover=` kwarg — tests inject a fake, `make_window` defaults it to a no-op so window tests never broadcast; result delivered via the queued `_rediscoveryFinished` signal). A scanned server matching the session's pinned cert fingerprint (discovery replies advertise it as `fp`; display-name match is the fallback for servers too old to send one) at a *different* address migrates the session: the `KnownServers` record is re-keyed (token preserved — no re-approval), the inventory row moves, sessions re-persist, and the connect is immediate with a fresh backoff. This is not background polling — it runs only while a session is failing to reconnect. **Manual activation adopts a moved pairing too** (`_adopt_moved_pairing`): picking a server from the discovery list whose advertised `fp` matches a `KnownServers` record at a *different* key (`KnownServers.key_for_fingerprint`) re-keys that pairing to the new address before connecting — a session still retrying the old address migrates into the activation (`_move_session`, the re-key half of `_migrate_session`), a stale old address is forgotten outright, and a session *connected* at the old address (multi-homed server) just gets the pairing copied. Without this, a server that rebooted onto a new DHCP address and was reconnected by hand prompted for approval again. **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.
82
88
  - **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).
83
89
  - **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.
84
90
  - **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).
@@ -87,7 +93,7 @@ Managed with `uv` (hatchling build backend, src layout):
87
93
  - **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.
88
94
  - **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}` plus an optional `fp` — the server's TLS cert fingerprint (`ServerInfo.fingerprint`, empty when absent; junk `fp` is blanked, never drops the reply), the stable identity auto-reconnect re-discovery matches on; 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).
89
95
  - 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.
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).
96
+ - **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs on the Qt event loop — no locks, and one deliberate exception to "no threads": **PNG encoding runs on a single worker thread** (`frames.FrameEncoder`, a one-worker `ThreadPoolExecutor`; PySide6 releases the GIL in `QImage.save`, verified). The GUI thread still captures and diffs (cheap: DXGI ~10 ms, memcmp diff ~3.5 ms for 4K), submits at most one job, and gets an `EncodeResult` back via a queued signal; **a tick that lands while an encode is in flight skips the capture** (`_ticks_skipped`) so DXGI accumulates and the next diff catches everything — a slow encode lowers the frame rate, never stacks latency. `FrameEncoder.close()` waits for the in-flight job, so no result lands after teardown. **The band diff is a C `memcmp` per band** (`frames.changed_bands`, via `PyObject_GetBuffer` to borrow the read-only `constBits()` address; `_changed_bands_python` is the fallback and the test reference) — the memoryview slice compare it replaced cost 43 ms per changed 4K tick, more than the whole 33 ms frame budget. **Flow control is per viewer and adaptive** (`backlog.py` `BacklogGovernor`): the cap on a viewer's unsent bytes is its measured socket drain rate × `QUEUE_TARGET_SECONDS` (50 ms), starting from a 100 Mbit/s assumption (the app assumes a modern LAN, usually wired: ≥100 Mbit/s, typically 1 Gbit/s), halved while the in-band RTT (from the performance monitor, when attached) exceeds its drifting floor by more than the target, clamped to `CAP_MIN`..`CAP_MAX`. **A withheld viewer no longer costs a keyframe**: the bands it missed accumulate in `_dirty[stream]` (`frames.merge_bands`) and ship as one merged delta when it drains; each distinct owed band set is encoded once per tick. "Not keeping up" is reported only after `_BACKLOG_REPORT_SECONDS` (1 s) continuously behind; `_send_cap(stream)` is the test seam (tests monkeypatch it on the instance, replacing the old `_MAX_SEND_BACKLOG` constant). A debug-log summary every 10 s (`_log_stats`) reports encode timings, skipped ticks, withheld frames, and each viewer's throughput estimate and cap. `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).
91
97
  - **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**.
92
98
  - **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.
93
99
  - **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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: remotedesktop
3
- Version: 2.2.0
3
+ Version: 2.4.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
@@ -84,7 +84,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
84
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.
85
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)).
86
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.
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. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt. Connections that were open when the app closed are restored on the next start.
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. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt (and choosing the moved server from the discovery list by hand reuses that pairing too). Connections that were open when the app closed are restored on the next start.
88
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.
89
89
  - 🗃️ **Persistent peer inventory** — a SQLite-backed history of every peer seen on the LAN, with one-click *Revoke* / *Forget*.
90
90
  - 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts (Preferences on *File ▸ Preferences*, Ctrl+,; About on the Help menu), a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
@@ -190,12 +190,26 @@ the pointer you see is always sharp and moves with zero latency.
190
190
  **Screen transfer.** Frames are captured at up to 30 fps and compared with
191
191
  the previous capture in 64-row bands; only the bands that changed are
192
192
  encoded — losslessly, as PNG — and sent as a delta the client patches onto
193
- its last frame. An unchanged screen sends nothing at all. Full PNG
194
- keyframes go to clients that just connected, fell behind (a client whose
195
- socket backlog grows gets frames dropped, then a fresh keyframe once it
196
- catches up), or asked for one because a delta failed to apply — so a
197
- desynced stream heals itself. The frame always travels at the server's
198
- full resolution; scaling to the viewer window happens on the client.
193
+ its last frame. An unchanged screen sends nothing at all. The comparison
194
+ is a C memory compare per band (a few milliseconds for a 4K frame), and
195
+ the PNG encoding the expensive step — runs on a worker thread, so the
196
+ GUI thread that services the viewers' input never waits on it; a tick
197
+ that arrives mid-encode is simply skipped. Full PNG keyframes go to
198
+ clients that just connected or asked for one because a delta failed to
199
+ apply — so a desynced stream heals itself. The frame always travels at
200
+ the server's full resolution; scaling to the viewer window happens on the
201
+ client.
202
+
203
+ **Flow control.** Unsent bytes queued in a viewer's socket are latency the
204
+ viewer will see, so each viewer has a cap on how much may be queued before
205
+ frames are withheld from it. The cap adapts to the link: the server
206
+ measures how fast that viewer's socket drains and allows about 50 ms
207
+ worth, starting from a 100 Mbit/s assumption (the app assumes a modern
208
+ LAN — at least 100 Mbit/s, usually a wired gigabit connection) and
209
+ tightening further if the round-trip time rises above its usual floor.
210
+ Withheld frames aren't lost: the bands the viewer missed accumulate and
211
+ ship as one merged delta once it drains, so a slow link gets fewer, larger
212
+ updates instead of a stale picture or a fresh keyframe.
199
213
 
200
214
  **Input injection.** The client's viewer widget captures your mouse and
201
215
  keyboard events, maps mouse positions to coordinates normalized 0..1 over
@@ -254,17 +268,26 @@ fingerprint. The fingerprint is the server's stable identity: when a
254
268
  reconnecting client can no longer reach a server at its last known address
255
269
  (typically a new DHCP lease after a reboot), it re-scans and matches the
256
270
  fingerprint to follow the server to its new address, keeping the stored
257
- pairing token so no fresh approval is needed. Windows Firewall
271
+ pairing token so no fresh approval is needed. The same applies when you
272
+ pick a server from the discovery list by hand: if its fingerprint matches
273
+ a pairing stored under an old address, that pairing moves to the new
274
+ address and the connection is admitted without a prompt. Windows Firewall
258
275
  must allow Python to receive inbound UDP on that port for a server to be
259
276
  discoverable from other machines.
260
277
 
261
278
  ## Development
262
279
 
263
280
  ```
264
- uv sync # set up the environment
265
- uv run pytest # run the tests
281
+ uv sync # set up the environment
282
+ uv run pytest # run the tests
283
+ uv run ruff check . # lint
284
+ uv run ty check . # type-check
266
285
  ```
267
286
 
287
+ All three are required checks on every pull request. `ruff` and `ty` are
288
+ pinned in `uv.lock`; Dependabot opens a weekly PR when newer versions are
289
+ available.
290
+
268
291
  Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
269
292
  MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
270
293
  From PowerShell, Qt uses the Windows schannel backend as intended.
@@ -36,6 +36,8 @@ dev = [
36
36
  "pillow>=12.3.0",
37
37
  "pytest>=8",
38
38
  "pytest-cov>=7.1.0",
39
+ "ruff>=0.16.7",
40
+ "ty>=0.0.80",
39
41
  ]
40
42
  # Only for building the Windows installer (scripts/make_installer.bat / release CI).
41
43
  ship = [
@@ -59,7 +59,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
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
- - 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt. Connections that were open when the app closed are restored on the next start.
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. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt (and choosing the moved server from the discovery list by hand reuses that pairing too). Connections that were open when the app closed are restored on the next start.
63
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 (Preferences on *File ▸ Preferences*, Ctrl+,; About on the Help menu), a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
@@ -165,12 +165,26 @@ the pointer you see is always sharp and moves with zero latency.
165
165
  **Screen transfer.** Frames are captured at up to 30 fps and compared with
166
166
  the previous capture in 64-row bands; only the bands that changed are
167
167
  encoded — losslessly, as PNG — and sent as a delta the client patches onto
168
- its last frame. An unchanged screen sends nothing at all. Full PNG
169
- keyframes go to clients that just connected, fell behind (a client whose
170
- socket backlog grows gets frames dropped, then a fresh keyframe once it
171
- catches up), or asked for one because a delta failed to apply — so a
172
- desynced stream heals itself. The frame always travels at the server's
173
- full resolution; scaling to the viewer window happens on the client.
168
+ its last frame. An unchanged screen sends nothing at all. The comparison
169
+ is a C memory compare per band (a few milliseconds for a 4K frame), and
170
+ the PNG encoding the expensive step — runs on a worker thread, so the
171
+ GUI thread that services the viewers' input never waits on it; a tick
172
+ that arrives mid-encode is simply skipped. Full PNG keyframes go to
173
+ clients that just connected or asked for one because a delta failed to
174
+ apply — so a desynced stream heals itself. The frame always travels at
175
+ the server's full resolution; scaling to the viewer window happens on the
176
+ client.
177
+
178
+ **Flow control.** Unsent bytes queued in a viewer's socket are latency the
179
+ viewer will see, so each viewer has a cap on how much may be queued before
180
+ frames are withheld from it. The cap adapts to the link: the server
181
+ measures how fast that viewer's socket drains and allows about 50 ms
182
+ worth, starting from a 100 Mbit/s assumption (the app assumes a modern
183
+ LAN — at least 100 Mbit/s, usually a wired gigabit connection) and
184
+ tightening further if the round-trip time rises above its usual floor.
185
+ Withheld frames aren't lost: the bands the viewer missed accumulate and
186
+ ship as one merged delta once it drains, so a slow link gets fewer, larger
187
+ updates instead of a stale picture or a fresh keyframe.
174
188
 
175
189
  **Input injection.** The client's viewer widget captures your mouse and
176
190
  keyboard events, maps mouse positions to coordinates normalized 0..1 over
@@ -229,17 +243,26 @@ fingerprint. The fingerprint is the server's stable identity: when a
229
243
  reconnecting client can no longer reach a server at its last known address
230
244
  (typically a new DHCP lease after a reboot), it re-scans and matches the
231
245
  fingerprint to follow the server to its new address, keeping the stored
232
- pairing token so no fresh approval is needed. Windows Firewall
246
+ pairing token so no fresh approval is needed. The same applies when you
247
+ pick a server from the discovery list by hand: if its fingerprint matches
248
+ a pairing stored under an old address, that pairing moves to the new
249
+ address and the connection is admitted without a prompt. Windows Firewall
233
250
  must allow Python to receive inbound UDP on that port for a server to be
234
251
  discoverable from other machines.
235
252
 
236
253
  ## Development
237
254
 
238
255
  ```
239
- uv sync # set up the environment
240
- uv run pytest # run the tests
256
+ uv sync # set up the environment
257
+ uv run pytest # run the tests
258
+ uv run ruff check . # lint
259
+ uv run ty check . # type-check
241
260
  ```
242
261
 
262
+ All three are required checks on every pull request. `ruff` and `ty` are
263
+ pinned in `uv.lock`; Dependabot opens a weekly PR when newer versions are
264
+ available.
265
+
243
266
  Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
244
267
  MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
245
268
  From PowerShell, Qt uses the Windows schannel backend as intended.
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.4.0"
@@ -993,7 +993,10 @@ class MainWindow(QMainWindow):
993
993
  self.log("Restarting: freeing ports and launching a new process")
994
994
  _log.info("Restart requested — relaunching %s %s", program, " ".join(args))
995
995
  self.sharing_tab.shutdown()
996
- if not QProcess.startDetached(program, args):
996
+ # PySide6's static startDetached returns (started, pid) — the tuple is
997
+ # always truthy, so it must be unpacked to detect a failed launch.
998
+ started, _unused_pid = QProcess.startDetached(program, args)
999
+ if not started:
997
1000
  # Extremely unlikely (the program path exists); the app stays open —
998
1001
  # sharing is stopped, but the machine isn't left with nothing.
999
1002
  self.log("Restart failed: could not launch a new process — restart manually")
@@ -1165,6 +1168,8 @@ class MainWindow(QMainWindow):
1165
1168
  self._tabs.setCurrentWidget(session.page)
1166
1169
  self.log(f"Already connected to {session.name} ({key})")
1167
1170
  return
1171
+ if session is None:
1172
+ session = self._adopt_moved_pairing(server)
1168
1173
  if session is None:
1169
1174
  session = self._create_session(key, server.name)
1170
1175
  else:
@@ -1172,6 +1177,42 @@ class MainWindow(QMainWindow):
1172
1177
  self._tabs.setTabText(self._sessions.index(session), session.name)
1173
1178
  self._connect_session(session, server.host, server.port)
1174
1179
 
1180
+ def _adopt_moved_pairing(self, server: ServerInfo) -> ServerSession | None:
1181
+ """A discovered server whose certificate is already paired, but at
1182
+ another address (DHCP moved it since the last visit): carry the
1183
+ stored token over so this connection needs no fresh approval.
1184
+
1185
+ A session still retrying the old address migrates whole (returned,
1186
+ for the caller to connect); otherwise only the pairing moves. A
1187
+ session *connected* at the old address means the server is reachable
1188
+ at both, so the pairing is copied and nothing is torn down.
1189
+ """
1190
+ new_key = f"{server.host}:{server.port}"
1191
+ if not server.fingerprint or self._known_servers.get(new_key) is not None:
1192
+ return None
1193
+ old_key = self._known_servers.key_for_fingerprint(server.fingerprint)
1194
+ if old_key is None:
1195
+ return None
1196
+ old_session = self._session_for_key(old_key)
1197
+ if old_session is not None and not old_session.connected:
1198
+ self._move_session(old_session, server)
1199
+ self.log(
1200
+ f"[{old_session.name}] Found at new address {new_key} (was {old_key}) "
1201
+ "— reusing its pairing"
1202
+ )
1203
+ return old_session
1204
+ record = self._known_servers.get(old_key)
1205
+ if record is None:
1206
+ return None
1207
+ self._known_servers.remember(new_key, server.fingerprint, record["token"])
1208
+ if old_session is None:
1209
+ # The old address is stale: drop its pairing and history row so
1210
+ # they don't linger as a permanently-unreachable duplicate.
1211
+ self._known_servers.forget(old_key)
1212
+ self.client_inventory.remove(old_key)
1213
+ self.log(f"{server.name} at {new_key} was paired at {old_key} — reusing that pairing")
1214
+ return None
1215
+
1175
1216
  def _create_session(self, key: str, name: str) -> ServerSession:
1176
1217
  viewer = ViewerWidget()
1177
1218
  # The tab page is a scroll area: fit mode (widgetResizable) sizes the
@@ -1492,9 +1533,23 @@ class MainWindow(QMainWindow):
1492
1533
  def _migrate_session(self, session: ServerSession, server: ServerInfo) -> None:
1493
1534
  """Move a session (and its stored pairing) to the server's new
1494
1535
  address, then reconnect immediately."""
1495
- old_key, new_key = session.key, f"{server.host}:{server.port}"
1496
- if self._session_for_key(new_key) is not None:
1536
+ if self._session_for_key(f"{server.host}:{server.port}") is not None:
1497
1537
  return # the user already opened a session at the new address
1538
+ old_key = session.key
1539
+ self._move_session(session, server)
1540
+ new_key = session.key
1541
+ message = f"Found {session.name} at new address {new_key} (was {old_key}) — reconnecting"
1542
+ self._set_session_status(session, message)
1543
+ self.log(f"[{session.name}] {message}")
1544
+ # A new address deserves a fresh backoff; the connect failure path
1545
+ # re-arms it (and further re-discovery) if this address fails too.
1546
+ self._cancel_reconnect(session)
1547
+ session.client.connect_to(server.host, server.port)
1548
+
1549
+ def _move_session(self, session: ServerSession, server: ServerInfo) -> None:
1550
+ """Re-key a session, its stored pairing, and its history row to the
1551
+ server's new address (no connection attempt)."""
1552
+ old_key, new_key = session.key, f"{server.host}:{server.port}"
1498
1553
  record = self._known_servers.get(old_key)
1499
1554
  if record is not None:
1500
1555
  # Re-key the pairing so the stored token still applies — the
@@ -1513,13 +1568,6 @@ class MainWindow(QMainWindow):
1513
1568
  new_key, "attempt", name=session.name, address=new_key, detail=new_key
1514
1569
  )
1515
1570
  self._persist_sessions()
1516
- message = f"Found {session.name} at new address {new_key} (was {old_key}) — reconnecting"
1517
- self._set_session_status(session, message)
1518
- self.log(f"[{session.name}] {message}")
1519
- # A new address deserves a fresh backoff; the connect failure path
1520
- # re-arms it (and further re-discovery) if this address fails too.
1521
- self._cancel_reconnect(session)
1522
- session.client.connect_to(server.host, server.port)
1523
1571
 
1524
1572
  def _request_server_log(self) -> None:
1525
1573
  session = self._session_for_page(self._tabs.currentWidget())
@@ -0,0 +1,103 @@
1
+ """Per-viewer send-backlog governor.
2
+
3
+ Unsent bytes sitting in a viewer's socket are pure latency: the viewer must
4
+ receive and render all of them before it shows anything current. So the
5
+ server withholds frames from a viewer whose queue is longer than a cap —
6
+ and the right cap depends on the link. 2 MiB is ~200 ms of queue on 100
7
+ Mbit/s but ~17 ms on gigabit, so a fixed number is either laggy on the slow
8
+ link or needlessly strict on the fast one.
9
+
10
+ `BacklogGovernor` sizes the cap from what it observes on the stream:
11
+
12
+ - **Bandwidth**: the socket's drain rate, sampled over intervals in which
13
+ the queue never emptied (so the sample reflects the link, not idleness).
14
+ A fast-up / slow-down average, starting from 100 Mbit/s — the slowest LAN
15
+ the app is designed for — so a gigabit link is recognized within a few
16
+ busy ticks. The cap is that throughput times `QUEUE_TARGET_SECONDS`.
17
+ - **Latency**: the in-band round-trip time (from the performance monitor's
18
+ pings, which queue behind frame data like everything else) against its
19
+ running floor. Excess above the target means bytes the byte counter can't
20
+ see are queued somewhere (the kernel's send buffer, the network), so the
21
+ cap is halved; it recovers geometrically while the excess stays small.
22
+ The floor drifts upward slowly so a genuinely changed baseline is
23
+ re-learned within about a minute.
24
+
25
+ Everything is pure arithmetic with an injectable clock — no Qt, no I/O.
26
+ """
27
+
28
+ import time
29
+ from collections.abc import Callable
30
+
31
+ # How much queued frame data a viewer may have before frames are withheld
32
+ # from it, expressed as time on the wire. 50 ms keeps the added latency
33
+ # below what a person notices while still letting one keyframe drain.
34
+ QUEUE_TARGET_SECONDS = 0.05
35
+ # The link assumed until measured: 100 Mbit/s, the slowest LAN the app
36
+ # targets (readme: "a modern LAN, often wired — at least 100 Mbit/s").
37
+ ASSUMED_BYTES_PER_SECOND = 100_000_000 / 8
38
+ # Bounds on the cap whatever the estimates say: never so small that a
39
+ # healthy link is throttled by measurement noise, never unbounded.
40
+ CAP_MIN = 256 * 1024
41
+ CAP_MAX = 16 * 1024 * 1024
42
+ # Throughput smoothing: believe a faster drain quickly, a slower one slowly
43
+ # (a single slow sample is more often noise than a slower link).
44
+ _ALPHA_UP = 0.25
45
+ _ALPHA_DOWN = 0.1
46
+ # Latency feedback: halve on excess, recover by a quarter per good sample.
47
+ _SCALE_MIN = 0.125
48
+ _FLOOR_DRIFT_MS = 1.0 # per RTT sample; samples arrive about once a second
49
+
50
+
51
+ class BacklogGovernor:
52
+ def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None:
53
+ self._clock = clock
54
+ self._throughput = ASSUMED_BYTES_PER_SECOND
55
+ self._last: tuple[float, int, int] | None = None # (time, pending, sent_total)
56
+ self._rtt_floor_ms: float | None = None
57
+ self._latency_scale = 1.0
58
+
59
+ @property
60
+ def throughput(self) -> float:
61
+ """Estimated drain rate of the viewer's socket, bytes per second."""
62
+ return self._throughput
63
+
64
+ @property
65
+ def latency_scale(self) -> float:
66
+ """1.0 while the round-trip time sits at its floor; smaller while
67
+ excess latency says the queue is longer than the byte count shows."""
68
+ return self._latency_scale
69
+
70
+ @property
71
+ def cap(self) -> int:
72
+ """Unsent bytes a viewer may have queued before frames are withheld."""
73
+ cap = self._throughput * QUEUE_TARGET_SECONDS * self._latency_scale
74
+ return int(min(CAP_MAX, max(CAP_MIN, cap)))
75
+
76
+ def observe(self, pending: int, sent_total: int) -> None:
77
+ """Sample the socket: `pending` unsent bytes right now and the
78
+ cumulative `sent_total` handed to it. Call once per broadcast tick."""
79
+ now = self._clock()
80
+ if self._last is not None:
81
+ then, pending_then, sent_then = self._last
82
+ elapsed = now - then
83
+ # Only an interval the queue never emptied measures the link;
84
+ # otherwise part of it was idle and the rate would read low.
85
+ if pending_then > 0 and pending > 0 and elapsed > 0:
86
+ drained = (sent_total - sent_then) - (pending - pending_then)
87
+ rate = max(0.0, drained / elapsed)
88
+ alpha = _ALPHA_UP if rate > self._throughput else _ALPHA_DOWN
89
+ self._throughput += alpha * (rate - self._throughput)
90
+ self._last = (now, pending, sent_total)
91
+
92
+ def observe_rtt(self, rtt_ms: float) -> None:
93
+ """Feed one round-trip-time sample (milliseconds)."""
94
+ if self._rtt_floor_ms is None:
95
+ self._rtt_floor_ms = rtt_ms
96
+ else:
97
+ self._rtt_floor_ms = min(rtt_ms, self._rtt_floor_ms + _FLOOR_DRIFT_MS)
98
+ excess = rtt_ms - self._rtt_floor_ms
99
+ target_ms = QUEUE_TARGET_SECONDS * 1000.0
100
+ if excess > target_ms:
101
+ self._latency_scale = max(_SCALE_MIN, self._latency_scale * 0.5)
102
+ elif excess < target_ms / 2:
103
+ self._latency_scale = min(1.0, self._latency_scale * 1.25)
@@ -132,6 +132,20 @@ class KnownServers:
132
132
  )
133
133
  self._db.commit()
134
134
 
135
+ def key_for_fingerprint(self, fingerprint: str) -> str | None:
136
+ """The "host:port" a server with this certificate was paired at, if any.
137
+
138
+ The certificate is the server's stable identity across an address
139
+ change; records without a pin never match.
140
+ """
141
+ if not fingerprint:
142
+ return None
143
+ row = self._db.execute(
144
+ "SELECT key FROM known_servers WHERE fingerprint = ? ORDER BY key LIMIT 1",
145
+ (fingerprint,),
146
+ ).fetchone()
147
+ return row[0] if row is not None else None
148
+
135
149
  def forget(self, key: str) -> None:
136
150
  """Drop a server's stored token and pin, so the next connection re-pairs."""
137
151
  self._db.execute("DELETE FROM known_servers WHERE key = ?", (key,))