remotedesktop 1.4.0__tar.gz → 1.5.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 (74) hide show
  1. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/.github/workflows/release.yml +27 -0
  2. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/.gitignore +6 -0
  3. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/CLAUDE.md +24 -2
  4. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/PKG-INFO +12 -5
  5. remotedesktop-1.5.0/docs/media/client-demo.gif +0 -0
  6. remotedesktop-1.5.0/docs/media/server-demo.gif +0 -0
  7. remotedesktop-1.5.0/icon/remotedesktop.ico +0 -0
  8. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/pyproject.toml +13 -0
  9. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/readme.md +11 -4
  10. remotedesktop-1.5.0/scripts/make_installer.bat +27 -0
  11. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/__init__.py +1 -1
  12. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/app.py +127 -48
  13. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/autostart.py +23 -1
  14. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/server.py +11 -6
  15. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/sharing.py +5 -0
  16. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_autostart.py +29 -1
  17. remotedesktop-1.5.0/tests/test_icon.py +31 -0
  18. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_main_window.py +82 -21
  19. remotedesktop-1.5.0/tools/make_icon.py +49 -0
  20. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/uv.lock +549 -0
  21. remotedesktop-1.4.0/docs/media/client-demo.gif +0 -0
  22. remotedesktop-1.4.0/docs/media/server-demo.gif +0 -0
  23. remotedesktop-1.4.0/tests/test_icon.py +0 -20
  24. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/.github/workflows/ci.yml +0 -0
  25. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/LICENSE +0 -0
  26. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/run.bat +0 -0
  27. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/run_claude.bat +0 -0
  28. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/scripts/make_coverage_badge.py +0 -0
  29. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/scripts/make_venv.bat +0 -0
  30. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/__main__.py +0 -0
  31. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/about.py +0 -0
  32. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/client.py +0 -0
  33. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/clipboard.py +0 -0
  34. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/compat.py +0 -0
  35. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/config.py +0 -0
  36. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/db.py +0 -0
  37. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/discovery.py +0 -0
  38. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/dxgi.py +0 -0
  39. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/frames.py +0 -0
  40. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/icon.py +0 -0
  41. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/input_injection.py +0 -0
  42. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/inventory.py +0 -0
  43. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/logs.py +0 -0
  44. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/modal_loop.py +0 -0
  45. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/performance.py +0 -0
  46. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/preferences.py +0 -0
  47. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/protocol.py +0 -0
  48. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/single_instance.py +0 -0
  49. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/tls.py +0 -0
  50. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/viewer.py +0 -0
  51. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/src/remotedesktop/window_state.py +0 -0
  52. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/conftest.py +0 -0
  53. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_about.py +0 -0
  54. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_clipboard.py +0 -0
  55. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_compat.py +0 -0
  56. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_config.py +0 -0
  57. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_discovery.py +0 -0
  58. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_dxgi.py +0 -0
  59. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_frames.py +0 -0
  60. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_input.py +0 -0
  61. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_input_injection.py +0 -0
  62. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_inventory.py +0 -0
  63. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_logs.py +0 -0
  64. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_modal_loop.py +0 -0
  65. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_performance.py +0 -0
  66. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_preferences.py +0 -0
  67. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_protocol.py +0 -0
  68. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_sharing.py +0 -0
  69. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_sharing_tab.py +0 -0
  70. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_single_instance.py +0 -0
  71. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_smoke.py +0 -0
  72. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_tls.py +0 -0
  73. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tests/test_window_state.py +0 -0
  74. {remotedesktop-1.4.0 → remotedesktop-1.5.0}/tools/make_demo_gifs.py +0 -0
@@ -20,6 +20,8 @@ jobs:
20
20
  runs-on: ubuntu-latest
21
21
  permissions:
22
22
  contents: write # create the tag and the release
23
+ outputs:
24
+ version: ${{ steps.version.outputs.version }}
23
25
  steps:
24
26
  - uses: actions/checkout@v4
25
27
  - uses: astral-sh/setup-uv@v6
@@ -46,3 +48,28 @@ jobs:
46
48
  --title "${{ steps.version.outputs.version }}" \
47
49
  --generate-notes \
