remotedesktop 1.3.0__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/CLAUDE.md +7 -7
  2. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/PKG-INFO +5 -5
  3. remotedesktop-1.4.0/docs/media/client-demo.gif +0 -0
  4. remotedesktop-1.4.0/docs/media/server-demo.gif +0 -0
  5. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/readme.md +4 -4
  6. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/__init__.py +1 -1
  7. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/app.py +171 -35
  8. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/client.py +25 -2
  9. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/discovery.py +41 -11
  10. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/preferences.py +36 -4
  11. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/server.py +11 -7
  12. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_discovery.py +146 -129
  13. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_main_window.py +155 -8
  14. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_sharing_tab.py +3 -3
  15. remotedesktop-1.3.0/docs/media/client-demo.gif +0 -0
  16. remotedesktop-1.3.0/docs/media/server-demo.gif +0 -0
  17. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/.github/workflows/ci.yml +0 -0
  18. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/.github/workflows/release.yml +0 -0
  19. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/.gitignore +0 -0
  20. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/LICENSE +0 -0
  21. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/pyproject.toml +0 -0
  22. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/run.bat +0 -0
  23. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/run_claude.bat +0 -0
  24. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/scripts/make_coverage_badge.py +0 -0
  25. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/scripts/make_venv.bat +0 -0
  26. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/__main__.py +0 -0
  27. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/about.py +0 -0
  28. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/autostart.py +0 -0
  29. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/clipboard.py +0 -0
  30. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/compat.py +0 -0
  31. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/config.py +0 -0
  32. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/db.py +0 -0
  33. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/dxgi.py +0 -0
  34. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/frames.py +0 -0
  35. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/icon.py +0 -0
  36. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/input_injection.py +0 -0
  37. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/inventory.py +0 -0
  38. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/logs.py +0 -0
  39. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/modal_loop.py +0 -0
  40. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/performance.py +0 -0
  41. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/protocol.py +0 -0
  42. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/sharing.py +0 -0
  43. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/single_instance.py +0 -0
  44. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/tls.py +0 -0
  45. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/viewer.py +0 -0
  46. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/src/remotedesktop/window_state.py +0 -0
  47. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/conftest.py +0 -0
  48. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_about.py +0 -0
  49. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_autostart.py +0 -0
  50. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_clipboard.py +0 -0
  51. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_compat.py +0 -0
  52. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_config.py +0 -0
  53. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_dxgi.py +0 -0
  54. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_frames.py +0 -0
  55. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_icon.py +0 -0
  56. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_input.py +0 -0
  57. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_input_injection.py +0 -0
  58. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_inventory.py +0 -0
  59. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_logs.py +0 -0
  60. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_modal_loop.py +0 -0
  61. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_performance.py +0 -0
  62. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_preferences.py +0 -0
  63. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_protocol.py +0 -0
  64. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_sharing.py +0 -0
  65. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_single_instance.py +0 -0
  66. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_smoke.py +0 -0
  67. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_tls.py +0 -0
  68. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tests/test_window_state.py +0 -0
  69. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/tools/make_demo_gifs.py +0 -0
  70. {remotedesktop-1.3.0 → remotedesktop-1.4.0}/uv.lock +0 -0
