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.
- remotedesktop-2.4.0/.github/dependabot.yml +22 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/.github/workflows/ci.yml +14 -8
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/.github/workflows/release.yml +4 -4
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/.gitignore +3 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/CLAUDE.md +9 -3
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/PKG-INFO +34 -11
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/pyproject.toml +2 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/readme.md +33 -10
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/app.py +58 -10
- remotedesktop-2.4.0/src/remotedesktop/backlog.py +103 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/config.py +14 -0
- remotedesktop-2.4.0/src/remotedesktop/frames.py +324 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/sharing.py +192 -56
- remotedesktop-2.4.0/tests/conftest.py +87 -0
- remotedesktop-2.4.0/tests/test_backlog.py +95 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_config.py +10 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_frames.py +74 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_main_window.py +235 -2
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_sharing.py +99 -8
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_smoke.py +23 -3
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/uv.lock +125 -71
- remotedesktop-2.2.0/src/remotedesktop/frames.py +0 -110
- remotedesktop-2.2.0/tests/conftest.py +0 -39
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/LICENSE +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/icon/remotedesktop.ico +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/run.bat +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/run_claude.bat +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/scripts/make_installer.bat +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/server.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/session_lock.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/visual_effects.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_about.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_autostart.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_compat.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_discovery.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_icon.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_input.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_inventory.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_logs.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_performance.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_preferences.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_protocol.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_session_lock.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_single_instance.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_tls.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_visual_effects.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tests/test_window_state.py +0 -0
- {remotedesktop-2.2.0 → remotedesktop-2.4.0}/tools/make_demo_gifs.py +0 -0
- {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@
|
|
13
|
-
- uses: astral-sh/setup-uv@
|
|
14
|
-
|
|
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@
|
|
20
|
-
- uses: astral-sh/setup-uv@
|
|
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:
|
|
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@
|
|
34
|
-
- uses: astral-sh/setup-uv@
|
|
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@
|
|
27
|
-
- uses: astral-sh/setup-uv@
|
|
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@
|
|
62
|
-
- uses: astral-sh/setup-uv@
|
|
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.
|
|
@@ -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.
|
|
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
|
|
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.
|
|
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.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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
|
|
265
|
-
uv run pytest
|
|
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.
|
|
@@ -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.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
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
|
|
240
|
-
uv run pytest
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,))
|