remotedesktop 0.16.0__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. remotedesktop-0.17.0/PKG-INFO +148 -0
  2. remotedesktop-0.17.0/docs/media/client-demo.gif +0 -0
  3. remotedesktop-0.17.0/docs/media/server-demo.gif +0 -0
  4. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/pyproject.toml +47 -46
  5. remotedesktop-0.17.0/readme.md +129 -0
  6. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/__init__.py +1 -1
  7. remotedesktop-0.17.0/tools/make_demo_gifs.py +287 -0
  8. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/uv.lock +384 -332
  9. remotedesktop-0.16.0/PKG-INFO +0 -137
  10. remotedesktop-0.16.0/readme.md +0 -118
  11. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/.github/workflows/ci.yml +0 -0
  12. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/.gitignore +0 -0
  13. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/CLAUDE.md +0 -0
  14. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/LICENSE +0 -0
  15. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/run_claude.bat +0 -0
  16. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/run_client.bat +0 -0
  17. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/run_server.bat +0 -0
  18. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/scripts/make_coverage_badge.py +0 -0
  19. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/scripts/make_venv.bat +0 -0
  20. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/autostart.py +0 -0
  21. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/client.py +0 -0
  22. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/clipboard.py +0 -0
  23. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/config.py +0 -0
  24. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/db.py +0 -0
  25. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/discovery.py +0 -0
  26. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/dxgi.py +0 -0
  27. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/frames.py +0 -0
  28. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/icon.py +0 -0
  29. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/input_injection.py +0 -0
  30. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/inventory.py +0 -0
  31. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/logs.py +0 -0
  32. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/modal_loop.py +0 -0
  33. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/performance.py +0 -0
  34. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/preferences.py +0 -0
  35. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/protocol.py +0 -0
  36. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/server.py +0 -0
  37. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/sharing.py +0 -0
  38. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/tls.py +0 -0
  39. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/viewer.py +0 -0
  40. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/src/remotedesktop/window_state.py +0 -0
  41. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/conftest.py +0 -0
  42. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_autostart.py +0 -0
  43. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_client_window.py +0 -0
  44. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_clipboard.py +0 -0
  45. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_config.py +0 -0
  46. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_discovery.py +0 -0
  47. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_dxgi.py +0 -0
  48. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_frames.py +0 -0
  49. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_icon.py +0 -0
  50. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_input.py +0 -0
  51. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_input_injection.py +0 -0
  52. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_inventory.py +0 -0
  53. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_logs.py +0 -0
  54. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_modal_loop.py +0 -0
  55. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_performance.py +0 -0
  56. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_preferences.py +0 -0
  57. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_protocol.py +0 -0
  58. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_server_window.py +0 -0
  59. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_sharing.py +0 -0
  60. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_smoke.py +0 -0
  61. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_tls.py +0 -0
  62. {remotedesktop-0.16.0 → remotedesktop-0.17.0}/tests/test_window_state.py +0 -0
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: remotedesktop
3
+ Version: 0.17.0
4
+ Summary: Remote desktop client/server for Windows computers on the same LAN, with autodiscovery. Provides screen, keyboard, mouse, and clipboard sharing without RDP or Microsoft authentication.
5
+ Author-email: James Abel <j@abel.co>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Environment :: Win32 (MS Windows)
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: System :: Networking
14
+ Requires-Python: >=3.14
15
+ Requires-Dist: cryptography>=49.0.0
16
+ Requires-Dist: platformdirs>=4.10.0
17
+ Requires-Dist: pyside6>=6.11.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Remote Desktop
21
+
22
+ [![CI](https://github.com/jamesabel/remotedesktop/actions/workflows/ci.yml/badge.svg)](https://github.com/jamesabel/remotedesktop/actions/workflows/ci.yml)
23
+ ![Coverage](https://raw.githubusercontent.com/jamesabel/remotedesktop/badges/coverage.svg)
24
+ [![PyPI](https://img.shields.io/pypi/v/remotedesktop)](https://pypi.org/project/remotedesktop/)
25
+ ![Python](https://img.shields.io/badge/python-3.14%2B-blue)
26
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
27
+
28
+ **Lossless, low-latency remote desktop for Windows computers on your LAN — pure Python, zero configuration.**
29
+
30
+ Run the server on the computer you want to reach and the client anywhere else
31
+ on the network: the server is discovered automatically, the first connection
32
+ is approved with one click on the server side, and from then on the client
33
+ reconnects instantly whenever the server is running. The screen stream is
34
+ pixel-exact at full resolution — built for documents, code, and terminals
35
+ rather than video — captured with DXGI desktop duplication and delta-compressed
36
+ so only the parts of the screen that changed are sent. No Windows RDP, no
37
+ Microsoft accounts, no cloud: just two apps and your LAN.
38
+
39
+ ## See it in action
40
+
41
+ ### Client
42
+
43
+ ![Client demo](https://raw.githubusercontent.com/jamesabel/remotedesktop/master/docs/media/client-demo.gif)
44
+
45
+ The client discovers the server on the LAN, connects, and streams its
46
+ desktop live in the *Remote Screen* tab — click into the view and your mouse
47
+ and keyboard control the remote machine. The *Performance* tab graphs
48
+ bandwidth and round-trip time with live statistics.
49
+
50
+ ### Server
51
+
52
+ ![Server demo](https://raw.githubusercontent.com/jamesabel/remotedesktop/master/docs/media/server-demo.gif)
53
+
54
+ The server's *Status* tab shows every connected viewer — who they are (login
55
+ name, computer, OS) and how the connection is doing (bandwidth, round-trip
56
+ time with mean/min/max/p99/jitter over the recent window).
57
+
58
+ ## Features
59
+
60
+ - 🔍 **Autodiscovery** — servers announce themselves over UDP; the client lists every server on the LAN, no addresses to type.
61
+ - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
62
+ - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys.
63
+ - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other.
64
+ - 🔒 **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.
65
+ - 📊 **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.
66
+ - 🔁 **Robust connections** — dead connections are detected and dropped within seconds, and approved clients reconnect automatically without ceremony.
67
+ - 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights) and a *Restart server* button usable from the remote session itself, so you can update the software without visiting the machine.
68
+ - 🗃️ **Persistent peer inventory** — both apps keep a SQLite-backed history of every peer seen on the LAN, with one-click *revoke access* / *forget server*.
69
+
70
+ In scope: screen, keyboard, mouse, and clipboard. Out of scope: shared
71
+ drives, devices, and audio — and smooth playback of fast-changing
72
+ full-screen content (video, games) is a non-goal; the stream is optimized
73
+ for mostly-static desktop work.
74
+
75
+ ## Installation
76
+
77
+ ```
78
+ pip install remotedesktop
79
+ ```
80
+
81
+ or, with [uv](https://docs.astral.sh/uv/):
82
+
83
+ ```
84
+ uv tool install remotedesktop
85
+ ```
86
+
87
+ Or run straight from a clone of this repository: double-click
88
+ `run_server.bat` on the computer to be shared and `run_client.bat` on the
89
+ viewing computer — each prepares the environment on first use and launches
90
+ the app.
91
+
92
+ ## Quick start
93
+
94
+ 1. On the computer to share, run `remotedesktop-server`.
95
+ 2. On the viewing computer, run `remotedesktop-client` — the server appears
96
+ in its *Servers* panel; double-click it.
97
+ 3. Approve the connection in the dialog that pops up on the server. That's
98
+ it — future connections from that client need no approval.
99
+
100
+ From a terminal in a clone of this repo, the same apps run with
101
+ `uv run remotedesktop-server` and `uv run remotedesktop-client`.
102
+
103
+ ## Security model
104
+
105
+ Connections are TLS-encrypted with a self-signed certificate the server
106
+ generates once and keeps. The trust model is tuned for a **trusted LAN**:
107
+ certificates are trusted on first use and a changed fingerprint is logged
108
+ rather than blocking the connection, favoring reliable reconnection over
109
+ strict certificate checking. Unapproved clients are limited to small
110
+ handshake messages until the server user admits them; access can be revoked
111
+ at any time from the server's *Clients on LAN* tab. There is no dependency
112
+ on Windows RDP or any Microsoft-based authentication.
113
+
114
+ ## Requirements
115
+
116
+ - Windows
117
+ - Python 3.14+
118
+
119
+ ## How discovery works
120
+
121
+ The client broadcasts a small JSON probe over UDP (port 48653); each server
122
+ on the LAN replies with its hostname and connection port. Windows Firewall
123
+ must allow Python to receive inbound UDP on that port for a server to be
124
+ discoverable from other machines.
125
+
126
+ ## Development
127
+
128
+ ```
129
+ uv sync # set up the environment
130
+ uv run pytest # run the tests
131
+ ```
132
+
133
+ Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
134
+ MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
135
+ From PowerShell, Qt uses the Windows schannel backend as intended.
136
+
137
+ The demo GIFs above are generated — entirely from synthetic data — by
138
+ `uv run python tools/make_demo_gifs.py`.
139
+
140
+ ### The `badges` branch
141
+
142
+ The coverage badge above is served from the `badges` branch
143
+ (`raw.githubusercontent.com/.../badges/coverage.svg`). CI regenerates the
144
+ SVG after each test run on `master` and force-pushes it there as a single
145
+ orphan commit. It lives on its own branch because `master` only accepts
146
+ pull requests (a repository ruleset), so CI cannot commit to it directly;
147
+ keeping the badge in the repo avoids depending on an external coverage
148
+ service. The branch is generated output — never branch from it or merge it.
@@ -1,46 +1,47 @@
1
- [project]
2
- name = "remotedesktop"
3
- dynamic = ["version"]
4
- description = "Remote desktop client/server for Windows computers on the same LAN, with autodiscovery. Provides screen, keyboard, mouse, and clipboard sharing without RDP or Microsoft authentication."
5
- readme = "readme.md"
6
- license = "MIT"
7
- authors = [{ name = "James Abel", email = "j@abel.co" }]
8
- requires-python = ">=3.14"
9
- classifiers = [
10
- "Development Status :: 2 - Pre-Alpha",
11
- "Environment :: Win32 (MS Windows)",
12
- "Intended Audience :: End Users/Desktop",
13
- "Operating System :: Microsoft :: Windows",
14
- "Programming Language :: Python :: 3",
15
- "Topic :: System :: Networking",
16
- ]
17
- dependencies = [
18
- "cryptography>=49.0.0",
19
- "platformdirs>=4.10.0",
20
- "pyside6>=6.11.1",
21
- ]
22
-
23
- [project.gui-scripts]
24
- remotedesktop-client = "remotedesktop.client:main"
25
- remotedesktop-server = "remotedesktop.server:main"
26
-
27
- [dependency-groups]
28
- dev = [
29
- "pytest>=8",
30
- "pytest-cov>=7.1.0",
31
- ]
32
-
33
- [build-system]
34
- requires = ["hatchling"]
35
- build-backend = "hatchling.build"
36
-
37
- [tool.hatch.version]
38
- path = "src/remotedesktop/__init__.py"
39
-
40
- [tool.pytest.ini_options]
41
- testpaths = ["tests"]
42
-
43
- [tool.ty.environment]
44
- # The app targets Windows (SendInput via ctypes.windll); type-check as such
45
- # even when ty runs on a Linux CI runner.
46
- python-platform = "win32"
1
+ [project]
2
+ name = "remotedesktop"
3
+ dynamic = ["version"]
4
+ description = "Remote desktop client/server for Windows computers on the same LAN, with autodiscovery. Provides screen, keyboard, mouse, and clipboard sharing without RDP or Microsoft authentication."
5
+ readme = "readme.md"
6
+ license = "MIT"
7
+ authors = [{ name = "James Abel", email = "j@abel.co" }]
8
+ requires-python = ">=3.14"
9
+ classifiers = [
10
+ "Development Status :: 2 - Pre-Alpha",
11
+ "Environment :: Win32 (MS Windows)",
12
+ "Intended Audience :: End Users/Desktop",
13
+ "Operating System :: Microsoft :: Windows",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: System :: Networking",
16
+ ]
17
+ dependencies = [
18
+ "cryptography>=49.0.0",
19
+ "platformdirs>=4.10.0",
20
+ "pyside6>=6.11.1",
21
+ ]
22
+
23
+ [project.gui-scripts]
24
+ remotedesktop-client = "remotedesktop.client:main"
25
+ remotedesktop-server = "remotedesktop.server:main"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pillow>=12.3.0",
30
+ "pytest>=8",
31
+ "pytest-cov>=7.1.0",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.version]
39
+ path = "src/remotedesktop/__init__.py"
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+
44
+ [tool.ty.environment]
45
+ # The app targets Windows (SendInput via ctypes.windll); type-check as such
46
+ # even when ty runs on a Linux CI runner.
47
+ python-platform = "win32"
@@ -0,0 +1,129 @@
1
+ # Remote Desktop
2
+
3
+ [![CI](https://github.com/jamesabel/remotedesktop/actions/workflows/ci.yml/badge.svg)](https://github.com/jamesabel/remotedesktop/actions/workflows/ci.yml)
4
+ ![Coverage](https://raw.githubusercontent.com/jamesabel/remotedesktop/badges/coverage.svg)
5
+ [![PyPI](https://img.shields.io/pypi/v/remotedesktop)](https://pypi.org/project/remotedesktop/)
6
+ ![Python](https://img.shields.io/badge/python-3.14%2B-blue)
7
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
8
+
9
+ **Lossless, low-latency remote desktop for Windows computers on your LAN — pure Python, zero configuration.**
10
+
11
+ Run the server on the computer you want to reach and the client anywhere else
12
+ on the network: the server is discovered automatically, the first connection
13
+ is approved with one click on the server side, and from then on the client
14
+ reconnects instantly whenever the server is running. The screen stream is
15
+ pixel-exact at full resolution — built for documents, code, and terminals
16
+ rather than video — captured with DXGI desktop duplication and delta-compressed
17
+ so only the parts of the screen that changed are sent. No Windows RDP, no
18
+ Microsoft accounts, no cloud: just two apps and your LAN.
19
+
20
+ ## See it in action
21
+
22
+ ### Client
23
+
24
+ ![Client demo](https://raw.githubusercontent.com/jamesabel/remotedesktop/master/docs/media/client-demo.gif)
25
+
26
+ The client discovers the server on the LAN, connects, and streams its
27
+ desktop live in the *Remote Screen* tab — click into the view and your mouse
28
+ and keyboard control the remote machine. The *Performance* tab graphs
29
+ bandwidth and round-trip time with live statistics.
30
+
31
+ ### Server
32
+
33
+ ![Server demo](https://raw.githubusercontent.com/jamesabel/remotedesktop/master/docs/media/server-demo.gif)
34
+
35
+ The server's *Status* tab shows every connected viewer — who they are (login
36
+ name, computer, OS) and how the connection is doing (bandwidth, round-trip
37
+ time with mean/min/max/p99/jitter over the recent window).
38
+
39
+ ## Features
40
+
41
+ - 🔍 **Autodiscovery** — servers announce themselves over UDP; the client lists every server on the LAN, no addresses to type.
42
+ - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
43
+ - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys.
44
+ - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other.
45
+ - 🔒 **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.
46
+ - 📊 **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.
47
+ - 🔁 **Robust connections** — dead connections are detected and dropped within seconds, and approved clients reconnect automatically without ceremony.
48
+ - 🚀 **Hands-off operation** — optional start-at-login (per-user, no admin rights) and a *Restart server* button usable from the remote session itself, so you can update the software without visiting the machine.
49
+ - 🗃️ **Persistent peer inventory** — both apps keep a SQLite-backed history of every peer seen on the LAN, with one-click *revoke access* / *forget server*.
50
+
51
+ In scope: screen, keyboard, mouse, and clipboard. Out of scope: shared
52
+ drives, devices, and audio — and smooth playback of fast-changing
53
+ full-screen content (video, games) is a non-goal; the stream is optimized
54
+ for mostly-static desktop work.
55
+
56
+ ## Installation
57
+
58
+ ```
59
+ pip install remotedesktop
60
+ ```
61
+
62
+ or, with [uv](https://docs.astral.sh/uv/):
63
+
64
+ ```
65
+ uv tool install remotedesktop
66
+ ```
67
+
68
+ Or run straight from a clone of this repository: double-click
69
+ `run_server.bat` on the computer to be shared and `run_client.bat` on the
70
+ viewing computer — each prepares the environment on first use and launches
71
+ the app.
72
+
73
+ ## Quick start
74
+
75
+ 1. On the computer to share, run `remotedesktop-server`.
76
+ 2. On the viewing computer, run `remotedesktop-client` — the server appears
77
+ in its *Servers* panel; double-click it.
78
+ 3. Approve the connection in the dialog that pops up on the server. That's
79
+ it — future connections from that client need no approval.
80
+
81
+ From a terminal in a clone of this repo, the same apps run with
82
+ `uv run remotedesktop-server` and `uv run remotedesktop-client`.
83
+
84
+ ## Security model
85
+
86
+ Connections are TLS-encrypted with a self-signed certificate the server
87
+ generates once and keeps. The trust model is tuned for a **trusted LAN**:
88
+ certificates are trusted on first use and a changed fingerprint is logged
89
+ rather than blocking the connection, favoring reliable reconnection over
90
+ strict certificate checking. Unapproved clients are limited to small
91
+ handshake messages until the server user admits them; access can be revoked
92
+ at any time from the server's *Clients on LAN* tab. There is no dependency
93
+ on Windows RDP or any Microsoft-based authentication.
94
+
95
+ ## Requirements
96
+
97
+ - Windows
98
+ - Python 3.14+
99
+
100
+ ## How discovery works
101
+
102
+ The client broadcasts a small JSON probe over UDP (port 48653); each server
103
+ on the LAN replies with its hostname and connection port. Windows Firewall
104
+ must allow Python to receive inbound UDP on that port for a server to be
105
+ discoverable from other machines.
106
+
107
+ ## Development
108
+
109
+ ```
110
+ uv sync # set up the environment
111
+ uv run pytest # run the tests
112
+ ```
113
+
114
+ Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
115
+ MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
116
+ From PowerShell, Qt uses the Windows schannel backend as intended.
117
+
118
+ The demo GIFs above are generated — entirely from synthetic data — by
119
+ `uv run python tools/make_demo_gifs.py`.
120
+
121
+ ### The `badges` branch
122
+
123
+ The coverage badge above is served from the `badges` branch
124
+ (`raw.githubusercontent.com/.../badges/coverage.svg`). CI regenerates the
125
+ SVG after each test run on `master` and force-pushes it there as a single
126
+ orphan commit. It lives on its own branch because `master` only accepts
127
+ pull requests (a repository ruleset), so CI cannot commit to it directly;
128
+ keeping the badge in the repo avoids depending on an external coverage
129
+ service. The branch is generated output — never branch from it or merge it.
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.16.0"
3
+ __version__ = "0.17.0"
@@ -0,0 +1,287 @@
1
+ """Generate the README demo GIFs (docs/media/*.gif) from synthetic data.
2
+
3
+ Runs the real client and server GUI apps in one process, connected over
4
+ loopback TLS with temp-file databases, and drives a short scripted session:
5
+ discovery, approval, live streaming of a *drawn* fake desktop (the
6
+ ShareServer._capture test seam — no real screen content is ever captured),
7
+ tab switches. Both windows are rendered with WA_DontShowOnScreen, grabbed on
8
+ a timer, and assembled into GIFs with Pillow.
9
+
10
+ Machine identities are synthetic too ("DEN-PC" serving, "LAPTOP" viewing,
11
+ user "alex"), so nothing about the generating machine leaks into the README.
12
+
13
+ Usage: uv run python tools/make_demo_gifs.py [output_dir]
14
+ (default output_dir: docs/media)
15
+ """
16
+
17
+ import io
18
+ import math
19
+ import socket
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ import uuid
24
+ from pathlib import Path
25
+
26
+ from PIL import Image
27
+ from PySide6.QtCore import QBuffer, QPointF, Qt
28
+ from PySide6.QtGui import QColor, QFont, QImage, QLinearGradient, QPainter, QPolygonF
29
+ from PySide6.QtWidgets import QApplication, QMessageBox, QTabWidget
30
+
31
+ from remotedesktop import db, sharing, tls
32
+ from remotedesktop.autostart import Autostart
33
+ from remotedesktop.client import ClientWindow
34
+ from remotedesktop.config import PairedClients, Settings
35
+ from remotedesktop.discovery import ServerInfo
36
+ from remotedesktop.server import ServerWindow
37
+ from remotedesktop import client as client_module
38
+
39
+ SCREEN_W, SCREEN_H = 1280, 800
40
+ GIF_WIDTH = 900
41
+ CAPTURE_INTERVAL = 0.15 # seconds per frame, also the GIF frame duration
42
+ TOTAL_SECONDS = 11.0
43
+ _CODE_LINES = [
44
+ "def changed_bands(previous, current):",
45
+ ' """Bands of `current` that differ from `previous`."""',
46
+ " stride = current.bytesPerLine()",
47
+ " bands, y = [], 0",
48
+ " while y < current.height():",
49
+ " h = min(BAND_HEIGHT, height - y)",
50
+ " if prev[y*stride:(y+h)*stride] != cur[y*stride:(y+h)*stride]:",
51
+ " bands.append((y, h))",
52
+ " y += h",
53
+ " return bands",
54
+ "",
55
+ "class ShareServer(QObject):",
56
+ " clientCountChanged = Signal(int)",
57
+ " def listen(self, port):",
58
+ " return self._server.listen(Any, port)",
59
+ ]
60
+
61
+
62
+ class SyntheticDesktop:
63
+ """Draws an animated fake desktop: wallpaper, an editor window with
64
+ scrolling code, a terminal appending lines, a clock, a gliding cursor."""
65
+
66
+ def __init__(self) -> None:
67
+ self._start = time.monotonic()
68
+ self._background = self._draw_background()
69
+
70
+ @staticmethod
71
+ def _draw_background() -> QImage:
72
+ image = QImage(SCREEN_W, SCREEN_H, QImage.Format.Format_RGB32)
73
+ painter = QPainter(image)
74
+ gradient = QLinearGradient(0, 0, SCREEN_W, SCREEN_H)
75
+ gradient.setColorAt(0.0, QColor(16, 42, 84))
76
+ gradient.setColorAt(1.0, QColor(52, 18, 90))
77
+ painter.fillRect(0, 0, SCREEN_W, SCREEN_H, gradient)
78
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
79
+ painter.setPen(Qt.PenStyle.NoPen)
80
+ for i, radius in enumerate((260, 180, 120)):
81
+ painter.setBrush(QColor(255, 255, 255, 10 + i * 4))
82
+ painter.drawEllipse(QPointF(1020 + i * 40, 190 - i * 20), radius, radius)
83
+ painter.end()
84
+ return image
85
+
86
+ def grab(self) -> QImage:
87
+ t = time.monotonic() - self._start
88
+ image = self._background.copy()
89
+ painter = QPainter(image)
90
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
91
+ self._editor(painter, t)
92
+ self._terminal(painter, t)
93
+ self._taskbar(painter, t)
94
+ self._cursor(painter, t)
95
+ painter.end()
96
+ return image
97
+
98
+ def _window_frame(self, painter: QPainter, x: int, y: int, w: int, h: int, title: str) -> None:
99
+ painter.setPen(QColor(70, 70, 70))
100
+ painter.setBrush(QColor(32, 33, 36))
101
+ painter.drawRoundedRect(x, y, w, h, 6, 6)
102
+ painter.fillRect(x + 1, y + 1, w - 2, 30, QColor(48, 49, 52))
103
+ painter.setPen(QColor(220, 220, 220))
104
+ painter.setFont(QFont("Segoe UI", 10))
105
+ painter.drawText(x + 12, y + 21, title)
106
+ for i, color in enumerate(("#ff5f57", "#febc2e", "#28c840")):
107
+ painter.setPen(Qt.PenStyle.NoPen)
108
+ painter.setBrush(QColor(color))
109
+ painter.drawEllipse(QPointF(x + w - 20 - i * 22, y + 15), 6, 6)
110
+
111
+ def _editor(self, painter: QPainter, t: float) -> None:
112
+ x, y, w, h = 60, 60, 720, 520
113
+ self._window_frame(painter, x, y, w, h, "frames.py — editor")
114
+ painter.setClipRect(x + 1, y + 32, w - 2, h - 40)
115
+ painter.setFont(QFont("Consolas", 11))
116
+ offset = int(t * 22) % (len(_CODE_LINES) * 24)
117
+ for i in range(24):
118
+ line = _CODE_LINES[(i + offset // 24) % len(_CODE_LINES)]
119
+ line_y = y + 56 + i * 24 - offset % 24
120
+ painter.setPen(QColor(90, 96, 105))
121
+ painter.drawText(x + 16, line_y, f"{(i + offset // 24) % 99 + 1:>3}")
122
+ keyword = line.lstrip().startswith(("def ", "class ", "return", "while", "if "))
123
+ painter.setPen(QColor("#c586c0") if keyword else QColor("#9cdcfe"))
124
+ painter.drawText(x + 56, line_y, line)
125
+ painter.setClipping(False)
126
+
127
+ def _terminal(self, painter: QPainter, t: float) -> None:
128
+ x, y, w, h = 620, 320, 580, 360
129
+ self._window_frame(painter, x, y, w, h, "PowerShell")
130
+ painter.setClipRect(x + 1, y + 32, w - 2, h - 40)
131
+ painter.setFont(QFont("Consolas", 10))
132
+ lines = int(t * 2) + 1
133
+ for i in range(min(lines, 12)):
134
+ step = lines - min(lines, 12) + i
135
+ painter.setPen(QColor("#4ec9b0"))
136
+ painter.drawText(x + 14, y + 54 + i * 26, f"PS> uv run pytest -q # run {step}")
137
+ painter.setPen(QColor(200, 200, 200))
138
+ painter.drawText(x + 300, y + 54 + i * 26, f"{170 + step} passed")
139
+ painter.setClipping(False)
140
+
141
+ def _taskbar(self, painter: QPainter, t: float) -> None:
142
+ painter.fillRect(0, SCREEN_H - 44, SCREEN_W, 44, QColor(24, 24, 28))
143
+ painter.setPen(Qt.PenStyle.NoPen)
144
+ for i in range(6):
145
+ painter.setBrush(QColor(70 + i * 12, 120, 200, 180))
146
+ painter.drawRoundedRect(16 + i * 52, SCREEN_H - 36, 40, 28, 4, 4)
147
+ painter.setPen(QColor(230, 230, 230))
148
+ painter.setFont(QFont("Segoe UI", 10))
149
+ clock = f"{(14 + int(t) // 3600) % 24:02d}:{(31 + int(t) // 60) % 60:02d}:{int(t) % 60:02d}"
150
+ painter.drawText(SCREEN_W - 90, SCREEN_H - 16, clock)
151
+
152
+ def _cursor(self, painter: QPainter, t: float) -> None:
153
+ cx = 640 + 380 * math.sin(t * 0.9)
154
+ cy = 400 + 240 * math.sin(t * 0.6 + 1.3)
155
+ arrow = QPolygonF(
156
+ [
157
+ QPointF(cx, cy),
158
+ QPointF(cx, cy + 21),
159
+ QPointF(cx + 6, cy + 16),
160
+ QPointF(cx + 13, cy + 15),
161
+ ]
162
+ )
163
+ painter.setPen(QColor(0, 0, 0))
164
+ painter.setBrush(QColor(255, 255, 255))
165
+ painter.drawPolygon(arrow)
166
+
167
+
168
+ def free_udp_port() -> int:
169
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
170
+ probe.bind(("", 0))
171
+ return probe.getsockname()[1]
172
+
173
+
174
+ def tab_index(tabs: QTabWidget, label: str) -> int:
175
+ for index in range(tabs.count()):
176
+ if tabs.tabText(index) == label:
177
+ return index
178
+ raise LookupError(label)
179
+
180
+
181
+ def to_gif_frame(window) -> Image.Image:
182
+ image = window.grab().toImage().scaledToWidth(
183
+ GIF_WIDTH, Qt.TransformationMode.SmoothTransformation
184
+ )
185
+ buffer = QBuffer()
186
+ buffer.open(QBuffer.OpenModeFlag.WriteOnly)
187
+ image.save(buffer, "PNG")
188
+ png = bytes(buffer.data()) # ty: ignore[invalid-argument-type]
189
+ return Image.open(io.BytesIO(png)).convert("RGB")
190
+
191
+
192
+ def save_gif(frames: list[Image.Image], path: Path) -> None:
193
+ quantized = [f.convert("P", palette=Image.Palette.ADAPTIVE, colors=128) for f in frames]
194
+ quantized[0].save(
195
+ path,
196
+ save_all=True,
197
+ append_images=quantized[1:],
198
+ duration=int(CAPTURE_INTERVAL * 1000),
199
+ loop=0,
200
+ optimize=True,
201
+ )
202
+ print(f"{path}: {len(frames)} frames, {path.stat().st_size // 1024} KB")
203
+
204
+
205
+ def main() -> None:
206
+ out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("docs/media")
207
+ out_dir.mkdir(parents=True, exist_ok=True)
208
+ app = QApplication(sys.argv)
209
+
210
+ # Everything below is synthetic: names, users, databases, screen content.
211
+ socket.gethostname = lambda: "DEN-PC" # ty: ignore[invalid-assignment]
212
+ sharing._client_details = lambda: { # ty: ignore[invalid-assignment]
213
+ "user": "alex",
214
+ "host": "LAPTOP",
215
+ "os": "Windows 11 (10.0.26200)",
216
+ }
217
+ QMessageBox.show = lambda self: None # approval prompt answers itself
218
+ QMessageBox.exec = lambda self: QMessageBox.StandardButton.Yes
219
+
220
+ with tempfile.TemporaryDirectory() as tmp:
221
+ tmp_path = Path(tmp)
222
+ credentials = tls.load_or_create_credentials(
223
+ tmp_path / "cert.pem", tmp_path / "key.pem"
224
+ )
225
+ server_db = db.connect(tmp_path / "server.db")
226
+ server_window = ServerWindow(
227
+ discovery_port=free_udp_port(), # ephemeral: invisible to the real LAN
228
+ connect_port=0,
229
+ paired=PairedClients(server_db),
230
+ credentials=credentials,
231
+ connection=server_db,
232
+ autostart=Autostart(key_path=r"Software\remotedesktop-tests\Demo", value_name="demo"),
233
+ )
234
+ desktop = SyntheticDesktop()
235
+ server_window.share_server._capture = desktop.grab # the test seam # ty: ignore[invalid-assignment]
236
+
237
+ client_db = db.connect(tmp_path / "client.db")
238
+ client_settings = Settings(client_db)
239
+ client_settings.set("client_id", str(uuid.uuid4()))
240
+ client_settings.set("client_name", "LAPTOP")
241
+ client_window = ClientWindow(connection=client_db, auto_scan=False)
242
+
243
+ for window, size in ((server_window, (1180, 620)), (client_window, (1180, 760))):
244
+ window.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
245
+ window.resize(*size)
246
+ window.show()
247
+
248
+ info = ServerInfo(name="DEN-PC", host="127.0.0.1", port=server_window.share_server.port)
249
+ client_module.discover_servers = lambda: [info] # ty: ignore[invalid-assignment]
250
+ client_tabs = client_window.centralWidget()
251
+ server_tabs = server_window.centralWidget()
252
+ assert isinstance(client_tabs, QTabWidget) and isinstance(server_tabs, QTabWidget)
253
+
254
+ script = [
255
+ (0.8, lambda: client_window.discovery_panel.refresh()),
256
+ (2.0, lambda: client_window.discovery_panel.serverActivated.emit(info)),
257
+ (6.8, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Performance"))),
258
+ (7.2, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Performance"))),
259
+ (9.2, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Remote Screen"))),
260
+ (9.6, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Status"))),
261
+ ]
262
+ client_frames: list[Image.Image] = []
263
+ server_frames: list[Image.Image] = []
264
+ start = time.monotonic()
265
+ next_capture = 0.0
266
+ while (elapsed := time.monotonic() - start) < TOTAL_SECONDS:
267
+ while script and elapsed >= script[0][0]:
268
+ script.pop(0)[1]()
269
+ if elapsed >= next_capture:
270
+ client_frames.append(to_gif_frame(client_window))
271
+ server_frames.append(to_gif_frame(server_window))
272
+ next_capture += CAPTURE_INTERVAL
273
+ app.processEvents()
274
+ time.sleep(0.005)
275
+
276
+ save_gif(client_frames, out_dir / "client-demo.gif")
277
+ save_gif(server_frames, out_dir / "server-demo.gif")
278
+ client_window.close()
279
+ server_window.close()
280
+ app.processEvents()
281
+ # The temp dir can't be removed while the SQLite files are open.
282
+ client_db.close()
283
+ server_db.close()
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()