remotedesktop 0.1.0__tar.gz → 0.3.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 (46) hide show
  1. remotedesktop-0.3.0/.github/workflows/ci.yml +53 -0
  2. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/.gitignore +25 -24
  3. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/CLAUDE.md +59 -52
  4. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/PKG-INFO +6 -2
  5. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/readme.md +98 -94
  6. remotedesktop-0.3.0/scripts/make_venv.bat +13 -0
  7. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/__init__.py +3 -3
  8. remotedesktop-0.3.0/src/remotedesktop/autostart.py +63 -0
  9. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/client.py +7 -5
  10. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/server.py +189 -171
  11. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/sharing.py +4 -0
  12. remotedesktop-0.3.0/tests/conftest.py +39 -0
  13. remotedesktop-0.3.0/tests/test_autostart.py +54 -0
  14. remotedesktop-0.3.0/tests/test_client_window.py +164 -0
  15. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_clipboard.py +64 -1
  16. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_discovery.py +55 -0
  17. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_input.py +61 -2
  18. remotedesktop-0.3.0/tests/test_input_injection.py +103 -0
  19. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_inventory.py +18 -0
  20. remotedesktop-0.3.0/tests/test_server_window.py +147 -0
  21. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_sharing.py +112 -0
  22. remotedesktop-0.3.0/tests/test_tls.py +19 -0
  23. remotedesktop-0.1.0/.github/workflows/ci.yml +0 -49
  24. remotedesktop-0.1.0/badges/coverage.svg +0 -16
  25. remotedesktop-0.1.0/tests/conftest.py +0 -16
  26. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/LICENSE +0 -0
  27. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/pyproject.toml +0 -0
  28. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/run_claude.bat +0 -0
  29. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/run_client.bat +0 -0
  30. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/run_server.bat +0 -0
  31. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/scripts/make_coverage_badge.py +0 -0
  32. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/clipboard.py +0 -0
  33. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/config.py +0 -0
  34. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/db.py +0 -0
  35. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/discovery.py +0 -0
  36. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/input_injection.py +0 -0
  37. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/inventory.py +0 -0
  38. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/protocol.py +0 -0
  39. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/tls.py +0 -0
  40. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/viewer.py +0 -0
  41. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/src/remotedesktop/window_state.py +0 -0
  42. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_config.py +0 -0
  43. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_protocol.py +0 -0
  44. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_smoke.py +0 -0
  45. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/tests/test_window_state.py +0 -0
  46. {remotedesktop-0.1.0 → remotedesktop-0.3.0}/uv.lock +0 -0