48
50
  dist/*
51
+
52
+ # Unsigned installer build (the signing token is local-only). To replace it
53
+ # with a signed one: run scripts\make_installer.bat locally, then
54
+ # gh release upload v<version> installers\remotedesktop_installer_win64.exe --clobber
55
+ installer:
56
+ needs: release
57
+ runs-on: windows-latest # .NET Framework csc.exe is preinstalled (launcher build)
58
+ permissions:
59
+ contents: write # upload the installer to the release
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+ - uses: astral-sh/setup-uv@v6
63
+ - name: Install NSIS
64
+ # Not preinstalled on windows-latest; choco puts makensis.exe at
65
+ # C:\Program Files (x86)\NSIS\makensis.exe, pyship's default path.
66
+ run: choco install nsis -y --no-progress
67
+ - name: Build installer
68
+ run: uv run --group ship python -m pyship --noupload
69
+ - name: Attach installer to release
70
+ env:
71
+ GH_TOKEN: ${{ github.token }}
72
+ run: >
73
+ gh release upload "v${{ needs.release.outputs.version }}"
74
+ --repo "$env:GITHUB_REPOSITORY" --clobber
75
+ installers/remotedesktop_installer_win64.exe
@@ -10,6 +10,12 @@ dist/
10
10
  .venv/
11
11
  venv/
12
12
 
13
+ # pyship build output (scripts/make_installer.bat); *.nsi is left behind
14
+ # only if makensis fails mid-run
15
+ app/
16
+ installers/
17
+ *.nsi
18
+
13
19
  # Testing
14
20
  .pytest_cache/
15
21
  .coverage
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
6
6
 
7
7
  A Python remote desktop GUI application (PySide6) for Windows computers on the same LAN, per `readme.md`:
8
8
 
9
- - **One app, either or both roles**: every instance can view other computers, and opts in to *sharing* its own screen via a three-state Screen sharing choice in Preferences — Not shared / view only / full control (persisted; a viewing-only instance binds no ports and creates no TLS credentials). The **viewer role is opt-out** too: Preferences' "Client (viewer)" checkbox (`viewer_enabled`, default on, `load_viewer_enabled`) — when off, `MainWindow._set_viewer_enabled` closes open sessions and hides all client UI (Server placeholder tab, discovery panel, "Server history" group), disables F5, skips session restore, and refuses activations; discovery never broadcasts. Two **role indicator buttons** at the top of the left dock (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`).
9
+ - **One app, either or both roles**: every instance can view other computers, and opts in to *sharing* its own screen via a three-state Screen sharing choice in Preferences — Not shared / view only / full control (persisted; a viewing-only instance binds no ports and creates no TLS credentials). The **viewer role is opt-out** too: Preferences' "Client (viewer)" checkbox (`viewer_enabled`, default on, `load_viewer_enabled`) — when off, `MainWindow._set_viewer_enabled` closes open sessions and hides all client UI (Server placeholder tab, discovery panel, "Server history" group), disables F5, skips session restore, and refuses activations; discovery never broadcasts. Two **role indicator buttons** at the top of the left dock (`panel_dock`; headerless — the View menu names it "Panel") (`client_role_button`/`server_role_button` — checkable QPushButtons styled green when on, made purely informational via `WA_TransparentForMouseEvents`; roles change only in Preferences) say what the instance is, updated on viewer toggle and `sharingChanged`. The dock always exists (a server-only instance shows just the indicators) and is **headerless** no title bar at all (`setTitleBarWidget` with an empty widget, `NoDockWidgetFeatures`); View ▸ Panel is the only way to show/hide it. Only one instance runs per user session — a second launch activates the first (`single_instance.py`).
10
10
  - **Autodiscovery and connection** of sharing computers on the LAN.
11
11
  - **In scope:** desktop screen, keyboard, mouse, and clipboard.
12
12
  - **Out of scope:** shared drives, devices, and multimedia (e.g., audio). Smooth playback of fast-changing full-screen content (video, games) is a non-goal: the screen transfer is lossless and optimized for mostly-static desktop work, not for video bandwidth.
@@ -34,6 +34,21 @@ attached. It is idempotent (skips if the release exists) and can be run
34
34
  manually via workflow_dispatch. PyPI publishing stays manual (`uv publish`),
35
35
  only on the user's explicit request.
36
36
 
37
+ The same workflow's `installer` job (windows-latest) builds the **pyship
38
+ installer** (`uv run --group ship python -m pyship --noupload`) and attaches
39
+ it to the release — **unsigned**, because the signing token is local-only. To
40
+ ship a signed installer, build locally (`scripts\make_installer.bat`) and
41
+ replace the asset:
42
+ `gh release upload v<version> installers\remotedesktop_installer_win64.exe --clobber`.
43
+ The frozen app is a pyship CLIP (a full standalone CPython), so `pythonw.exe
44
+ -m remotedesktop` still works inside it; `autostart.installed_launcher()`
45
+ detects the install (CLIP dir name `remotedesktop_<version>`) and points
46
+ autostart/restart at the launcher exe, which always starts the newest
47
+ installed version. Users must quit the app before running an installer — a
48
+ running instance holds the CLIP's DLLs locked. Do not set
49
+ `[tool.pyship] run_on_startup` or signing keys in `pyproject.toml` (see the
50
+ comments there).
51
+
37
52
  ## Commands
38
53
 
39
54
  Managed with `uv` (hatchling build backend, src layout):
@@ -44,11 +59,18 @@ Managed with `uv` (hatchling build backend, src layout):
44
59
  - `uv run remotedesktop` — launch the app. It's a `gui-script`, so it runs detached with no console output; use `uv run python -m remotedesktop` when you need stdout/tracebacks. `--minimized` starts hidden in the tray when sharing is on (what the autostart registration passes).
45
60
  - `uv build` — build sdist and wheel into `dist/`
46
61
  - `uv publish` — publish to PyPI
62
+ - `scripts\make_installer.bat` — build the **signed Windows installer** with pyship
63
+ (`installers\remotedesktop_installer_win64.exe`). Needs the hardware signing token and a
64
+ local console session (pyship refuses token signing over RDP); set
65
+ `PYSHIP_SIGNING_CERTIFICATE_PIN` for unattended signing. pyship wipes and rebuilds
66
+ `dist/`, `app/` (launcher exe + frozen CLIP), and `installers/` — all gitignored.
67
+ `icon/remotedesktop.ico` is the build-input icon; regenerate with
68
+ `uv run python tools/make_icon.py` after changing `icon.py` (a test guards it).
47
69
 
48
70
  ## Architecture
49
71
 
50
72
  - One PySide6 GUI app: `src/remotedesktop/app.py` (`MainWindow` + `main()`, wired to the `remotedesktop` GUI script in `pyproject.toml`; `__main__.py` makes `python -m remotedesktop` work — the in-app Restart relaunches that). `client.py` holds the viewing-role widgets (`DiscoveryPanel`, `ServerSession`), `server.py` the sharing-role widgets (`SharingTab`, `ViewersTable`) — neither has a window or entry point of its own anymore.
51
- - **The sharing lifecycle** (`server.py` `SharingTab`) lives on the "Connections" tab, 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`).
73
+ - **The sharing lifecycle** (`server.py` `SharingTab`) lives on the "Connections" tab, arranged as one outer QGroupBox per role, side by side as widgets with "Connection log" full-width below: "Client (viewer)" (left; nests "Connected servers" — `app._SessionsTable`, the client-side twin of the viewers table fed by `_ConnectedServersSource` from the live sessions and `client_performance` — over "Server history", client-role inventory, Forget) and "Server (sharing)" (right; the `SharingTab` summary + viewers table over "Client history", server-role inventory, Revoke). **Each role box is visible only while its role is on** (`_update_connections_groups`); boxes are widgets (not column layouts) so a hidden role frees its space and the other starts at the left edge. **The mode itself is chosen in Preferences**: a three-state radio group (`preferences.py`, `sharingModeChanged` signal) wired by `MainWindow` to `SharingTab.set_mode("off"/"view"/"control")` — `server.py` exposes `SHARING_MODE_*` constants and `load_sharing_mode(settings)`, persisted as the two legacy keys `server_enabled` + `allow_remote_input` so old installs migrate for free. View ↔ control switches apply live (`ShareServer.set_input_allowed`) without dropping viewers; off/on tears down / creates the `ShareServer` + `DiscoveryResponder` (a fresh `ShareServer` per enable; `close()` leaves teardown state), so a viewing-only instance binds no ports and never creates TLS credentials (loaded lazily on first enable). The host window calls `restore_sharing()` after wiring `statusMessage`/`peerEvent`/`sharingChanged`, so no startup status message is emitted before anyone listens. `serving` is mode-on AND actually listening. `revoke_client` works even while not sharing (direct `PairedClients.revoke`). The Restart app button also lives in Preferences (`preferences_tab.restart_button` → `MainWindow._restart_app`).
52
74
  - **Tray + lifecycle** (`app.py`): while sharing, closing the window hides to a `QSystemTrayIcon` (menu: Show / Restart / Quit) and sharing continues; not sharing → close quits. `main()` sets `setQuitOnLastWindowClosed(False)`, so **every exit path must reach `QApplication.quit()`** (plain close, tray Quit, restart). `tray_available=` is injectable — tests always pass an explicit value and never call `isSystemTrayAvailable()`. `--minimized` (used by the autostart registration) starts hidden in the tray when sharing is on. `single_instance.py` (`SingleInstance`, QLocalServer-based) enforces one instance per user session; a second launch tells the first to `bring_to_front()` and exits. `autostart.py` registers `remotedesktop.exe --minimized` under HKCU Run value `"remotedesktop"` and migrates the pre-1.0 `"remotedesktop-server"` value at startup (`migrate_legacy`).
53
75
  - The remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window. **Multiple simultaneous server connections**: each is a `ServerSession` in `client.py` (its own `ShareClient` + `ViewerWidget`, hosted in a `QScrollArea` tab page — `session.page` is the tab widget, `session.viewer` the viewer), shown as a closable tab at the front of `MainWindow`'s central `QTabWidget`, titled with the server's reported hostname (fixed tabs get their close buttons stripped). Fit mode is `widgetResizable(True)`; **View → Actual size** flips the page to non-resizable and the viewer sizes itself to `frame / devicePixelRatio` (1:1 device pixels, the no-resample paint branch; input mapping unchanged because the frame fills the widget). Sessions are keyed by `host:port`; activating an already-known server reconnects in its existing tab, activating a connected one just focuses it. The window title lists the connected server names and a "— sharing" suffix while serving (`_update_window_title`); the status bar's message area follows the currently selected session tab (`status_text` per session) and a permanent right-side label shows "Sharing — N viewer(s)" whenever sharing is on (driven by `SharingTab.viewerCountChanged`). The `ClipboardSync`, inventories, and Connection log are shared across sessions; `client_performance.reset()` is only called when no *other* session is connected (resetting detaches every stream). Per-session status log lines are prefixed `[name]`.
54
76
  - **Auto-reconnect** (`app.py`): a session that *was connected* and drops retries `client.connect_to` with exponential backoff (base `reconnect_base_seconds=2.0` kwarg — tests inject ~0.05 — doubling to a 30 s cap), driven by a per-session single-shot QTimer. `ShareClient.connectionFailed` exists because **Qt emits `disconnected` only for sockets that reached ConnectedState** — refused/unreachable attempts fire `errorOccurred` with the socket back in UnconnectedState. The active-timer guard dedupes the errorOccurred+disconnected double-fire. Denial cancels until a manual activation; tab close/forget/quit cancel; retries bypass `_connect_session` on purpose (no perf reset, no tab steal, no inventory attempt spam). **Open session tabs persist** (`open_sessions` JSON in Settings, written on connect/rename/close) and are restored + reconnected at startup with `auto_reconnect` pre-armed, so a not-yet-available server is retried on the backoff loop; deliberately closed tabs are not restored. While no session exists, a non-closable "Server" placeholder tab (instructions) sits at index 0 and swaps with the first session's tab.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Remote desktop app for Windows computers on the same LAN, with autodiscovery. View other computers and optionally share this one — screen, keyboard, mouse, and clipboard, without RDP or Microsoft authentication.
5
5
  Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
6
6
  Project-URL: Repository, https://github.com/jamesabel/remotedesktop
@@ -62,7 +62,7 @@ time with live statistics.
62
62
 
63
63
  Sharing is turned on in Preferences (*view only* or *full control*). The
64
64
  *Connections* tab then shows everything in one place: every connected
65
- viewer — who they are (login name, computer, OS) and how the connection is
65
+ viewer — who they are (name, login, OS) and how the connection is
66
66
  doing (bandwidth, round-trip time with mean/min/max/p99/jitter over the
67
67
  recent window) — the history of servers seen and clients paired with
68
68
  one-click *Forget* / *Revoke*, and the live connection log.
@@ -73,7 +73,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
73
73
  - 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
74
74
  - 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
75
75
  - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
76
- - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared viewers can watch only* and sharing becomes view-only, switchable live.
76
+ - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
77
77
  - 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
78
78
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
79
79
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
@@ -90,6 +90,13 @@ for mostly-static desktop work.
90
90
 
91
91
  ## Installation
92
92
 
93
+ The easiest way: download `remotedesktop_installer_win64.exe` from the
94
+ [latest release](https://github.com/jamesabel/remotedesktop/releases/latest)
95
+ and run it — no Python required. To upgrade, quit the app first (tray →
96
+ Quit if it's sharing), then run the newer installer.
97
+
98
+ With Python, from PyPI:
99
+
93
100
  ```
94
101
  pip install remotedesktop
95
102
  ```
@@ -108,8 +115,8 @@ it prepares the environment on first use and launches the app.
108
115
  1. Run `remotedesktop` on both computers.
109
116
  2. On the computer to share, open *Preferences* and set *Server (sharing)*
110
117
  to one of the *Shared* modes.
111
- 3. On the viewing computer, the shared computer appears in the *Servers*
112
- panel — double-click it.
118
+ 3. On the viewing computer, the shared computer appears in the panel on
119
+ the left — double-click it (or select it and click *Connect*).
113
120
  4. Approve the connection in the dialog that pops up on the shared
114
121
  computer. That's it — future connections from that computer need no
115
122
  approval.
@@ -35,6 +35,10 @@ dev = [
35
35
  "pytest>=8",
36
36
  "pytest-cov>=7.1.0",
37
37
  ]
38
+ # Only for building the Windows installer (scripts/make_installer.bat / release CI).
39
+ ship = [
40
+ "pyship>=0.7.4",
41
+ ]
38
42
 
39
43
  [build-system]
40
44
  requires = ["hatchling"]
@@ -46,6 +50,15 @@ path = "src/remotedesktop/__init__.py"
46
50
  [tool.pytest.ini_options]
47
51
  testpaths = ["tests"]
48
52
 
53
+ [tool.pyship]
54
+ ui = "gui"
55
+ # Never upload to S3, even without --noupload. Code signing stays out of this
56
+ # config on purpose: CI installer builds are unsigned (the signing token is
57
+ # local-only); scripts/make_installer.bat passes the signing flags.
58
+ upload = false
59
+ # run_on_startup deliberately NOT set: it would write an HKLM Run value named
60
+ # "remotedesktop", colliding with the app's own HKCU autostart preference.
61
+
49
62
  [tool.ty.environment]
50
63
  # The app targets Windows (SendInput via ctypes.windll); type-check as such
51
64
  # even when ty runs on a Linux CI runner.
@@ -39,7 +39,7 @@ time with live statistics.
39
39
 
40
40
  Sharing is turned on in Preferences (*view only* or *full control*). The
41
41
  *Connections* tab then shows everything in one place: every connected
42
- viewer — who they are (login name, computer, OS) and how the connection is
42
+ viewer — who they are (name, login, OS) and how the connection is
43
43
  doing (bandwidth, round-trip time with mean/min/max/p99/jitter over the
44
44
  recent window) — the history of servers seen and clients paired with
45
45
  one-click *Forget* / *Revoke*, and the live connection log.
@@ -50,7 +50,7 @@ one-click *Forget* / *Revoke*, and the live connection log.
50
50
  - 🔍 **Autodiscovery** — sharing computers announce themselves over UDP; the app lists every one on the LAN at startup, no addresses to type (press *Refresh* or F5 to rescan).
51
51
  - 🗂️ **Multiple computers at once** — view and control several computers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
52
52
  - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
53
- - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared viewers can watch only* and sharing becomes view-only, switchable live.
53
+ - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys. Prefer eyes-only? Choose *Shared, view only* and sharing becomes view-only, switchable live.
54
54
  - 🖼️ **View your way** — each connection scales to fit or shows the remote screen at 1:1 pixels with panning, and F11 goes full screen. While you type into a remote session every key is forwarded — F11 is the one key that stays local.
55
55
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other; a Preferences toggle turns syncing off entirely.
56
56
  - 🔒 **TLS + approve-once pairing** — every connection is encrypted; the server user approves a new client once, after which it reconnects with a stored token and no prompt.
@@ -67,6 +67,13 @@ for mostly-static desktop work.
67
67
 
68
68
  ## Installation
69
69
 
70
+ The easiest way: download `remotedesktop_installer_win64.exe` from the
71
+ [latest release](https://github.com/jamesabel/remotedesktop/releases/latest)
72
+ and run it — no Python required. To upgrade, quit the app first (tray →
73
+ Quit if it's sharing), then run the newer installer.
74
+
75
+ With Python, from PyPI:
76
+
70
77
  ```
71
78
  pip install remotedesktop
72
79
  ```
@@ -85,8 +92,8 @@ it prepares the environment on first use and launches the app.
85
92
  1. Run `remotedesktop` on both computers.
86
93
  2. On the computer to share, open *Preferences* and set *Server (sharing)*
87
94
  to one of the *Shared* modes.
88
- 3. On the viewing computer, the shared computer appears in the *Servers*
89
- panel — double-click it.
95
+ 3. On the viewing computer, the shared computer appears in the panel on
96
+ the left — double-click it (or select it and click *Connect*).
90
97
  4. Approve the connection in the dialog that pops up on the shared
91
98
  computer. That's it — future connections from that computer need no
92
99
  approval.
@@ -0,0 +1,27 @@
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0.."
4
+
5
+ rem Build the signed Windows installer with pyship.
6
+ rem Requirements: the hardware signing token plugged in, a LOCAL console
7
+ rem session (pyship refuses to sign over RDP - failed PIN attempts can lock
8
+ rem the token), and optionally PYSHIP_SIGNING_CERTIFICATE_PIN set for
9
+ rem unattended signing (otherwise the token middleware prompts for the PIN).
10
+ rem CI builds the same installer unsigned; replace the release asset with
11
+ rem this signed one via:
12
+ rem gh release upload v<version> installers\remotedesktop_installer_win64.exe --clobber
13
+
14
+ uv sync --group ship
15
+ if errorlevel 1 (
16
+ echo Failed to create the environment.
17
+ exit /b 1
18
+ )
19
+
20
+ rem pyship rebuilds dist\ (wheel), app\ (launcher + frozen CLIP), and installers\.
21
+ uv run --group ship python -m pyship --noupload --code-sign --certificate-auto-select
22
+ if errorlevel 1 (
23
+ echo Installer build FAILED.
24
+ exit /b 1
25
+ )
26
+
27
+ echo Installer: installers\remotedesktop_installer_win64.exe
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "1.4.0"
3
+ __version__ = "1.5.0"
@@ -16,7 +16,7 @@ import sqlite3
16
16
  import sys
17
17
  import time
18
18
 
19
- from PySide6.QtCore import QProcess, Qt, QTimer
19
+ from PySide6.QtCore import QObject, QProcess, Qt, QTimer, Signal
20
20
  from PySide6.QtGui import QCloseEvent, QKeySequence
21
21
  from PySide6.QtNetwork import QNetworkInterface
22
22
  from PySide6.QtWidgets import (
@@ -41,7 +41,7 @@ from PySide6.QtWidgets import (
41
41
 
42
42
  from remotedesktop import __version__, compat, db, icon, logs, window_state
43
43
  from remotedesktop.about import AboutTab
44
- from remotedesktop.autostart import Autostart
44
+ from remotedesktop.autostart import Autostart, installed_launcher
45
45
  from remotedesktop.client import DiscoveryPanel, ServerSession
46
46
  from remotedesktop.clipboard import ClipboardSync
47
47
  from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
@@ -56,7 +56,7 @@ from remotedesktop.preferences import (
56
56
  load_performance_window_seconds,
57
57
  load_viewer_enabled,
58
58
  )
59
- from remotedesktop.server import SharingTab
59
+ from remotedesktop.server import SharingTab, ViewersTable
60
60
  from remotedesktop.sharing import ShareClient
61
61
  from remotedesktop.single_instance import SingleInstance
62
62
  from remotedesktop.viewer import ViewerWidget
@@ -67,6 +67,52 @@ _log = logging.getLogger("remotedesktop.app")
67
67
  _RECONNECT_CAP_SECONDS = 30.0
68
68
 
69
69
 
70
+ class _SessionsTable(ViewersTable):
71
+ """The client-side twin of the viewers table: one row per connected
72
+ server session, with the same live network statistics."""
73
+
74
+ _COLUMNS = [
75
+ "Name", "Address", "Version", "Send", "Receive",
76
+ "RTT", "RTT mean", "RTT min", "RTT max", "RTT p99", "RTT jitter",
77
+ ]
78
+ _RATE_COLUMNS = (3, 4)
79
+ _MS_COLUMNS = (5, 6, 7, 8, 9, 10)
80
+ _METRIC_COLUMNS = _RATE_COLUMNS + _MS_COLUMNS
81
+
82
+ def _identity_values(self, viewer: dict) -> list[str]:
83
+ return [
84
+ viewer["name"] or "(unknown)",
85
+ viewer["address"],
86
+ self._version_cell(viewer["app_version"]),
87
+ ]
88
+
89
+
90
+ class _ConnectedServersSource(QObject):
91
+ """Adapts the window's connected sessions to the ViewersTable source
92
+ protocol (a `viewers()` method plus a change signal)."""
93
+
94
+ clientCountChanged = Signal(int)
95
+
96
+ def __init__(self, window: "MainWindow") -> None:
97
+ super().__init__(window)
98
+ self._window = window
99
+
100
+ def viewers(self) -> list[dict]:
101
+ return [
102
+ {
103
+ "name": session.name,
104
+ "address": session.key,
105
+ "app_version": session.client.server_app_version,
106
+ "stream": session.client.stream,
107
+ }
108
+ for session in self._window._sessions
109
+ if session.connected
110
+ ]
111
+
112
+ def notify(self) -> None:
113
+ self.clientCountChanged.emit(len(self.viewers()))
114
+
115
+
70
116
  class MainWindow(QMainWindow):
71
117
  def __init__(
72
118
  self,
@@ -161,20 +207,38 @@ class MainWindow(QMainWindow):
161
207
  self.get_client_log_button.clicked.connect(self.sharing_tab.request_client_log)
162
208
 
163
209
  connections_tab = QWidget()
164
- sharing_group = QGroupBox("Server (sharing this computer)")
165
- QVBoxLayout(sharing_group).addWidget(self.sharing_tab)
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
210
+ # One outer group box per role, side by side as WIDGETS (not column
211
+ # layouts): a hidden widget frees its space, so when a role is off
212
+ # the other box starts at the left edge instead of leaving a gap.
213
+ # Client box: live connected-server sessions (with the same network
214
+ # statistics as the viewers table) over the persisted server
215
+ # records. Server box: live sharing status/viewers over the
216
+ # persisted client pairings.
217
+ self._client_role_group = QGroupBox("Client (viewer)")
218
+ client_role_layout = QVBoxLayout(self._client_role_group)
219
+ self._sessions_source = _ConnectedServersSource(self)
220
+ self.sessions_table = _SessionsTable(self.client_performance)
221
+ self.sessions_table.set_share_server(self._sessions_source)
222
+ sessions_box = QGroupBox("Connected servers")
223
+ QVBoxLayout(sessions_box).addWidget(self.sessions_table)
224
+ # "History" (not "on LAN") — the live discovery list is the left
169
225
  # panel; these tables are the persisted first/last-seen records.
170
- self._servers_group = QGroupBox("Server history")
171
- QVBoxLayout(self._servers_group).addWidget(
226
+ servers_history_box = QGroupBox("Server history")
227
+ QVBoxLayout(servers_history_box).addWidget(
172
228
  InventoryTab(self.client_inventory, "Forget", self._forget_server)
173
229
  )
174
- clients_group = QGroupBox("Client history")
175
- QVBoxLayout(clients_group).addWidget(
230
+ client_role_layout.addWidget(sessions_box)
231
+ client_role_layout.addWidget(servers_history_box)
232
+
233
+ self._server_role_group = QGroupBox("Server (sharing)")
234
+ server_role_layout = QVBoxLayout(self._server_role_group)
235
+ server_role_layout.addWidget(self.sharing_tab)
236
+ clients_history_box = QGroupBox("Client history")
237
+ QVBoxLayout(clients_history_box).addWidget(
176
238
  InventoryTab(self.server_inventory, "Revoke", self._revoke_client)
177
239
  )
240
+ server_role_layout.addWidget(clients_history_box)
241
+
178
242
  log_group = QGroupBox("Connection log")
179
243
  log_layout = QVBoxLayout(log_group)
180
244
  log_buttons = QHBoxLayout()
@@ -183,20 +247,15 @@ class MainWindow(QMainWindow):
183
247
  log_buttons.addStretch(1)
184
248
  log_layout.addLayout(log_buttons)
185
249
  log_layout.addWidget(self.connection_log)
186
- # Left column: this computer (sharing status, log). Right column:
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()
250
+ # Client box left, Server box right (the order the indicators and
251
+ # Preferences use); the log spans the bottom.
252
+ columns = QHBoxLayout()
253
+ columns.addWidget(self._client_role_group, 1)
254
+ columns.addWidget(self._server_role_group, 1)
255
+ connections_layout = QVBoxLayout(connections_tab)
256
+ connections_layout.addLayout(columns, 2)
257
+ connections_layout.addWidget(log_group, 1)
258
+ self._update_connections_groups()
200
259
  self._tabs.addTab(connections_tab, "Connections")
201
260
  # The Performance sub-tabs follow the roles: "Viewing" exists only
202
261
  # with the viewer role, "Sharing" only while actually serving —
@@ -250,16 +309,18 @@ class MainWindow(QMainWindow):
250
309
  if not self._viewer_enabled:
251
310
  self.discovery_panel.hide()
252
311
  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)
312
+ # No title bar at all: the contents are self-explanatory, and the
313
+ # View menu ("Panel") is the way to show or hide it — a blank header
314
+ # strip whose only job was the X earned no space. The Closable
315
+ # feature must stay even though there is no X: without it Qt
316
+ # disables the toggleViewAction, killing the View-menu toggle.
317
+ self.panel_dock = QDockWidget("", self)
256
318
  # An object name is required for saveState() to persist the dock.
257
- self.servers_dock.setObjectName("servers_dock")
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)
262
- self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.servers_dock)
319
+ self.panel_dock.setObjectName("panel_dock")
320
+ self.panel_dock.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)
321
+ self.panel_dock.setTitleBarWidget(QWidget(self.panel_dock))
322
+ self.panel_dock.setWidget(dock_body)
323
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.panel_dock)
263
324
 
264
325
  self.preferences_tab = PreferencesTab(
265
326
  self._settings,
@@ -342,9 +403,9 @@ class MainWindow(QMainWindow):
342
403
  self.quit_action.triggered.connect(self._quit)
343
404
  self._view_menu = bar.addMenu("&View")
344
405
  # The dock's own toggle action: the way back after closing the panel.
345
- self.servers_panel_action = self.servers_dock.toggleViewAction()
346
- self.servers_panel_action.setText("&Servers on LAN panel")
347
- self._view_menu.addAction(self.servers_panel_action)
406
+ self.panel_action = self.panel_dock.toggleViewAction()
407
+ self.panel_action.setText("&Panel")
408
+ self._view_menu.addAction(self.panel_action)
348
409
  self.refresh_action = self._view_menu.addAction("&Refresh server list")
349
410
  self.refresh_action.setShortcut(QKeySequence("F5"))
350
411
  self.refresh_action.triggered.connect(self.discovery_panel.refresh)
@@ -385,7 +446,7 @@ class MainWindow(QMainWindow):
385
446
  "<h3>No server connected</h3>"
386
447
  "<p>Computers sharing their screen appear automatically in the "
387
448
  "panel on the left<br>"
388
- "(View&nbsp;▸&nbsp;Servers on LAN panel if it is hidden).</p>"
449
+ "(View&nbsp;▸&nbsp;Panel if it is hidden).</p>"
389
450
  "<p>Double-click one — or select it and click <b>Connect</b> — "
390
451
  "and this tab becomes your view of that computer.</p>"
391
452
  "<p>To make a computer appear in the list, enable "
@@ -429,6 +490,13 @@ class MainWindow(QMainWindow):
429
490
  button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
430
491
  return button
431
492
 
493
+ def _update_connections_groups(self) -> None:
494
+ """Show each Connections-tab role box only while its role is on —
495
+ a client-only instance has no viewers or pairings to display, and a
496
+ server-only one has no sessions or server history."""
497
+ self._client_role_group.setVisible(self._viewer_enabled)
498
+ self._server_role_group.setVisible(self.sharing_tab.serving)
499
+
432
500
  def _update_performance_tabs(self) -> None:
433
501
  """Show a Performance sub-tab per role that can produce data."""
434
502
  pages = self.performance_pages
@@ -473,10 +541,8 @@ class MainWindow(QMainWindow):
473
541
  if placeholder_index != -1:
474
542
  self._tabs.removeTab(placeholder_index)
475
543
  self.discovery_panel.hide()
476
- self._servers_group.hide()
477
544
  else:
478
545
  self.discovery_panel.show()
479
- self._servers_group.show()
480
546
  self._ensure_placeholder()
481
547
  self._tabs.setCurrentIndex(0)
482
548
  if self._auto_scan:
@@ -485,6 +551,7 @@ class MainWindow(QMainWindow):
485
551
  self._update_dock_layout()
486
552
  self._update_role_indicators()
487
553
  self._update_performance_tabs()
554
+ self._update_connections_groups()
488
555
 
489
556
  def _on_current_tab_changed(self, _index: int) -> None:
490
557
  self._refresh_status_bar()
@@ -523,11 +590,11 @@ class MainWindow(QMainWindow):
523
590
  return
524
591
  self._fullscreen_state = {
525
592
  "maximized": self.isMaximized(),
526
- "dock_visible": self.servers_dock.isVisible(),
593
+ "dock_visible": self.panel_dock.isVisible(),
527
594
  }
528
595
  self.menuBar().hide()
529
596
  self.statusBar().hide()
530
- self.servers_dock.hide()
597
+ self.panel_dock.hide()
531
598
  self._tabs.tabBar().hide()
532
599
  self.showFullScreen()
533
600
  self.fullscreen_action.setChecked(True)
@@ -544,7 +611,7 @@ class MainWindow(QMainWindow):
544
611
  self.menuBar().show()
545
612
  self.statusBar().show()
546
613
  # A dock the user had closed before fullscreen stays closed.
547
- self.servers_dock.setVisible(state["dock_visible"])
614
+ self.panel_dock.setVisible(state["dock_visible"])
548
615
  self._tabs.tabBar().show()
549
616
  if state["maximized"]:
550
617
  self.showMaximized()
@@ -639,6 +706,7 @@ class MainWindow(QMainWindow):
639
706
  self._update_window_title()
640
707
  self._update_role_indicators()
641
708
  self._update_performance_tabs()
709
+ self._update_connections_groups()
642
710
 
643
711
  def _quit(self) -> None:
644
712
  self._quitting = True
@@ -710,14 +778,22 @@ class MainWindow(QMainWindow):
710
778
  )
711
779
  if answer != QMessageBox.StandardButton.Yes:
712
780
  return
781
+ # An installed (pyship) instance relaunches via the launcher exe, which
782
+ # picks the newest installed version — so restart-after-update actually
783
+ # starts the update. From source/venv, relaunch this interpreter.
784
+ launcher = installed_launcher()
785
+ if launcher is not None:
786
+ program, args = str(launcher), []
787
+ else:
788
+ program, args = sys.executable, ["-m", "remotedesktop"]
713
789
  self.log("Restarting: freeing ports and launching a new process")
714
- _log.info("Restart requested — relaunching %s -m remotedesktop", sys.executable)
790
+ _log.info("Restart requested — relaunching %s %s", program, " ".join(args))
715
791
  self.sharing_tab.shutdown()
716
- if not QProcess.startDetached(sys.executable, ["-m", "remotedesktop"]):
717
- # Extremely unlikely (sys.executable exists); the app stays open —
792
+ if not QProcess.startDetached(program, args):
793
+ # Extremely unlikely (the program path exists); the app stays open —
718
794
  # sharing is stopped, but the machine isn't left with nothing.
719
795
  self.log("Restart failed: could not launch a new process — restart manually")
720
- _log.error("QProcess.startDetached failed for %s", sys.executable)
796
+ _log.error("QProcess.startDetached failed for %s", program)
721
797
  return
722
798
  self._quit()
723
799
 
@@ -977,6 +1053,7 @@ class MainWindow(QMainWindow):
977
1053
  self.log(f"Closed connection to {session.name} ({session.key})")
978
1054
  self._update_window_title()
979
1055
  self._refresh_status_bar()
1056
+ self._sessions_source.notify()
980
1057
 
981
1058
  def _on_tab_close_requested(self, index: int) -> None:
982
1059
  if index < len(self._sessions): # fixed tabs carry no close button
@@ -1032,6 +1109,7 @@ class MainWindow(QMainWindow):
1032
1109
  box.show() # non-modal: streaming continues behind it
1033
1110
  self.client_inventory.record(session.key, "connected", name=session.name)
1034
1111
  self._persist_sessions() # the welcome may have renamed the session
1112
+ self._sessions_source.notify()
1035
1113
  session.viewer.setFocus()
1036
1114
  self._set_session_status(
1037
1115
  session,
@@ -1069,6 +1147,7 @@ class MainWindow(QMainWindow):
1069
1147
  session.viewer.clear("Disconnected")
1070
1148
  self._set_session_status(session, f"Disconnected from {session.name}")
1071
1149
  self._update_window_title()
1150
+ self._sessions_source.notify()
1072
1151
  if session.auto_reconnect and not session.denied and not self._quitting:
1073
1152
  self._schedule_reconnect(session)
1074
1153
 
@@ -11,6 +11,7 @@ Installations upgraded from the separate server app may still carry the old
11
11
  startup) moves that registration to the new value name and command.
12
12
  """
13
13
 
14
+ import re
14
15
  import sys
15
16
  from pathlib import Path
16
17
 
@@ -22,10 +23,31 @@ _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
22
23
  _VALUE_NAME = "remotedesktop"
23
24
  _LEGACY_VALUE_NAME = "remotedesktop-server" # the pre-1.0 server-only app
24
25
 
26
+ # A pyship CLIP directory: <install dir>\remotedesktop_<version>\pythonw.exe.
27
+ _CLIP_DIR_RE = re.compile(r"remotedesktop_\d+(\.\d+)*", re.IGNORECASE)
28
+
29
+
30
+ def installed_launcher() -> Path | None:
31
+ """The pyship launcher exe, when running from an installed CLIP.
32
+
33
+ The launcher always starts the newest installed remotedesktop_<version>
34
+ directory, so registrations and relaunches that go through it survive
35
+ upgrades — a path into this CLIP would go stale instead.
36
+ """
37
+ exe_dir = Path(sys.executable).parent
38
+ if _CLIP_DIR_RE.fullmatch(exe_dir.name):
39
+ launcher = exe_dir.parent / "remotedesktop" / "remotedesktop.exe"
40
+ if launcher.exists():
41
+ return launcher
42
+ return None
43
+
25
44
 
26
45
  def app_command() -> str:
27
46
  """The command line that launches this installation at login."""
28
- exe = Path(sys.executable).with_name("remotedesktop.exe")
47
+ launcher = installed_launcher()
48
+ if launcher is not None:
49
+ return f'"{launcher}" --minimized'
50
+ exe = Path(sys.executable).with_name("remotedesktop.exe") # venv Scripts dir
29
51
  if exe.exists():
30
52
  return f'"{exe}" --minimized'
31
53
  # Fallback (e.g. running from source without the entry-point exe).