remotedesktop 1.4.0__tar.gz → 1.6.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-1.4.0 → remotedesktop-1.6.0}/.github/workflows/release.yml +27 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/.gitignore +6 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/CLAUDE.md +26 -2
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/PKG-INFO +12 -5
- remotedesktop-1.6.0/docs/media/client-demo.gif +0 -0
- remotedesktop-1.6.0/docs/media/server-demo.gif +0 -0
- remotedesktop-1.6.0/icon/remotedesktop.ico +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/pyproject.toml +13 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/readme.md +11 -4
- remotedesktop-1.6.0/scripts/make_installer.bat +27 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/app.py +133 -48
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/autostart.py +23 -1
- remotedesktop-1.6.0/src/remotedesktop/cursor_shape.py +89 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/preferences.py +65 -6
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/server.py +13 -6
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/sharing.py +41 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/viewer.py +44 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_autostart.py +29 -1
- remotedesktop-1.6.0/tests/test_cursor_shape.py +20 -0
- remotedesktop-1.6.0/tests/test_icon.py +31 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_input.py +15 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_main_window.py +95 -21
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_preferences.py +31 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_sharing.py +36 -1
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_smoke.py +35 -0
- remotedesktop-1.6.0/tools/make_icon.py +49 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/uv.lock +549 -0
- remotedesktop-1.4.0/docs/media/client-demo.gif +0 -0
- remotedesktop-1.4.0/docs/media/server-demo.gif +0 -0
- remotedesktop-1.4.0/tests/test_icon.py +0 -20
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/LICENSE +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/run.bat +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/run_claude.bat +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/conftest.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_about.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_compat.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_config.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_discovery.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_frames.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_inventory.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_logs.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_performance.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_protocol.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_single_instance.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_tls.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tests/test_window_state.py +0 -0
- {remotedesktop-1.4.0 → remotedesktop-1.6.0}/tools/make_demo_gifs.py +0 -0
|
@@ -20,6 +20,8 @@ jobs:
|
|
|
20
20
|
runs-on: ubuntu-latest
|
|
21
21
|
permissions:
|
|
22
22
|
contents: write # create the tag and the release
|
|
23
|
+
outputs:
|
|
24
|
+
version: ${{ steps.version.outputs.version }}
|
|
23
25
|
steps:
|
|
24
26
|
- uses: actions/checkout@v4
|
|
25
27
|
- uses: astral-sh/setup-uv@v6
|
|
@@ -46,3 +48,28 @@ jobs:
|
|
|
46
48
|
--title "${{ steps.version.outputs.version }}" \
|
|
47
49
|
--generate-notes \
|
|
48
50
|
dist/*
|
|
51
|
+
|
|
52
|
+
# Unsigned installer build (the signing token is local-only). To replace it
|
|
53
|
+
# with a signed one: run scripts\make_installer.bat locally, then
|
|
54
|
+
# gh release upload v<version> installers\remotedesktop_installer_win64.exe --clobber
|
|
55
|
+
installer:
|
|
56
|
+
needs: release
|
|
57
|
+
runs-on: windows-latest # .NET Framework csc.exe is preinstalled (launcher build)
|
|
58
|
+
permissions:
|
|
59
|
+
contents: write # upload the installer to the release
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v4
|
|
62
|
+
- uses: astral-sh/setup-uv@v6
|
|
63
|
+
- name: Install NSIS
|
|
64
|
+
# Not preinstalled on windows-latest; choco puts makensis.exe at
|
|
65
|
+
# C:\Program Files (x86)\NSIS\makensis.exe, pyship's default path.
|
|
66
|
+
run: choco install nsis -y --no-progress
|
|
67
|
+
- name: Build installer
|
|
68
|
+
run: uv run --group ship python -m pyship --noupload
|
|
69
|
+
- name: Attach installer to release
|
|
70
|
+
env:
|
|
71
|
+
GH_TOKEN: ${{ github.token }}
|
|
72
|
+
run: >
|
|
73
|
+
gh release upload "v${{ needs.release.outputs.version }}"
|
|
74
|
+
--repo "$env:GITHUB_REPOSITORY" --clobber
|
|
75
|
+
installers/remotedesktop_installer_win64.exe
|
|
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
6
6
|
|
|
7
7
|
A Python remote desktop GUI application (PySide6) for Windows computers on the same LAN, per `readme.md`:
|
|
8
8
|
|
|
9
|
-
- **One app, either or both roles**: every instance can view other computers, and opts in to *sharing* its own screen via a three-state Screen sharing choice in Preferences — Not shared / view only / full control (persisted; a viewing-only instance binds no ports and creates no TLS credentials). The **viewer role is opt-out** too: Preferences' "Client (viewer)" checkbox (`viewer_enabled`, default on, `load_viewer_enabled`) — when off, `MainWindow._set_viewer_enabled` closes open sessions and hides all client UI (Server placeholder tab, discovery panel, "Server history" group), disables F5, skips session restore, and refuses activations; discovery never broadcasts. Two **role indicator buttons** at the top of the left dock (
|
|
9
|
+
- **One app, either or both roles**: every instance can view other computers, and opts in to *sharing* its own screen via a three-state Screen sharing choice in Preferences — Not shared / view only / full control (persisted; a viewing-only instance binds no ports and creates no TLS credentials). The **viewer role is opt-out** too: Preferences' "Client (viewer)" checkbox (`viewer_enabled`, default on, `load_viewer_enabled`) — when off, `MainWindow._set_viewer_enabled` closes open sessions and hides all client UI (Server placeholder tab, discovery panel, "Server history" group), disables F5, skips session restore, and refuses activations; discovery never broadcasts. Two **role indicator buttons** at the top of the left dock (`panel_dock`; headerless — the View menu names it "Panel") (`client_role_button`/`server_role_button` — checkable QPushButtons styled green when on, made purely informational via `WA_TransparentForMouseEvents`; roles change only in Preferences) say what the instance is, updated on viewer toggle and `sharingChanged`. The dock always exists (a server-only instance shows just the indicators) and is **headerless** — no title bar at all (`setTitleBarWidget` with an empty widget, `NoDockWidgetFeatures`); View ▸ Panel is the only way to show/hide it. Only one instance runs per user session — a second launch activates the first (`single_instance.py`).
|
|
10
10
|
- **Autodiscovery and connection** of sharing computers on the LAN.
|
|
11
11
|
- **In scope:** desktop screen, keyboard, mouse, and clipboard.
|
|
12
12
|
- **Out of scope:** shared drives, devices, and multimedia (e.g., audio). Smooth playback of fast-changing full-screen content (video, games) is a non-goal: the screen transfer is lossless and optimized for mostly-static desktop work, not for video bandwidth.
|
|
@@ -34,6 +34,21 @@ attached. It is idempotent (skips if the release exists) and can be run
|
|
|
34
34
|
manually via workflow_dispatch. PyPI publishing stays manual (`uv publish`),
|
|
35
35
|
only on the user's explicit request.
|
|
36
36
|
|
|
37
|
+
The same workflow's `installer` job (windows-latest) builds the **pyship
|
|
38
|
+
installer** (`uv run --group ship python -m pyship --noupload`) and attaches
|
|
39
|
+
it to the release — **unsigned**, because the signing token is local-only. To
|
|
40
|
+
ship a signed installer, build locally (`scripts\make_installer.bat`) and
|
|
41
|
+
replace the asset:
|
|
42
|
+
`gh release upload v<version> installers\remotedesktop_installer_win64.exe --clobber`.
|
|
43
|
+
The frozen app is a pyship CLIP (a full standalone CPython), so `pythonw.exe
|
|
44
|
+
-m remotedesktop` still works inside it; `autostart.installed_launcher()`
|
|
45
|
+
detects the install (CLIP dir name `remotedesktop_<version>`) and points
|
|
46
|
+
autostart/restart at the launcher exe, which always starts the newest
|
|
47
|
+
installed version. Users must quit the app before running an installer — a
|
|
48
|
+
running instance holds the CLIP's DLLs locked. Do not set
|
|
49
|
+
`[tool.pyship] run_on_startup` or signing keys in `pyproject.toml` (see the
|
|
50
|
+
comments there).
|
|
51
|
+
|
|
37
52
|
## Commands
|
|
38
53
|
|
|
39
54
|
Managed with `uv` (hatchling build backend, src layout):
|
|
@@ -44,16 +59,24 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
44
59
|
- `uv run remotedesktop` — launch the app. It's a `gui-script`, so it runs detached with no console output; use `uv run python -m remotedesktop` when you need stdout/tracebacks. `--minimized` starts hidden in the tray when sharing is on (what the autostart registration passes).
|
|
45
60
|
- `uv build` — build sdist and wheel into `dist/`
|
|
46
61
|
- `uv publish` — publish to PyPI
|
|
62
|
+
- `scripts\make_installer.bat` — build the **signed Windows installer** with pyship
|
|
63
|
+
(`installers\remotedesktop_installer_win64.exe`). Needs the hardware signing token and a
|
|
64
|
+
local console session (pyship refuses token signing over RDP); set
|
|
65
|
+
`PYSHIP_SIGNING_CERTIFICATE_PIN` for unattended signing. pyship wipes and rebuilds
|
|
66
|
+
`dist/`, `app/` (launcher exe + frozen CLIP), and `installers/` — all gitignored.
|
|
67
|
+
`icon/remotedesktop.ico` is the build-input icon; regenerate with
|
|
68
|
+
`uv run python tools/make_icon.py` after changing `icon.py` (a test guards it).
|
|
47
69
|
|
|
48
70
|
## Architecture
|
|
49
71
|
|
|
50
72
|
- One PySide6 GUI app: `src/remotedesktop/app.py` (`MainWindow` + `main()`, wired to the `remotedesktop` GUI script in `pyproject.toml`; `__main__.py` makes `python -m remotedesktop` work — the in-app Restart relaunches that). `client.py` holds the viewing-role widgets (`DiscoveryPanel`, `ServerSession`), `server.py` the sharing-role widgets (`SharingTab`, `ViewersTable`) — neither has a window or entry point of its own anymore.
|
|
51
|
-
- **The sharing lifecycle** (`server.py` `SharingTab`) lives on the "Connections" tab,
|
|
73
|
+
- **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 two legacy keys `server_enabled` + `allow_remote_input` so old installs migrate for free. 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`).
|
|
52
74
|
- **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` (used by the autostart registration) starts hidden in the tray when sharing is on. `single_instance.py` (`SingleInstance`, QLocalServer-based) enforces one instance per user session; a second launch tells the first to `bring_to_front()` and exits. `autostart.py` registers `remotedesktop.exe --minimized` under HKCU Run value `"remotedesktop"` and migrates the pre-1.0 `"remotedesktop-server"` value at startup (`migrate_legacy`).
|
|
53
75
|
- 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]`.
|
|
54
76
|
- **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.
|
|
55
77
|
- **Menus & shortcuts contract** (`app.py` `_build_menus`, `viewer.py`): File/View/Help menu bar; shortcuts Ctrl+W (close session tab), Ctrl+Q (quit), F5 (rescan), F11 (fullscreen). 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).
|
|
56
78
|
- **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.
|
|
79
|
+
- **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.
|
|
57
80
|
- **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.
|
|
58
81
|
- **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. **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).
|
|
59
82
|
- 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.
|
|
@@ -65,6 +88,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
65
88
|
- **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; each row gets its own button labeled `action_label` (in the trailing stretch column) whose callback receives that row's peer key. "Client history" wires "Revoke" → `SharingTab.revoke_client(client_id)` (via `ShareServer.revoke_client` while sharing — removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` emits a "revoked" peer event; a `_revoked` set distinguishes it from a plain disconnect — or directly via `PairedClients.revoke` while not sharing). "Server history" wires "Forget" → `KnownServers.forget(key)` + disconnect + `ConnectionInventory.remove(key)`. **Both delete the peer's row from the table and the DB outright** (`MainWindow._record_server_peer` turns a "revoked" event into a `remove`); a rescan or new connection attempt records the peer afresh. After either, the next connection needs a fresh approval. The roles use *separate tables* (`server_peers` / `client_peers`, selected via the `table=` arg) so they don't commingle; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
|
|
66
89
|
- 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.
|
|
67
90
|
- **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.
|
|
91
|
+
- **Remote cursor shape** (`cursor_shape.py`): the captured frame never contains the mouse pointer (DXGI duplication and BitBlt both exclude it), so the client mirrors the server's cursor on its *local* cursor instead. `ShareServer` takes an opt-in `cursor_probe=` callable (the `clipboard=` pattern — only the GUI passes one, `SharingTab` wires `current_cursor_shape`: GetCursorInfo compared against the shared standard IDC_* handles; custom cursors → "arrow", not showing → "hidden"). The probe is polled each broadcast tick and `{"type": "cursor", "shape": ...}` is sent on change only, plus once to each just-admitted stream. `ShareClient.cursorShapeChanged` → `ViewerWidget.set_remote_cursor` maps names to Qt cursors (unknown names → arrow); `clear()` unsets the cursor. Peers without the feature never send / silently ignore the message.
|
|
68
92
|
- **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for a sharing instance this deadlocked when a *remote* click grabbed the window's own title bar (the mouse-up sat unread on the socket). `MainWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). The caption buttons (min/max/close) are the exception — their tracking loop dispatches only mouse messages, so no timer can pump inside it; the pump instead performs those via its `caption_action` callback on press and consumes the message (nativeEvent must return True when `handle_native_event` does), so DefWindowProc's button tracking never starts. Tests inject a fake `timers` backend — never let them create real Win32 timers.
|
|
69
93
|
- **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the app passes `logs.read_log_tail("remotedesktop")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. The Connection log group on the Connections tab has both buttons — "Get server log" asks the server of the current session tab, "Get client log" asks the most recently admitted viewer; the reply opens a `logs.PeerLogDialog`.
|
|
70
94
|
- **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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -62,7 +62,7 @@ time with live statistics.
|
|
|
62
62
|
|
|
63
63
|
Sharing is turned on in Preferences (*view only* or *full control*). The
|
|
64
64
|
*Connections* tab then shows everything in one place: every connected
|
|
65
|
-
viewer — who they are (
|
|
65
|
+
viewer — who they are (name, login, OS) and how the connection is
|
|
66
66
|
doing (bandwidth, round-trip time with mean/min/max/p99/jitter over the
|
|
67
67
|
recent window) — the history of servers seen and clients paired with
|
|
68
68
|
one-click *Forget* / *Revoke*, and the live connection log.
|
|
@@ -73,7 +73,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
73
73
|
- 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
|
|
74
74
|
- 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
|
|
75
75
|
- 🖥️ **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.
|
|
76
|
-
- ⌨️🖱️ **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. Prefer eyes-only? Choose *Shared
|
|
76
|
+
- ⌨️🖱️ **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. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
|
|
77
77
|
- 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
|
|
78
78
|
- 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
|
|
79
79
|
- 🔒 **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.
|
|
@@ -90,6 +90,13 @@ for mostly-static desktop work.
|
|
|
90
90
|
|
|
91
91
|
## Installation
|
|
92
92
|
|
|
93
|
+
The easiest way: download `remotedesktop_installer_win64.exe` from the
|
|
94
|
+
[latest release](https://github.com/jamesabel/remotedesktop/releases/latest)
|
|
95
|
+
and run it — no Python required. To upgrade, quit the app first (tray →
|
|
96
|
+
Quit if it's sharing), then run the newer installer.
|
|
97
|
+
|
|
98
|
+
With Python, from PyPI:
|
|
99
|
+
|
|
93
100
|
```
|
|
94
101
|
pip install remotedesktop
|
|
95
102
|
```
|
|
@@ -108,8 +115,8 @@ it prepares the environment on first use and launches the app.
|
|
|
108
115
|
1. Run `remotedesktop` on both computers.
|
|
109
116
|
2. On the computer to share, open *Preferences* and set *Server (sharing)*
|
|
110
117
|
to one of the *Shared* modes.
|
|
111
|
-
3. On the viewing computer, the shared computer appears in the
|
|
112
|
-
|
|
118
|
+
3. On the viewing computer, the shared computer appears in the panel on
|
|
119
|
+
the left — double-click it (or select it and click *Connect*).
|
|
113
120
|
4. Approve the connection in the dialog that pops up on the shared
|
|
114
121
|
computer. That's it — future connections from that computer need no
|
|
115
122
|
approval.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -35,6 +35,10 @@ dev = [
|
|
|
35
35
|
"pytest>=8",
|
|
36
36
|
"pytest-cov>=7.1.0",
|
|
37
37
|
]
|
|
38
|
+
# Only for building the Windows installer (scripts/make_installer.bat / release CI).
|
|
39
|
+
ship = [
|
|
40
|
+
"pyship>=0.7.4",
|
|
41
|
+
]
|
|
38
42
|
|
|
39
43
|
[build-system]
|
|
40
44
|
requires = ["hatchling"]
|
|
@@ -46,6 +50,15 @@ path = "src/remotedesktop/__init__.py"
|
|
|
46
50
|
[tool.pytest.ini_options]
|
|
47
51
|
testpaths = ["tests"]
|
|
48
52
|
|
|
53
|
+
[tool.pyship]
|
|
54
|
+
ui = "gui"
|
|
55
|
+
# Never upload to S3, even without --noupload. Code signing stays out of this
|
|
56
|
+
# config on purpose: CI installer builds are unsigned (the signing token is
|
|
57
|
+
# local-only); scripts/make_installer.bat passes the signing flags.
|
|
58
|
+
upload = false
|
|
59
|
+
# run_on_startup deliberately NOT set: it would write an HKLM Run value named
|
|
60
|
+
# "remotedesktop", colliding with the app's own HKCU autostart preference.
|
|
61
|
+
|
|
49
62
|
[tool.ty.environment]
|
|
50
63
|
# The app targets Windows (SendInput via ctypes.windll); type-check as such
|
|
51
64
|
# even when ty runs on a Linux CI runner.
|
|
@@ -39,7 +39,7 @@ time with live statistics.
|
|
|
39
39
|
|
|
40
40
|
Sharing is turned on in Preferences (*view only* or *full control*). The
|
|
41
41
|
*Connections* tab then shows everything in one place: every connected
|
|
42
|
-
viewer — who they are (
|
|
42
|
+
viewer — who they are (name, login, OS) and how the connection is
|
|
43
43
|
doing (bandwidth, round-trip time with mean/min/max/p99/jitter over the
|
|
44
44
|
recent window) — the history of servers seen and clients paired with
|
|
45
45
|
one-click *Forget* / *Revoke*, and the live connection log.
|
|
@@ -50,7 +50,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
50
50
|
- 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
|
|
51
51
|
- 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
|
|
52
52
|
- 🖥️ **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.
|
|
53
|
-
- ⌨️🖱️ **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. Prefer eyes-only? Choose *Shared
|
|
53
|
+
- ⌨️🖱️ **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. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
|
|
54
54
|
- 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
|
|
55
55
|
- 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
|
|
56
56
|
- 🔒 **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.
|
|
@@ -67,6 +67,13 @@ for mostly-static desktop work.
|
|
|
67
67
|
|
|
68
68
|
## Installation
|
|
69
69
|
|
|
70
|
+
The easiest way: download `remotedesktop_installer_win64.exe` from the
|
|
71
|
+
[latest release](https://github.com/jamesabel/remotedesktop/releases/latest)
|
|
72
|
+
and run it — no Python required. To upgrade, quit the app first (tray →
|
|
73
|
+
Quit if it's sharing), then run the newer installer.
|
|
74
|
+
|
|
75
|
+
With Python, from PyPI:
|
|
76
|
+
|
|
70
77
|
```
|
|
71
78
|
pip install remotedesktop
|
|
72
79
|
```
|
|
@@ -85,8 +92,8 @@ it prepares the environment on first use and launches the app.
|
|
|
85
92
|
1. Run `remotedesktop` on both computers.
|
|
86
93
|
2. On the computer to share, open *Preferences* and set *Server (sharing)*
|
|
87
94
|
to one of the *Shared* modes.
|
|
88
|
-
3. On the viewing computer, the shared computer appears in the
|
|
89
|
-
|
|
95
|
+
3. On the viewing computer, the shared computer appears in the panel on
|
|
96
|
+
the left — double-click it (or select it and click *Connect*).
|
|
90
97
|
4. Approve the connection in the dialog that pops up on the shared
|
|
91
98
|
computer. That's it — future connections from that computer need no
|
|
92
99
|
approval.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
setlocal
|
|
3
|
+
cd /d "%~dp0.."
|
|
4
|
+
|
|
5
|
+
rem Build the signed Windows installer with pyship.
|
|
6
|
+
rem Requirements: the hardware signing token plugged in, a LOCAL console
|
|
7
|
+
rem session (pyship refuses to sign over RDP - failed PIN attempts can lock
|
|
8
|
+
rem the token), and optionally PYSHIP_SIGNING_CERTIFICATE_PIN set for
|
|
9
|
+
rem unattended signing (otherwise the token middleware prompts for the PIN).
|
|
10
|
+
rem CI builds the same installer unsigned; replace the release asset with
|
|
11
|
+
rem this signed one via:
|
|
12
|
+
rem gh release upload v<version> installers\remotedesktop_installer_win64.exe --clobber
|
|
13
|
+
|
|
14
|
+
uv sync --group ship
|
|
15
|
+
if errorlevel 1 (
|
|
16
|
+
echo Failed to create the environment.
|
|
17
|
+
exit /b 1
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
rem pyship rebuilds dist\ (wheel), app\ (launcher + frozen CLIP), and installers\.
|
|
21
|
+
uv run --group ship python -m pyship --noupload --code-sign --certificate-auto-select
|
|
22
|
+
if errorlevel 1 (
|
|
23
|
+
echo Installer build FAILED.
|
|
24
|
+
exit /b 1
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
echo Installer: installers\remotedesktop_installer_win64.exe
|