@@ -0,0 +1,53 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ ruff:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v6
14
+ - run: uvx ruff check .
15
+
16
+ ty:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: astral-sh/setup-uv@v6
21
+ # ty resolves imports from the project venv; pyproject pins the
22
+ # type-checking platform to win32 (the app's target).
23
+ - run: uv sync
24
+ - run: uvx ty check .
25
+
26
+ test:
27
+ # The app targets Windows: input injection, schannel TLS, and the GUI
28
+ # tests all assume it.
29
+ runs-on: windows-latest
30
+ permissions:
31
+ contents: write # to push the updated coverage badge
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ - uses: astral-sh/setup-uv@v6
35
+ - run: uv sync
36
+ - run: uv run pytest --cov=remotedesktop --cov-report=term
37
+ - name: Publish coverage badge
38
+ # master is PR-only (repo ruleset), so the badge lives on its own
39
+ # branch: a single-file orphan commit, force-pushed each time.
40
+ if: github.ref == 'refs/heads/master' && github.event_name == 'push'
41
+ run: |
42
+ uv run python scripts/make_coverage_badge.py badges/coverage.svg
43
+ git config user.name "github-actions[bot]"
44
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
45
+ $blob = (git hash-object -w badges/coverage.svg).Trim()
46
+ # Build the tree via a temp index, not `... | git mktree`: PowerShell
47
+ # pipes CRLF to native stdin, which puts a literal \r in the filename.
48
+ $env:GIT_INDEX_FILE = "$env:RUNNER_TEMP\badge-index"
49
+ git update-index --add --cacheinfo "100644,$blob,coverage.svg"
50
+ $tree = (git write-tree).Trim()
51
+ Remove-Item Env:\GIT_INDEX_FILE
52
+ $commit = (git commit-tree $tree -m "Update coverage badge").Trim()
53
+ git push --force origin "${commit}:refs/heads/badges"
@@ -1,24 +1,25 @@
1
- # Python
2
- __pycache__/
3
- *.py[cod]
4
- *.egg-info/
5
- .eggs/
6
- build/
7
- dist/
8
-
9
- # Virtual environments
10
- .venv/
11
- venv/
12
-
13
- # Testing
14
- .pytest_cache/
15
- .coverage
16
- htmlcov/
17
-
18
- # Tooling caches
19
- .mypy_cache/
20
- .ruff_cache/
21
-
22
- # IDE
23
- .idea/
24
- .vscode/
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Testing
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+ badges/
18
+
19
+ # Tooling caches
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+
23
+ # IDE
24
+ .idea/
25
+ .vscode/
@@ -1,52 +1,59 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## What This Project Is
6
-
7
- A Python client/server remote desktop GUI application (PySide6) for Windows computers on the same LAN, per `readme.md`:
8
-
9
- - **Autodiscovery and connection** of servers on the LAN.
10
- - **In scope:** desktop screen, keyboard, mouse, and clipboard.
11
- - **Out of scope:** shared drives, devices, and multimedia (e.g., audio).
12
- - **Two GUI apps:** a client and a server, each run on its respective computer.
13
- - **Trust model:** on first connection, the user on the server side must explicitly permit the client. After that, the client may reconnect whenever the server is running without further approval.
14
- - **Constraint:** does not use Windows RDP and does not rely on any Microsoft-based authentication.
15
-
16
- LAN autodiscovery, screen sharing, keyboard/mouse forwarding, clipboard sync, and a TLS-encrypted transport with a token-authenticated handshake are all implemented — the readme feature set plus security hardening.
17
-
18
- ## Commands
19
-
20
- Managed with `uv` (hatchling build backend, src layout):
21
-
22
- - `uv sync` create/update the venv with the project and dev dependencies
23
- - `uv run pytest` run all tests
24
- - `uv run pytest tests/test_smoke.py::test_version` — run a single test
25
- - `uv run remotedesktop-client` / `uv run remotedesktop-server` — launch the apps. These are `gui-scripts`, so they run detached with no console output; use `uv run python -m remotedesktop.client` (or `.server`) when you need stdout/tracebacks.
26
- - `uv build` — build sdist and wheel into `dist/`
27
- - `uv publish` publish to PyPI
28
-
29
- ## Architecture
30
-
31
- - Both apps are PySide6 GUI applications. `src/remotedesktop/client.py` (`ClientWindow`) and `src/remotedesktop/server.py` (`ServerWindow`) hold the app entry points; `main()` in each is wired to the `remotedesktop-client` / `remotedesktop-server` GUI scripts in `pyproject.toml`.
32
- - The client-side remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`, hosted as `ClientWindow`'s central widget. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window.
33
- - **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}`; datagrams with the wrong magic/version/type are dropped. The server runs a `DiscoveryResponder` thread while its window is open; the client's `DiscoveryPanel` (dock in `ClientWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. `DEFAULT_CONNECT_PORT` (48654) is reserved for the future desktop connection.
34
- - 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.
35
- - **Screen sharing** (`sharing.py` + `protocol.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages or JPEG frames; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen via `QScreen.grabWindow(0)`, JPEG-encodes once, and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. `ShareClient` decodes frames to QImage for the viewer.
36
- - **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 GUIs open one connection per window and share it. Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files for the server and client** since they model different machines.
37
- - **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; `ServerWindow` implements it as a modal QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
38
- - **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, and `DiscoveryPanel` emit human-readable `status` signals for every connection phase, and both windows show them in a timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path there's a test asserting the server's phase messages.
39
- - **Connection inventory** (`inventory.py`): both windows have a tab (server: "Clients on LAN"; client: "Servers on LAN") backed by a `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The server drives it from `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases). The client drives it from `ClientWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
40
- - **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; the callback gets the selected peer's key. The server's tab wires "Revoke access" → `ShareServer.revoke_client(client_id)` (removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` reports state "revoked"; a `_revoked` set distinguishes it from a plain disconnect). The client's tab wires "Forget server" → `KnownServers.forget(key)` + disconnect. After either, the next connection needs a fresh approval. Server and client each use a *separate table* (`server_peers` / `client_peers`, selected via the `table=` arg) so a machine running both apps doesn't commingle them; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
41
- - Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
42
- - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
43
- - **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
44
- - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
45
- - **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy → server and server copy → all clients, as `{"type": "clipboard", "text"/"image_png"}` messages. It's opt-in `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUIs (and clipboard tests) enable it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
46
- - The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place.
47
- - Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
48
-
49
- ## Environment Notes
50
-
51
- - Target platform is Windows; development happens on Windows 11. Requires Python >=3.14.
52
- - **Run the tests from PowerShell/cmd, not Git Bash.** Git Bash puts `C:\Program Files\Git\mingw64\bin` on PATH, so Qt loads its `openssl` TLS backend against Git's MinGW-built OpenSSL DLLs and crashes with an access violation on the first TLS connection. From PowerShell those DLLs aren't on PATH and Qt uses schannel, as intended.
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Project Is
6
+
7
+ A Python client/server remote desktop GUI application (PySide6) for Windows computers on the same LAN, per `readme.md`:
8
+
9
+ - **Autodiscovery and connection** of servers on the LAN.
10
+ - **In scope:** desktop screen, keyboard, mouse, and clipboard.
11
+ - **Out of scope:** shared drives, devices, and multimedia (e.g., audio).
12
+ - **Two GUI apps:** a client and a server, each run on its respective computer.
13
+ - **Trust model:** on first connection, the user on the server side must explicitly permit the client. After that, the client may reconnect whenever the server is running without further approval.
14
+ - **Constraint:** does not use Windows RDP and does not rely on any Microsoft-based authentication.
15
+
16
+ LAN autodiscovery, screen sharing, keyboard/mouse forwarding, clipboard sync, and a TLS-encrypted transport with a token-authenticated handshake are all implemented — the readme feature set plus security hardening.
17
+
18
+ ## Workflow
19
+
20
+ **Never push directly to `master`** a repository ruleset rejects it. Every
21
+ change goes through a pull request: branch → push → PR → CI green → merge.
22
+ CI publishes the coverage badge to the unprotected `badges` branch (readme
23
+ references it by raw URL), so no workflow ever needs to write to `master`.
24
+
25
+ ## Commands
26
+
27
+ Managed with `uv` (hatchling build backend, src layout):
28
+
29
+ - `uv sync` — create/update the venv with the project and dev dependencies
30
+ - `uv run pytest` — run all tests
31
+ - `uv run pytest tests/test_smoke.py::test_version` run a single test
32
+ - `uv run remotedesktop-client` / `uv run remotedesktop-server` launch the apps. These are `gui-scripts`, so they run detached with no console output; use `uv run python -m remotedesktop.client` (or `.server`) when you need stdout/tracebacks.
33
+ - `uv build` build sdist and wheel into `dist/`
34
+ - `uv publish` publish to PyPI
35
+
36
+ ## Architecture
37
+
38
+ - Both apps are PySide6 GUI applications. `src/remotedesktop/client.py` (`ClientWindow`) and `src/remotedesktop/server.py` (`ServerWindow`) hold the app entry points; `main()` in each is wired to the `remotedesktop-client` / `remotedesktop-server` GUI scripts in `pyproject.toml`.
39
+ - The client-side remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`, hosted as `ClientWindow`'s central widget. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window.
40
+ - **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}`; datagrams with the wrong magic/version/type are dropped. The server runs a `DiscoveryResponder` thread while its window is open; the client's `DiscoveryPanel` (dock in `ClientWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. `DEFAULT_CONNECT_PORT` (48654) is reserved for the future desktop connection.
41
+ - 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.
42
+ - **Screen sharing** (`sharing.py` + `protocol.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages or JPEG frames; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen via `QScreen.grabWindow(0)`, JPEG-encodes once, and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. `ShareClient` decodes frames to QImage for the viewer.
43
+ - **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 GUIs open one connection per window and share it. Both window classes take a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_client_window.py` / `test_server_window.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files for the server and client** since they model different machines.
44
+ - **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; `ServerWindow` implements it as a modal QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
45
+ - **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, and `DiscoveryPanel` emit human-readable `status` signals for every connection phase, and both windows show them in a timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path there's a test asserting the server's phase messages.
46
+ - **Connection inventory** (`inventory.py`): both windows have a tab (server: "Clients on LAN"; client: "Servers on LAN") backed by a `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The server drives it from `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases). The client drives it from `ClientWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
47
+ - **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; the callback gets the selected peer's key. The server's tab wires "Revoke access" → `ShareServer.revoke_client(client_id)` (removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` reports state "revoked"; a `_revoked` set distinguishes it from a plain disconnect). The client's tab wires "Forget server" → `KnownServers.forget(key)` + disconnect. After either, the next connection needs a fresh approval. Server and client each use a *separate table* (`server_peers` / `client_peers`, selected via the `table=` arg) so a machine running both apps doesn't commingle them; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
48
+ - Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
49
+ - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
50
+ - **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
51
+ - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
52
+ - **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy server and server copy all clients, as `{"type": "clipboard", "text"/"image_png"}` messages. It's opt-in — `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUIs (and clipboard tests) enable it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
53
+ - The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place.
54
+ - Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
55
+
56
+ ## Environment Notes
57
+
58
+ - Target platform is Windows; development happens on Windows 11. Requires Python >=3.14.
59
+ - **Run the tests from PowerShell/cmd, not Git Bash.** Git Bash puts `C:\Program Files\Git\mingw64\bin` on PATH, so Qt loads its `openssl` TLS backend against Git's MinGW-built OpenSSL DLLs and crashes with an access violation on the first TLS connection. From PowerShell those DLLs aren't on PATH and Qt uses schannel, as intended.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
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
5
  Author-email: James Abel <j@abel.co>
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Description-Content-Type: text/markdown
20
20
  # Remote Desktop
21
21
 
22
22
  [![CI](https://github.com/jamesabel/remotedesktop/actions/workflows/ci.yml/badge.svg)](https://github.com/jamesabel/remotedesktop/actions/workflows/ci.yml)
23
- ![Coverage](badges/coverage.svg)
23
+ ![Coverage](https://raw.githubusercontent.com/jamesabel/remotedesktop/badges/coverage.svg)
24
24
  [![PyPI](https://img.shields.io/pypi/v/remotedesktop)](https://pypi.org/project/remotedesktop/)
25
25
  ![Python](https://img.shields.io/badge/python-3.14%2B-blue)
26
26
  ![License: MIT](https://img.shields.io/badge/license-MIT-green)
@@ -28,6 +28,10 @@ Description-Content-Type: text/markdown
28
28
  A Python client/server application that provides remote desktop for Windows
29
29
  computers on the same LAN, with autodiscovery of servers.
30
30
 
31
+ The server can optionally start automatically when you log in to Windows —
32
+ a checkbox on its Status tab registers it under the per-user Run key (no
33
+ administrator rights needed).
34
+
31
35
  Connections are made to the desktop screen, keyboard, mouse, and clipboard.
32
36
  Other connections are not provided, such as shared drives, devices, or
33
37
  multimedia (e.g., audio).
@@ -1,94 +1,98 @@
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](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
- A Python client/server application that provides remote desktop for Windows
10
- computers on the same LAN, with autodiscovery of servers.
11
-
12
- Connections are made to the desktop screen, keyboard, mouse, and clipboard.
13
- Other connections are not provided, such as shared drives, devices, or
14
- multimedia (e.g., audio).
15
-
16
- The client and server are PySide6 (Qt) GUI apps, each run on its respective
17
- computer. The client requests connection to the server, and for the initial
18
- connection the user on the server side must permit the connection. After
19
- that, the client can connect to the server whenever the server is running,
20
- without the server having to grant permission again.
21
-
22
- This does not use Windows RDP nor rely on any Microsoft-based authentication.
23
-
24
- ## Status
25
-
26
- The core feature set works: autodiscovery, live screen viewing,
27
- mouse/keyboard control, and two-way clipboard sync. A running server appears
28
- in the client's *Servers* panel; opening it (after the server user approves
29
- the first connection) shows the remote screen, clicking into the view
30
- forwards mouse and keyboard input, and text/images copied on either side
31
- appear on the other. Input forwarding is safe against interruptions: if the
32
- viewer loses focus, a drag ends outside the view, or a client disconnects
33
- mid-keystroke, anything still held down is released on the server — no stuck
34
- keys or mouse buttons.
35
-
36
- The connection is encrypted with TLS, and after the server user approves a
37
- client once, that client reconnects automatically using a stored token.
38
- Unapproved connections are limited to small handshake messages until the
39
- server user admits them. The security model is tuned for a trusted LAN: the
40
- server's certificate is self-signed and trusted on first use, favoring
41
- reliable reconnection over strict certificate checking if the server's
42
- certificate ever changes, the client logs a warning and updates its stored
43
- fingerprint rather than refusing to connect.
44
-
45
- Both apps have a second tab listing every peer seen on the LAN — on the
46
- server, the clients that have connected or attempted to; on the client, the
47
- servers it has discovered or tried to reach — with each peer's current state,
48
- number of attempts, and when it was first and last seen. From that tab the
49
- server can revoke a client (disconnecting it and requiring approval to
50
- reconnect) and the client can forget a server. This inventory, and all other
51
- state (identity, pairings, settings), is stored in a SQLite database and
52
- persists across restarts. Its location is chosen by `platformdirs` (on
53
- Windows, `%LOCALAPPDATA%\remotedesktop`).
54
-
55
- ## Requirements
56
-
57
- - Windows
58
- - Python 3.14+
59
- - [uv](https://docs.astral.sh/uv/)
60
-
61
- ## Running
62
-
63
- From a clone of this repository, double-click `run_server.bat` on the
64
- computer to be shared and `run_client.bat` on the viewing computer. Each
65
- script prepares the environment (installing dependencies on first use) and
66
- then launches the app, closing its console window once the app is running.
67
-
68
- To run from a terminal instead:
69
-
70
- ```
71
- uv run remotedesktop-server
72
- uv run remotedesktop-client
73
- ```
74
-
75
- The apps are also published on PyPI as
76
- [`remotedesktop`](https://pypi.org/project/remotedesktop/).
77
-
78
- ## How discovery works
79
-
80
- The client broadcasts a small JSON probe over UDP (port 48653); each server
81
- on the LAN replies with its hostname and connection port. Windows Firewall
82
- must allow Python to receive inbound UDP on that port for a server to be
83
- discoverable from other machines.
84
-
85
- ## Development
86
-
87
- ```
88
- uv sync # set up the environment
89
- uv run pytest # run the tests
90
- ```
91
-
92
- Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
93
- MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
94
- From PowerShell, Qt uses the Windows schannel backend as intended.
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
+ A Python client/server application that provides remote desktop for Windows
10
+ computers on the same LAN, with autodiscovery of servers.
11
+
12
+ The server can optionally start automatically when you log in to Windows —
13
+ a checkbox on its Status tab registers it under the per-user Run key (no
14
+ administrator rights needed).
15
+
16
+ Connections are made to the desktop screen, keyboard, mouse, and clipboard.
17
+ Other connections are not provided, such as shared drives, devices, or
18
+ multimedia (e.g., audio).
19
+
20
+ The client and server are PySide6 (Qt) GUI apps, each run on its respective
21
+ computer. The client requests connection to the server, and for the initial
22
+ connection the user on the server side must permit the connection. After
23
+ that, the client can connect to the server whenever the server is running,
24
+ without the server having to grant permission again.
25
+
26
+ This does not use Windows RDP nor rely on any Microsoft-based authentication.
27
+
28
+ ## Status
29
+
30
+ The core feature set works: autodiscovery, live screen viewing,
31
+ mouse/keyboard control, and two-way clipboard sync. A running server appears
32
+ in the client's *Servers* panel; opening it (after the server user approves
33
+ the first connection) shows the remote screen, clicking into the view
34
+ forwards mouse and keyboard input, and text/images copied on either side
35
+ appear on the other. Input forwarding is safe against interruptions: if the
36
+ viewer loses focus, a drag ends outside the view, or a client disconnects
37
+ mid-keystroke, anything still held down is released on the server — no stuck
38
+ keys or mouse buttons.
39
+
40
+ The connection is encrypted with TLS, and after the server user approves a
41
+ client once, that client reconnects automatically using a stored token.
42
+ Unapproved connections are limited to small handshake messages until the
43
+ server user admits them. The security model is tuned for a trusted LAN: the
44
+ server's certificate is self-signed and trusted on first use, favoring
45
+ reliable reconnection over strict certificate checking if the server's
46
+ certificate ever changes, the client logs a warning and updates its stored
47
+ fingerprint rather than refusing to connect.
48
+
49
+ Both apps have a second tab listing every peer seen on the LAN — on the
50
+ server, the clients that have connected or attempted to; on the client, the
51
+ servers it has discovered or tried to reach with each peer's current state,
52
+ number of attempts, and when it was first and last seen. From that tab the
53
+ server can revoke a client (disconnecting it and requiring approval to
54
+ reconnect) and the client can forget a server. This inventory, and all other
55
+ state (identity, pairings, settings), is stored in a SQLite database and
56
+ persists across restarts. Its location is chosen by `platformdirs` (on
57
+ Windows, `%LOCALAPPDATA%\remotedesktop`).
58
+
59
+ ## Requirements
60
+
61
+ - Windows
62
+ - Python 3.14+
63
+ - [uv](https://docs.astral.sh/uv/)
64
+
65
+ ## Running
66
+
67
+ From a clone of this repository, double-click `run_server.bat` on the
68
+ computer to be shared and `run_client.bat` on the viewing computer. Each
69
+ script prepares the environment (installing dependencies on first use) and
70
+ then launches the app, closing its console window once the app is running.
71
+
72
+ To run from a terminal instead:
73
+
74
+ ```
75
+ uv run remotedesktop-server
76
+ uv run remotedesktop-client
77
+ ```
78
+
79
+ The apps are also published on PyPI as
80
+ [`remotedesktop`](https://pypi.org/project/remotedesktop/).
81
+
82
+ ## How discovery works
83
+
84
+ The client broadcasts a small JSON probe over UDP (port 48653); each server
85
+ on the LAN replies with its hostname and connection port. Windows Firewall
86
+ must allow Python to receive inbound UDP on that port for a server to be
87
+ discoverable from other machines.
88
+
89
+ ## Development
90
+
91
+ ```
92
+ uv sync # set up the environment
93
+ uv run pytest # run the tests
94
+ ```
95
+
96
+ Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
97
+ MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
98
+ From PowerShell, Qt uses the Windows schannel backend as intended.
@@ -0,0 +1,13 @@
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0.."
4
+
5
+ rem Create/update .venv with the project and dev dependencies.
6
+ uv sync
7
+ if errorlevel 1 (
8
+ echo Failed to create the environment.
9
+ pause
10
+ exit /b 1
11
+ )
12
+
13
+ echo .venv is ready.
@@ -1,3 +1,3 @@
1
- """Remote desktop client/server for Windows computers on the same LAN."""
2
-
3
- __version__ = "0.1.0"
1
+ """Remote desktop client/server for Windows computers on the same LAN."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,63 @@
1
+ """Start the server automatically when the user logs in to Windows.
2
+
3
+ Uses the per-user Run registry key (HKCU), so no administrator rights are
4
+ needed and the server starts in the interactive session — which it needs,
5
+ because approving a new client is a GUI prompt. On non-Windows platforms
6
+ this is an inert stub, like input injection.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ _IS_WINDOWS = sys.platform == "win32"
13
+ if _IS_WINDOWS:
14
+ import winreg
15
+
16
+ _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
17
+ _VALUE_NAME = "remotedesktop-server"
18
+
19
+
20
+ def server_command() -> str:
21
+ """The command line that launches this installation's server."""
22
+ exe = Path(sys.executable).with_name("remotedesktop-server.exe")
23
+ if exe.exists():
24
+ return f'"{exe}"'
25
+ # Fallback (e.g. running from source without the entry-point exe).
26
+ return f'"{sys.executable}" -m remotedesktop.server'
27
+
28
+
29
+ class Autostart:
30
+ """Reads and writes the login-autostart registration.
31
+
32
+ Tests pass their own `key_path` so they never touch the real Run key.
33
+ """
34
+
35
+ def __init__(self, *, key_path: str = _RUN_KEY, value_name: str = _VALUE_NAME) -> None:
36
+ self.available = _IS_WINDOWS
37
+ self._key_path = key_path
38
+ self._value_name = value_name
39
+
40
+ def is_enabled(self) -> bool:
41
+ if not self.available:
42
+ return False
43
+ try:
44
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self._key_path) as key:
45
+ winreg.QueryValueEx(key, self._value_name)
46
+ return True
47
+ except OSError:
48
+ return False
49
+
50
+ def set_enabled(self, enabled: bool) -> None:
51
+ if not self.available:
52
+ return
53
+ if enabled:
54
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, self._key_path) as key:
55
+ winreg.SetValueEx(key, self._value_name, 0, winreg.REG_SZ, server_command())
56
+ else:
57
+ try:
58
+ with winreg.OpenKey(
59
+ winreg.HKEY_CURRENT_USER, self._key_path, 0, winreg.KEY_SET_VALUE
60
+ ) as key:
61
+ winreg.DeleteValue(key, self._value_name)
62
+ except OSError:
63
+ pass # already not registered