@@ -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, 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). Only one instance runs per user session — a second launch activates the first (`single_instance.py`).
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 (no title text — the View menu names it "Servers on LAN 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 **closable but not floatable/movable** (`setFeatures(DockWidgetClosable)`). 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.
@@ -48,28 +48,28 @@ Managed with `uv` (hatchling build backend, src layout):
48
48
  ## Architecture
49
49
 
50
50
  - 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, a 2×2 QGridLayout of QGroupBox sections: "Sharing this computer" (the `SharingTab`: summary + viewers table), "Servers on LAN" (client-role inventory, Forget), "Clients on LAN" (server-role inventory, Revoke), and "Connection log" (the log pane + both get-log buttons). **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`).
51
+ - **The sharing lifecycle** (`server.py` `SharingTab`) lives on the "Connections" tab, a 2×2 QGridLayout of QGroupBox sections: "Server (sharing this computer)" (the `SharingTab`: summary + viewers table), "Server history" (client-role inventory, Forget), "Client history" (server-role inventory, Revoke), and "Connection log" (the log pane + both get-log buttons). **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
52
  - **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
53
  - 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
54
  - **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
55
  - **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
56
  - **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.
57
- - **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 "Viewing"/"Sharing" sub-tabs (`MainWindow.performance_pages`); `PreferencesTab` takes the list of monitors and applies the history-window setting to all.
58
- - **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. 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).
57
+ - **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
+ - **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
59
  - 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.
60
60
  - **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). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / 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).
61
61
  - **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. `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**.
62
62
  - **Trust model plumbing** (`config.py`): the client has a stable UUID identity (`load_client_identity`, in the `settings` table) and the server maps each approved client-id to a token (`PairedClients`). `ShareServer` takes an `approve_client(client_id, name) -> bool` callback; `SharingTab` implements it as a modal stay-on-top QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
63
63
  - **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, `SharingTab`, and `DiscoveryPanel` emit human-readable `status`/`statusMessage` signals for every connection phase, shown in the window's timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the pane shows is also written to a rotating debug log file (`logs.py`; `logs.init_logging("remotedesktop")` is called only by `app.main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\remotedesktop.log` on Windows; pre-1.0 `client.log`/`server.log` files just age out). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
64
- - **Connection inventory** (`inventory.py`): the Connections tab has two grouped tables ("Clients on LAN" for the sharing role, "Servers on LAN" for the viewing role), each backed by its own `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The sharing side is driven by `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases), forwarded through `SharingTab.peerEvent`. The viewing side is driven by `MainWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
65
- - **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. "Clients on LAN" 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). "Servers on LAN" 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).
64
+ - **Connection inventory** (`inventory.py`): the Connections tab has two grouped tables ("Client history" for the sharing role, "Server history" for the viewing role), each backed by its own `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The sharing side is driven by `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases), forwarded through `SharingTab.peerEvent`. The viewing side is driven by `MainWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
65
+ - **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
66
  - 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
67
  - **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.
68
68
  - **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
69
  - **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
70
  - **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.
71
71
  - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
72
- - **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings **every** attached stream (each ping id maps to its stream; a pong is honored only on the stream it was sent to). The graphs' RTT series follow only the *active* (most recently admitted) stream; per-stream send/recv/RTT snapshots come from `metrics_for(stream)` and feed the Server tab's `ViewersTable` (`server.py`; it takes the monitor and follows the current ShareServer via `set_share_server`, empty when None), which joins them with `ShareServer.viewers()` — identity fields (`user`/`host`/`os`) that the client hello reports (`_client_details()`; missing on old clients, shown as dashes). The ping is: `{"type": "ping", "id", "rtt"}` / `{"type": "pong", "id"}` — each side measures its own RTT and piggybacks it on its next ping so both ends graph both directions; peers without the feature ignore pings harmlessly (RTT graphs show "no data"). Ping/pong is admitted-streams-only on the server, like input/clipboard. The "Performance" tab (`PerformanceTab`, custom QPainter graphs, one per role sub-tab) schedules **no paint work while hidden** (gated on `isVisible()`, repaint on `showEvent`); the "Preferences" tab's history-window setting persists via the `Settings` store (`performance_window_seconds`, default 120). The monitor's timer runs only while streams are attached; all timing is `time.monotonic()` with an injectable `clock=` for tests — never monkeypatch `time.monotonic` (the test `pump()` helper depends on it).
72
+ - **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings **every** attached stream (each ping id maps to its stream; a pong is honored only on the stream it was sent to). The graphs' RTT series follow only the *active* (most recently admitted) stream; per-stream send/recv/RTT snapshots come from `metrics_for(stream)` and feed the Server tab's `ViewersTable` (`server.py`; it takes the monitor and follows the current ShareServer via `set_share_server`, empty when None), which joins them with `ShareServer.viewers()` — identity fields the client hello reports (`_client_details()`; `user`/`os` are displayed — `host` is still on the wire but not shown, since the display name defaults to the hostname; missing on old clients, shown as dashes). The ping is: `{"type": "ping", "id", "rtt"}` / `{"type": "pong", "id"}` — each side measures its own RTT and piggybacks it on its next ping so both ends graph both directions; peers without the feature ignore pings harmlessly (RTT graphs show "no data"). Ping/pong is admitted-streams-only on the server, like input/clipboard. The "Performance" tab (`PerformanceTab`, custom QPainter graphs, one per role sub-tab) schedules **no paint work while hidden** (gated on `isVisible()`, repaint on `showEvent`); the "Preferences" tab's history-window setting persists via the `Settings` store (`performance_window_seconds`, default 120). The monitor's timer runs only while streams are attached; all timing is `time.monotonic()` with an injectable `clock=` for tests — never monkeypatch `time.monotonic` (the test `pump()` helper depends on it).
73
73
  - **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy → server and server copy → all clients, as `{"type": "clipboard", "text"/"image_png"}` messages. It's opt-in — `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUI (and clipboard tests) enables it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** The app shares ONE `ClipboardSync` across both roles (one OS clipboard): a real local copy fans out to connected servers and own viewers; a peer-received payload records its signature before applying, so it never re-emits `changed` and cannot loop between the roles. Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
