remotedesktop 2.1.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.1.0 → remotedesktop-2.4.0}/.github/workflows/ci.yml +14 -8
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/.github/workflows/release.yml +4 -4
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/.gitignore +3 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/CLAUDE.md +10 -4
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/PKG-INFO +45 -16
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/pyproject.toml +2 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/readme.md +43 -14
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/app.py +180 -3
- remotedesktop-2.4.0/src/remotedesktop/backlog.py +103 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/client.py +3 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/config.py +14 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/discovery.py +16 -2
- remotedesktop-2.4.0/src/remotedesktop/frames.py +324 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/server.py +7 -1
- {remotedesktop-2.1.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.1.0 → remotedesktop-2.4.0}/tests/test_config.py +10 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_discovery.py +42 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_frames.py +74 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_main_window.py +349 -4
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_sharing.py +99 -8
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_smoke.py +23 -3
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/uv.lock +125 -71
- remotedesktop-2.1.0/.claude/scheduled_tasks.lock +0 -1
- remotedesktop-2.1.0/src/remotedesktop/frames.py +0 -110
- remotedesktop-2.1.0/tests/conftest.py +0 -39
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/LICENSE +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/icon/remotedesktop.ico +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/run.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/run_claude.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/scripts/make_installer.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/session_lock.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/visual_effects.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_about.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_autostart.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_compat.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_icon.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_input.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_inventory.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_logs.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_performance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_preferences.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_protocol.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_session_lock.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_single_instance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_tls.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_visual_effects.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tests/test_window_state.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.4.0}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-2.1.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,16 +84,16 @@ 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). **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).
|
|
85
91
|
- **Reduce visual effects while sharing** (`visual_effects.py`): animations/fades are a burst of changed frames for the lossless pipeline, so while the preference is on (default on, `reduce_effects_enabled`, `load_reduce_effects_enabled`) **and ≥1 viewer is connected**, `MainWindow` applies `VisualEffectsReducer` and restores it when the last viewer leaves (driven by `SharingTab.viewerCountChanged`; explicit safety-net restore in `closeEvent`). The reduced set: SPI_SETUIEFFECTS off, drop shadows off, client-area + min/max animations off, submenu delay lowered to 50 ms (never raised). All via `SystemParametersInfo` **without** SPIF_UPDATEINIFILE — session-scoped, never persisted, self-heals at logoff; knobs already at the reduced value are left untouched so the user's own choices are never "restored" over. The backend (`SpiBackend`) is injectable — **tests must always inject `FakeSpiBackend`** (the preference defaults on, so a real reducer in a test would change the host's actual Windows settings; `make_window` in `tests/test_main_window.py` injects it for every window test, and real-backend tests may only read).
|
|
86
92
|
- **Theme preference** (`preferences.py`): a three-radio group in Preferences — follow the Windows light/dark setting (default) / Light / Dark — persisted as `THEME_KEY` (`"system"`/`"light"`/`"dark"`, `load_theme` falls back to system on junk). `apply_theme` drives `QStyleHints.setColorScheme` (`unsetColorScheme` for system, so OS-side switches keep applying); the radios apply live and `MainWindow.__init__` re-applies the persisted choice before building widgets. Tests that set a non-system theme must restore with `apply_theme(THEME_SYSTEM)` — the color scheme is process-global QApplication state.
|
|
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
|
-
- **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}
|
|
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
|
-
Metadata-Version: 2.
|
|
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,10 +84,10 @@ 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. 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
|
-
- 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts, a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
|
|
90
|
+
- 🧭 **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.
|
|
91
91
|
|
|
92
92
|
In scope: screen, keyboard, mouse, and clipboard. Out of scope: shared
|
|
93
93
|
drives, devices, and audio — and smooth playback of fast-changing
|
|
@@ -119,8 +119,8 @@ it prepares the environment on first use and launches the app.
|
|
|
119
119
|
## Quick start
|
|
120
120
|
|
|
121
121
|
1. Run `remotedesktop` on both computers.
|
|
122
|
-
2. On the computer to share, open *Preferences* and set
|
|
123
|
-
to one of the *Shared* modes.
|
|
122
|
+
2. On the computer to share, open *File ▸ Preferences* (Ctrl+,) and set
|
|
123
|
+
*Server (sharing)* to one of the *Shared* modes.
|
|
124
124
|
3. On the viewing computer, the shared computer appears in the panel on
|
|
125
125
|
the left — double-click it (or select it and click *Connect*).
|
|
126
126
|
4. Approve the connection in the dialog that pops up on the shared
|
|
@@ -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
|
|
@@ -228,7 +242,8 @@ certificate the server generates and keeps, then simple length-prefixed
|
|
|
228
242
|
messages on top — JSON for control (hello/welcome, input, clipboard,
|
|
229
243
|
ping/pong for the round-trip-time graphs, log exchange) and binary
|
|
230
244
|
payloads for frames and deltas. Discovery is a UDP broadcast probe that
|
|
231
|
-
every server answers with its name and
|
|
245
|
+
every server answers with its name, port, and certificate fingerprint
|
|
246
|
+
(see below).
|
|
232
247
|
|
|
233
248
|
## Versioning
|
|
234
249
|
|
|
@@ -248,17 +263,31 @@ not guaranteed. Keep both computers on the same version for best results.
|
|
|
248
263
|
## How discovery works
|
|
249
264
|
|
|
250
265
|
The client broadcasts a small JSON probe over UDP (port 48653); each server
|
|
251
|
-
on the LAN replies with its hostname
|
|
266
|
+
on the LAN replies with its hostname, connection port, and TLS certificate
|
|
267
|
+
fingerprint. The fingerprint is the server's stable identity: when a
|
|
268
|
+
reconnecting client can no longer reach a server at its last known address
|
|
269
|
+
(typically a new DHCP lease after a reboot), it re-scans and matches the
|
|
270
|
+
fingerprint to follow the server to its new address, keeping the stored
|
|
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
|
|
252
275
|
must allow Python to receive inbound UDP on that port for a server to be
|
|
253
276
|
discoverable from other machines.
|
|
254
277
|
|
|
255
278
|
## Development
|
|
256
279
|
|
|
257
280
|
```
|
|
258
|
-
uv sync
|
|
259
|
-
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
|
|
260
285
|
```
|
|
261
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
|
+
|
|
262
291
|
Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
|
|
263
292
|
MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
|
|
264
293
|
From PowerShell, Qt uses the Windows schannel backend as intended.
|
|
@@ -59,10 +59,10 @@ 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. 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
|
-
- 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts, a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
|
|
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.
|
|
66
66
|
|
|
67
67
|
In scope: screen, keyboard, mouse, and clipboard. Out of scope: shared
|
|
68
68
|
drives, devices, and audio — and smooth playback of fast-changing
|
|
@@ -94,8 +94,8 @@ it prepares the environment on first use and launches the app.
|
|
|
94
94
|
## Quick start
|
|
95
95
|
|
|
96
96
|
1. Run `remotedesktop` on both computers.
|
|
97
|
-
2. On the computer to share, open *Preferences* and set
|
|
98
|
-
to one of the *Shared* modes.
|
|
97
|
+
2. On the computer to share, open *File ▸ Preferences* (Ctrl+,) and set
|
|
98
|
+
*Server (sharing)* to one of the *Shared* modes.
|
|
99
99
|
3. On the viewing computer, the shared computer appears in the panel on
|
|
100
100
|
the left — double-click it (or select it and click *Connect*).
|
|
101
101
|
4. Approve the connection in the dialog that pops up on the shared
|
|
@@ -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
|
|
@@ -203,7 +217,8 @@ certificate the server generates and keeps, then simple length-prefixed
|
|
|
203
217
|
messages on top — JSON for control (hello/welcome, input, clipboard,
|
|
204
218
|
ping/pong for the round-trip-time graphs, log exchange) and binary
|
|
205
219
|
payloads for frames and deltas. Discovery is a UDP broadcast probe that
|
|
206
|
-
every server answers with its name and
|
|
220
|
+
every server answers with its name, port, and certificate fingerprint
|
|
221
|
+
(see below).
|
|
207
222
|
|
|
208
223
|
## Versioning
|
|
209
224
|
|
|
@@ -223,17 +238,31 @@ not guaranteed. Keep both computers on the same version for best results.
|
|
|
223
238
|
## How discovery works
|
|
224
239
|
|
|
225
240
|
The client broadcasts a small JSON probe over UDP (port 48653); each server
|
|
226
|
-
on the LAN replies with its hostname
|
|
241
|
+
on the LAN replies with its hostname, connection port, and TLS certificate
|
|
242
|
+
fingerprint. The fingerprint is the server's stable identity: when a
|
|
243
|
+
reconnecting client can no longer reach a server at its last known address
|
|
244
|
+
(typically a new DHCP lease after a reboot), it re-scans and matches the
|
|
245
|
+
fingerprint to follow the server to its new address, keeping the stored
|
|
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
|
|
227
250
|
must allow Python to receive inbound UDP on that port for a server to be
|
|
228
251
|
discoverable from other machines.
|
|
229
252
|
|
|
230
253
|
## Development
|
|
231
254
|
|
|
232
255
|
```
|
|
233
|
-
uv sync
|
|
234
|
-
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
|
|
235
260
|
```
|
|
236
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
|
+
|
|
237
266
|
Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
|
|
238
267
|
MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
|
|
239
268
|
From PowerShell, Qt uses the Windows schannel backend as intended.
|