remotedesktop 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/CLAUDE.md +2 -2
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/PKG-INFO +14 -8
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/readme.md +12 -6
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/app.py +131 -2
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/client.py +3 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/discovery.py +16 -2
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/server.py +7 -1
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_discovery.py +42 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_main_window.py +114 -2
- remotedesktop-2.1.0/.claude/scheduled_tasks.lock +0 -1
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/.github/workflows/release.yml +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/.gitignore +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/LICENSE +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/icon/remotedesktop.ico +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/pyproject.toml +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/run.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/run_claude.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/scripts/make_installer.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/cursor_shape.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/session_lock.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/sharing.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/visual_effects.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/conftest.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_about.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_autostart.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_compat.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_config.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_cursor_shape.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_frames.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_icon.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_input.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_inventory.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_logs.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_performance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_preferences.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_protocol.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_session_lock.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_sharing.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_single_instance.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_smoke.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_tls.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_visual_effects.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tests/test_window_state.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/tools/make_icon.py +0 -0
- {remotedesktop-2.1.0 → remotedesktop-2.2.0}/uv.lock +0 -0
|
@@ -78,14 +78,14 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
78
78
|
- **The sharing lifecycle** (`server.py` `SharingTab`) lives on the "Connections" tab, arranged as one outer QGroupBox per role, side by side as widgets with "Connection log" full-width below: "Client (viewer)" (left; nests "Connected servers" — `app._SessionsTable`, the client-side twin of the viewers table fed by `_ConnectedServersSource` from the live sessions and `client_performance` — over "Server history", client-role inventory, Forget) and "Server (sharing)" (right; the `SharingTab` summary + viewers table over "Client history", server-role inventory, Revoke). **Each role box is visible only while its role is on** (`_update_connections_groups`); boxes are widgets (not column layouts) so a hidden role frees its space and the other starts at the left edge. **The mode itself is chosen in Preferences**: a three-state radio group (`preferences.py`, `sharingModeChanged` signal) wired by `MainWindow` to `SharingTab.set_mode("off"/"view"/"control")` — `server.py` exposes `SHARING_MODE_*` constants and `load_sharing_mode(settings)`, persisted as the single `sharing_mode` key. View ↔ control switches apply live (`ShareServer.set_input_allowed`) without dropping viewers; off/on tears down / creates the `ShareServer` + `DiscoveryResponder` (a fresh `ShareServer` per enable; `close()` leaves teardown state), so a viewing-only instance binds no ports and never creates TLS credentials (loaded lazily on first enable). The host window calls `restore_sharing()` after wiring `statusMessage`/`peerEvent`/`sharingChanged`, so no startup status message is emitted before anyone listens. `serving` is mode-on AND actually listening. `revoke_client` works even while not sharing (direct `PairedClients.revoke`). The Restart app button also lives in Preferences (`preferences_tab.restart_button` → `MainWindow._restart_app`).
|
|
79
79
|
- **Tray + lifecycle** (`app.py`): while sharing, closing the window hides to a `QSystemTrayIcon` (menu: Show / Restart / Quit) and sharing continues; not sharing → close quits. `main()` sets `setQuitOnLastWindowClosed(False)`, so **every exit path must reach `QApplication.quit()`** (plain close, tray Quit, restart). `tray_available=` is injectable — tests always pass an explicit value and never call `isSystemTrayAvailable()`. `--minimized` starts hidden in the tray when sharing is on, otherwise minimized; `--maximized` starts maximized. `single_instance.py` (`SingleInstance`, QLocalServer-based) enforces one instance per user session; a second launch tells the first to `bring_to_front()` and exits. **Start at login is a four-way start mode** (`autostart.py` `START_MINIMIZED/NORMAL/MAXIMIZED/OFF`): Preferences radios persist it as `autostart_mode` (`load_autostart_mode`, default **minimized** — the recommended setting) and `PreferencesTab` mirrors it into the HKCU Run value `"remotedesktop"` at every construction, so the start-minimized default takes effect on a fresh install and the registered path follows the current installation. The mode is encoded in the registered command's flag (`--minimized` / none / `--maximized`; no value = off, `Autostart.mode()` reads it back). Tests always inject `Autostart(key_path=...)` pointed at an isolated test key — a real `Autostart` in a test would rewrite the developer's actual Run key at tab construction.
|
|
80
80
|
- The remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window. **Multiple simultaneous server connections**: each is a `ServerSession` in `client.py` (its own `ShareClient` + `ViewerWidget`, hosted in a `QScrollArea` tab page — `session.page` is the tab widget, `session.viewer` the viewer), shown as a closable tab at the front of `MainWindow`'s central `QTabWidget`, titled with the server's reported hostname (fixed tabs get their close buttons stripped). Fit mode is `widgetResizable(True)`; **View → Actual size** flips the page to non-resizable and the viewer sizes itself to `frame / devicePixelRatio` (1:1 device pixels, the no-resample paint branch; input mapping unchanged because the frame fills the widget). Sessions are keyed by `host:port`; activating an already-known server reconnects in its existing tab, activating a connected one just focuses it. The window title lists the connected server names and a "— sharing" suffix while serving (`_update_window_title`); the status bar's message area follows the currently selected session tab (`status_text` per session) and a permanent right-side label shows "Sharing — N viewer(s)" whenever sharing is on (driven by `SharingTab.viewerCountChanged`). The `ClipboardSync`, inventories, and Connection log are shared across sessions; `client_performance.reset()` is only called when no *other* session is connected (resetting detaches every stream). Per-session status log lines are prefixed `[name]`.
|
|
81
|
-
- **Auto-reconnect** (`app.py`): a session that *was connected* and drops retries `client.connect_to` with exponential backoff (base `reconnect_base_seconds=2.0` kwarg — tests inject ~0.05 — doubling to a 30 s cap), driven by a per-session single-shot QTimer. `ShareClient.connectionFailed` exists because **Qt emits `disconnected` only for sockets that reached ConnectedState** — refused/unreachable attempts fire `errorOccurred` with the socket back in UnconnectedState. The active-timer guard dedupes the errorOccurred+disconnected double-fire. Denial cancels until a manual activation; tab close/forget/quit cancel; retries bypass `_connect_session` on purpose (no perf reset, no tab steal, no inventory attempt spam). **Open session tabs persist** (`open_sessions` JSON in Settings, written on connect/rename/close) and are restored + reconnected at startup with `auto_reconnect` pre-armed, so a not-yet-available server is retried on the backoff loop; deliberately closed tabs are not restored. While no session exists, a non-closable "Server" placeholder tab (instructions) sits at index 0 and swaps with the first session's tab.
|
|
81
|
+
- **Auto-reconnect** (`app.py`): a session that *was connected* and drops retries `client.connect_to` with exponential backoff (base `reconnect_base_seconds=2.0` kwarg — tests inject ~0.05 — doubling to a 30 s cap), driven by a per-session single-shot QTimer. `ShareClient.connectionFailed` exists because **Qt emits `disconnected` only for sockets that reached ConnectedState** — refused/unreachable attempts fire `errorOccurred` with the socket back in UnconnectedState. The active-timer guard dedupes the errorOccurred+disconnected double-fire. Denial cancels until a manual activation; tab close/forget/quit cancel; retries bypass `_connect_session` on purpose (no perf reset, no tab steal, no inventory attempt spam). **Re-discovery follows a moved server**: sessions, tokens, and pins are all keyed by `host:port`, so a DHCP address change across a server reboot would otherwise strand every retry — once `_REDISCOVER_AFTER_ATTEMPTS` (2) direct attempts have failed, each further failure also runs a discovery scan on a worker thread (`rediscover=` kwarg — tests inject a fake, `make_window` defaults it to a no-op so window tests never broadcast; result delivered via the queued `_rediscoveryFinished` signal). A scanned server matching the session's pinned cert fingerprint (discovery replies advertise it as `fp`; display-name match is the fallback for servers too old to send one) at a *different* address migrates the session: the `KnownServers` record is re-keyed (token preserved — no re-approval), the inventory row moves, sessions re-persist, and the connect is immediate with a fresh backoff. This is not background polling — it runs only while a session is failing to reconnect. **Open session tabs persist** (`open_sessions` JSON in Settings, written on connect/rename/close) and are restored + reconnected at startup with `auto_reconnect` pre-armed, so a not-yet-available server is retried on the backoff loop; deliberately closed tabs are not restored. While no session exists, a non-closable "Server" placeholder tab (instructions) sits at index 0 and swaps with the first session's tab.
|
|
82
82
|
- **Menus & shortcuts contract** (`app.py` `_build_menus`, `viewer.py`): File/View/Help menu bar; shortcuts Ctrl+W (close the current session tab or the Preferences tab), Ctrl+, (Preferences), Ctrl+Q (quit), F5 (rescan), F11 (fullscreen). **Preferences and About are not pinned tabs**: File ▸ Preferences… shows `preferences_tab` as an on-demand *closable* tab (the widget is built once at startup — its constructor and signal wiring drive the roles — and `removeTab` on close keeps it alive for the next open); Help ▸ About shows the single modeless `about.AboutDialog` instance (`_about_dialog`, wrapping `AboutPage`). While a viewer shows a frame it **accepts `ShortcutOverride`** so every key forwards to the remote machine — **F11 is the single reserved local key**. Shortcut actions are also `addAction`-ed on the window so they fire with the menu bar hidden (fullscreen). Keep every menu/action as an attribute — PySide6 gives Python ownership of `addMenu`/`addAction` (and `QAction.menu()`) wrappers, and a GC'd local deletes the C++ object. Fullscreen hides menu/status bars, dock, and tab bar and restores them (including pre-fullscreen dock visibility) on exit; `closeEvent` exits fullscreen before persisting layout. Window geometry AND dock layout persist (`window_state.save_state`/`restore_state`, key `main_window_state`; the dock needs its objectName).
|
|
83
83
|
- **Privacy toggles**: Preferences' "Sync clipboard" checkbox (`clipboard_sync_enabled`, default on) drives `ClipboardSync.enabled` — while off, local copies aren't sent and peer payloads aren't applied. View-only sharing is the "view" mode of the Screen sharing radios (`ShareServer.set_input_allowed` — a server-side drop, no wire change, that releases held input when switched off). Quitting with ≥1 viewer connected asks for confirmation (declining resets `_quitting`); restart shuts sharing down first so it never double-prompts.
|
|
84
84
|
- **Screen capture** (`app.py`): the dock's "Screen capture" group (Copy / Save…, viewer-role UI hidden with the panel) and the matching File-menu actions (`copy_capture_action`/`save_capture_action`, same handlers) capture the current session tab's last frame at full server resolution (`ViewerWidget.frame_image()` — the lock overlay is display-only and never part of it). Copy goes through `ClipboardSync.copy_image`, which records the content signature *before* setting the clipboard so the capture is never echoed to peers, and works with sync disabled (the preference governs syncing, not local copies). Save is `QFileDialog.getSaveFileName` (tests monkeypatch it) defaulting to `Pictures\<server> <timestamp>.png`, PNG only. All four widgets enable only while the current tab shows a frame — `_update_capture_actions()`, called from tab change, `_on_frame` (unconditionally: auto-reconnect refills a cleared viewer without `_connect_session`), and every `viewer.clear` path (connect/denied/approval-pending/disconnect).
|
|
85
85
|
- **Reduce visual effects while sharing** (`visual_effects.py`): animations/fades are a burst of changed frames for the lossless pipeline, so while the preference is on (default on, `reduce_effects_enabled`, `load_reduce_effects_enabled`) **and ≥1 viewer is connected**, `MainWindow` applies `VisualEffectsReducer` and restores it when the last viewer leaves (driven by `SharingTab.viewerCountChanged`; explicit safety-net restore in `closeEvent`). The reduced set: SPI_SETUIEFFECTS off, drop shadows off, client-area + min/max animations off, submenu delay lowered to 50 ms (never raised). All via `SystemParametersInfo` **without** SPIF_UPDATEINIFILE — session-scoped, never persisted, self-heals at logoff; knobs already at the reduced value are left untouched so the user's own choices are never "restored" over. The backend (`SpiBackend`) is injectable — **tests must always inject `FakeSpiBackend`** (the preference defaults on, so a real reducer in a test would change the host's actual Windows settings; `make_window` in `tests/test_main_window.py` injects it for every window test, and real-backend tests may only read).
|
|
86
86
|
- **Theme preference** (`preferences.py`): a three-radio group in Preferences — follow the Windows light/dark setting (default) / Light / Dark — persisted as `THEME_KEY` (`"system"`/`"light"`/`"dark"`, `load_theme` falls back to system on junk). `apply_theme` drives `QStyleHints.setColorScheme` (`unsetColorScheme` for system, so OS-side switches keep applying); the radios apply live and `MainWindow.__init__` re-applies the persisted choice before building widgets. Tests that set a non-system theme must restore with `apply_theme(THEME_SYSTEM)` — the color scheme is process-global QApplication state.
|
|
87
87
|
- **Two `PerformanceMonitor`s, never one**: `MainWindow.client_performance` (viewing sessions) and `server_performance` (the SharingTab's viewers). `ShareServer.close()` resets its monitor, so sharing a monitor across roles would wipe the other role's graphs. The Performance tab has "Client (viewer)"/"Server (sharing)" sub-tabs (`MainWindow.performance_pages`) — but only for roles that can produce data: the client sub-tab exists only with the viewer role, the server one only while actually serving (`_update_performance_tabs`, driven from the viewer toggle and `sharingChanged`; both off shows an enable-a-role hint). `PreferencesTab` takes the list of monitors and applies the history-window setting to all.
|
|
88
|
-
- **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}
|
|
88
|
+
- **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}` plus an optional `fp` — the server's TLS cert fingerprint (`ServerInfo.fingerprint`, empty when absent; junk `fp` is blanked, never drops the reply), the stable identity auto-reconnect re-discovery matches on; datagrams with the wrong magic/version/type are dropped. **Scan robustness**: probes are re-sent every `_PROBE_INTERVAL` (0.3 s) for the whole scan window (a lost broadcast only delays discovery), ICMP port-unreachable resets never abort the scan (Windows `SIO_UDP_CONNRESET` ioctl + `ConnectionResetError` → continue), and the panel passes `client._broadcast_hosts()` — 255.255.255.255 plus every up non-loopback interface's directed broadcast, because Windows routes the limited broadcast out only one interface (VPN/virtual adapters made scans come up empty). A sharing instance runs a `DiscoveryResponder` thread while sharing is enabled; the `DiscoveryPanel` (dock in `MainWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. With `auto_scan=True` (the app; tests default to False and never broadcast) it scans **once at startup only** — further scans happen only on manual Refresh/F5 (deliberately no periodic background scanning; the user asked for this), guarded against overlapping scans and preserving the selection across repopulation; it also has a Connect button, a right-click Connect menu, and an empty-state hint. The "(this computer)" entry is not connectable (Connect disabled, activation swallowed, plus a `MainWindow._on_server_activated` guard). `DEFAULT_CONNECT_PORT` is 48654. An instance that shares and scans discovers itself — `DiscoveryPanel`'s `is_self=` callable labels that entry "(this computer)" (never hidden).
|
|
89
89
|
- Discovery tests run over loopback with ephemeral ports (`bind_host`/`discovery_port`/`broadcast_hosts` parameters exist for this), so they never touch the real LAN or fixed ports.
|
|
90
90
|
- **Screen sharing** (`sharing.py` + `protocol.py` + `frames.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, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). Every client is assumed delta-capable (no negotiation). Each variant (keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
|
|
91
91
|
- **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. **Boolean settings go through `Settings.get_bool`/`set_bool`** (the `tobool` package's `to_bool_strict`; on-disk form stays the historical `"1"`/`"0"`, junk falls back to the caller's default) — don't hand-roll `!= "0"` comparisons. This applies to binary values stored as strings/ints; ctypes Win32 `BOOL`s keep plain `bool()` (TRUE is "any nonzero", which strict parsing correctly refuses). `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
|
|
6
6
|
Project-URL: Repository, https://github.com/jamesabel/remotedesktop
|
|
@@ -84,10 +84,10 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
84
84
|
- 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
|
|
85
85
|
- 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
|
|
86
86
|
- 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
|
|
87
|
-
- 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
|
|
87
|
+
- 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt. Connections that were open when the app closed are restored on the next start.
|
|
88
88
|
- 🚀 **Hands-off operation** — start-at-login (per-user, no admin rights) with a choice of minimized (the default and recommended — sharing resumes after a reboot with no clicks), normal, maximized, or not starting at all; close-to-tray while sharing (the screen stays available with the window closed); and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
|
|
89
89
|
- 🗃️ **Persistent peer inventory** — a SQLite-backed history of every peer seen on the LAN, with one-click *Revoke* / *Forget*.
|
|
90
|
-
- 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts, a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
|
|
90
|
+
- 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts (Preferences on *File ▸ Preferences*, Ctrl+,; About on the Help menu), a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
|
|
91
91
|
|
|
92
92
|
In scope: screen, keyboard, mouse, and clipboard. Out of scope: shared
|
|
93
93
|
drives, devices, and audio — and smooth playback of fast-changing
|
|
@@ -119,8 +119,8 @@ it prepares the environment on first use and launches the app.
|
|
|
119
119
|
## Quick start
|
|
120
120
|
|
|
121
121
|
1. Run `remotedesktop` on both computers.
|
|
122
|
-
2. On the computer to share, open *Preferences* and set
|
|
123
|
-
to one of the *Shared* modes.
|
|
122
|
+
2. On the computer to share, open *File ▸ Preferences* (Ctrl+,) and set
|
|
123
|
+
*Server (sharing)* to one of the *Shared* modes.
|
|
124
124
|
3. On the viewing computer, the shared computer appears in the panel on
|
|
125
125
|
the left — double-click it (or select it and click *Connect*).
|
|
126
126
|
4. Approve the connection in the dialog that pops up on the shared
|
|
@@ -228,7 +228,8 @@ certificate the server generates and keeps, then simple length-prefixed
|
|
|
228
228
|
messages on top — JSON for control (hello/welcome, input, clipboard,
|
|
229
229
|
ping/pong for the round-trip-time graphs, log exchange) and binary
|
|
230
230
|
payloads for frames and deltas. Discovery is a UDP broadcast probe that
|
|
231
|
-
every server answers with its name and
|
|
231
|
+
every server answers with its name, port, and certificate fingerprint
|
|
232
|
+
(see below).
|
|
232
233
|
|
|
233
234
|
## Versioning
|
|
234
235
|
|
|
@@ -248,7 +249,12 @@ not guaranteed. Keep both computers on the same version for best results.
|
|
|
248
249
|
## How discovery works
|
|
249
250
|
|
|
250
251
|
The client broadcasts a small JSON probe over UDP (port 48653); each server
|
|
251
|
-
on the LAN replies with its hostname
|
|
252
|
+
on the LAN replies with its hostname, connection port, and TLS certificate
|
|
253
|
+
fingerprint. The fingerprint is the server's stable identity: when a
|
|
254
|
+
reconnecting client can no longer reach a server at its last known address
|
|
255
|
+
(typically a new DHCP lease after a reboot), it re-scans and matches the
|
|
256
|
+
fingerprint to follow the server to its new address, keeping the stored
|
|
257
|
+
pairing token so no fresh approval is needed. Windows Firewall
|
|
252
258
|
must allow Python to receive inbound UDP on that port for a server to be
|
|
253
259
|
discoverable from other machines.
|
|
254
260
|
|
|
@@ -59,10 +59,10 @@ one-click *Forget* / *Revoke*, and the live connection log.
|
|
|
59
59
|
- 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
|
|
60
60
|
- 🔐 **Honest lock-screen behavior** — a locked server tells viewers so with a clear on-screen notice instead of a frozen frame, and streaming resumes by itself once someone signs in at the machine (see [The Windows lock screen](#the-windows-lock-screen)).
|
|
61
61
|
- 📊 **Built-in performance monitoring** — live bandwidth and round-trip-time graphs with window statistics (mean/min/max/p99/jitter), plus a per-viewer table on the server.
|
|
62
|
-
- 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Connections that were open when the app closed are restored on the next start.
|
|
62
|
+
- 🔁 **Robust connections** — dead connections are detected within seconds, and dropped sessions reconnect automatically with backoff; a server restart heals by itself, no clicks needed. Even a server that comes back on a **different IP address** (a new DHCP lease after a reboot) is found again: reconnection re-scans the LAN, recognizes the server by its certificate identity, and follows it to the new address — pairing intact, so still no prompt. Connections that were open when the app closed are restored on the next start.
|
|
63
63
|
- 🚀 **Hands-off operation** — start-at-login (per-user, no admin rights) with a choice of minimized (the default and recommended — sharing resumes after a reboot with no clicks), normal, maximized, or not starting at all; close-to-tray while sharing (the screen stays available with the window closed); and a *Restart app* button usable from the remote session itself, so you can update the software without visiting the machine.
|
|
64
64
|
- 🗃️ **Persistent peer inventory** — a SQLite-backed history of every peer seen on the LAN, with one-click *Revoke* / *Forget*.
|
|
65
|
-
- 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts, a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
|
|
65
|
+
- 🧭 **Desktop-app niceties** — a real menu bar with standard shortcuts (Preferences on *File ▸ Preferences*, Ctrl+,; About on the Help menu), a status-bar sharing indicator, a confirmation before quitting with viewers connected, and window/panel layout that persists across restarts.
|
|
66
66
|
|
|
67
67
|
In scope: screen, keyboard, mouse, and clipboard. Out of scope: shared
|
|
68
68
|
drives, devices, and audio — and smooth playback of fast-changing
|
|
@@ -94,8 +94,8 @@ it prepares the environment on first use and launches the app.
|
|
|
94
94
|
## Quick start
|
|
95
95
|
|
|
96
96
|
1. Run `remotedesktop` on both computers.
|
|
97
|
-
2. On the computer to share, open *Preferences* and set
|
|
98
|
-
to one of the *Shared* modes.
|
|
97
|
+
2. On the computer to share, open *File ▸ Preferences* (Ctrl+,) and set
|
|
98
|
+
*Server (sharing)* to one of the *Shared* modes.
|
|
99
99
|
3. On the viewing computer, the shared computer appears in the panel on
|
|
100
100
|
the left — double-click it (or select it and click *Connect*).
|
|
101
101
|
4. Approve the connection in the dialog that pops up on the shared
|
|
@@ -203,7 +203,8 @@ certificate the server generates and keeps, then simple length-prefixed
|
|
|
203
203
|
messages on top — JSON for control (hello/welcome, input, clipboard,
|
|
204
204
|
ping/pong for the round-trip-time graphs, log exchange) and binary
|
|
205
205
|
payloads for frames and deltas. Discovery is a UDP broadcast probe that
|
|
206
|
-
every server answers with its name and
|
|
206
|
+
every server answers with its name, port, and certificate fingerprint
|
|
207
|
+
(see below).
|
|
207
208
|
|
|
208
209
|
## Versioning
|
|
209
210
|
|
|
@@ -223,7 +224,12 @@ not guaranteed. Keep both computers on the same version for best results.
|
|
|
223
224
|
## How discovery works
|
|
224
225
|
|
|
225
226
|
The client broadcasts a small JSON probe over UDP (port 48653); each server
|
|
226
|
-
on the LAN replies with its hostname
|
|
227
|
+
on the LAN replies with its hostname, connection port, and TLS certificate
|
|
228
|
+
fingerprint. The fingerprint is the server's stable identity: when a
|
|
229
|
+
reconnecting client can no longer reach a server at its last known address
|
|
230
|
+
(typically a new DHCP lease after a reboot), it re-scans and matches the
|
|
231
|
+
fingerprint to follow the server to its new address, keeping the stored
|
|
232
|
+
pairing token so no fresh approval is needed. Windows Firewall
|
|
227
233
|
must allow Python to receive inbound UDP on that port for a server to be
|
|
228
234
|
discoverable from other machines.
|
|
229
235
|
|
|
@@ -15,7 +15,9 @@ import logging
|
|
|
15
15
|
import re
|
|
16
16
|
import sqlite3
|
|
17
17
|
import sys
|
|
18
|
+
import threading
|
|
18
19
|
import time
|
|
20
|
+
from collections.abc import Callable
|
|
19
21
|
from pathlib import Path
|
|
20
22
|
|
|
21
23
|
from PySide6.QtCore import QObject, QProcess, QStandardPaths, Qt, QTimer, Signal
|
|
@@ -45,10 +47,15 @@ from PySide6.QtWidgets import (
|
|
|
45
47
|
from remotedesktop import __version__, compat, db, icon, logs, window_state
|
|
46
48
|
from remotedesktop.about import AboutDialog
|
|
47
49
|
from remotedesktop.autostart import Autostart, installed_launcher
|
|
48
|
-
from remotedesktop.client import DiscoveryPanel, ServerSession
|
|
50
|
+
from remotedesktop.client import DiscoveryPanel, ServerSession, _broadcast_hosts
|
|
49
51
|
from remotedesktop.clipboard import ClipboardSync
|
|
50
52
|
from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
|
|
51
|
-
from remotedesktop.discovery import
|
|
53
|
+
from remotedesktop.discovery import (
|
|
54
|
+
DEFAULT_CONNECT_PORT,
|
|
55
|
+
DISCOVERY_PORT,
|
|
56
|
+
ServerInfo,
|
|
57
|
+
discover_servers,
|
|
58
|
+
)
|
|
52
59
|
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
53
60
|
from remotedesktop.logs import PeerLogDialog, read_log_tail
|
|
54
61
|
from remotedesktop.modal_loop import HTCLOSE, HTMAXBUTTON, HTMINBUTTON, ModalLoopPump
|
|
@@ -73,6 +80,14 @@ _log = logging.getLogger("remotedesktop.app")
|
|
|
73
80
|
# Auto-reconnect backoff never waits longer than this between attempts.
|
|
74
81
|
_RECONNECT_CAP_SECONDS = 30.0
|
|
75
82
|
|
|
83
|
+
# Once this many direct reconnect attempts at the last known address have
|
|
84
|
+
# failed, each further failure also starts a discovery scan: the server may
|
|
85
|
+
# have come back on a different address (DHCP after a reboot), and sessions,
|
|
86
|
+
# tokens, and pins are all keyed by host:port. A re-discovered server is
|
|
87
|
+
# matched by certificate fingerprint (name as the fallback) and the session
|
|
88
|
+
# migrates to the new address — token included, so no re-approval.
|
|
89
|
+
_REDISCOVER_AFTER_ATTEMPTS = 2
|
|
90
|
+
|
|
76
91
|
|
|
77
92
|
class _SessionsTable(ViewersTable):
|
|
78
93
|
"""The client-side twin of the viewers table: one row per connected
|
|
@@ -121,6 +136,9 @@ class _ConnectedServersSource(QObject):
|
|
|
121
136
|
|
|
122
137
|
|
|
123
138
|
class MainWindow(QMainWindow):
|
|
139
|
+
# A re-discovery scan finished on its worker thread: (session, servers).
|
|
140
|
+
_rediscoveryFinished = Signal(object, list)
|
|
141
|
+
|
|
124
142
|
def __init__(
|
|
125
143
|
self,
|
|
126
144
|
*,
|
|
@@ -132,10 +150,19 @@ class MainWindow(QMainWindow):
|
|
|
132
150
|
connect_port: int = DEFAULT_CONNECT_PORT,
|
|
133
151
|
tray_available: bool | None = None,
|
|
134
152
|
reconnect_base_seconds: float = 2.0,
|
|
153
|
+
rediscover: Callable[[], list[ServerInfo]] | None = None,
|
|
135
154
|
effects_reducer: VisualEffectsReducer | None = None,
|
|
136
155
|
) -> None:
|
|
137
156
|
super().__init__()
|
|
138
157
|
self._reconnect_base = reconnect_base_seconds
|
|
158
|
+
# The blocking scan auto-reconnect uses to find a moved server; tests
|
|
159
|
+
# inject a fake so window tests never broadcast on the LAN.
|
|
160
|
+
self._rediscover = (
|
|
161
|
+
rediscover
|
|
162
|
+
if rediscover is not None
|
|
163
|
+
else lambda: discover_servers(broadcast_hosts=_broadcast_hosts())
|
|
164
|
+
)
|
|
165
|
+
self._rediscoveryFinished.connect(self._on_rediscovery_finished)
|
|
139
166
|
self._auto_scan = auto_scan
|
|
140
167
|
self.setWindowIcon(icon.app_icon("app"))
|
|
141
168
|
# Tests inject a connection to a temp database; the app uses the default.
|
|
@@ -1370,6 +1397,10 @@ class MainWindow(QMainWindow):
|
|
|
1370
1397
|
session.viewer.clear(message)
|
|
1371
1398
|
self._set_session_status(session, f"{session.name}: {message}")
|
|
1372
1399
|
self.log(f"[{session.name}] {message}")
|
|
1400
|
+
# The direct address keeps failing — the server may be back under a
|
|
1401
|
+
# different one. Scan alongside the backoff timer, not instead of it.
|
|
1402
|
+
if session.reconnect_attempts >= _REDISCOVER_AFTER_ATTEMPTS:
|
|
1403
|
+
self._start_rediscovery(session)
|
|
1373
1404
|
|
|
1374
1405
|
def _attempt_reconnect(self, session: ServerSession) -> None:
|
|
1375
1406
|
if (
|
|
@@ -1392,6 +1423,104 @@ class MainWindow(QMainWindow):
|
|
|
1392
1423
|
session.reconnect_timer.stop()
|
|
1393
1424
|
session.reconnect_attempts = 0
|
|
1394
1425
|
|
|
1426
|
+
# -------------------------------------------- reconnect re-discovery
|
|
1427
|
+
|
|
1428
|
+
def _start_rediscovery(self, session: ServerSession) -> None:
|
|
1429
|
+
"""Scan the LAN for a server that moved to a new address.
|
|
1430
|
+
|
|
1431
|
+
Runs the blocking scan on a worker thread (the DiscoveryPanel
|
|
1432
|
+
pattern) and delivers the result through a queued signal. This is
|
|
1433
|
+
not background polling: it only runs while a session is actively
|
|
1434
|
+
failing to reconnect, and stops the moment it succeeds.
|
|
1435
|
+
"""
|
|
1436
|
+
if session.rediscovering:
|
|
1437
|
+
return
|
|
1438
|
+
session.rediscovering = True
|
|
1439
|
+
_log.debug("Re-discovery scan for %s (%s)", session.name, session.key)
|
|
1440
|
+
scan = self._rediscover
|
|
1441
|
+
|
|
1442
|
+
def run() -> None:
|
|
1443
|
+
try:
|
|
1444
|
+
servers = scan()
|
|
1445
|
+
except OSError as error:
|
|
1446
|
+
_log.warning("Re-discovery scan failed: %s", error)
|
|
1447
|
+
servers = []
|
|
1448
|
+
try:
|
|
1449
|
+
self._rediscoveryFinished.emit(session, servers)
|
|
1450
|
+
except RuntimeError:
|
|
1451
|
+
pass # the window was torn down while the scan ran
|
|
1452
|
+
|
|
1453
|
+
threading.Thread(target=run, name="reconnect-rediscovery", daemon=True).start()
|
|
1454
|
+
|
|
1455
|
+
def _on_rediscovery_finished(self, session: ServerSession, servers: list) -> None:
|
|
1456
|
+
session.rediscovering = False
|
|
1457
|
+
if (
|
|
1458
|
+
session not in self._sessions
|
|
1459
|
+
or session.connected
|
|
1460
|
+
or session.denied
|
|
1461
|
+
or not session.auto_reconnect
|
|
1462
|
+
or self._quitting
|
|
1463
|
+
):
|
|
1464
|
+
return
|
|
1465
|
+
match = self._match_rediscovered(session, servers)
|
|
1466
|
+
if match is None:
|
|
1467
|
+
_log.debug("Re-discovery: %s not found among %d server(s)", session.name, len(servers))
|
|
1468
|
+
return
|
|
1469
|
+
if f"{match.host}:{match.port}" == session.key:
|
|
1470
|
+
return # still advertised at the failing address; keep direct retries
|
|
1471
|
+
self._migrate_session(session, match)
|
|
1472
|
+
|
|
1473
|
+
def _match_rediscovered(self, session: ServerSession, servers: list) -> ServerInfo | None:
|
|
1474
|
+
"""The discovered server that is `session`'s peer, if any.
|
|
1475
|
+
|
|
1476
|
+
The pinned certificate fingerprint is the identity; the display name
|
|
1477
|
+
is the fallback for servers too old to advertise one (soft matching,
|
|
1478
|
+
like the soft cert pin — robust connections over strict identity).
|
|
1479
|
+
"""
|
|
1480
|
+
record = self._known_servers.get(session.key)
|
|
1481
|
+
pinned = (record or {}).get("fingerprint") or ""
|
|
1482
|
+
candidates = [s for s in servers if not self._is_own_server(s)]
|
|
1483
|
+
if pinned:
|
|
1484
|
+
for server in candidates:
|
|
1485
|
+
if server.fingerprint == pinned:
|
|
1486
|
+
return server
|
|
1487
|
+
for server in candidates:
|
|
1488
|
+
if server.name == session.name:
|
|
1489
|
+
return server
|
|
1490
|
+
return None
|
|
1491
|
+
|
|
1492
|
+
def _migrate_session(self, session: ServerSession, server: ServerInfo) -> None:
|
|
1493
|
+
"""Move a session (and its stored pairing) to the server's new
|
|
1494
|
+
address, then reconnect immediately."""
|
|
1495
|
+
old_key, new_key = session.key, f"{server.host}:{server.port}"
|
|
1496
|
+
if self._session_for_key(new_key) is not None:
|
|
1497
|
+
return # the user already opened a session at the new address
|
|
1498
|
+
record = self._known_servers.get(old_key)
|
|
1499
|
+
if record is not None:
|
|
1500
|
+
# Re-key the pairing so the stored token still applies — the
|
|
1501
|
+
# whole point: no fresh approval just because DHCP moved the
|
|
1502
|
+
# server.
|
|
1503
|
+
self._known_servers.remember(
|
|
1504
|
+
new_key, record.get("fingerprint") or "", record["token"]
|
|
1505
|
+
)
|
|
1506
|
+
self._known_servers.forget(old_key)
|
|
1507
|
+
session.key = new_key
|
|
1508
|
+
session.host, session.port = server.host, server.port
|
|
1509
|
+
# The peer's inventory row moves with it; the stale address would
|
|
1510
|
+
# otherwise linger as a permanently-unreachable duplicate.
|
|
1511
|
+
self.client_inventory.remove(old_key)
|
|
1512
|
+
self.client_inventory.record(
|
|
1513
|
+
new_key, "attempt", name=session.name, address=new_key, detail=new_key
|
|
1514
|
+
)
|
|
1515
|
+
self._persist_sessions()
|
|
1516
|
+
message = f"Found {session.name} at new address {new_key} (was {old_key}) — reconnecting"
|
|
1517
|
+
self._set_session_status(session, message)
|
|
1518
|
+
self.log(f"[{session.name}] {message}")
|
|
1519
|
+
# A new address deserves a fresh backoff; the connect failure path
|
|
1520
|
+
# re-arms it (and further re-discovery) if this address fails too.
|
|
1521
|
+
self._cancel_reconnect(session)
|
|
1522
|
+
session.client.connect_to(server.host, server.port)
|
|
1523
|
+
|
|
1395
1524
|
def _request_server_log(self) -> None:
|
|
1396
1525
|
session = self._session_for_page(self._tabs.currentWidget())
|
|
1397
1526
|
if session is None:
|
|
@@ -233,5 +233,8 @@ class ServerSession:
|
|
|
233
233
|
self.auto_reconnect = False
|
|
234
234
|
self.reconnect_attempts = 0
|
|
235
235
|
self.reconnect_timer: QTimer | None = None
|
|
236
|
+
# True while a re-discovery scan for this session runs on a worker
|
|
237
|
+
# thread (the server may have come back on a different address).
|
|
238
|
+
self.rediscovering = False
|
|
236
239
|
# What the status bar shows while this session's tab is current.
|
|
237
240
|
self.status_text = ""
|
|
@@ -28,6 +28,10 @@ class ServerInfo:
|
|
|
28
28
|
name: str
|
|
29
29
|
host: str
|
|
30
30
|
port: int
|
|
31
|
+
# The server's TLS certificate fingerprint, when it advertises one —
|
|
32
|
+
# a stable identity that survives address changes (DHCP gave the server
|
|
33
|
+
# a new IP after a reboot). Empty from servers too old to send it.
|
|
34
|
+
fingerprint: str = ""
|
|
31
35
|
|
|
32
36
|
|
|
33
37
|
def _parse(data: bytes, expected_type: str) -> dict | None:
|
|
@@ -58,10 +62,14 @@ class DiscoveryResponder:
|
|
|
58
62
|
name: str,
|
|
59
63
|
connect_port: int,
|
|
60
64
|
*,
|
|
65
|
+
fingerprint: str = "",
|
|
61
66
|
discovery_port: int = DISCOVERY_PORT,
|
|
62
67
|
bind_host: str = "",
|
|
63
68
|
) -> None:
|
|
64
|
-
|
|
69
|
+
# The fingerprint (`fp`) is optional in the reply: clients that
|
|
70
|
+
# predate it simply ignore the extra field.
|
|
71
|
+
extra = {"fp": fingerprint} if fingerprint else {}
|
|
72
|
+
self._reply = _encode("reply", name=name, port=connect_port, **extra)
|
|
65
73
|
self._discovery_port = discovery_port
|
|
66
74
|
self._bind_host = bind_host
|
|
67
75
|
self._socket: socket.socket | None = None
|
|
@@ -181,6 +189,12 @@ def discover_servers(
|
|
|
181
189
|
if not isinstance(name, str) or not isinstance(port, int):
|
|
182
190
|
_log.debug("Ignoring malformed reply from %s: %r", host, message)
|
|
183
191
|
continue
|
|
184
|
-
|
|
192
|
+
fingerprint = message.get("fp")
|
|
193
|
+
if not isinstance(fingerprint, str):
|
|
194
|
+
fingerprint = ""
|
|
195
|
+
found.setdefault(
|
|
196
|
+
(host, port),
|
|
197
|
+
ServerInfo(name=name, host=host, port=port, fingerprint=fingerprint),
|
|
198
|
+
)
|
|
185
199
|
_log.debug("Discovery scan finished: %d server(s) found", len(found))
|
|
186
200
|
return list(found.values())
|
|
@@ -295,8 +295,14 @@ class SharingTab(QWidget):
|
|
|
295
295
|
self._listening = server.listen(self._connect_port)
|
|
296
296
|
self._discoverable = False
|
|
297
297
|
if self._listening:
|
|
298
|
+
# Advertising the cert fingerprint lets clients recognize this
|
|
299
|
+
# server by identity when its IP changes (auto-reconnect
|
|
300
|
+
# re-discovery), not just by address.
|
|
298
301
|
responder = DiscoveryResponder(
|
|
299
|
-
self._name,
|
|
302
|
+
self._name,
|
|
303
|
+
server.port,
|
|
304
|
+
fingerprint=tls.certificate_fingerprint(self._credentials[0]),
|
|
305
|
+
discovery_port=self._discovery_port,
|
|
300
306
|
)
|
|
301
307
|
try:
|
|
302
308
|
responder.start()
|
|
@@ -120,6 +120,48 @@ def test_replies_with_malformed_fields_are_ignored() -> None:
|
|
|
120
120
|
assert servers == [ServerInfo(name="ok", host=LOOPBACK, port=9)]
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
def test_reply_carries_the_advertised_fingerprint() -> None:
|
|
124
|
+
port = free_udp_port()
|
|
125
|
+
fingerprint = "ab" * 32
|
|
126
|
+
responder = DiscoveryResponder(
|
|
127
|
+
"fpbox", 2345, fingerprint=fingerprint, discovery_port=port, bind_host=LOOPBACK
|
|
128
|
+
)
|
|
129
|
+
responder.start()
|
|
130
|
+
try:
|
|
131
|
+
servers = discover_servers(
|
|
132
|
+
timeout=2.0, discovery_port=port, broadcast_hosts=(LOOPBACK,)
|
|
133
|
+
)
|
|
134
|
+
finally:
|
|
135
|
+
responder.stop()
|
|
136
|
+
assert servers == [
|
|
137
|
+
ServerInfo(name="fpbox", host=LOOPBACK, port=2345, fingerprint=fingerprint)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_reply_with_malformed_fingerprint_still_counts() -> None:
|
|
142
|
+
# A junk `fp` blanks the fingerprint but never costs the discovery —
|
|
143
|
+
# matching just falls back to the name.
|
|
144
|
+
port = free_udp_port()
|
|
145
|
+
|
|
146
|
+
def fake_server(sock: socket.socket) -> None:
|
|
147
|
+
_probe, sender = sock.recvfrom(4096)
|
|
148
|
+
reply = {
|
|
149
|
+
"magic": "remotedesktop", "version": 1, "type": "reply",
|
|
150
|
+
"name": "ok", "port": 9, "fp": 12345,
|
|
151
|
+
}
|
|
152
|
+
sock.sendto(json.dumps(reply).encode(), sender)
|
|
153
|
+
|
|
154
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
155
|
+
sock.bind((LOOPBACK, port))
|
|
156
|
+
thread = threading.Thread(target=fake_server, args=(sock,), daemon=True)
|
|
157
|
+
thread.start()
|
|
158
|
+
servers = discover_servers(
|
|
159
|
+
timeout=1.0, discovery_port=port, broadcast_hosts=(LOOPBACK,)
|
|
160
|
+
)
|
|
161
|
+
thread.join()
|
|
162
|
+
assert servers == [ServerInfo(name="ok", host=LOOPBACK, port=9, fingerprint="")]
|
|
163
|
+
|
|
164
|
+
|
|
123
165
|
def test_probe_send_failure_is_tolerated() -> None:
|
|
124
166
|
servers = discover_servers(
|
|
125
167
|
timeout=0.1,
|
|
@@ -9,7 +9,7 @@ from PySide6.QtGui import QImage
|
|
|
9
9
|
from PySide6.QtWidgets import QApplication, QListWidgetItem, QMessageBox
|
|
10
10
|
|
|
11
11
|
from remotedesktop import client as client_module
|
|
12
|
-
from remotedesktop import db
|
|
12
|
+
from remotedesktop import db, tls
|
|
13
13
|
from remotedesktop.app import MainWindow
|
|
14
14
|
from remotedesktop.autostart import START_OFF, Autostart
|
|
15
15
|
from remotedesktop.client import DiscoveryPanel
|
|
@@ -37,7 +37,7 @@ def _clean_test_run_key():
|
|
|
37
37
|
def make_window(
|
|
38
38
|
tmp_path, credentials=None, *, serving=False, viewer=True, tray_available=False,
|
|
39
39
|
db_name="app.db", discovery_port=None, reconnect_base_seconds=2.0,
|
|
40
|
-
effects_reducer=None,
|
|
40
|
+
rediscover=None, effects_reducer=None,
|
|
41
41
|
):
|
|
42
42
|
"""A MainWindow on a temp DB with everything injected.
|
|
43
43
|
|
|
@@ -63,6 +63,9 @@ def make_window(
|
|
|
63
63
|
connect_port=0,
|
|
64
64
|
tray_available=tray_available,
|
|
65
65
|
reconnect_base_seconds=reconnect_base_seconds,
|
|
66
|
+
# Auto-reconnect re-discovery must never broadcast on the LAN from a
|
|
67
|
+
# test: without an injected fake it stays a no-op.
|
|
68
|
+
rediscover=rediscover if rediscover is not None else (lambda: []),
|
|
66
69
|
effects_reducer=(
|
|
67
70
|
effects_reducer
|
|
68
71
|
if effects_reducer is not None
|
|
@@ -961,6 +964,115 @@ def test_auto_reconnect_backs_off_while_server_stays_down(qapp, credentials, tmp
|
|
|
961
964
|
server.close()
|
|
962
965
|
|
|
963
966
|
|
|
967
|
+
def test_auto_reconnect_follows_server_to_new_address(qapp, credentials, tmp_path):
|
|
968
|
+
"""The server comes back on a different port (the moved-DHCP-address
|
|
969
|
+
stand-in): re-discovery matches it by certificate fingerprint and the
|
|
970
|
+
session migrates — stored token included, so no re-approval prompt."""
|
|
971
|
+
server = make_share_server(credentials, tmp_path)
|
|
972
|
+
discovered: list[ServerInfo] = []
|
|
973
|
+
window = make_window(
|
|
974
|
+
tmp_path, reconnect_base_seconds=0.05, rediscover=lambda: list(discovered)
|
|
975
|
+
)
|
|
976
|
+
try:
|
|
977
|
+
window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
|
|
978
|
+
session = window._sessions[0]
|
|
979
|
+
pump(qapp, lambda: session.connected)
|
|
980
|
+
old_key = session.key
|
|
981
|
+
server.close()
|
|
982
|
+
pump(qapp, lambda: not session.connected) # the drop must land first
|
|
983
|
+
|
|
984
|
+
# Any approval request would be denied: the migrated token must make
|
|
985
|
+
# the reconnect promptless.
|
|
986
|
+
replacement = ShareServer(
|
|
987
|
+
approve_client=lambda *_: False,
|
|
988
|
+
credentials=credentials,
|
|
989
|
+
paired=PairedClients(db.connect(tmp_path / "server.db")),
|
|
990
|
+
)
|
|
991
|
+
assert replacement.listen(0)
|
|
992
|
+
try:
|
|
993
|
+
# The name deliberately differs: the fingerprint is the identity.
|
|
994
|
+
discovered.append(
|
|
995
|
+
ServerInfo(
|
|
996
|
+
name="not-the-display-name",
|
|
997
|
+
host="127.0.0.1",
|
|
998
|
+
port=replacement.port,
|
|
999
|
+
fingerprint=tls.certificate_fingerprint(credentials[0]),
|
|
1000
|
+
)
|
|
1001
|
+
)
|
|
1002
|
+
pump(qapp, lambda: session.connected, timeout=15.0)
|
|
1003
|
+
new_key = f"127.0.0.1:{replacement.port}"
|
|
1004
|
+
assert session.key == new_key
|
|
1005
|
+
assert (session.host, session.port) == ("127.0.0.1", replacement.port)
|
|
1006
|
+
# The pairing moved with the session: token at the new key only.
|
|
1007
|
+
record = window._known_servers.get(new_key)
|
|
1008
|
+
assert record is not None and record["token"]
|
|
1009
|
+
assert window._known_servers.get(old_key) is None
|
|
1010
|
+
finally:
|
|
1011
|
+
replacement.close()
|
|
1012
|
+
finally:
|
|
1013
|
+
window.close()
|
|
1014
|
+
server.close()
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def test_rediscovery_falls_back_to_the_name_for_old_servers(qapp, credentials, tmp_path):
|
|
1018
|
+
# A server too old to advertise a fingerprint is matched by its display
|
|
1019
|
+
# name (which a connected session tracks from the welcome message — the
|
|
1020
|
+
# server's reported hostname).
|
|
1021
|
+
server = make_share_server(credentials, tmp_path)
|
|
1022
|
+
discovered: list[ServerInfo] = []
|
|
1023
|
+
window = make_window(
|
|
1024
|
+
tmp_path, reconnect_base_seconds=0.05, rediscover=lambda: list(discovered)
|
|
1025
|
+
)
|
|
1026
|
+
try:
|
|
1027
|
+
window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
|
|
1028
|
+
session = window._sessions[0]
|
|
1029
|
+
pump(qapp, lambda: session.connected)
|
|
1030
|
+
assert session.name == socket.gethostname() # renamed by the welcome
|
|
1031
|
+
server.close()
|
|
1032
|
+
pump(qapp, lambda: not session.connected) # the drop must land first
|
|
1033
|
+
|
|
1034
|
+
replacement = ShareServer(
|
|
1035
|
+
approve_client=lambda *_: False,
|
|
1036
|
+
credentials=credentials,
|
|
1037
|
+
paired=PairedClients(db.connect(tmp_path / "server.db")),
|
|
1038
|
+
)
|
|
1039
|
+
assert replacement.listen(0)
|
|
1040
|
+
try:
|
|
1041
|
+
discovered.append(
|
|
1042
|
+
ServerInfo(name=session.name, host="127.0.0.1", port=replacement.port)
|
|
1043
|
+
)
|
|
1044
|
+
pump(qapp, lambda: session.connected, timeout=15.0)
|
|
1045
|
+
assert session.key == f"127.0.0.1:{replacement.port}"
|
|
1046
|
+
finally:
|
|
1047
|
+
replacement.close()
|
|
1048
|
+
finally:
|
|
1049
|
+
window.close()
|
|
1050
|
+
server.close()
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def test_rediscovery_ignores_unrelated_servers(qapp, credentials, tmp_path):
|
|
1054
|
+
server = make_share_server(credentials, tmp_path)
|
|
1055
|
+
discovered = [
|
|
1056
|
+
ServerInfo(name="someone-else", host="127.0.0.1", port=59999, fingerprint="ff" * 32)
|
|
1057
|
+
]
|
|
1058
|
+
window = make_window(
|
|
1059
|
+
tmp_path, reconnect_base_seconds=0.05, rediscover=lambda: list(discovered)
|
|
1060
|
+
)
|
|
1061
|
+
try:
|
|
1062
|
+
window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
|
|
1063
|
+
session = window._sessions[0]
|
|
1064
|
+
pump(qapp, lambda: session.connected)
|
|
1065
|
+
old_key = session.key
|
|
1066
|
+
server.close()
|
|
1067
|
+
# Enough failures for several re-discovery scans to have run.
|
|
1068
|
+
pump(qapp, lambda: session.reconnect_attempts >= 4, timeout=15.0)
|
|
1069
|
+
assert session.key == old_key
|
|
1070
|
+
assert not session.connected
|
|
1071
|
+
finally:
|
|
1072
|
+
window.close()
|
|
1073
|
+
server.close()
|
|
1074
|
+
|
|
1075
|
+
|
|
964
1076
|
def test_denial_stops_auto_reconnect(qapp, credentials, tmp_path, monkeypatch):
|
|
965
1077
|
server = make_share_server(credentials, tmp_path)
|
|
966
1078
|
window = make_window(tmp_path, reconnect_base_seconds=0.05)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"sessionId":"06786f54-7244-453c-8fd6-c0ad270c6bfc","pid":25604,"procStart":"639204255108546380","acquiredAt":1784854861122}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|