74
74
  - The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place. **Versioning is strict semver** (`compat.py`, using the `semver` package): major = client/server compatibility contract, minor = backward-compatible features, patch = bug fixes — bump the major when the wire protocol or behavior breaks older peers. Both sides compare majors on every connection (hello/welcome carry `app_version`) and show a strong warning on mismatch (server status log + ⚠ in the viewers table's Version column; client log + non-modal dialog + status-bar marker) but never block the connection. Peers too old to report a version produce no warning.
75
75
  - Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
5
5
  Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
6
6
  Project-URL: Repository, https://github.com/jamesabel/remotedesktop
@@ -31,9 +31,9 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  **Lossless, low-latency remote desktop for Windows computers on your LAN — one app, pure Python, zero configuration.**
33
33
 
34
- Run the same app on every computer. Turn on *Screen sharing* (view only or
34
+ Run the same app on every computer. Turn on *Server (sharing)* (view only or
35
35
  full control) in Preferences on the ones you want to reach, and they appear
36
- automatically in every other instance's *Servers* panel: the first
36
+ automatically in every other instance's left-hand panel: the first
37
37
  connection is approved with one click on the shared computer, and from then
38
38
  on it reconnects instantly. View several computers at once — each in its own tab — while
39
39
  optionally sharing your own screen at the same time; closing the window
@@ -69,7 +69,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
69
69
 
70
70
  ## Features
71
71
 
72
- - 🧩 **One app, both roles** — every install can view other computers and share its own screen at the same time; sharing is a three-state choice in Preferences (*Not shared* / *view only* / *full control*), and only one instance runs per computer (launching it again just raises the existing window).
72
+ - 🧩 **One app, either or both roles** — every install can view other computers and share its own screen at the same time. Both roles are chosen in Preferences: *Server (sharing)* is a three-state choice (*Not shared* / *view only* / *full control*), and a *Client (viewer)* toggle turns the client side off entirely for dedicated servers — the UI shows only the components for the roles you enabled, with indicators in the left pane saying what this instance is. Only one instance runs per computer (launching it again just raises the existing window).
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.
@@ -106,7 +106,7 @@ it prepares the environment on first use and launches the app.
106
106
  ## Quick start
107
107
 
108
108
  1. Run `remotedesktop` on both computers.
109
- 2. On the computer to share, open *Preferences* and set *Screen sharing*
109
+ 2. On the computer to share, open *Preferences* and set *Server (sharing)*
110
110
  to one of the *Shared* modes.
111
111
  3. On the viewing computer, the shared computer appears in the *Servers*
112
112
  panel — double-click it.
@@ -8,9 +8,9 @@
8
8
 
9
9
  **Lossless, low-latency remote desktop for Windows computers on your LAN — one app, pure Python, zero configuration.**
10
10
 
11
- Run the same app on every computer. Turn on *Screen sharing* (view only or
11
+ Run the same app on every computer. Turn on *Server (sharing)* (view only or
12
12
  full control) in Preferences on the ones you want to reach, and they appear
13
- automatically in every other instance's *Servers* panel: the first
13
+ automatically in every other instance's left-hand panel: the first
14
14
  connection is approved with one click on the shared computer, and from then
15
15
  on it reconnects instantly. View several computers at once — each in its own tab — while
16
16
  optionally sharing your own screen at the same time; closing the window
@@ -46,7 +46,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
46
46
 
47
47
  ## Features
48
48
 
49
- - 🧩 **One app, both roles** — every install can view other computers and share its own screen at the same time; sharing is a three-state choice in Preferences (*Not shared* / *view only* / *full control*), and only one instance runs per computer (launching it again just raises the existing window).
49
+ - 🧩 **One app, either or both roles** — every install can view other computers and share its own screen at the same time. Both roles are chosen in Preferences: *Server (sharing)* is a three-state choice (*Not shared* / *view only* / *full control*), and a *Client (viewer)* toggle turns the client side off entirely for dedicated servers — the UI shows only the components for the roles you enabled, with indicators in the left pane saying what this instance is. Only one instance runs per computer (launching it again just raises the existing window).
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.
@@ -83,7 +83,7 @@ it prepares the environment on first use and launches the app.
83
83
  ## Quick start
84
84
 
85
85
  1. Run `remotedesktop` on both computers.
86
- 2. On the computer to share, open *Preferences* and set *Screen sharing*
86
+ 2. On the computer to share, open *Preferences* and set *Server (sharing)*
87
87
  to one of the *Shared* modes.
88
88
  3. On the viewing computer, the shared computer appears in the *Servers*
89
89
  panel — double-click it.
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.4.0"
@@ -23,7 +23,6 @@ from PySide6.QtWidgets import (
23
23
  QApplication,
24
24
  QDockWidget,
25
25
  QFrame,
26
- QGridLayout,
27
26
  QGroupBox,
28
27
  QHBoxLayout,
29
28
  QLabel,
@@ -55,6 +54,7 @@ from remotedesktop.preferences import (
55
54
  PreferencesTab,
56
55
  load_clipboard_sync_enabled,
57
56
  load_performance_window_seconds,
57
+ load_viewer_enabled,
58
58
  )
59
59
  from remotedesktop.server import SharingTab
60
60
  from remotedesktop.sharing import ShareClient
@@ -82,10 +82,14 @@ class MainWindow(QMainWindow):
82
82
  ) -> None:
83
83
  super().__init__()
84
84
  self._reconnect_base = reconnect_base_seconds
85
+ self._auto_scan = auto_scan
85
86
  self.setWindowIcon(icon.app_icon("app"))
86
87
  # Tests inject a connection to a temp database; the app uses the default.
87
88
  self._db = connection if connection is not None else db.connect(default_db_path())
88
89
  self._settings = Settings(self._db)
90
+ # The viewer (client) role is opt-out: a dedicated server turns it
91
+ # off and loses the client-side UI (Server tab, discovery, history).
92
+ self._viewer_enabled = load_viewer_enabled(self._settings)
89
93
  # One monitor per role: ShareServer.close() resets its monitor, so
90
94
  # toggling sharing must not share a monitor with the viewing sessions
91
95
  # (and vice versa for _connect_session's reset).
@@ -137,7 +141,8 @@ class MainWindow(QMainWindow):
137
141
  self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
138
142
  self._tabs.currentChanged.connect(self._on_current_tab_changed)
139
143
  self._no_session_page = self._build_no_session_page()
140
- self._tabs.addTab(self._no_session_page, "Server")
144
+ if self._viewer_enabled:
145
+ self._tabs.addTab(self._no_session_page, "Server")
141
146
  # One "Connections" tab holds everything connection-related in a 2×2
142
147
  # grid: sharing status + viewers, both peer inventories, and the
143
148
  # connection log.
@@ -156,14 +161,17 @@ class MainWindow(QMainWindow):
156
161
  self.get_client_log_button.clicked.connect(self.sharing_tab.request_client_log)
157
162
 
158
163
  connections_tab = QWidget()
159
- grid = QGridLayout(connections_tab)
160
- sharing_group = QGroupBox("Sharing this computer")
164
+ sharing_group = QGroupBox("Server (sharing this computer)")
161
165
  QVBoxLayout(sharing_group).addWidget(self.sharing_tab)
162
- servers_group = QGroupBox("Servers on LAN")
163
- QVBoxLayout(servers_group).addWidget(
166
+ # Kept as an attribute: hidden when the viewer role is off (a
167
+ # server-only instance has no server history of its own). "History"
168
+ # (not "on LAN") — the live discovery list is the Servers on LAN
169
+ # panel; these tables are the persisted first/last-seen records.
170
+ self._servers_group = QGroupBox("Server history")
171
+ QVBoxLayout(self._servers_group).addWidget(
164
172
  InventoryTab(self.client_inventory, "Forget", self._forget_server)
165
173
  )
166
- clients_group = QGroupBox("Clients on LAN")
174
+ clients_group = QGroupBox("Client history")
167
175
  QVBoxLayout(clients_group).addWidget(
168
176
  InventoryTab(self.server_inventory, "Revoke", self._revoke_client)
169
177
  )
@@ -176,39 +184,81 @@ class MainWindow(QMainWindow):
176
184
  log_layout.addLayout(log_buttons)
177
185
  log_layout.addWidget(self.connection_log)
178
186
  # Left column: this computer (sharing status, log). Right column:
179
- # the LAN peer tables.
180
- grid.addWidget(sharing_group, 0, 0)
181
- grid.addWidget(log_group, 1, 0)
182
- grid.addWidget(servers_group, 0, 1)
183
- grid.addWidget(clients_group, 1, 1)
184
- for index in (0, 1):
185
- grid.setColumnStretch(index, 1)
186
- grid.setRowStretch(index, 1)
187
+ # the LAN peer tables. Column-level vboxes so a hidden group lets
188
+ # its neighbor take the full column.
189
+ left_column = QVBoxLayout()
190
+ left_column.addWidget(sharing_group)
191
+ left_column.addWidget(log_group)
192
+ right_column = QVBoxLayout()
193
+ right_column.addWidget(self._servers_group)
194
+ right_column.addWidget(clients_group)
195
+ columns = QHBoxLayout(connections_tab)
196
+ columns.addLayout(left_column, 1)
197
+ columns.addLayout(right_column, 1)
198
+ if not self._viewer_enabled:
199
+ self._servers_group.hide()
187
200
  self._tabs.addTab(connections_tab, "Connections")
201
+ # The Performance sub-tabs follow the roles: "Viewing" exists only
202
+ # with the viewer role, "Sharing" only while actually serving —
203
+ # a role that produces no data gets no sub-tab. The pages are kept
204
+ # as attributes so _update_performance_tabs can re-insert them.
188
205
  self.performance_pages = QTabWidget()
189
- self.performance_pages.addTab(
190
- PerformanceTab(self.client_performance, local="client", remote="server"),
191
- "Viewing",
206
+ self._viewing_perf_page = PerformanceTab(
207
+ self.client_performance, local="client", remote="server"
192
208
  )
193
- self.performance_pages.addTab(
194
- PerformanceTab(self.server_performance, local="server", remote="client"),
195
- "Sharing",
209
+ self._sharing_perf_page = PerformanceTab(
210
+ self.server_performance, local="server", remote="client"
196
211
  )
212
+ self._performance_hint = QLabel(
213
+ "Nothing to measure — enable the Client (viewer) or "
214
+ "Server (sharing) role in the Preferences tab"
215
+ )
216
+ self._performance_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
217
+ self._performance_hint.hide()
197
218
  performance_tab = QWidget()
198
219
  performance_layout = QVBoxLayout(performance_tab)
199
- performance_layout.addWidget(self.performance_pages)
220
+ performance_layout.addWidget(self.performance_pages, 1)
221
+ performance_layout.addWidget(
222
+ self._performance_hint, 1, Qt.AlignmentFlag.AlignCenter
223
+ )
200
224
  self._tabs.addTab(performance_tab, "Performance")
201
225
  self.setCentralWidget(self._tabs)
202
226
 
203
- # auto_scan=False (tests) never broadcasts on the LAN and starts no
204
- # rescan timer; the app scans at startup and every 15 s while visible.
227
+ # auto_scan=False (tests) never broadcasts on the LAN; the app scans
228
+ # once at startup and not at all without the viewer role.
205
229
  self.discovery_panel = DiscoveryPanel(
206
- self, is_self=self._is_own_server, auto_scan=auto_scan
230
+ self,
231
+ is_self=self._is_own_server,
232
+ auto_scan=auto_scan and self._viewer_enabled,
207
233
  )
208
- self.servers_dock = QDockWidget("Servers", self)
234
+ # The dock holds the role indicators (always) and the discovery
235
+ # panel (viewer role only). The indicators are button-shaped for
236
+ # visibility but purely informational: mouse events pass straight
237
+ # through, and the roles are changed only in Preferences.
238
+ self.client_role_button = self._make_role_button()
239
+ self.server_role_button = self._make_role_button()
240
+ dock_body = QWidget()
241
+ self._dock_layout = QVBoxLayout(dock_body)
242
+ self._dock_layout.addWidget(self.client_role_button)
243
+ self._dock_layout.addWidget(self.server_role_button)
244
+ self._dock_layout.addWidget(self.discovery_panel)
245
+ # A permanent trailing spacer: it takes the leftover height while
246
+ # the discovery panel is hidden (keeping the indicators pinned to
247
+ # the top), and collapses to nothing while the panel is visible
248
+ # (the stretch factors swap in _update_dock_layout).
249
+ self._dock_layout.addStretch(0)
250
+ if not self._viewer_enabled:
251
+ self.discovery_panel.hide()
252
+ self._update_dock_layout()
253
+ # No title text: the indicators and server list are self-explanatory.
254
+ # (The View menu still names it "Servers on LAN panel" for reopening.)
255
+ self.servers_dock = QDockWidget("", self)
209
256
  # An object name is required for saveState() to persist the dock.
210
257
  self.servers_dock.setObjectName("servers_dock")
211
- self.servers_dock.setWidget(self.discovery_panel)
258
+ # Closable (the X) but never floatable/movable: the panel lives on
259
+ # the left, and the View menu brings it back.
260
+ self.servers_dock.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)
261
+ self.servers_dock.setWidget(dock_body)
212
262
  self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.servers_dock)
213
263
 
214
264
  self.preferences_tab = PreferencesTab(
@@ -218,9 +268,10 @@ class MainWindow(QMainWindow):
218
268
  clipboard=self._clipboard,
219
269
  )
220
270
  self.preferences_tab.statusMessage.connect(self.log)
221
- # Preferences drives the sharing lifecycle (the three-state choice
222
- # lives there); SharingTab owns persistence and the ShareServer.
271
+ # Preferences drives both roles: the sharing lifecycle (the
272
+ # three-state choice) and the viewer role's UI.
223
273
  self.preferences_tab.sharingModeChanged.connect(self.sharing_tab.set_mode)
274
+ self.preferences_tab.viewerModeChanged.connect(self._set_viewer_enabled)
224
275
  self.preferences_tab.restart_button.clicked.connect(self._restart_app)
225
276
  self._tabs.addTab(self.preferences_tab, "Preferences")
226
277
  self._about_tab = AboutTab()
@@ -260,6 +311,8 @@ class MainWindow(QMainWindow):
260
311
  # that were open when the app last ran.
261
312
  self.sharing_tab.restore_sharing()
262
313
  self._restore_sessions()
314
+ self._update_role_indicators()
315
+ self._update_performance_tabs()
263
316
 
264
317
  # ------------------------------------------------------------- logging
265
318
 
@@ -290,11 +343,12 @@ class MainWindow(QMainWindow):
290
343
  self._view_menu = bar.addMenu("&View")
291
344
  # The dock's own toggle action: the way back after closing the panel.
292
345
  self.servers_panel_action = self.servers_dock.toggleViewAction()
293
- self.servers_panel_action.setText("&Servers panel")
346
+ self.servers_panel_action.setText("&Servers on LAN panel")
294
347
  self._view_menu.addAction(self.servers_panel_action)
295
348
  self.refresh_action = self._view_menu.addAction("&Refresh server list")
296
349
  self.refresh_action.setShortcut(QKeySequence("F5"))
297
350
  self.refresh_action.triggered.connect(self.discovery_panel.refresh)
351
+ self.refresh_action.setEnabled(self._viewer_enabled)
298
352
  self._view_menu.addSeparator()
299
353
  self.actual_size_action = self._view_menu.addAction("&Actual size")
300
354
  self.actual_size_action.setCheckable(True)
@@ -330,12 +384,12 @@ class MainWindow(QMainWindow):
330
384
  label = QLabel(
331
385
  "<h3>No server connected</h3>"
332
386
  "<p>Computers sharing their screen appear automatically in the "
333
- "<b>Servers</b> panel on the left<br>(View&nbsp;▸&nbsp;Servers "
334
- "panel if it is hidden).</p>"
387
+ "panel on the left<br>"
388
+ "(View&nbsp;▸&nbsp;Servers on LAN panel if it is hidden).</p>"
335
389
  "<p>Double-click one — or select it and click <b>Connect</b> — "
336
390
  "and this tab becomes your view of that computer.</p>"
337
- "<p>To make a computer appear in the list, turn on "
338
- "<b>Screen sharing</b> in the Preferences tab on that computer.</p>"
391
+ "<p>To make a computer appear in the list, enable "
392
+ "<b>Server (sharing)</b> in the Preferences tab on that computer.</p>"
339
393
  )
340
394
  label.setAlignment(Qt.AlignmentFlag.AlignCenter)
341
395
  label.setWordWrap(True)
@@ -352,11 +406,86 @@ class MainWindow(QMainWindow):
352
406
 
353
407
  def _ensure_placeholder(self) -> None:
354
408
  """Re-show the "Server" instructions tab once no session remains."""
355
- if self._sessions or self._tabs.indexOf(self._no_session_page) != -1:
409
+ if (
410
+ not self._viewer_enabled
411
+ or self._sessions
412
+ or self._tabs.indexOf(self._no_session_page) != -1
413
+ ):
356
414
  return
357
415
  self._tabs.insertTab(0, self._no_session_page, "Server")
358
416
  self._strip_tab_buttons(0) # a fresh insert grows new close buttons
359
417
 
418
+ @staticmethod
419
+ def _make_role_button() -> QPushButton:
420
+ button = QPushButton()
421
+ button.setCheckable(True)
422
+ button.setStyleSheet(
423
+ "QPushButton { padding: 4px 8px; }"
424
+ "QPushButton:checked { background-color: #2e7d32; color: white; "
425
+ "border: 1px solid #1b5e20; border-radius: 3px; }"
426
+ )
427
+ # Indicator only: clicks fall through, nothing toggles from here.
428
+ button.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
429
+ button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
430
+ return button
431
+
432
+ def _update_performance_tabs(self) -> None:
433
+ """Show a Performance sub-tab per role that can produce data."""
434
+ pages = self.performance_pages
435
+
436
+ def sync(page: QWidget, title: str, wanted: bool, front: bool) -> None:
437
+ index = pages.indexOf(page)
438
+ if wanted and index == -1:
439
+ pages.insertTab(0 if front else pages.count(), page, title)
440
+ elif not wanted and index != -1:
441
+ pages.removeTab(index)
442
+
443
+ sync(self._viewing_perf_page, "Client (viewer)", self._viewer_enabled, front=True)
444
+ sync(self._sharing_perf_page, "Server (sharing)", self.sharing_tab.serving, front=False)
445
+ empty = pages.count() == 0
446
+ pages.setVisible(not empty)
447
+ self._performance_hint.setVisible(empty)
448
+
449
+ def _update_dock_layout(self) -> None:
450
+ """Give the dock's leftover height to the panel or the spacer."""
451
+ panel_visible = self._viewer_enabled
452
+ self._dock_layout.setStretch(2, 1 if panel_visible else 0) # panel
453
+ self._dock_layout.setStretch(3, 0 if panel_visible else 1) # spacer
454
+
455
+ def _update_role_indicators(self) -> None:
456
+ self.client_role_button.setChecked(self._viewer_enabled)
457
+ self.client_role_button.setText(
458
+ "Client (viewer): " + ("on" if self._viewer_enabled else "off")
459
+ )
460
+ serving = self.sharing_tab.serving
461
+ self.server_role_button.setChecked(serving)
462
+ self.server_role_button.setText(
463
+ "Server (sharing): " + ("on" if serving else "off")
464
+ )
465
+
466
+ def _set_viewer_enabled(self, enabled: bool) -> None:
467
+ """Show or hide the client-side UI as the viewer role toggles."""
468
+ self._viewer_enabled = enabled
469
+ if not enabled:
470
+ for session in list(self._sessions):
471
+ self._close_session(session) # drops any open connections
472
+ placeholder_index = self._tabs.indexOf(self._no_session_page)
473
+ if placeholder_index != -1:
474
+ self._tabs.removeTab(placeholder_index)
475
+ self.discovery_panel.hide()
476
+ self._servers_group.hide()
477
+ else:
478
+ self.discovery_panel.show()
479
+ self._servers_group.show()
480
+ self._ensure_placeholder()
481
+ self._tabs.setCurrentIndex(0)
482
+ if self._auto_scan:
483
+ self.discovery_panel.refresh() # becoming a viewer: scan now
484
+ self.refresh_action.setEnabled(enabled)
485
+ self._update_dock_layout()
486
+ self._update_role_indicators()
487
+ self._update_performance_tabs()
488
+
360
489
  def _on_current_tab_changed(self, _index: int) -> None:
361
490
  self._refresh_status_bar()
362
491
  if getattr(self, "close_tab_action", None) is None:
@@ -508,6 +637,8 @@ class MainWindow(QMainWindow):
508
637
  self._tray.deleteLater()
509
638
  self._tray = None
510
639
  self._update_window_title()
640
+ self._update_role_indicators()
641
+ self._update_performance_tabs()
511
642
 
512
643
  def _quit(self) -> None:
513
644
  self._quitting = True
@@ -659,6 +790,8 @@ class MainWindow(QMainWindow):
659
790
  self._settings.set("open_sessions", json.dumps(entries))
660
791
 
661
792
  def _restore_sessions(self) -> None:
793
+ if not self._viewer_enabled:
794
+ return
662
795
  raw = self._settings.get("open_sessions")
663
796
  if not raw:
664
797
  return
@@ -738,6 +871,9 @@ class MainWindow(QMainWindow):
738
871
  )
739
872
 
740
873
  def _on_server_activated(self, server: ServerInfo) -> None:
874
+ if not self._viewer_enabled:
875
+ self.log("Viewer role is off — enable it in Preferences to connect")
876
+ return
741
877
  if self._is_own_server(server):
742
878
  # Viewing your own screen through yourself is a hall of mirrors;
743
879
  # the panel already blocks this, but guard the entry point too.
@@ -12,6 +12,7 @@ from collections.abc import Callable
12
12
 
13
13
  from PySide6.QtCore import Qt, QTimer, Signal
14
14
  from PySide6.QtGui import QPainter
15
+ from PySide6.QtNetwork import QNetworkInterface
15
16
  from PySide6.QtWidgets import (
16
17
  QListWidget,
17
18
  QListWidgetItem,
@@ -29,11 +30,33 @@ from remotedesktop.viewer import ViewerWidget
29
30
  _log = logging.getLogger("remotedesktop.client")
30
31
 
31
32
 
33
+ def _broadcast_hosts() -> tuple[str, ...]:
34
+ """The limited broadcast plus every interface's directed broadcast.
35
+
36
+ Windows routes 255.255.255.255 out only one interface; on a machine
37
+ with a VPN or virtual adapters that can be the wrong one, making every
38
+ scan come up empty. Directed subnet broadcasts (e.g. 192.168.1.255)
39
+ reach each attached network explicitly.
40
+ """
41
+ hosts = {"255.255.255.255"}
42
+ for interface in QNetworkInterface.allInterfaces():
43
+ flags = interface.flags()
44
+ if not (flags & QNetworkInterface.InterfaceFlag.IsUp) or (
45
+ flags & QNetworkInterface.InterfaceFlag.IsLoopBack
46
+ ):
47
+ continue
48
+ for entry in interface.addressEntries():
49
+ broadcast = entry.broadcast()
50
+ if not broadcast.isNull() and broadcast.toString():
51
+ hosts.add(broadcast.toString())
52
+ return tuple(sorted(hosts))
53
+
54
+
32
55
  class _ServerList(QListWidget):
33
56
  """Server list that paints a hint while empty."""
34
57
 
35
58
  placeholder_text = (
36
- "No servers found yet — turn on Screen sharing in the Preferences "
59
+ "No servers found yet — enable Server (sharing) in the Preferences "
37
60
  "tab on the computer to share"
38
61
  )
39
62
 
@@ -112,7 +135,7 @@ class DiscoveryPanel(QWidget):
112
135
  # thread. Any failure must still emit, or the button stays disabled.
113
136
  servers: list[ServerInfo] = []
114
137
  try:
115
- servers = discover_servers()
138
+ servers = discover_servers(broadcast_hosts=_broadcast_hosts())
116
139
  except Exception:
117
140
  pass
118
141
  self._scanFinished.emit(servers)